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