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.

319 lines
10 KiB
TypeScript

// frontend/components/mail/Triage.tsx — 아리 비서 다이제스트(아리 정리 보기)
// 새 메일 중 '챙길 거리'를 발신자 카드로 정리: 요약 + 할 일/일정/답장 액션을 바로 적용.
"use client";
import { Icon } from "@/components/Icon";
import type {
AiAnalysis,
AiReply,
EmailRow,
ExtractKind,
MailAccount,
Person,
} from "@/lib/types";
const PRIO_RANK: Record<string, number> = { : 0, : 1, : 2 };
// 메일 한 통에 달린 액션 수(할 일 + 일정 + 답장 1).
function actionCount(ai: AiAnalysis): number {
return ai.tasks.length + ai.events.length + (ai.replies.length ? 1 : 0);
}
// 카드 상태 배지 — 우선순위·액션 종류에서 비서 톤으로 산출.
function statusOf(ai: AiAnalysis): { text: string; tone: string } {
if (ai.priority === "높음") return { text: "중요 · 액션 필요", tone: "coral" };
if (ai.events.length) return { text: "일정 · 준비 필요", tone: "violet" };
if (ai.tasks.length) return { text: "할 일 · 확인 필요", tone: "blue" };
if (ai.replies.length) return { text: "답장 추천", tone: "green" };
return { text: "검토 필요", tone: "muted" };
}
function ActionRow({
icon,
kind,
title,
meta,
done,
onAdd,
}: {
icon: "check" | "cal";
kind: "task" | "event";
title: string;
meta: string;
done: boolean;
onAdd: () => void;
}) {
return (
<div className={"sc-item " + kind}>
<div className={"si-ico " + kind}>
<Icon name={icon} />
</div>
<div className="si-body">
<b>{title}</b>
{meta && <span className="si-meta">{meta}</span>}
</div>
<button
className={"si-add" + (done ? " done" : "")}
onClick={onAdd}
disabled={done}
aria-label={done ? "추가됨" : kind === "task" ? "할 일로 추가" : "일정에 추가"}
>
<Icon name={done ? "check" : "plus"} />
</button>
</div>
);
}
function SecretaryCard({
m,
name,
account,
accColor,
added,
onOpen,
onAdd,
onAddAll,
onReply,
onDismiss,
}: {
m: EmailRow;
name: string;
account?: MailAccount;
accColor: string;
added: Record<string, boolean>;
onOpen: (id: string) => void;
onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void;
onAddAll: (m: EmailRow) => void;
onReply: (m: EmailRow, idx: number, r: AiReply) => void;
onDismiss: (id: string) => void;
}) {
const ai = m.ai;
if (!ai) return null;
const status = statusOf(ai);
const n = actionCount(ai);
const reply = ai.replies[0];
// 아바타는 받은편지함 행과 동일하게 '어느 계정'(발신자 아님) — 계정 색 + 계정 이니셜.
const accName = account?.name ?? m.account;
const avaColor = accColor ?? (account?.tone ? `var(--${account.tone})` : "var(--muted)");
return (
<article className="sc-card">
<header className="sc-head">
<div
className="sc-ava"
style={{ background: avaColor }}
title={`${accName} · ${account?.email ?? ""}`}
>
{accName.slice(0, 1).toUpperCase()}
</div>
<div className="sc-who">
<b>{name}</b>
<span className={"sc-status " + status.tone}>{status.text}</span>
</div>
<button className="sc-open" onClick={() => onOpen(m.id)} aria-label="메일 열기">
<Icon name="arrow" />
</button>
<button
className="sc-dismiss"
onClick={() => onDismiss(m.id)}
aria-label="정리에서 빼기"
title="이 메일은 정리에서 빼기(받은편지함엔 남아요)"
>
<Icon name="x" />
</button>
</header>
<h3 className="sc-title" onClick={() => onOpen(m.id)}>
{ai.summary || m.subject}
</h3>
{ai.summary && m.subject !== ai.summary && <p className="sc-sub">{m.subject}</p>}
{n > 0 && (
<div className="sc-actions">
{ai.tasks.map((t, i) => (
<ActionRow
key={"t" + i}
icon="check"
kind="task"
title={t.text}
meta={[t.due, t.project].filter(Boolean).join(" · ")}
done={!!added["t:" + m.id + ":" + i]}
onAdd={() => onAdd(m.id, "t:" + m.id + ":" + i, "task", i)}
/>
))}
{ai.events.map((e, i) => (
<ActionRow
key={"e" + i}
icon="cal"
kind="event"
title={e.title}
meta={[e.date && `${e.date}${e.day ? ` (${e.day})` : ""}`, e.time, e.place]
.filter(Boolean)
.join(" · ")}
done={!!added["e:" + m.id + ":" + i]}
onAdd={() => onAdd(m.id, "e:" + m.id + ":" + i, "event", i)}
/>
))}
{reply && (
<button className="sc-item reply" onClick={() => onReply(m, 0, reply)}>
<div className="si-ico reply">
<Icon name="reply" />
</div>
<div className="si-body">
<b> · {reply.tone}</b>
<span className="si-meta">{reply.preview}</span>
</div>
<Icon name="arrow" />
</button>
)}
</div>
)}
{n > 1 && (
<button className="sc-applyall" onClick={() => onAddAll(m)}>
<Icon name="zap" />
{n}
</button>
)}
</article>
);
}
export function Triage({
emails,
people,
accounts,
accColor,
added,
newTotal,
onOpenLog,
onOpen,
onAdd,
onAddAll,
onReply,
onDismiss,
}: {
emails: EmailRow[];
people: Record<string, Person>;
accounts: Record<string, MailAccount>;
accColor: (id: string) => string;
added: Record<string, boolean>;
newTotal: number;
onOpenLog: () => void;
onOpen: (id: string) => void;
onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void;
onAddAll: (m: EmailRow) => void;
onReply: (m: EmailRow, idx: number, r: AiReply) => void;
onDismiss: (id: string) => void;
}) {
const nameOf = (m: EmailRow) => people[m.from]?.name ?? m.from_name ?? m.from;
// 새 메일 = 기준 시각 이후 도착(서버가 smart 보기로 이미 필터). 읽음 여부와 무관 —
// 사용자가 폰/다른 앱에서 먼저 읽어도 아리가 챙길 거리를 정리한다.
const fresh = emails.filter((m) => m.ai);
// 챙길 거리: 할 일/일정/답장이 있거나 중요(높음)인 새 메일.
const cards = fresh
.filter((m) => m.ai && (actionCount(m.ai) > 0 || m.ai.priority === "높음"))
.sort((a, b) => {
const pa = PRIO_RANK[a.ai!.priority] ?? 1;
const pb = PRIO_RANK[b.ai!.priority] ?? 1;
if (pa !== pb) return pa - pb;
return actionCount(b.ai!) - actionCount(a.ai!);
});
const tot = cards.reduce(
(acc, m) => {
acc.tasks += m.ai!.tasks.length;
acc.events += m.ai!.events.length;
acc.replies += m.ai!.replies.length ? 1 : 0;
return acc;
},
{ tasks: 0, events: 0, replies: 0 },
);
// 아직 아리가 정리하지 않은(분석 전) 새 메일 수.
const pending = emails.filter((m) => !m.ai).length;
const hasStats = tot.tasks + tot.events + tot.replies > 0;
// 아리가 백그라운드로 자동 정리 중인 상태(수동 버튼 없음 — auto-sync 루프가 알아서 분석).
const working = pending > 0;
// 화면 상태 → 제목 한 줄 + 설명 한 줄. 같은 말을 두 번 하지 않도록 히어로 한 곳에서만 보여준다.
let title: string;
let sub: string;
if (cards.length > 0) {
title = `챙길 메일 ${cards.length}건을 추렸어요`;
sub = `새 메일 ${fresh.length}통에서 챙길 거리만 골랐어요.`;
} else if (working) {
title = `새 메일 ${pending}통, 아리가 정리하는 중…`;
sub = "잠시 후 챙길 거리만 카드로 정리해 둘게요.";
} else if (newTotal > 0) {
title = `오늘 온 새 메일 ${newTotal}통, 챙길 건 없었어요`;
sub = "챙길 게 생기면 할 일·일정·답장 카드로 여기 정리해 둘게요.";
} else {
title = "지금은 챙길 새 메일이 없어요";
sub = "새 메일이 오면 아리가 할 일·일정·답장으로 정리해 둘게요.";
}
return (
<div className="secretary">
<div className="sec-scroll" role="region" aria-label="아리 비서 정리" tabIndex={0}>
<header className="sec-hero">
<div className={"sec-spark" + (working ? " working" : "")}>
<Icon name={working ? "refresh" : "spark"} />
</div>
<div className="sec-hero-main">
<h2>{title}</h2>
<p>{sub}</p>
{hasStats && (
<div className="sec-stats">
{tot.tasks > 0 && (
<span className="sec-stat task">
<Icon name="check" />
<b>{tot.tasks}</b>
</span>
)}
{tot.events > 0 && (
<span className="sec-stat event">
<Icon name="cal" />
<b>{tot.events}</b>
</span>
)}
{tot.replies > 0 && (
<span className="sec-stat reply">
<Icon name="reply" />
<b>{tot.replies}</b>
</span>
)}
</div>
)}
{/* 오늘 새 메일이 어떻게 분류/분석됐는지 상세는 다이얼로그로. */}
<button className="sec-loglink" onClick={onOpenLog}>
<Icon name="list" />
</button>
</div>
</header>
{cards.length > 0 && (
<div className="sec-cards">
{cards.map((m) => (
<SecretaryCard
key={m.id}
m={m}
name={nameOf(m)}
account={accounts[m.account]}
accColor={accColor(m.account)}
added={added}
onOpen={onOpen}
onAdd={onAdd}
onAddAll={onAddAll}
onReply={onReply}
onDismiss={onDismiss}
/>
))}
</div>
)}
</div>
</div>
);
}