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.
189 lines
6.8 KiB
TypeScript
189 lines
6.8 KiB
TypeScript
// frontend/components/mail/TriageLogModal.tsx — '오늘 메일 상세 로그' 다이얼로그(히어로 버튼으로 열림)
|
|
// 정직성: 집중/홍보/스팸 '분류'는 Gmail·Outlook 이 한 것. 아리가 한 일은 '집중 메일 분석'뿐.
|
|
"use client";
|
|
import { useEffect } from "react";
|
|
import useSWR from "swr";
|
|
import { Icon } from "@/components/Icon";
|
|
import { mailApi, type TriageLogEntry } from "@/lib/mail/api";
|
|
import type { MailAccount } from "@/lib/types";
|
|
|
|
// ISO → 'HH:MM'(로컬).
|
|
function fmtTime(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
return d.toLocaleTimeString("ko-KR", { hour: "numeric", minute: "2-digit" });
|
|
}
|
|
|
|
// 분류 버킷 → 칩 색 클래스 / 표시 순서.
|
|
const BUCKET_TONE: Record<string, string> = {
|
|
집중: "focus",
|
|
홍보: "promo",
|
|
소셜: "social",
|
|
업데이트: "update",
|
|
스팸: "spam",
|
|
휴지통: "muted",
|
|
보관: "muted",
|
|
기타: "muted",
|
|
};
|
|
const BUCKET_ORDER = ["집중", "홍보", "소셜", "업데이트", "스팸", "휴지통", "보관", "기타"];
|
|
|
|
export function TriageLogModal({
|
|
open,
|
|
onClose,
|
|
log,
|
|
accounts,
|
|
accColor,
|
|
onOpenMail,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
log: TriageLogEntry[];
|
|
accounts: Record<string, MailAccount>;
|
|
accColor: (id: string) => string;
|
|
onOpenMail: (id: string) => void;
|
|
}) {
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [open, onClose]);
|
|
|
|
// 열렸을 때만 상세 분류를 가져온다.
|
|
const { data: rows } = useSWR(
|
|
open ? "mail/triage/classified" : null,
|
|
() => {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
return mailApi.triageClassified(d.toISOString());
|
|
},
|
|
{ revalidateOnFocus: false },
|
|
);
|
|
|
|
if (!open) return null;
|
|
const list = rows ?? [];
|
|
const counts: Record<string, number> = {};
|
|
list.forEach((r) => (counts[r.bucket] = (counts[r.bucket] ?? 0) + 1));
|
|
const buckets = BUCKET_ORDER.filter((b) => counts[b]);
|
|
|
|
return (
|
|
<div
|
|
className="tlog-overlay"
|
|
onClick={onClose}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="오늘 메일 상세 로그"
|
|
>
|
|
<div className="tlog-panel" onClick={(e) => e.stopPropagation()}>
|
|
<header className="tlog-head">
|
|
<div className="tlog-title">
|
|
<Icon name="list" /> 오늘 메일 상세 로그
|
|
</div>
|
|
<button className="tlog-close" onClick={onClose} aria-label="닫기">
|
|
<Icon name="x" />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="tlog-body">
|
|
{/* 아리가 실제로 한 일 = 집중 메일 LLM 분석(분류 아님). */}
|
|
<section className="tlog-sec">
|
|
<h4>아리가 분석한 집중 메일</h4>
|
|
{log.length === 0 ? (
|
|
<p className="tlog-muted">오늘 아리가 분석한 집중 메일이 아직 없어요.</p>
|
|
) : (
|
|
<ul className="tlog-events">
|
|
{log.map((e, i) => (
|
|
<li key={i} className={e.error ? "err" : ""}>
|
|
<time>{fmtTime(e.at)}</time>
|
|
<span>
|
|
{e.error
|
|
? `분석 실패 · ${e.error}`
|
|
: `집중 메일 ${e.analyzed}통 분석${e.cards ? ` · ${e.cards}건 챙길 거리` : ""}`}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{/* 버킷 분류는 Gmail·Outlook 이 한 것 — 정직하게 출처 표기. */}
|
|
<section className="tlog-sec">
|
|
<h4>오늘 새 메일 {list.length}통</h4>
|
|
<p className="tlog-note">
|
|
집중·홍보·스팸 분류는 <b>Gmail·Outlook</b>이 한 거예요. 아리는 집중 메일만 읽어 할
|
|
일·일정·답장을 정리해요.
|
|
</p>
|
|
{buckets.length > 0 && (
|
|
<div className="tlog-chips">
|
|
{buckets.map((b) => (
|
|
<span key={b} className={"tlog-chip " + (BUCKET_TONE[b] ?? "muted")}>
|
|
{b} {counts[b]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!rows ? (
|
|
<p className="tlog-muted">불러오는 중…</p>
|
|
) : list.length === 0 ? (
|
|
<p className="tlog-muted">오늘 도착한 새 메일이 없어요.</p>
|
|
) : (
|
|
<ul className="tlog-mails">
|
|
{list.map((r) => {
|
|
const accName = accounts[r.account]?.name ?? r.account;
|
|
const noAction = r.tasks === 0 && r.events === 0 && r.replies === 0;
|
|
return (
|
|
<li
|
|
key={r.id}
|
|
className="tlog-mail"
|
|
onClick={() => {
|
|
onOpenMail(r.id);
|
|
onClose();
|
|
}}
|
|
>
|
|
<div
|
|
className="tm-ava"
|
|
style={{ background: accColor(r.account) }}
|
|
title={accName}
|
|
>
|
|
{accName.slice(0, 1).toUpperCase()}
|
|
</div>
|
|
<div className="tm-main">
|
|
<div className="tm-top">
|
|
<span className="tm-from">{r.from_name}</span>
|
|
<span className="tm-time">{r.time}</span>
|
|
</div>
|
|
<div className="tm-subj">{r.subject}</div>
|
|
</div>
|
|
<div className="tm-tags">
|
|
<span className={"tlog-chip " + (BUCKET_TONE[r.bucket] ?? "muted")}>
|
|
{r.bucket}
|
|
</span>
|
|
{r.focused &&
|
|
(r.analyzed ? (
|
|
<span className="tm-ana">
|
|
{[
|
|
r.priority,
|
|
r.tasks > 0 && `할 일 ${r.tasks}`,
|
|
r.events > 0 && `일정 ${r.events}`,
|
|
r.replies > 0 && "답장",
|
|
noAction && "정보성",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
</span>
|
|
) : (
|
|
<span className="tm-ana wait">분석 대기</span>
|
|
))}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|