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.

358 lines
10 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// frontend/components/calendar/MeetDrawer.tsx — 회의 도우미 드로어 (done/live/upcoming/1:1)
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { Icon } from "@/components/Icon";
import type { IconName } from "@/components/icons/paths";
import { calendarApi } from "@/lib/calendar/api";
import type { CalEvent, Calendar, Meeting, MeetingAction } from "@/lib/calendar/types";
function Sec({
icon,
title,
hint,
children,
}: {
icon: IconName;
title: string;
hint?: string;
children: ReactNode;
}) {
return (
<section className="md-sec">
<div className="md-sh">
<Icon name={icon} />
<b>{title}</b>
{hint && <span className="md-hint">{hint}</span>}
</div>
{children}
</section>
);
}
/* 액션 아이템 한 줄 — '작업으로' 보내기 */
function ActionRow({ a, onAdd }: { a: MeetingAction; onAdd: () => void }) {
return (
<div className={"md-act" + (a.added ? " added" : "")}>
<span className="md-act-check">
<Icon name="tick" w={3} />
</span>
<span className="md-act-text">{a.text}</span>
<span className="md-act-who">
{a.who} · {a.when}
</span>
{a.added ? (
<span className="md-act-done">
<Icon name="tick" w={2.6} />
</span>
) : (
<button className="md-act-add" onClick={onAdd}>
<Icon name="plus" />
</button>
)}
</div>
);
}
/* ---- 1:1 — 지난 대화 · 약속 · 시그널 · 대화 주제 ---- */
function OneOnOneBody({ meet }: { meet: Meeting }) {
return (
<>
<Sec icon="msg" title="지난 1:1 요약" hint={meet.last_meeting.when}>
<p className="md-note">{meet.last_meeting.note}</p>
</Sec>
<Sec icon="flag" title="지난번 약속한 것">
{meet.promises.map((p, i) => (
<div key={i} className={"md-chk" + (p.done ? " done" : "")}>
<span className="md-cbx">
<Icon name="tick" w={3} />
</span>
{p.text}
{!p.done && <span className="md-tag warn"></span>}
</div>
))}
</Sec>
<Sec icon="zap" title={meet.person.name + "님 시그널"} hint="아리가 작업·메일에서 정리">
{meet.signals.map((s, i) => (
<div key={i} className="md-sig">
<span className="md-sig-dot" style={{ background: `var(--${s.tone})` }} />
<span className="md-sig-k">{s.k}</span>
<span className="md-sig-v">{s.v}</span>
</div>
))}
</Sec>
<Sec icon="spark" title="이런 이야기를 나눠보세요">
<ol className="md-points">
{meet.talking_points.map((t, i) => (
<li key={i}>{t}</li>
))}
</ol>
</Sec>
</>
);
}
/* ---- 예정 회의 — 안건 · 지난 회의 · 지난 액션 · 인사이트 ---- */
function UpcomingBody({ meet }: { meet: Meeting }) {
const doneCount = meet.last_actions.filter((a) => a.done).length;
return (
<>
<Sec icon="list" title="오늘 안건">
<ol className="md-points">
{meet.agenda.map((a, i) => (
<li key={i}>{a}</li>
))}
</ol>
</Sec>
<Sec icon="msg" title="지난 회의에서" hint={meet.last_meeting.when}>
<p className="md-note">{meet.last_meeting.note}</p>
</Sec>
<Sec
icon="target"
title="지난 액션 아이템"
hint={doneCount + "/" + meet.last_actions.length + " 완료"}
>
{meet.last_actions.map((a, i) => (
<div key={i} className={"md-chk" + (a.done ? " done" : "")}>
<span className="md-cbx">
<Icon name="tick" w={3} />
</span>
{a.text}
<span className="md-who">{a.who}</span>
{!a.done && <span className="md-tag warn"></span>}
</div>
))}
</Sec>
<Sec icon="spark" title="아리 인사이트">
{meet.insights.map((t, i) => (
// 신뢰된 시드 데이터 (<b> 강조). overview §15 화이트리스트 준수.
<p key={i} className="md-insight" dangerouslySetInnerHTML={{ __html: t }} />
))}
</Sec>
{meet.docs.length > 0 && (
<div className="md-docs">
{meet.docs.map((d, i) => (
<span key={i} className="md-doc">
<Icon name="file" />
{d}
</span>
))}
</div>
)}
</>
);
}
/* ---- 끝난/진행 중 회의 — 요약 · 결정 · 액션 추출 ---- */
function DoneLiveBody({
meet,
remaining,
onAddOne,
onAddAll,
}: {
meet: Meeting;
remaining: number;
onAddOne: (idx: number) => void;
onAddAll: () => void;
}) {
const live = meet.phase === "live";
return (
<>
<Sec
icon={live ? "mic" : "msg"}
title={live ? "지금까지 논의" : "핵심 논의 요약"}
hint={live ? "실시간" : "음성 기록 기반"}
>
<ul className="md-sum">
{meet.summary.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
</Sec>
{meet.decisions.length > 0 && (
<Sec icon="flag" title="결정 사항">
{meet.decisions.map((d, i) => (
<div key={i} className="md-decision">
<Icon name="tick" w={2.6} />
{d}
</div>
))}
</Sec>
)}
<Sec icon="target" title="액션 아이템" hint="아리가 대화에서 추출">
{meet.actions.map((a) => (
<ActionRow key={a.idx} a={a} onAdd={() => onAddOne(a.idx)} />
))}
{remaining > 0 ? (
<button className="md-addall" onClick={onAddAll}>
<Icon name="check" />
{remaining}
</button>
) : (
<div className="md-allok">
<Icon name="tick" w={2.6} />
</div>
)}
</Sec>
</>
);
}
export function MeetDrawer({
ev,
cal,
onClose,
}: {
ev: CalEvent;
cal: Calendar;
onClose: () => void;
}) {
const [meet, setMeet] = useState<Meeting | null>(null);
const [err, setErr] = useState(false);
useEffect(() => {
let active = true;
setMeet(null);
setErr(false);
calendarApi
.meeting(ev.id)
.then((m) => active && setMeet(m))
.catch(() => active && setErr(true));
return () => {
active = false;
};
}, [ev.id]);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const addOne = async (idx: number) => {
// 낙관적 업데이트 후 federation → 작업 페이지에 등장
setMeet((m) =>
m ? { ...m, actions: m.actions.map((a) => (a.idx === idx ? { ...a, added: true } : a)) } : m,
);
await calendarApi.materialize(ev.id, idx);
};
const addAll = async () => {
setMeet((m) => (m ? { ...m, actions: m.actions.map((a) => ({ ...a, added: true })) } : m));
await calendarApi.materializeAll(ev.id);
};
if (err) {
return (
<>
<div className="dp-backdrop" onClick={onClose} />
<aside className="mdrawer" role="dialog" aria-label="회의 도우미" aria-modal="true">
<div className="md-head">
<div className="md-ht">
<b> </b>
</div>
<button className="md-x" onClick={onClose} aria-label="닫기">
<Icon name="x" />
</button>
</div>
</aside>
</>
);
}
if (!meet) {
return (
<>
<div className="dp-backdrop" onClick={onClose} />
<aside
className="mdrawer"
role="dialog"
aria-label="회의 도우미"
aria-modal="true"
aria-busy="true"
>
<div className="md-head">
<div className="md-ht">
<b>{ev.title}</b>
</div>
<button className="md-x" onClick={onClose} aria-label="닫기">
<Icon name="x" />
</button>
</div>
</aside>
</>
);
}
const tone = meet.one_on_one && meet.person.tone ? meet.person.tone : cal.tone;
const remaining = meet.actions.filter((a) => !a.added).length;
const phaseBadge =
meet.phase === "done" ? (
<span className="md-phase done">
<Icon name="tick" w={2.6} />
</span>
) : meet.phase === "live" ? (
<span className="md-phase live">
<span className="md-pulse" />
</span>
) : (
<span className="md-phase up">
<Icon name="clock" />
{meet.starts_in}
</span>
);
const kicker = meet.one_on_one
? "1:1 어시스턴트"
: meet.phase === "upcoming"
? "사전 브리핑"
: "회의 도우미";
return (
<>
<div className="dp-backdrop" onClick={onClose} />
<aside
className={"mdrawer tint-" + tone}
role="dialog"
aria-label="회의 도우미"
aria-modal="true"
>
<div className="md-head">
<div className="md-ic">
<Icon name={meet.one_on_one ? "heart" : "spark"} />
</div>
<div className="md-ht">
<span className="md-k">{kicker}</span>
<b>{ev.title}</b>
<span className="md-sub">
{ev.start}{ev.end}
{ev.loc ? " · " + ev.loc : ""} · {cal.name}
</span>
</div>
<button className="md-x" onClick={onClose} aria-label="닫기">
<Icon name="x" />
</button>
</div>
<div className="md-bar">
{phaseBadge}
{meet.meta && <span className="md-meta">{meet.meta}</span>}
</div>
<div className="md-body">
{meet.one_on_one && <OneOnOneBody meet={meet} />}
{!meet.one_on_one && meet.phase === "upcoming" && <UpcomingBody meet={meet} />}
{!meet.one_on_one && meet.phase !== "upcoming" && (
<DoneLiveBody meet={meet} remaining={remaining} onAddOne={addOne} onAddAll={addAll} />
)}
</div>
</aside>
</>
);
}