|
|
// 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>
|
|
|
</>
|
|
|
);
|
|
|
}
|