|
|
// frontend/components/calendar/EvPopover.tsx — 이벤트 상세 팝오버
|
|
|
"use client";
|
|
|
import { useState } from "react";
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
import { fmt } from "@/lib/calendar/time";
|
|
|
import type { Attendee, CalEvent, Calendar } from "@/lib/calendar/types";
|
|
|
|
|
|
function rruleLabel(r: string): string {
|
|
|
if (!r) return "";
|
|
|
if (r.includes("DAILY")) return "매일 반복";
|
|
|
if (r.includes("BYDAY=MO,TU,WE,TH,FR")) return "평일 반복";
|
|
|
if (r.includes("WEEKLY")) return "매주 반복";
|
|
|
if (r.includes("MONTHLY")) return "매월 반복";
|
|
|
if (r.includes("YEARLY")) return "매년 반복";
|
|
|
return "반복 일정";
|
|
|
}
|
|
|
function reminderLabel(mins: number[]): string {
|
|
|
if (!mins.length) return "";
|
|
|
return mins.map((m) => (m === 0 ? "시작 시각" : `${m}분 전`)).join(", ") + " 알림";
|
|
|
}
|
|
|
const STATUS_KO: Record<string, string> = {
|
|
|
accepted: "수락",
|
|
|
declined: "거절",
|
|
|
tentative: "미정",
|
|
|
needsAction: "대기",
|
|
|
notResponded: "대기",
|
|
|
none: "",
|
|
|
};
|
|
|
|
|
|
export function EvPopover({
|
|
|
ev,
|
|
|
x,
|
|
|
y,
|
|
|
cal,
|
|
|
onClose,
|
|
|
onMeet,
|
|
|
onEdit,
|
|
|
onDelete,
|
|
|
onAddToTasks,
|
|
|
onRsvp,
|
|
|
}: {
|
|
|
ev: CalEvent;
|
|
|
x: number;
|
|
|
y: number;
|
|
|
cal: Calendar;
|
|
|
onClose: () => void;
|
|
|
onMeet: (ev: CalEvent) => void;
|
|
|
onEdit: (ev: CalEvent) => void;
|
|
|
onDelete: (ev: CalEvent) => void;
|
|
|
onAddToTasks: (ev: CalEvent) => Promise<void>;
|
|
|
onRsvp: (ev: CalEvent, status: "accepted" | "declined" | "tentative") => Promise<void>;
|
|
|
}) {
|
|
|
// 작업 추가 상태: idle → busy → done | error
|
|
|
const [addState, setAddState] = useState<"idle" | "busy" | "done" | "error">("idle");
|
|
|
const addToTasks = async () => {
|
|
|
if (addState === "busy" || addState === "done") return;
|
|
|
setAddState("busy");
|
|
|
try {
|
|
|
await onAddToTasks(ev);
|
|
|
setAddState("done");
|
|
|
} catch {
|
|
|
setAddState("error");
|
|
|
}
|
|
|
};
|
|
|
let left = x;
|
|
|
let top = y;
|
|
|
if (typeof window !== "undefined") {
|
|
|
if (left + 300 > window.innerWidth - 12) left = window.innerWidth - 312;
|
|
|
if (top + 280 > window.innerHeight - 12) top = window.innerHeight - 292;
|
|
|
}
|
|
|
return (
|
|
|
<>
|
|
|
<div className="dp-backdrop" onClick={onClose} />
|
|
|
<div className={"epop tint-" + cal.tone} style={{ left: left + "px", top: top + "px" }}>
|
|
|
<div className="epop-bar" />
|
|
|
<div className="epop-title">{ev.title}</div>
|
|
|
<div className="epop-row">
|
|
|
<Icon name="clock" /> {fmt(ev.start)} – {fmt(ev.end)}
|
|
|
</div>
|
|
|
<div className="epop-row">
|
|
|
<Icon name="cal" />{" "}
|
|
|
<span className="epop-cat">
|
|
|
<span className="pdot" /> {cal.name}
|
|
|
</span>
|
|
|
</div>
|
|
|
{ev.loc && (
|
|
|
<div className="epop-row">
|
|
|
<Icon name="pin" /> {ev.loc}
|
|
|
</div>
|
|
|
)}
|
|
|
{ev.rrule && (
|
|
|
<div className="epop-row">
|
|
|
<Icon name="repeat" /> {rruleLabel(ev.rrule)}
|
|
|
</div>
|
|
|
)}
|
|
|
{ev.reminders.length > 0 && (
|
|
|
<div className="epop-row">
|
|
|
<Icon name="bell" /> {reminderLabel(ev.reminders)}
|
|
|
</div>
|
|
|
)}
|
|
|
{ev.attendees.length > 0 ? (
|
|
|
<div className="epop-attendees">
|
|
|
<div className="epop-row">
|
|
|
<Icon name="users" /> 참석자 {ev.attendees.length}명
|
|
|
</div>
|
|
|
{ev.attendees.map((a: Attendee, i) => (
|
|
|
<div className="epop-att" key={i}>
|
|
|
<span className="epa-name">{a.name || a.email}</span>
|
|
|
{STATUS_KO[a.status] && (
|
|
|
<span className={"epa-status st-" + a.status}>{STATUS_KO[a.status]}</span>
|
|
|
)}
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
) : (
|
|
|
ev.people.length > 0 && (
|
|
|
<div className="epop-row">
|
|
|
<Icon name="users" /> {ev.people.join(", ")}
|
|
|
</div>
|
|
|
)
|
|
|
)}
|
|
|
{ev.attendees.length > 0 && (
|
|
|
<div className="epop-rsvp">
|
|
|
<span className="rsvp-label">내 응답</span>
|
|
|
{(["accepted", "tentative", "declined"] as const).map((st) => (
|
|
|
<button
|
|
|
key={st}
|
|
|
className={"rsvp-btn st-" + st + (ev.response_status === st ? " on" : "")}
|
|
|
onClick={() => onRsvp(ev, st)}
|
|
|
>
|
|
|
{STATUS_KO[st]}
|
|
|
</button>
|
|
|
))}
|
|
|
</div>
|
|
|
)}
|
|
|
{ev.note && <div className="epop-note">{ev.note}</div>}
|
|
|
{ev.actions.length > 0 && (
|
|
|
<div className="epop-actions">
|
|
|
<div className="epa-head">
|
|
|
<Icon name="spark" />
|
|
|
내가 맡기로 한 일<span className="n">{ev.actions.length}</span>
|
|
|
</div>
|
|
|
{ev.actions.map((a, i) => (
|
|
|
<div className="epa-row" key={i}>
|
|
|
<span className="epa-check">
|
|
|
<Icon name="tick" w={3} />
|
|
|
</span>
|
|
|
<span className="epa-text">{a.text}</span>
|
|
|
<span className="epa-who">{a.when}</span>
|
|
|
</div>
|
|
|
))}
|
|
|
<button
|
|
|
className={"epa-add" + (addState === "done" ? " done" : "")}
|
|
|
onClick={addToTasks}
|
|
|
disabled={addState === "busy" || addState === "done"}
|
|
|
>
|
|
|
<Icon name="check" />
|
|
|
{addState === "done"
|
|
|
? "작업에 추가됨"
|
|
|
: addState === "busy"
|
|
|
? "추가 중…"
|
|
|
: addState === "error"
|
|
|
? "다시 시도"
|
|
|
: "내 작업에 추가"}
|
|
|
</button>
|
|
|
</div>
|
|
|
)}
|
|
|
{ev.has_meeting && (
|
|
|
<button className="meet-open" onClick={() => onMeet(ev)}>
|
|
|
<Icon name="spark" />
|
|
|
{ev.meet_label}
|
|
|
</button>
|
|
|
)}
|
|
|
<div className="epop-acts">
|
|
|
<button className="edit" onClick={() => onEdit(ev)}>
|
|
|
<Icon name="pen" /> 편집
|
|
|
</button>
|
|
|
<button className="del" aria-label="삭제" onClick={() => onDelete(ev)}>
|
|
|
<Icon name="trash" />
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</>
|
|
|
);
|
|
|
}
|