feat(mail): 메모를 메일 리스트의 독립 열로(클릭 전에도 보임·인라인 편집) + 수신자 요약
All checks were successful
build-and-push / build (push) Successful in 31s

- 각 메일 행 우측에 '메모 열' 상시 표시, 바로 요약 메모 입력(blur 저장)·마지막 수정자 표시
- 보낸이→받는이 표시에서 수신자 많으면 '외 N명'으로 축약, hover(title)로 전체 노출
- 펼침 상세에 전체 받는사람/참조/시각/메일함

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 12:54:14 +09:00
parent 05eb82c635
commit 6bbdf4311d

View File

@ -540,6 +540,23 @@ function MailTab({ projectId }: { projectId: string }) {
);
}
// "Name <a@b>" 또는 "a@b" → 표시용 짧은 이름(이름 또는 @앞)
function addrName(a: string): string {
a = a.trim();
const m = a.match(/^"?([^"<]+?)"?\s*<[^>]*>$/);
if (m) return m[1].trim();
const at = a.indexOf("@");
return at > 0 ? a.slice(0, at) : a;
}
// 수신자(받는사람+참조)가 많으면 "둘 + 외 N명"으로 줄이고, 전체는 title(hover)로.
function recipientSummary(to: string, cc: string) {
const parts = `${to},${cc}`.split(",").map((s) => s.trim()).filter(Boolean);
const names = parts.map(addrName);
const full = parts.join(", ");
const short = names.length <= 2 ? names.join(", ") : `${names.slice(0, 2).join(", ")}${names.length - 2}`;
return { short: short || "—", full: full || "—" };
}
function MailRow({ projectId, mail, nameOf }: { projectId: string; mail: ProjectMail; nameOf: (e?: string | null) => string }) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
@ -549,52 +566,56 @@ function MailRow({ projectId, mail, nameOf }: { projectId: string; mail: Project
const save = useMutation({ mutationFn: () => putMailNote(projectId, mail.messageId, memo), onSuccess: invalidate });
const hide = useMutation({ mutationFn: () => hideMail(projectId, mail.messageId, !mail.hidden), onSuccess: invalidate });
const when = mail.ts ? formatDateTime(new Date(mail.ts).toISOString()) : mail.date;
const rcpt = recipientSummary(mail.to, mail.cc);
return (
<Card className={classNames("overflow-hidden", mail.hidden && "opacity-60")}>
<div className="flex items-start gap-1 px-2 sm:px-3 py-3">
<button onClick={() => setOpen((o) => !o)} className="mt-0.5 text-ink-muted shrink-0 p-1 rounded hover:bg-canvas">
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<button onClick={() => setOpen((o) => !o)} className="min-w-0 flex-1 text-left">
<span className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink truncate">{mail.subject || "(제목 없음)"}</span>
{mail.note && <span className="text-[10px] bg-chip-bg text-navy rounded-pill px-1.5 py-0.5 shrink-0"></span>}
{mail.hidden && <span className="text-[10px] bg-divider text-ink-muted rounded-pill px-1.5 py-0.5 shrink-0"></span>}
</span>
<span className="block text-xs text-ink-muted truncate mt-0.5">{mail.from}</span>
<span className="block text-sm text-ink-secondary truncate mt-1">{mail.snippet}</span>
{/* 메일 리스트에 메모도 함께 표시 */}
{mail.note && (
<span className="mt-2 block text-xs text-ink bg-navy-subtle/40 border border-navy-subtle rounded-control px-2.5 py-1.5 line-clamp-2">
📝 {mail.note}
</span>
)}
</button>
<div className="flex items-center gap-1 shrink-0">
<span className="text-[11px] text-ink-muted tabular hidden sm:block">{when}</span>
<button onClick={() => hide.mutate()} title={mail.hidden ? "다시 보이기" : "숨기기"}
className="p-1.5 rounded text-ink-muted hover:text-ink hover:bg-canvas">
{mail.hidden ? <Eye size={15} /> : <EyeOff size={15} />}
<div className="flex flex-col sm:flex-row sm:items-stretch">
{/* 메일 본문 요약 (제목·보낸이→받는이·스니펫) */}
<div className="flex items-start gap-1 min-w-0 flex-1 px-2 sm:px-3 py-3">
<button onClick={() => setOpen((o) => !o)} className="mt-0.5 text-ink-muted shrink-0 p-1 rounded hover:bg-canvas">
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<button onClick={() => setOpen((o) => !o)} className="min-w-0 flex-1 text-left">
<span className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink truncate">{mail.subject || "(제목 없음)"}</span>
{mail.hidden && <span className="text-[10px] bg-divider text-ink-muted rounded-pill px-1.5 py-0.5 shrink-0"></span>}
</span>
<span className="block text-xs text-ink-muted truncate mt-0.5">
{addrName(mail.from)} <span className="text-ink-muted/70"></span>{" "}
<span title={rcpt.full} className="cursor-help underline decoration-dotted decoration-ink-muted/40 underline-offset-2">{rcpt.short}</span>
</span>
<span className="block text-sm text-ink-secondary truncate mt-1">{mail.snippet}</span>
</button>
<div className="flex items-center gap-1 shrink-0">
<span className="text-[11px] text-ink-muted tabular hidden md:block">{when}</span>
<button onClick={() => hide.mutate()} title={mail.hidden ? "다시 보이기" : "숨기기"}
className="p-1.5 rounded text-ink-muted hover:text-ink hover:bg-canvas">
{mail.hidden ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
</div>
</div>
{/* 메모 열 — 클릭 전에도 항상 보이고 바로 요약을 적을 수 있다 */}
<div className="sm:w-72 shrink-0 border-t sm:border-t-0 sm:border-l border-divider px-3 py-2 flex flex-col bg-canvas/30">
<textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }}
placeholder="요약 메모 입력…"
rows={2}
className="w-full text-xs leading-snug text-ink bg-transparent rounded-control px-2 py-1.5 resize-none border border-transparent hover:border-border focus:border-navy focus:bg-surface focus:outline-none min-h-[44px]"
/>
{mail.noteEditedBy && <span className="text-[10px] text-ink-muted mt-1 px-2 truncate"> {nameOf(mail.noteEditedBy)}</span>}
</div>
</div>
{open && (
<div className="px-4 pb-4 pt-1 border-t border-divider space-y-3">
<div className="px-4 pb-4 pt-2 border-t border-divider">
<div className="text-xs text-ink-muted grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
<div><span className="text-ink-secondary font-medium"></span> {mail.from}</div>
<div><span className="text-ink-secondary font-medium"></span> {mail.to}</div>
<div className="sm:col-span-2"><span className="text-ink-secondary font-medium"></span> {mail.from}</div>
<div className="sm:col-span-2"><span className="text-ink-secondary font-medium"></span> {mail.to || "—"}</div>
{mail.cc && <div className="sm:col-span-2"><span className="text-ink-secondary font-medium"></span> {mail.cc}</div>}
<div className="sm:col-span-2 sm:hidden"><span className="text-ink-secondary font-medium"></span> {when}</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="form-label !mb-0"> <span className="text-ink-muted font-normal">· </span></span>
{mail.noteEditedBy && <span className="text-[11px] text-ink-muted"> {nameOf(mail.noteEditedBy)}</span>}
</div>
<Textarea value={memo} onChange={(e) => setMemo(e.target.value)}
onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }}
placeholder="이 메일에 대한 팀 메모를 남기세요. (대응 방향·할 일·합의 내용 등)" style={{ minHeight: 80 }} />
<div><span className="text-ink-secondary font-medium"></span> {when}</div>
<div><span className="text-ink-secondary font-medium"> </span> {nameOf(mail.mailbox)}</div>
</div>
</div>
)}