You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
221 lines
7.3 KiB
TypeScript
221 lines
7.3 KiB
TypeScript
// frontend/components/calendar/EvEditor.tsx — 새 일정 생성/편집 모달
|
|
"use client";
|
|
import { useState } from "react";
|
|
import { Icon } from "@/components/Icon";
|
|
import { calendarApi } from "@/lib/calendar/api";
|
|
import { addHour } from "@/lib/calendar/time";
|
|
import type { Calendar, CalEvent, EventWrite } from "@/lib/calendar/types";
|
|
|
|
// 쓰기 가능한 캘린더(Google = gcal-*, Outlook = ocal-*, 로컬 = local).
|
|
function writableCalendars(cals: Calendar[]): Calendar[] {
|
|
const ok = cals.filter(
|
|
(c) => c.id.startsWith("gcal-") || c.id.startsWith("ocal-") || c.id === "local",
|
|
);
|
|
return ok.length ? ok : cals;
|
|
}
|
|
|
|
export function EvEditor({
|
|
calendars,
|
|
initial,
|
|
defaultDate,
|
|
defaultTime,
|
|
onClose,
|
|
onSaved,
|
|
onDeleted,
|
|
}: {
|
|
calendars: Calendar[];
|
|
initial?: CalEvent | null; // 있으면 편집, 없으면 생성
|
|
defaultDate: string; // 생성 시 기본 날짜(ISO)
|
|
defaultTime?: string; // 생성 시 기본 시작시각("HH:MM", 슬롯 클릭)
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
onDeleted?: () => void;
|
|
}) {
|
|
const editing = !!initial;
|
|
const writable = writableCalendars(calendars);
|
|
const [title, setTitle] = useState(initial?.title ?? "");
|
|
const [date, setDate] = useState(initial?.date || defaultDate);
|
|
const [allDay, setAllDay] = useState(editing ? !initial?.start : false);
|
|
const [start, setStart] = useState(initial?.start || defaultTime || "09:00");
|
|
const [end, setEnd] = useState(
|
|
initial?.end || (defaultTime ? addHour(defaultTime, 1) : "10:00"),
|
|
);
|
|
const [loc, setLoc] = useState(initial?.loc ?? "");
|
|
const [note, setNote] = useState(initial?.note ?? "");
|
|
const [cal, setCal] = useState(initial?.cal || writable[0]?.id || "");
|
|
const [people, setPeople] = useState((initial?.people ?? []).join(", "));
|
|
const [rrule, setRrule] = useState(initial?.rrule ?? "");
|
|
const [reminder, setReminder] = useState<number>(initial?.reminders?.[0] ?? -1); // -1 = 없음
|
|
const [busy, setBusy] = useState(false);
|
|
const [err, setErr] = useState("");
|
|
|
|
const save = async () => {
|
|
if (!title.trim()) {
|
|
setErr("제목을 입력하세요");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setErr("");
|
|
const body: EventWrite = {
|
|
title: title.trim(),
|
|
date,
|
|
start: allDay ? "" : start,
|
|
end: allDay ? "" : end,
|
|
loc: loc.trim(),
|
|
note: note.trim(),
|
|
people: people
|
|
.split(",")
|
|
.map((p) => p.trim())
|
|
.filter(Boolean),
|
|
cal,
|
|
rrule,
|
|
reminders: reminder >= 0 ? [reminder] : [],
|
|
};
|
|
try {
|
|
if (editing && initial) await calendarApi.updateEvent(initial.id, body);
|
|
else await calendarApi.createEvent(body);
|
|
onSaved();
|
|
} catch (e) {
|
|
setErr(e instanceof Error ? e.message : "저장하지 못했어요");
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const del = async () => {
|
|
if (!initial) return;
|
|
setBusy(true);
|
|
setErr("");
|
|
try {
|
|
await calendarApi.deleteEvent(initial.id);
|
|
onDeleted?.();
|
|
} catch (e) {
|
|
setErr(e instanceof Error ? e.message : "삭제하지 못했어요");
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="dp-backdrop" onClick={busy ? undefined : onClose} />
|
|
<div className="eed" role="dialog" aria-modal="true" aria-label={editing ? "일정 편집" : "새 일정"}>
|
|
<div className="eed-head">
|
|
<h2>{editing ? "일정 편집" : "새 일정"}</h2>
|
|
<button className="eed-x" aria-label="닫기" onClick={onClose} disabled={busy}>
|
|
<Icon name="x" />
|
|
</button>
|
|
</div>
|
|
|
|
<label className="eed-field">
|
|
<span>제목</span>
|
|
<input
|
|
autoFocus
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="일정 제목"
|
|
/>
|
|
</label>
|
|
|
|
<label className="eed-field">
|
|
<span>날짜</span>
|
|
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
|
</label>
|
|
|
|
<label className="eed-check">
|
|
<input type="checkbox" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} />
|
|
<span>종일</span>
|
|
</label>
|
|
|
|
{!allDay && (
|
|
<div className="eed-times">
|
|
<label className="eed-field">
|
|
<span>시작</span>
|
|
<input type="time" value={start} onChange={(e) => setStart(e.target.value)} />
|
|
</label>
|
|
<label className="eed-field">
|
|
<span>종료</span>
|
|
<input type="time" value={end} onChange={(e) => setEnd(e.target.value)} />
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
<label className="eed-field">
|
|
<span>장소</span>
|
|
<input value={loc} onChange={(e) => setLoc(e.target.value)} placeholder="(선택)" />
|
|
</label>
|
|
|
|
<label className="eed-field">
|
|
<span>캘린더</span>
|
|
<select value={cal} onChange={(e) => setCal(e.target.value)}>
|
|
{writable.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<div className="eed-times">
|
|
<label className="eed-field">
|
|
<span>반복</span>
|
|
<select value={rrule} onChange={(e) => setRrule(e.target.value)}>
|
|
<option value="">반복 안 함</option>
|
|
<option value="FREQ=DAILY">매일</option>
|
|
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">주중 매일(평일)</option>
|
|
<option value="FREQ=WEEKLY">매주</option>
|
|
<option value="FREQ=MONTHLY">매월</option>
|
|
<option value="FREQ=YEARLY">매년</option>
|
|
</select>
|
|
</label>
|
|
<label className="eed-field">
|
|
<span>알림</span>
|
|
<select value={reminder} onChange={(e) => setReminder(Number(e.target.value))}>
|
|
<option value={-1}>없음</option>
|
|
<option value={0}>시작 시각</option>
|
|
<option value={5}>5분 전</option>
|
|
<option value={10}>10분 전</option>
|
|
<option value={15}>15분 전</option>
|
|
<option value={30}>30분 전</option>
|
|
<option value={60}>1시간 전</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<label className="eed-field">
|
|
<span>참석자</span>
|
|
<input
|
|
value={people}
|
|
onChange={(e) => setPeople(e.target.value)}
|
|
placeholder="이메일 — 쉼표로 구분 (선택)"
|
|
/>
|
|
</label>
|
|
|
|
<label className="eed-field">
|
|
<span>메모</span>
|
|
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} placeholder="(선택)" />
|
|
</label>
|
|
|
|
{err && (
|
|
<div className="eed-err" role="alert">
|
|
{err}
|
|
</div>
|
|
)}
|
|
|
|
<div className="eed-acts">
|
|
{editing && (
|
|
<button className="eed-del" onClick={del} disabled={busy}>
|
|
<Icon name="trash" /> 삭제
|
|
</button>
|
|
)}
|
|
<span className="eed-spacer" />
|
|
<button className="eed-cancel" onClick={onClose} disabled={busy}>
|
|
취소
|
|
</button>
|
|
<button className="eed-save" onClick={save} disabled={busy}>
|
|
{busy ? "저장 중…" : editing ? "저장" : "추가"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|