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