feat(events): PR-2 UI MVP — 4-tab + 빠른 행동 기록 + 상세/생성/이력
plan v6 PR-2 scope. 5초 행동 기록 UX 가 핵심 가설.
Backend:
- GET /api/events/{id}/history — events_history timeline 조회 (lifecycle op 자동 기록)
Frontend (SvelteKit 5 runes mode):
- /events 메인 — 4-tab (오늘/Inbox/예정/활동) + 빠른 행동 기록 widget
· 단일 입력 + Enter → POST /api/events kind=activity_log
· status=done + 시간 default 채워짐 (서버 측) → Activity 탭 즉시 반영
· 새 항목을 list 최상단 prepend (refetch 불필요)
· 연속 입력 위해 입력 ref focus 유지
· lifecycle 버튼 (complete/defer/cancel/reactivate) — activity_log 는 lifecycle 대상 X
- /events/[id] 상세 — PATCH 허용 필드 edit (title/desc/시간/priority/project_tag) + history timeline
· PATCH 금지 필드는 UI 노출 X (status/completed_at/cancelled_at/defer_until 은 별 버튼)
- /events/new — kind 선택 (task/calendar_event/activity_log) 후 필드 분기 form
· task: due_at + start_at (선택, "14:00 전화" 같은 시각 task 허용 — 라운드 10)
· calendar_event: start_at 필수 + end_at + all_day
· activity_log: started_at/ended_at 비우면 서버 default now()
- Sidebar 메모 옆에 events 진입점 (CalendarCheck icon)
API helpers: frontend/src/lib/utils/events.ts (createEvent / logActivity / list*
/ lifecycle ops / kind&status enum label/color).
quickref doc: docs/events_api_quickref.md (이전 commit, PR-2 frontend reference).
PR-2 핵심 가설 검증 = 빠른 입력 → 저장 → Activity 즉시 반영 → 새로고침 유지.
PR-1 deferred HTTP behavior 5건도 본 UI 의 자연 사용으로 닫힘.
This commit is contained in:
@@ -147,6 +147,20 @@ class EventListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class EventHistoryResponse(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
changed_at: datetime
|
||||
changed_by: str
|
||||
change_kind: str
|
||||
before: dict[str, Any] | None
|
||||
after: dict[str, Any]
|
||||
|
||||
|
||||
class EventHistoryListResponse(BaseModel):
|
||||
items: list[EventHistoryResponse]
|
||||
|
||||
|
||||
# ─── 헬퍼 ───
|
||||
|
||||
|
||||
@@ -476,6 +490,26 @@ async def get_event(
|
||||
return _to_response(ev)
|
||||
|
||||
|
||||
@router.get("/{event_id}/history", response_model=EventHistoryListResponse)
|
||||
async def get_event_history(
|
||||
event_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""events_history 조회 — 상세 페이지 timeline. lifecycle op 자동 기록만 (v1)."""
|
||||
await _load_owned(session, event_id, user) # owner 검증
|
||||
rows = await session.execute(
|
||||
select(EventHistory)
|
||||
.where(EventHistory.event_id == event_id)
|
||||
.order_by(EventHistory.changed_at.desc())
|
||||
)
|
||||
items = [
|
||||
EventHistoryResponse.model_validate(h, from_attributes=True)
|
||||
for h in rows.scalars().all()
|
||||
]
|
||||
return EventHistoryListResponse(items=items)
|
||||
|
||||
|
||||
# ─── PATCH ───
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap } from 'lucide-svelte';
|
||||
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap, CalendarCheck } from 'lucide-svelte';
|
||||
|
||||
let tree = $state([]);
|
||||
let loading = $state(true);
|
||||
@@ -209,6 +209,16 @@
|
||||
메모
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/events"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{$page.url.pathname.startsWith('/events') ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<CalendarCheck size={16} />
|
||||
events
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/study"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// events 도메인 helper (PR-2 UI MVP).
|
||||
// type 정의 + 표시용 라벨 + lifecycle 호출 wrapper.
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export type EventKind = 'task' | 'calendar_event' | 'activity_log';
|
||||
export type EventStatus =
|
||||
| 'inbox'
|
||||
| 'next'
|
||||
| 'scheduled'
|
||||
| 'in_progress'
|
||||
| 'done'
|
||||
| 'cancelled'
|
||||
| 'deferred';
|
||||
export type EventSource =
|
||||
| 'manual'
|
||||
| 'memo'
|
||||
| 'email'
|
||||
| 'chat'
|
||||
| 'webhook'
|
||||
| 'git_commit'
|
||||
| 'claude_code';
|
||||
export type EventActor = 'manual' | 'eid' | 'email_ingest' | 'system';
|
||||
|
||||
export interface EventItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
kind: EventKind;
|
||||
status: EventStatus;
|
||||
due_at: string | null;
|
||||
start_at: string | null;
|
||||
end_at: string | null;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
all_day: boolean;
|
||||
timezone: string | null;
|
||||
defer_until: string | null;
|
||||
completed_at: string | null;
|
||||
cancelled_at: string | null;
|
||||
priority: number | null;
|
||||
project_tag: string | null;
|
||||
tags: unknown[];
|
||||
source: EventSource;
|
||||
source_ref: string | null;
|
||||
raw_metadata: Record<string, unknown>;
|
||||
memo_document_id: number | null;
|
||||
user_id: number;
|
||||
created_by: EventActor;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface EventListResponse {
|
||||
items: EventItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const KIND_LABEL: Record<EventKind, string> = {
|
||||
task: '할 일',
|
||||
calendar_event: '일정',
|
||||
activity_log: '기록',
|
||||
};
|
||||
|
||||
export const STATUS_LABEL: Record<EventStatus, string> = {
|
||||
inbox: 'Inbox',
|
||||
next: '다음',
|
||||
scheduled: '예정',
|
||||
in_progress: '진행 중',
|
||||
done: '완료',
|
||||
cancelled: '취소',
|
||||
deferred: '연기',
|
||||
};
|
||||
|
||||
export const STATUS_COLOR: Record<EventStatus, string> = {
|
||||
inbox: 'bg-slate-100 text-slate-700',
|
||||
next: 'bg-indigo-100 text-indigo-700',
|
||||
scheduled: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-amber-100 text-amber-700',
|
||||
done: 'bg-emerald-100 text-emerald-700',
|
||||
cancelled: 'bg-rose-100 text-rose-700',
|
||||
deferred: 'bg-slate-100 text-slate-500',
|
||||
};
|
||||
|
||||
export const KIND_COLOR: Record<EventKind, string> = {
|
||||
task: 'border-l-4 border-indigo-400',
|
||||
calendar_event: 'border-l-4 border-blue-400',
|
||||
activity_log: 'border-l-4 border-emerald-400',
|
||||
};
|
||||
|
||||
// ─── API wrappers ───
|
||||
|
||||
export interface EventCreatePayload {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
kind: EventKind;
|
||||
status?: EventStatus | null;
|
||||
due_at?: string | null;
|
||||
start_at?: string | null;
|
||||
end_at?: string | null;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
all_day?: boolean;
|
||||
timezone?: string | null;
|
||||
priority?: number | null;
|
||||
project_tag?: string | null;
|
||||
tags?: unknown[];
|
||||
memo_document_id?: number | null;
|
||||
source?: EventSource;
|
||||
source_ref?: string | null;
|
||||
raw_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function createEvent(payload: EventCreatePayload): Promise<EventItem> {
|
||||
return api<EventItem>('/events/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
// 빠른 행동 기록 (5초 UX 핵심) — title 1개만 받고 서버 default 활용.
|
||||
export async function logActivity(title: string, projectTag?: string | null): Promise<EventItem> {
|
||||
const payload: EventCreatePayload = {
|
||||
title,
|
||||
kind: 'activity_log',
|
||||
};
|
||||
if (projectTag) payload.project_tag = projectTag;
|
||||
return createEvent(payload);
|
||||
}
|
||||
|
||||
export interface EventPatchPayload {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
due_at?: string | null;
|
||||
start_at?: string | null;
|
||||
end_at?: string | null;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
all_day?: boolean;
|
||||
timezone?: string | null;
|
||||
priority?: number | null;
|
||||
project_tag?: string | null;
|
||||
tags?: unknown[];
|
||||
memo_document_id?: number | null;
|
||||
}
|
||||
|
||||
export async function patchEvent(id: number, payload: EventPatchPayload): Promise<EventItem> {
|
||||
return api<EventItem>(`/events/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEvent(id: number): Promise<EventItem> {
|
||||
return api<EventItem>(`/events/${id}`);
|
||||
}
|
||||
|
||||
export async function listToday(timezone = 'Asia/Seoul'): Promise<EventListResponse> {
|
||||
return api<EventListResponse>(`/events/today?timezone=${encodeURIComponent(timezone)}`);
|
||||
}
|
||||
|
||||
export async function listInbox(): Promise<EventListResponse> {
|
||||
return api<EventListResponse>('/events/inbox');
|
||||
}
|
||||
|
||||
export async function listActivity(fromIso?: string, toIso?: string): Promise<EventListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (fromIso) params.set('from', fromIso);
|
||||
if (toIso) params.set('to', toIso);
|
||||
const qs = params.toString();
|
||||
return api<EventListResponse>(`/events/activity${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
export async function listUpcoming(days = 7): Promise<EventListResponse> {
|
||||
const now = new Date();
|
||||
const to = new Date(now.getTime() + days * 86400_000);
|
||||
const params = new URLSearchParams({
|
||||
from: now.toISOString(),
|
||||
to: to.toISOString(),
|
||||
status: 'scheduled,next,deferred',
|
||||
});
|
||||
return api<EventListResponse>(`/events/?${params.toString()}`);
|
||||
}
|
||||
|
||||
// Lifecycle endpoints
|
||||
|
||||
export async function completeEvent(id: number): Promise<EventItem> {
|
||||
return api<EventItem>(`/events/${id}/complete`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function cancelEvent(id: number): Promise<EventItem> {
|
||||
return api<EventItem>(`/events/${id}/cancel`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function deferEvent(id: number, deferUntilIso: string): Promise<EventItem> {
|
||||
return api<EventItem>(`/events/${id}/defer`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ defer_until: deferUntilIso }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function reactivateEvent(id: number): Promise<EventItem> {
|
||||
return api<EventItem>(`/events/${id}/reactivate`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// 표시 유틸
|
||||
|
||||
export function formatDateTimeKst(iso: string | null | undefined, opts?: { dateOnly?: boolean }): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
if (opts?.dateOnly) {
|
||||
return d.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul', month: 'numeric', day: 'numeric', weekday: 'short' });
|
||||
}
|
||||
return d.toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function relativeTimeKo(iso: string | null | undefined, nowMs: number = Date.now()): string {
|
||||
if (!iso) return '';
|
||||
const t = new Date(iso).getTime();
|
||||
if (isNaN(t)) return '';
|
||||
const diff = nowMs - t;
|
||||
const abs = Math.abs(diff);
|
||||
const future = diff < 0;
|
||||
if (abs < 60_000) return future ? '곧' : '방금';
|
||||
const min = Math.round(abs / 60_000);
|
||||
if (min < 60) return future ? `${min}분 후` : `${min}분 전`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return future ? `${hr}시간 후` : `${hr}시간 전`;
|
||||
const day = Math.round(hr / 24);
|
||||
if (day < 30) return future ? `${day}일 후` : `${day}일 전`;
|
||||
const mo = Math.round(day / 30);
|
||||
if (mo < 12) return future ? `${mo}달 후` : `${mo}달 전`;
|
||||
const yr = Math.round(mo / 12);
|
||||
return future ? `${yr}년 후` : `${yr}년 전`;
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
import {
|
||||
Inbox,
|
||||
Sun,
|
||||
CalendarClock,
|
||||
History,
|
||||
Check,
|
||||
X as XIcon,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
type EventItem,
|
||||
type EventKind,
|
||||
KIND_LABEL,
|
||||
STATUS_LABEL,
|
||||
STATUS_COLOR,
|
||||
KIND_COLOR,
|
||||
logActivity,
|
||||
listToday,
|
||||
listInbox,
|
||||
listActivity,
|
||||
listUpcoming,
|
||||
completeEvent,
|
||||
cancelEvent,
|
||||
deferEvent,
|
||||
reactivateEvent,
|
||||
formatDateTimeKst,
|
||||
relativeTimeKo,
|
||||
} from '$lib/utils/events';
|
||||
|
||||
// 활성 탭
|
||||
let activeTab = $state('today');
|
||||
|
||||
// 데이터
|
||||
let todayItems = $state<EventItem[]>([]);
|
||||
let inboxItems = $state<EventItem[]>([]);
|
||||
let upcomingItems = $state<EventItem[]>([]);
|
||||
let activityItems = $state<EventItem[]>([]);
|
||||
let loading = $state<Record<string, boolean>>({ today: true, inbox: true, upcoming: true, activity: true });
|
||||
|
||||
// 빠른 행동 기록 (5초 UX 핵심)
|
||||
let quickInput = $state('');
|
||||
let quickSubmitting = $state(false);
|
||||
let quickInputRef = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// 상대 시각 tick (1분)
|
||||
let nowTick = $state(Date.now());
|
||||
$effect(() => {
|
||||
const id = setInterval(() => { nowTick = Date.now(); }, 60_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadAll();
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadToday(), loadInbox(), loadActivity(), loadUpcoming()]);
|
||||
}
|
||||
|
||||
async function loadToday() {
|
||||
loading.today = true;
|
||||
try {
|
||||
const res = await listToday();
|
||||
todayItems = res.items;
|
||||
} catch (err) {
|
||||
addToast('error', '오늘 일정 로드 실패');
|
||||
} finally {
|
||||
loading.today = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInbox() {
|
||||
loading.inbox = true;
|
||||
try {
|
||||
const res = await listInbox();
|
||||
inboxItems = res.items;
|
||||
} catch (err) {
|
||||
addToast('error', 'Inbox 로드 실패');
|
||||
} finally {
|
||||
loading.inbox = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActivity() {
|
||||
loading.activity = true;
|
||||
try {
|
||||
// 최근 7일
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - 7 * 86400_000);
|
||||
const res = await listActivity(from.toISOString(), to.toISOString());
|
||||
activityItems = res.items;
|
||||
} catch (err) {
|
||||
addToast('error', '활동 로드 실패');
|
||||
} finally {
|
||||
loading.activity = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUpcoming() {
|
||||
loading.upcoming = true;
|
||||
try {
|
||||
const res = await listUpcoming(7);
|
||||
upcomingItems = res.items;
|
||||
} catch (err) {
|
||||
addToast('error', 'Upcoming 로드 실패');
|
||||
} finally {
|
||||
loading.upcoming = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 빠른 행동 기록 — Enter 한 번에 저장
|
||||
async function submitQuick(e?: Event) {
|
||||
e?.preventDefault();
|
||||
const title = quickInput.trim();
|
||||
if (!title || quickSubmitting) return;
|
||||
quickSubmitting = true;
|
||||
try {
|
||||
const item = await logActivity(title);
|
||||
// 새 항목을 Activity 탭 최상단에 즉시 반영
|
||||
activityItems = [item, ...activityItems];
|
||||
quickInput = '';
|
||||
addToast('success', '기록됨');
|
||||
// 입력 포커스 유지 (연속 입력)
|
||||
quickInputRef?.focus();
|
||||
} catch (err) {
|
||||
const detail = (err as { detail?: string })?.detail ?? '저장 실패';
|
||||
addToast('error', detail);
|
||||
} finally {
|
||||
quickSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle ops
|
||||
async function doComplete(item: EventItem) {
|
||||
try {
|
||||
const updated = await completeEvent(item.id);
|
||||
replaceItem(updated);
|
||||
addToast('success', '완료 처리');
|
||||
} catch (err) {
|
||||
addToast('error', '완료 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function doCancel(item: EventItem) {
|
||||
if (!confirm(`"${item.title}" 취소할까요?`)) return;
|
||||
try {
|
||||
const updated = await cancelEvent(item.id);
|
||||
replaceItem(updated);
|
||||
addToast('success', '취소됨');
|
||||
} catch (err) {
|
||||
addToast('error', '취소 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function doDefer(item: EventItem) {
|
||||
// 기본 = 내일 같은 시각
|
||||
const dt = new Date(Date.now() + 86400_000);
|
||||
const isoLocal = dt.toISOString().slice(0, 16);
|
||||
const input = prompt('연기할 시각 (YYYY-MM-DDTHH:MM, KST)', isoLocal);
|
||||
if (!input) return;
|
||||
try {
|
||||
const iso = new Date(input).toISOString();
|
||||
const updated = await deferEvent(item.id, iso);
|
||||
replaceItem(updated);
|
||||
addToast('success', '연기됨');
|
||||
} catch (err) {
|
||||
addToast('error', '연기 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function doReactivate(item: EventItem) {
|
||||
try {
|
||||
const updated = await reactivateEvent(item.id);
|
||||
replaceItem(updated);
|
||||
addToast('success', '재활성');
|
||||
} catch (err) {
|
||||
addToast('error', '재활성 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function replaceItem(item: EventItem) {
|
||||
const replacer = (arr: EventItem[]) =>
|
||||
arr.map((x) => (x.id === item.id ? item : x));
|
||||
todayItems = replacer(todayItems);
|
||||
inboxItems = replacer(inboxItems);
|
||||
upcomingItems = replacer(upcomingItems);
|
||||
activityItems = replacer(activityItems);
|
||||
}
|
||||
|
||||
function timeLabel(item: EventItem): string {
|
||||
if (item.kind === 'task') {
|
||||
return item.due_at ? `마감 ${formatDateTimeKst(item.due_at)}` : '';
|
||||
}
|
||||
if (item.kind === 'calendar_event') {
|
||||
const s = item.start_at ? formatDateTimeKst(item.start_at) : '';
|
||||
const e = item.end_at ? formatDateTimeKst(item.end_at) : '';
|
||||
return s && e ? `${s} – ${e}` : s;
|
||||
}
|
||||
// activity_log
|
||||
return item.started_at ? relativeTimeKo(item.started_at, nowTick) : '';
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'today', label: '오늘' },
|
||||
{ id: 'inbox', label: 'Inbox' },
|
||||
{ id: 'upcoming', label: '예정' },
|
||||
{ id: 'activity', label: '활동' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>events · hyungi PKM</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
<header class="flex items-end justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl font-semibold">events</h1>
|
||||
<p class="text-sm text-slate-500">개인 운영 로그 · 일정 · 할 일 · 회고</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" icon={Plus} href="/events/new">새 항목</Button>
|
||||
</header>
|
||||
|
||||
<!-- 빠른 행동 기록 (5초 UX 핵심) -->
|
||||
<form onsubmit={submitQuick} class="flex items-stretch gap-2 rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
|
||||
<input
|
||||
bind:this={quickInputRef}
|
||||
bind:value={quickInput}
|
||||
type="text"
|
||||
placeholder="방금 한 일 기록… (Enter 저장)"
|
||||
class="flex-1 rounded-md border-0 bg-transparent px-2 py-2 text-sm outline-none focus:ring-0"
|
||||
disabled={quickSubmitting}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button type="submit" variant="primary" size="sm" loading={quickSubmitting} disabled={!quickInput.trim()} icon={Plus}>
|
||||
기록
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Tabs {tabs} bind:value={activeTab}>
|
||||
{#snippet children(activeId)}
|
||||
{#if activeId === 'today'}
|
||||
{@render eventList(todayItems, loading.today, 'today', Sun, '오늘 할 일이 없습니다')}
|
||||
{:else if activeId === 'inbox'}
|
||||
{@render eventList(inboxItems, loading.inbox, 'inbox', Inbox, 'Inbox 가 비어 있습니다')}
|
||||
{:else if activeId === 'upcoming'}
|
||||
{@render eventList(upcomingItems, loading.upcoming, 'upcoming', CalendarClock, '예정된 일정 없음')}
|
||||
{:else if activeId === 'activity'}
|
||||
{@render eventList(activityItems, loading.activity, 'activity', History, '최근 활동 없음')}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{#snippet eventList(items: EventItem[], isLoading: boolean, tabId: string, EmptyIcon: typeof Sun, emptyMsg: string)}
|
||||
{#if isLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(3) as _}
|
||||
<Skeleton class="h-16 rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<EmptyState icon={EmptyIcon} message={emptyMsg} />
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each items as item (item.id)}
|
||||
<li>
|
||||
<Card class="flex items-start gap-3 p-3 {KIND_COLOR[item.kind]}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>{KIND_LABEL[item.kind]}</span>
|
||||
<span class="rounded px-1.5 py-0.5 text-[10px] {STATUS_COLOR[item.status]}">
|
||||
{STATUS_LABEL[item.status]}
|
||||
</span>
|
||||
{#if item.project_tag}
|
||||
<span class="text-slate-400">#{item.project_tag}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<a href="/events/{item.id}" class="mt-1 block break-words text-sm font-medium hover:underline">
|
||||
{item.title}
|
||||
</a>
|
||||
{#if timeLabel(item)}
|
||||
<div class="mt-0.5 text-xs text-slate-500">{timeLabel(item)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if item.status !== 'done' && item.status !== 'cancelled' && item.kind !== 'activity_log'}
|
||||
<Button variant="ghost" size="sm" icon={Check} onclick={() => doComplete(item)} title="완료" />
|
||||
<Button variant="ghost" size="sm" icon={Pause} onclick={() => doDefer(item)} title="연기" />
|
||||
<Button variant="ghost" size="sm" icon={XIcon} onclick={() => doCancel(item)} title="취소" />
|
||||
{:else if (item.status === 'done' || item.status === 'cancelled') && item.kind !== 'activity_log'}
|
||||
<Button variant="ghost" size="sm" icon={RotateCcw} onclick={() => doReactivate(item)} title="재활성" />
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -0,0 +1,308 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import TextInput from '$lib/components/ui/TextInput.svelte';
|
||||
import Textarea from '$lib/components/ui/Textarea.svelte';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
X as XIcon,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Save,
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
type EventItem,
|
||||
type EventKind,
|
||||
KIND_LABEL,
|
||||
STATUS_LABEL,
|
||||
STATUS_COLOR,
|
||||
KIND_COLOR,
|
||||
getEvent,
|
||||
patchEvent,
|
||||
completeEvent,
|
||||
cancelEvent,
|
||||
deferEvent,
|
||||
reactivateEvent,
|
||||
formatDateTimeKst,
|
||||
relativeTimeKo,
|
||||
} from '$lib/utils/events';
|
||||
|
||||
const eventId = $derived(parseInt(page.params.id ?? '0', 10));
|
||||
|
||||
let item = $state<EventItem | null>(null);
|
||||
let history = $state<HistoryEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
|
||||
// edit fields (PATCH 허용만)
|
||||
let editTitle = $state('');
|
||||
let editDescription = $state('');
|
||||
let editDueAt = $state('');
|
||||
let editStartAt = $state('');
|
||||
let editEndAt = $state('');
|
||||
let editAllDay = $state(false);
|
||||
let editPriority = $state<number | ''>('');
|
||||
let editProjectTag = $state('');
|
||||
|
||||
interface HistoryEntry {
|
||||
id: number;
|
||||
event_id: number;
|
||||
changed_at: string;
|
||||
changed_by: string;
|
||||
change_kind: string;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const CHANGE_KIND_LABEL: Record<string, string> = {
|
||||
create: '생성',
|
||||
reschedule: '시간 변경',
|
||||
defer: '연기',
|
||||
reactivate: '재활성',
|
||||
complete: '완료',
|
||||
cancel: '취소',
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (!eventId) {
|
||||
addToast('error', '유효하지 않은 ID');
|
||||
goto('/events');
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [ev, hist] = await Promise.all([
|
||||
getEvent(eventId),
|
||||
api<{ items: HistoryEntry[] }>(`/events/${eventId}/history`),
|
||||
]);
|
||||
item = ev;
|
||||
history = hist.items;
|
||||
// edit fields seed
|
||||
editTitle = ev.title;
|
||||
editDescription = ev.description ?? '';
|
||||
editDueAt = isoToLocalInput(ev.due_at);
|
||||
editStartAt = isoToLocalInput(ev.start_at);
|
||||
editEndAt = isoToLocalInput(ev.end_at);
|
||||
editAllDay = ev.all_day;
|
||||
editPriority = ev.priority ?? '';
|
||||
editProjectTag = ev.project_tag ?? '';
|
||||
} catch (err) {
|
||||
const detail = (err as { detail?: string })?.detail ?? '로드 실패';
|
||||
addToast('error', detail);
|
||||
if ((err as { status?: number })?.status === 404) goto('/events');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isoToLocalInput(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
// YYYY-MM-DDTHH:MM (KST 변환은 브라우저 default)
|
||||
const off = d.getTimezoneOffset();
|
||||
const local = new Date(d.getTime() - off * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function localInputToIso(s: string): string | null {
|
||||
if (!s) return null;
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!item || saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (editTitle !== item.title) patch.title = editTitle;
|
||||
if ((editDescription || null) !== (item.description ?? null))
|
||||
patch.description = editDescription || null;
|
||||
const newDue = localInputToIso(editDueAt);
|
||||
if (newDue !== item.due_at) patch.due_at = newDue;
|
||||
const newStart = localInputToIso(editStartAt);
|
||||
if (newStart !== item.start_at) patch.start_at = newStart;
|
||||
const newEnd = localInputToIso(editEndAt);
|
||||
if (newEnd !== item.end_at) patch.end_at = newEnd;
|
||||
if (editAllDay !== item.all_day) patch.all_day = editAllDay;
|
||||
const newPriority = editPriority === '' ? null : Number(editPriority);
|
||||
if (newPriority !== item.priority) patch.priority = newPriority;
|
||||
if ((editProjectTag || null) !== (item.project_tag ?? null))
|
||||
patch.project_tag = editProjectTag || null;
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
addToast('info', '변경 사항 없음');
|
||||
return;
|
||||
}
|
||||
const updated = await patchEvent(item.id, patch);
|
||||
item = updated;
|
||||
addToast('success', '저장됨');
|
||||
// history 가 새 reschedule row 가질 수 있어 reload
|
||||
await reloadHistory();
|
||||
} catch (err) {
|
||||
const detail = (err as { detail?: string })?.detail ?? '저장 실패';
|
||||
addToast('error', detail);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadHistory() {
|
||||
if (!item) return;
|
||||
try {
|
||||
const hist = await api<{ items: HistoryEntry[] }>(`/events/${item.id}/history`);
|
||||
history = hist.items;
|
||||
} catch (err) {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
async function lifecycle(action: 'complete' | 'cancel' | 'defer' | 'reactivate') {
|
||||
if (!item) return;
|
||||
try {
|
||||
let updated: EventItem;
|
||||
if (action === 'complete') updated = await completeEvent(item.id);
|
||||
else if (action === 'cancel') {
|
||||
if (!confirm(`"${item.title}" 취소할까요?`)) return;
|
||||
updated = await cancelEvent(item.id);
|
||||
} else if (action === 'defer') {
|
||||
const dt = new Date(Date.now() + 86400_000);
|
||||
const input = prompt('연기할 시각 (YYYY-MM-DDTHH:MM, KST)', isoToLocalInput(dt.toISOString()));
|
||||
if (!input) return;
|
||||
updated = await deferEvent(item.id, new Date(input).toISOString());
|
||||
} else {
|
||||
updated = await reactivateEvent(item.id);
|
||||
}
|
||||
item = updated;
|
||||
addToast('success', '처리됨');
|
||||
await reloadHistory();
|
||||
} catch (err) {
|
||||
const detail = (err as { detail?: string })?.detail ?? '실패';
|
||||
addToast('error', detail);
|
||||
}
|
||||
}
|
||||
|
||||
function actorLabel(actor: string): string {
|
||||
return { manual: '나', eid: '이드', email_ingest: '메일', system: '시스템' }[actor] ?? actor;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{item?.title ?? 'events'} · hyungi PKM</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" icon={ArrowLeft} href="/events" />
|
||||
<h1 class="text-xl font-semibold">events 상세</h1>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<Skeleton class="h-64 rounded-lg" />
|
||||
{:else if item}
|
||||
<Card class="space-y-4 p-4 {KIND_COLOR[item.kind]}">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span>{KIND_LABEL[item.kind]}</span>
|
||||
<span class="rounded px-1.5 py-0.5 text-[10px] {STATUS_COLOR[item.status]}">
|
||||
{STATUS_LABEL[item.status]}
|
||||
</span>
|
||||
<span class="text-slate-400">id #{item.id}</span>
|
||||
<span class="text-slate-400">source: {item.source}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">제목</span>
|
||||
<TextInput bind:value={editTitle} />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">설명 (markdown)</span>
|
||||
<Textarea bind:value={editDescription} rows={4} />
|
||||
</label>
|
||||
|
||||
{#if item.kind === 'task'}
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">마감 (due_at)</span>
|
||||
<input type="datetime-local" bind:value={editDueAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if item.kind === 'calendar_event' || item.kind === 'task'}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">시작 (start_at)</span>
|
||||
<input type="datetime-local" bind:value={editStartAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">종료 (end_at)</span>
|
||||
<input type="datetime-local" bind:value={editEndAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={editAllDay} />
|
||||
<span>all-day</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">우선순위 (1–4)</span>
|
||||
<input type="number" min="1" max="4" bind:value={editPriority} class="w-20 rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">프로젝트 태그</span>
|
||||
<TextInput bind:value={editProjectTag} placeholder="(없음)" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 pt-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if item.status !== 'done' && item.status !== 'cancelled' && item.kind !== 'activity_log'}
|
||||
<Button variant="primary" size="sm" icon={Check} onclick={() => lifecycle('complete')}>완료</Button>
|
||||
<Button variant="secondary" size="sm" icon={Pause} onclick={() => lifecycle('defer')}>연기</Button>
|
||||
<Button variant="ghost" size="sm" icon={XIcon} onclick={() => lifecycle('cancel')}>취소</Button>
|
||||
{:else if (item.status === 'done' || item.status === 'cancelled') && item.kind !== 'activity_log'}
|
||||
<Button variant="secondary" size="sm" icon={RotateCcw} onclick={() => lifecycle('reactivate')}>재활성</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="primary" size="sm" icon={Save} loading={saving} onclick={save}>저장</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h2 class="text-sm font-semibold text-slate-700">변경 이력</h2>
|
||||
{#if history.length === 0}
|
||||
<p class="text-sm text-slate-500">이력 없음</p>
|
||||
{:else}
|
||||
<ol class="space-y-1.5 border-l border-slate-200 pl-4">
|
||||
{#each history as h (h.id)}
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-slate-300"></span>
|
||||
<div class="text-xs text-slate-500">
|
||||
{formatDateTimeKst(h.changed_at)} · {actorLabel(h.changed_by)}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{CHANGE_KIND_LABEL[h.change_kind] ?? h.change_kind}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import TextInput from '$lib/components/ui/TextInput.svelte';
|
||||
import Textarea from '$lib/components/ui/Textarea.svelte';
|
||||
import { ArrowLeft } from 'lucide-svelte';
|
||||
import {
|
||||
type EventKind,
|
||||
type EventCreatePayload,
|
||||
KIND_LABEL,
|
||||
createEvent,
|
||||
} from '$lib/utils/events';
|
||||
|
||||
let kind = $state<EventKind>('task');
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let dueAt = $state('');
|
||||
let startAt = $state('');
|
||||
let endAt = $state('');
|
||||
let allDay = $state(false);
|
||||
let startedAt = $state('');
|
||||
let endedAt = $state('');
|
||||
let priority = $state<number | ''>('');
|
||||
let projectTag = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
function localToIso(s: string): string | null {
|
||||
if (!s) return null;
|
||||
const d = new Date(s);
|
||||
return isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting) return;
|
||||
if (!title.trim()) {
|
||||
addToast('error', '제목을 입력하세요');
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
try {
|
||||
const payload: EventCreatePayload = {
|
||||
title: title.trim(),
|
||||
kind,
|
||||
};
|
||||
if (description.trim()) payload.description = description.trim();
|
||||
if (kind === 'task') {
|
||||
if (dueAt) payload.due_at = localToIso(dueAt);
|
||||
if (startAt) payload.start_at = localToIso(startAt);
|
||||
} else if (kind === 'calendar_event') {
|
||||
if (!startAt) {
|
||||
addToast('error', 'calendar_event 는 시작 시간 필수');
|
||||
submitting = false;
|
||||
return;
|
||||
}
|
||||
payload.start_at = localToIso(startAt);
|
||||
if (endAt) payload.end_at = localToIso(endAt);
|
||||
payload.all_day = allDay;
|
||||
} else if (kind === 'activity_log') {
|
||||
if (startedAt) payload.started_at = localToIso(startedAt);
|
||||
if (endedAt) payload.ended_at = localToIso(endedAt);
|
||||
// 모두 비우면 서버 default (now())
|
||||
}
|
||||
if (priority !== '') payload.priority = Number(priority);
|
||||
if (projectTag.trim()) payload.project_tag = projectTag.trim();
|
||||
|
||||
const item = await createEvent(payload);
|
||||
addToast('success', '생성됨');
|
||||
goto(`/events/${item.id}`);
|
||||
} catch (err) {
|
||||
const detail = (err as { detail?: string })?.detail ?? '생성 실패';
|
||||
addToast('error', detail);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>새 events · hyungi PKM</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-xl space-y-4 px-4 py-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" icon={ArrowLeft} href="/events" />
|
||||
<h1 class="text-xl font-semibold">새 events</h1>
|
||||
</div>
|
||||
|
||||
<Card class="space-y-4 p-4">
|
||||
<div>
|
||||
<span class="block text-xs text-slate-500">유형</span>
|
||||
<div class="mt-1 flex gap-2">
|
||||
{#each ['task', 'calendar_event', 'activity_log'] as k}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-1.5 text-sm transition {kind === k ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 hover:bg-slate-50'}"
|
||||
onclick={() => (kind = k as EventKind)}
|
||||
>
|
||||
{KIND_LABEL[k as EventKind]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">제목 *</span>
|
||||
<TextInput bind:value={title} placeholder="할 일 / 일정 / 기록한 행동" />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">설명 (markdown, 선택)</span>
|
||||
<Textarea bind:value={description} rows={3} />
|
||||
</label>
|
||||
|
||||
{#if kind === 'task'}
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">마감 (due_at, 선택)</span>
|
||||
<input type="datetime-local" bind:value={dueAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">시작 (start_at, 선택 — "14:00 전화" 같은 경우)</span>
|
||||
<input type="datetime-local" bind:value={startAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
{:else if kind === 'calendar_event'}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">시작 (start_at) *</span>
|
||||
<input type="datetime-local" bind:value={startAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" required />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">종료 (end_at, 선택)</span>
|
||||
<input type="datetime-local" bind:value={endAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={allDay} />
|
||||
<span>all-day</span>
|
||||
</label>
|
||||
{:else if kind === 'activity_log'}
|
||||
<p class="text-xs text-slate-500">
|
||||
비워두면 시작·종료 모두 <code>now()</code> 로 기록됩니다 (방금 한 일).
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">시작 시각 (선택)</span>
|
||||
<input type="datetime-local" bind:value={startedAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">종료 시각 (선택)</span>
|
||||
<input type="datetime-local" bind:value={endedAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">우선순위 (1–4)</span>
|
||||
<input type="number" min="1" max="4" bind:value={priority} class="w-20 rounded-md border border-slate-200 px-2 py-1 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-slate-500">프로젝트 태그</span>
|
||||
<TextInput bind:value={projectTag} placeholder="예: doc-server" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="primary" size="md" loading={submitting} onclick={submit}>생성</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
Reference in New Issue
Block a user