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.

329 lines
11 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/mail/Triage.tsx — 아리 비서 다이제스트(아리 정리 보기)
// 새 메일 중 '챙길 거리'를 발신자 카드로 정리: 요약 + 할 일/일정/답장 액션을 바로 적용.
"use client";
import { Icon } from "@/components/Icon";
import type { AiAnalysis, AiReply, EmailRow, ExtractKind, Person } from "@/lib/types";
const PALETTE = ["var(--blue)", "var(--violet)", "var(--coral)", "var(--green)", "var(--amber)"];
const PRIO_RANK: Record<string, number> = { : 0, : 1, : 2 };
// 기준 시각(ISO) → 사용자 로컬 표기('6월 18일 오후 2:30').
function fmtSince(iso?: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("ko-KR", {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
// 발신자 이름 → 안정적 색(메일마다 같은 발신자는 같은 색).
function senderColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
// 메일 한 통에 달린 액션 수(할 일 + 일정 + 답장 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,
color,
added,
onOpen,
onAdd,
onAddAll,
onReply,
}: {
m: EmailRow;
name: string;
color: 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;
}) {
const ai = m.ai;
if (!ai) return null;
const status = statusOf(ai);
const n = actionCount(ai);
const reply = ai.replies[0];
return (
<article className="sc-card">
<header className="sc-head">
<div className="sc-ava" style={{ background: color }}>
{name.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>
</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,
accColor,
added,
analyzing,
since,
onAnalyze,
onResetBaseline,
onOpen,
onAdd,
onAddAll,
onReply,
}: {
emails: EmailRow[];
people: Record<string, Person>;
accColor: (id: string) => string;
added: Record<string, boolean>;
analyzing: boolean;
since?: string;
onAnalyze: () => void;
onResetBaseline: () => 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;
}) {
const nameOf = (m: EmailRow) => people[m.from]?.name ?? m.from_name ?? m.from;
const colorOf = (m: EmailRow) =>
people[m.from]?.color ?? senderColor(nameOf(m)) ?? accColor(m.account);
// 새 메일 = 기준 시각 이후 도착(서버가 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;
return (
<div className="secretary">
<div className="sec-scroll" role="region" aria-label="아리 비서 정리" tabIndex={0}>
<header className="sec-hero">
<div className="sec-spark">
<Icon name="spark" />
</div>
<div className="sec-hero-main">
<div className="sec-eyebrow"> · </div>
<h2>
{cards.length > 0
? `새 메일 ${fresh.length}통 중 ${cards.length}건을 챙겨뒀어요`
: pending > 0
? `새 메일 ${pending}통, 정리해 드릴까요?`
: "지금은 챙길 새 메일이 없어요"}
</h2>
<p>
<b> </b> ·· .
, .
</p>
<div className="sec-hero-row">
{cards.length > 0 && (
<div className="sec-stats">
<span className="sec-stat task">
<Icon name="check" />
<b>{tot.tasks}</b>
</span>
<span className="sec-stat event">
<Icon name="cal" />
<b>{tot.events}</b>
</span>
<span className="sec-stat reply">
<Icon name="reply" />
<b>{tot.replies}</b>
</span>
</div>
)}
<button className="sec-analyze" onClick={onAnalyze} disabled={analyzing}>
<Icon name={analyzing ? "refresh" : "spark"} />
{analyzing
? "아리가 읽는 중…"
: pending > 0
? `새 메일 ${pending}통 정리 맡기기`
: "새 메일 확인하기"}
</button>
</div>
{since && (
<div className="sec-baseline">
<span>
<b>{fmtSince(since)}</b>
</span>
<button onClick={onResetBaseline} disabled={analyzing}>
</button>
</div>
)}
</div>
</header>
{cards.length === 0 ? (
<div className="sec-empty">
<div className="se-ico">
<Icon name={pending > 0 ? "spark" : "check"} />
</div>
{pending > 0 ? (
<>
<h3> {pending} </h3>
<p> ·· .</p>
</>
) : (
<>
<h3> </h3>
<p> ·· .</p>
</>
)}
</div>
) : (
<div className="sec-cards">
{cards.map((m) => (
<SecretaryCard
key={m.id}
m={m}
name={nameOf(m)}
color={colorOf(m)}
added={added}
onOpen={onOpen}
onAdd={onAdd}
onAddAll={onAddAll}
onReply={onReply}
/>
))}
</div>
)}
</div>
</div>
);
}