feat(mail): 검색·스레드 이동(원문/이전/다음)·제목 정리·발신 아바타·HTML 본문
All checks were successful
build-and-push / build (push) Successful in 31s

- 메일 검색(제목·보낸이·내용·메모)
- 제목에서 Re:/Fwd: 제거, 대신 '원문/답장 N' 배지 + 상세에 '대화 i/n' 행
- 상세에 원문·이전·다음 이동 버튼(같은 스레드의 보이는 메일로 스크롤 이동)
- 행에 발신자 아바타(이니셜)로 한눈에 파악, HTML 본문은 sandbox iframe로 크게 렌더

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 15:50:12 +09:00
parent 50081a4142
commit a42318fc4c
2 changed files with 190 additions and 116 deletions

View File

@ -1,9 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useParams, Link } from "react-router-dom"; import { useParams, Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock, Pencil, ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock, Pencil,
Mail, ChevronDown, ChevronRight, RefreshCw, EyeOff, Eye, ExternalLink, Mail, ChevronDown, ChevronRight, RefreshCw, EyeOff, Eye, ExternalLink, Search,
ArrowLeftToLine, ArrowLeft as ArrowLeftIcon, ArrowRight,
} from "lucide-react"; } from "lucide-react";
import { import {
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles, getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
@ -496,11 +497,40 @@ function Contacts({ projectId }: { projectId: string }) {
} }
/* ---- project mail (Google Workspace) + 공동 메모 ---- */ /* ---- project mail (Google Workspace) + 공동 메모 ---- */
// RFC822 Message-ID로 보는 사람 본인 Gmail에서 해당 메일을 연다.
function gmailUrl(messageId: string): string {
return `https://mail.google.com/mail/u/0/#search/rfc822msgid:${encodeURIComponent(messageId)}`;
}
// "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;
}
// 제목에서 Re:/Fwd: 등 접두어 제거(순번은 별도 표기).
function cleanSubject(s: string): string {
const c = (s || "").replace(/^(\s*(re|fwd|fw|답장|전달)\s*:\s*)+/i, "").trim();
return c || "(제목 없음)";
}
// 수신자(받는사람+참조)가 많으면 "둘 + 외 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 MailTab({ projectId }: { projectId: string }) { function MailTab({ projectId }: { projectId: string }) {
const { nameOf } = useDirectory(); const { nameOf } = useDirectory();
const qc = useQueryClient(); const qc = useQueryClient();
const [showHidden, setShowHidden] = useState(false); const [showHidden, setShowHidden] = useState(false);
const [polling, setPolling] = useState(false); const [polling, setPolling] = useState(false);
const [query, setQuery] = useState("");
const [openId, setOpenId] = useState<string | null>(null);
const rowRefs = useRef<Record<string, HTMLDivElement | null>>({});
const q = useQuery({ const q = useQuery({
queryKey: ["mails", projectId], queryKey: ["mails", projectId],
queryFn: () => getProjectMails(projectId), queryFn: () => getProjectMails(projectId),
@ -512,7 +542,7 @@ function MailTab({ projectId }: { projectId: string }) {
onError: () => { setPolling(false); alert("동기화 요청에 실패했습니다. 잠시 후 다시 시도해 주세요."); }, onError: () => { setPolling(false); alert("동기화 요청에 실패했습니다. 잠시 후 다시 시도해 주세요."); },
onSettled: () => { onSettled: () => {
qc.invalidateQueries({ queryKey: ["mails", projectId] }); qc.invalidateQueries({ queryKey: ["mails", projectId] });
setTimeout(() => setPolling(false), 30000); // 백필 동안 자동 폴링 후 중단 setTimeout(() => setPolling(false), 30000);
}, },
}); });
const busy = sync.isPending || polling || !!q.data?.syncing; const busy = sync.isPending || polling || !!q.data?.syncing;
@ -527,8 +557,26 @@ function MailTab({ projectId }: { projectId: string }) {
return <EmptyState title="고객사 메일 도메인이 없습니다" icon={<Mail size={28} />} return <EmptyState title="고객사 메일 도메인이 없습니다" icon={<Mail size={28} />}
description="‘프로젝트 수정’에서 고객사 메일 도메인(예: meditech.com)을 입력하면 관련 메일을 모아 봅니다." />; description="‘프로젝트 수정’에서 고객사 메일 도메인(예: meditech.com)을 입력하면 관련 메일을 모아 봅니다." />;
} }
const visible = data.messages.filter((m) => showHidden || !m.hidden); const shown = data.messages.filter((m) => showHidden || !m.hidden);
const hiddenCount = data.messages.filter((m) => m.hidden).length; const hiddenCount = data.messages.filter((m) => m.hidden).length;
// 같은 스레드(보이는 것만, ts 오름차순) — 원문/이전/다음 이동용
const threadMap = new Map<string, ProjectMail[]>();
for (const m of shown) {
const arr = threadMap.get(m.threadId) ?? [];
arr.push(m);
threadMap.set(m.threadId, arr);
}
for (const arr of threadMap.values()) arr.sort((a, b) => a.ts - b.ts);
const ql = query.trim().toLowerCase();
const listed = ql
? shown.filter((m) => `${m.subject} ${m.from} ${m.to} ${m.snippet} ${m.note}`.toLowerCase().includes(ql))
: shown;
const navigate = (messageId: string) => {
setQuery("");
setOpenId(messageId);
setTimeout(() => rowRefs.current[messageId]?.scrollIntoView({ behavior: "smooth", block: "center" }), 60);
};
return ( return (
<div> <div>
@ -552,43 +600,39 @@ function MailTab({ projectId }: { projectId: string }) {
onClick={() => sync.mutate()} disabled={sync.isPending}>{busy ? "동기화 중" : "동기화"}</Button> onClick={() => sync.mutate()} disabled={sync.isPending}>{busy ? "동기화 중" : "동기화"}</Button>
</div> </div>
</div> </div>
{visible.length === 0 ? (
<EmptyState title={data.syncing ? "메일을 불러오는 중입니다" : "표시할 메일이 없습니다"} icon={<Mail size={28} />} {/* 검색 */}
<div className="relative mb-3">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-muted pointer-events-none" />
<input className="form-input pl-9" placeholder="제목·보낸이·내용·메모 검색" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
{listed.length === 0 ? (
<EmptyState title={data.syncing ? "메일을 불러오는 중입니다" : ql ? "검색 결과가 없습니다" : "표시할 메일이 없습니다"} icon={<Mail size={28} />}
description={data.syncing ? "잠시 후 자동으로 표시됩니다. (처음 동기화는 시간이 걸릴 수 있어요)" : "내가 참여한 이 고객사 도메인 메일이 없습니다."} /> description={data.syncing ? "잠시 후 자동으로 표시됩니다. (처음 동기화는 시간이 걸릴 수 있어요)" : "내가 참여한 이 고객사 도메인 메일이 없습니다."} />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{visible.map((m) => <MailRow key={m.messageId} projectId={projectId} mail={m} nameOf={nameOf} />)} {listed.map((m) => (
<MailRow key={m.messageId} projectId={projectId} mail={m} nameOf={nameOf}
open={openId === m.messageId}
onToggle={() => setOpenId((id) => (id === m.messageId ? null : m.messageId))}
siblings={threadMap.get(m.threadId) ?? [m]}
onNavigate={navigate}
innerRef={(el) => { rowRefs.current[m.messageId] = el; }} />
))}
</div> </div>
)} )}
</div> </div>
); );
} }
// RFC822 Message-ID로 보는 사람 본인 Gmail에서 해당 메일을 연다. function MailRow({ projectId, mail, nameOf, open, onToggle, siblings, onNavigate, innerRef }: {
function gmailUrl(messageId: string): string { projectId: string; mail: ProjectMail; nameOf: (e?: string | null) => string;
return `https://mail.google.com/mail/u/0/#search/rfc822msgid:${encodeURIComponent(messageId)}`; open: boolean; onToggle: () => void;
} siblings: ProjectMail[]; onNavigate: (messageId: string) => void;
innerRef: (el: HTMLDivElement | null) => void;
// "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 qc = useQueryClient();
const [open, setOpen] = useState(false);
const [memo, setMemo] = useState(mail.note ?? ""); const [memo, setMemo] = useState(mail.note ?? "");
useEffect(() => { setMemo(mail.note ?? ""); }, [mail.note]); useEffect(() => { setMemo(mail.note ?? ""); }, [mail.note]);
const invalidate = () => qc.invalidateQueries({ queryKey: ["mails", projectId] }); const invalidate = () => qc.invalidateQueries({ queryKey: ["mails", projectId] });
@ -596,104 +640,132 @@ function MailRow({ projectId, mail, nameOf }: { projectId: string; mail: Project
const hide = useMutation({ mutationFn: () => hideMail(projectId, mail.messageId, !mail.hidden), 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 when = mail.ts ? formatDateTime(new Date(mail.ts).toISOString()) : mail.date;
const rcpt = recipientSummary(mail.to, mail.cc); const rcpt = recipientSummary(mail.to, mail.cc);
// 펼칠 때만 본문 전문 + 첨부 리스트를 온디맨드로 가져온다.
const fullQ = useQuery({ const fullQ = useQuery({
queryKey: ["mail-full", projectId, mail.messageId], queryKey: ["mail-full", projectId, mail.messageId],
queryFn: () => getMailFull(projectId, mail.messageId), queryFn: () => getMailFull(projectId, mail.messageId),
enabled: open, enabled: open,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
const idx = siblings.findIndex((s) => s.messageId === mail.messageId);
const first = siblings[0];
const prev = idx > 0 ? siblings[idx - 1] : null;
const next = idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1] : null;
const isOriginal = mail.threadIndex <= 1;
const threadLabel = mail.threadCount > 1 ? (isOriginal ? "원문" : `답장 ${mail.threadIndex - 1}`) : "";
const sender = addrName(mail.from);
return ( return (
<Card className={classNames("overflow-hidden", mail.hidden && "opacity-60")}> <div ref={innerRef}>
<div className="flex flex-col sm:flex-row sm:items-stretch"> <Card className={classNames("overflow-hidden", mail.hidden && "opacity-60", open && "ring-1 ring-navy/30")}>
{/* 메일 본문 요약 (제목·보낸이→받는이·스니펫) */} <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"> <div className="flex items-start gap-2 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"> <button onClick={onToggle} title="자세히" className="mt-0.5 text-ink-muted shrink-0 p-1 rounded hover:bg-canvas">
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />} {open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{/* 메일 누르면 본인 Gmail에서 바로 열림 */}
<a href={gmailUrl(mail.messageId)} target="_blank" rel="noopener noreferrer" title="Gmail에서 열기" className="min-w-0 flex-1 group">
<span className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-ink truncate group-hover:text-navy">{mail.subject || "(제목 없음)"}</span>
<ExternalLink size={12} className="shrink-0 text-ink-muted opacity-0 group-hover:opacity-100" />
{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>
</a>
<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> </button>
</div> <span className="mt-0.5 w-8 h-8 rounded-full bg-navy text-white text-[12px] font-bold flex items-center justify-center shrink-0">
</div> {(sender.slice(0, 1) || "?").toUpperCase()}
{/* 메모 열 — 클릭 전에도 항상 보이고 바로 요약을 적을 수 있다 */} </span>
<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"> {/* 메일 누르면 본인 Gmail에서 바로 열림 */}
<textarea <a href={gmailUrl(mail.messageId)} target="_blank" rel="noopener noreferrer" title="Gmail에서 열기" className="min-w-0 flex-1 group">
value={memo} <span className="flex items-center gap-1.5">
onChange={(e) => setMemo(e.target.value)} {threadLabel && (
onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }} <span className={classNames("text-[10px] rounded-pill px-1.5 py-0.5 shrink-0 font-medium", isOriginal ? "bg-chip-bg text-navy" : "bg-[#EBE9FE] text-[#5925DC]")}>{threadLabel}</span>
placeholder="요약 메모 입력…" )}
rows={2} <span className="text-sm font-semibold text-ink truncate group-hover:text-navy">{cleanSubject(mail.subject)}</span>
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]" <ExternalLink size={12} className="shrink-0 text-ink-muted opacity-0 group-hover:opacity-100" />
/> {mail.hidden && <span className="text-[10px] bg-divider text-ink-muted rounded-pill px-1.5 py-0.5 shrink-0"></span>}
{mail.noteEditedBy && <span className="text-[10px] text-ink-muted mt-1 px-2 truncate"> {nameOf(mail.noteEditedBy)}</span>} </span>
</div> <span className="block text-xs text-ink-muted truncate mt-0.5">
</div> <span className="text-ink-secondary font-medium">{sender}</span> <span className="text-ink-muted/70"></span>{" "}
{open && ( <span title={rcpt.full} className="cursor-help underline decoration-dotted decoration-ink-muted/40 underline-offset-2">{rcpt.short}</span>
<div className="px-4 pb-4 pt-2 border-t border-divider space-y-3"> </span>
<div className="text-xs text-ink-muted grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1"> <span className="block text-sm text-ink-secondary truncate mt-1">{mail.snippet}</span>
<div className="sm:col-span-2"><span className="text-ink-secondary font-medium"></span> {mail.from}</div> </a>
<div className="sm:col-span-2"><span className="text-ink-secondary font-medium"></span> {mail.to || "—"}</div> <div className="flex items-center gap-1 shrink-0">
{mail.cc && <div className="sm:col-span-2"><span className="text-ink-secondary font-medium"></span> {mail.cc}</div>} <span className="text-[11px] text-ink-muted tabular hidden md:block">{when}</span>
<div><span className="text-ink-secondary font-medium"></span> {when}</div> <button onClick={() => hide.mutate()} title={mail.hidden ? "다시 보이기" : "숨기기"}
<div><span className="text-ink-secondary font-medium"> </span> {nameOf(mail.mailbox)}</div> className="p-1.5 rounded text-ink-muted hover:text-ink hover:bg-canvas">
</div> {mail.hidden ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
{/* 메일 전문 (본문) */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="form-label !mb-0"> </span>
<a href={gmailUrl(mail.messageId)} target="_blank" rel="noopener noreferrer" className="text-[11px] text-navy hover:underline inline-flex items-center gap-1">Gmail에서 <ExternalLink size={11} /></a>
</div> </div>
{fullQ.isLoading ? ( </div>
<div className="text-sm text-ink-muted py-4"> </div> {/* 메모 열 */}
) : fullQ.isError ? ( <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">
<div className="text-sm text-status-pending-fg py-4"> . ( / )</div> <textarea value={memo} onChange={(e) => setMemo(e.target.value)}
) : fullQ.data?.isHtml ? ( onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }}
<iframe title="메일 본문" sandbox="" srcDoc={fullQ.data.body} placeholder="요약 메모 입력…" rows={2}
className="w-full h-80 border border-divider rounded-control bg-white" /> 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>}
<pre className="whitespace-pre-wrap break-words text-sm text-ink-secondary bg-canvas/50 border border-divider rounded-control p-3 max-h-80 overflow-auto font-sans">{fullQ.data?.body || "(본문 없음)"}</pre> </div>
</div>
{open && (
<div className="px-4 pb-4 pt-2 border-t border-divider space-y-3">
{/* 스레드 순번 + 원문/이전/다음 이동 */}
{mail.threadCount > 1 && (
<div className="flex items-center justify-between gap-2 flex-wrap bg-canvas/60 rounded-control px-3 py-2">
<span className="text-xs text-ink-secondary"> {mail.threadIndex}/{mail.threadCount} · {isOriginal ? "원문" : `${mail.threadIndex - 1}번째 답장`}</span>
<div className="flex items-center gap-1">
<NavBtn label="원문" icon={<ArrowLeftToLine size={13} />} disabled={idx <= 0} onClick={() => first && onNavigate(first.messageId)} />
<NavBtn label="이전" icon={<ArrowLeftIcon size={13} />} disabled={!prev} onClick={() => prev && onNavigate(prev.messageId)} />
<NavBtn label="다음" icon={<ArrowRight size={13} />} disabled={!next} onClick={() => next && onNavigate(next.messageId)} iconRight />
</div>
</div>
)}
<div className="text-xs text-ink-muted grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
<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><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>
<a href={gmailUrl(mail.messageId)} target="_blank" rel="noopener noreferrer" className="text-[11px] text-navy hover:underline inline-flex items-center gap-1">Gmail에서 <ExternalLink size={11} /></a>
</div>
{fullQ.isLoading ? (
<div className="text-sm text-ink-muted py-4"> </div>
) : fullQ.isError ? (
<div className="text-sm text-status-pending-fg py-4"> . ( / )</div>
) : fullQ.data?.isHtml ? (
<iframe title="메일 본문" sandbox="" srcDoc={fullQ.data.body}
className="w-full h-96 border border-divider rounded-control bg-white" />
) : (
<pre className="whitespace-pre-wrap break-words text-sm text-ink-secondary bg-canvas/50 border border-divider rounded-control p-3 max-h-96 overflow-auto font-sans">{fullQ.data?.body || "(본문 없음)"}</pre>
)}
</div>
{/* 첨부파일 리스트 */}
{(fullQ.data?.attachments?.length ?? 0) > 0 && (
<div>
<span className="form-label"> ({fullQ.data!.attachments.length})</span>
<div className="flex flex-wrap gap-2">
{fullQ.data!.attachments.map((a) => (
<a key={a.attachmentId}
href={mailAttachmentUrl(projectId, { messageId: mail.messageId, gmailMsgId: fullQ.data!.gmailId, attachmentId: a.attachmentId, filename: a.filename })}
className="inline-flex items-center gap-2 text-sm text-ink border border-border rounded-control px-3 py-1.5 hover:border-navy hover:bg-navy-subtle/30">
<Download size={14} className="text-navy" />
<span className="truncate max-w-[200px]">{a.filename}</span>
<span className="text-[11px] text-ink-muted">{formatSize(a.size)}</span>
</a>
))}
</div>
</div>
)} )}
</div> </div>
)}
</Card>
</div>
);
}
{/* 첨부파일 리스트 */} function NavBtn({ label, icon, disabled, onClick, iconRight }: { label: string; icon: React.ReactNode; disabled?: boolean; onClick: () => void; iconRight?: boolean }) {
{(fullQ.data?.attachments?.length ?? 0) > 0 && ( return (
<div> <button onClick={onClick} disabled={disabled}
<span className="form-label"> ({fullQ.data!.attachments.length})</span> className={classNames("inline-flex items-center gap-1 text-xs px-2 py-1 rounded-control border border-border", disabled ? "opacity-40 cursor-not-allowed" : "text-ink hover:border-navy hover:bg-navy-subtle/30")}>
<div className="flex flex-wrap gap-2"> {!iconRight && icon}{label}{iconRight && icon}
{fullQ.data!.attachments.map((a) => ( </button>
<a key={a.attachmentId}
href={mailAttachmentUrl(projectId, { messageId: mail.messageId, gmailMsgId: fullQ.data!.gmailId, attachmentId: a.attachmentId, filename: a.filename })}
className="inline-flex items-center gap-2 text-sm text-ink border border-border rounded-control px-3 py-1.5 hover:border-navy hover:bg-navy-subtle/30">
<Download size={14} className="text-navy" />
<span className="truncate max-w-[200px]">{a.filename}</span>
<span className="text-[11px] text-ink-muted">{formatSize(a.size)}</span>
</a>
))}
</div>
</div>
)}
</div>
)}
</Card>
); );
} }

View File

@ -210,6 +210,8 @@ export interface ProjectMail {
hiddenBy: string; hiddenBy: string;
note: string; // 공동 메모 본문 (인라인 표시) note: string; // 공동 메모 본문 (인라인 표시)
noteEditedBy: string; // 메모 마지막 수정자 noteEditedBy: string; // 메모 마지막 수정자
threadIndex: number; // 스레드 내 순번 (1=원문)
threadCount: number; // 스레드 총 메일 수
} }
export interface ProjectMailsResponse { export interface ProjectMailsResponse {