Merge pull request 'feat/events-ui-mvp' (#6) from feat/events-ui-mvp into main
Reviewed-on: #6
This commit was merged in pull request #6.
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 ───
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# events API quickref (PR-1 → PR-2 frontend reference)
|
||||
|
||||
**Plan**: `~/.claude/plans/beszel-tingly-sloth.md` v6
|
||||
**PR-1 closure**: 2026-05-11, schema + endpoint registration 검증 완료. JWT 의존 HTTP behavior 는 PR-2 UI smoke 로 자연 검증.
|
||||
**Authoritative contract**: `GET /openapi.json` (자동 생성). 본 문서는 frontend 개발자용 cheat sheet.
|
||||
|
||||
## 인증
|
||||
|
||||
모든 events endpoint = JWT Bearer (기존 `get_current_user` 의존성).
|
||||
|
||||
```bash
|
||||
# 로그인 → access_token
|
||||
TOKEN=$(curl -s -X POST https://document.hyungi.net/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"<USERNAME>","password":"<PASSWORD>","totp_code":"<TOTP_IF_ENABLED>"}' \
|
||||
| jq -r .access_token)
|
||||
|
||||
# events 호출
|
||||
curl -H "Authorization: Bearer $TOKEN" https://document.hyungi.net/api/events/today
|
||||
```
|
||||
|
||||
Frontend (SvelteKit 5) 는 기존 `lib/api.ts` 의 fetch wrapper (JWT 자동 첨부) 그대로 사용.
|
||||
|
||||
## 9 endpoint
|
||||
|
||||
### Create / List / Detail
|
||||
|
||||
| Method | Path | 용도 |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/events/` | 생성. kind=task/calendar_event/activity_log. activity_log 면 status=done + ended_at=now() default. |
|
||||
| `GET` | `/api/events/` | 목록. `?kind&status&from&to&project_tag&source&page&page_size`. Upcoming = `?from=now&to=now+7d&status=scheduled,next,deferred`. |
|
||||
| `GET` | `/api/events/{id}` | 상세. |
|
||||
| `PATCH` | `/api/events/{id}` | edit. **허용**: title/description/시간 7필드/priority/project_tag/tags/memo_document_id. **금지** (422): status/completed_at/cancelled_at/defer_until/source/source_ref/raw_metadata/user_id/created_by. 시간 필드 변경 시 `reschedule` history 자동. |
|
||||
|
||||
### Lifecycle (각 호출당 events_history 1 row 자동)
|
||||
|
||||
| Method | Path | 효과 |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/events/{id}/complete` | status=done + completed_at=now() |
|
||||
| `POST` | `/api/events/{id}/cancel` | status=cancelled + cancelled_at=now() |
|
||||
| `POST` | `/api/events/{id}/defer` | body `{defer_until: ISO}` → status=deferred + defer_until 설정 |
|
||||
| `POST` | `/api/events/{id}/reactivate` | task → inbox, calendar_event → scheduled, activity_log → 400 거부 |
|
||||
|
||||
### View
|
||||
|
||||
| Method | Path | 정책 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/events/today` | `?timezone=Asia/Seoul` (default). due_at/start_at/started_at 이 오늘이고 status ∈ {inbox, next, scheduled, in_progress} 또는 (deferred AND defer_until <= now). |
|
||||
| `GET` | `/api/events/inbox` | status=inbox 만. |
|
||||
| `GET` | `/api/events/activity` | `?from&to`. kind=activity_log + status=done 만. Today 와 분리. |
|
||||
|
||||
## kind / status 의미 (UI 분기 가이드)
|
||||
|
||||
| kind | 주요 시간 필드 | default status | UI hint |
|
||||
|---|---|---|---|
|
||||
| `task` | due_at (start_at/end_at optional, "14:00 전화" 같은 시각 task 허용) | inbox | 체크박스 + 마감일 표시 |
|
||||
| `calendar_event` | start_at (필수) + end_at (optional) | scheduled | 캘린더 일정 카드 |
|
||||
| `activity_log` | started_at OR ended_at (둘 다 NULL 금지) | done | "방금 한 일" 입력 / Activity 타임라인 |
|
||||
|
||||
| status | 의미 |
|
||||
|---|---|
|
||||
| `inbox` | 아직 정리 안 됨 |
|
||||
| `next` | 다음 행동으로 선정 (시간 미정) |
|
||||
| `scheduled` | 시간/날짜 잡힘 |
|
||||
| `in_progress` | 진행 중 |
|
||||
| `done` | 완료 |
|
||||
| `cancelled` | 취소 |
|
||||
| `deferred` | defer_until 까지 숨김 |
|
||||
|
||||
## 빠른 행동 기록 5초 UX (PR-2 핵심 가설)
|
||||
|
||||
```js
|
||||
// 1입력 필드 → Enter → POST /api/events
|
||||
api.post('/api/events/', {
|
||||
kind: 'activity_log',
|
||||
title: '<사용자 입력>'
|
||||
// status/started_at/ended_at/completed_at 모두 server-side default
|
||||
})
|
||||
```
|
||||
|
||||
→ status=done + started_at=ended_at=completed_at=now() 자동 → Activity 탭 즉시 반영 + 새로고침 유지.
|
||||
|
||||
## 검증되지 않은 HTTP behavior 항목 (PR-2 smoke 시 닫기)
|
||||
|
||||
PR-1 closure 시 schema/endpoint registration 만 자동 검증. 아래 5건은 frontend 호출하며 자연 검증:
|
||||
|
||||
1. POST `/api/events` kind=activity_log + title only → status=done + 시간 default 채워짐
|
||||
2. POST `/api/events/{id}/complete` 호출 → events_history row 1건 (change_kind=complete) 자동 생성
|
||||
3. PATCH `/api/events/{id}` 시간 필드 변경 → reschedule history 자동
|
||||
4. PATCH `/api/events/{id}` 금지 필드 (status 등) 시도 → 422 응답 (Pydantic extra=forbid)
|
||||
5. GET `/api/events/today?timezone=Asia/Seoul` → 오늘 (KST 기준) 항목만 반환, deferred 는 defer_until ≤ now 조건 만족 시만
|
||||
|
||||
## events_history 조회 (PR-2 history timeline)
|
||||
|
||||
PR-1 에는 history 조회 endpoint 없음. PR-2 시 상세 페이지 timeline 필요하면 `GET /api/events/{id}/history` 신규 추가 (간단 — events_history 테이블 ORDER BY changed_at). 별 endpoint 추가 시 본 문서 갱신.
|
||||
@@ -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