feat(mail): 메일 상세에 전문(본문)+첨부파일 리스트 표시
All checks were successful
build-and-push / build (push) Successful in 31s

- 펼치면 본문 온디맨드 로드: 텍스트는 pre, HTML은 sandbox iframe로 안전 렌더
- 첨부파일 리스트(파일명·크기) + 다운로드 링크(/mails/attachment)
- 상세 상단에 'Gmail에서 열기' 링크

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 14:18:50 +09:00
parent effd72761e
commit 50081a4142
3 changed files with 66 additions and 3 deletions

View File

@ -2,7 +2,7 @@ import axios from "axios";
import type { import type {
Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact, Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact,
Company, Contract, ContractFile, Dashboard, Department, DirectoryEntry, IncentiveConfig, Company, Contract, ContractFile, Dashboard, Department, DirectoryEntry, IncentiveConfig,
LeaveBalance, LeaveRequest, MailNote, Me, Member, MyIncentive, NavItem, Notification, LeaveBalance, LeaveRequest, MailFull, MailNote, Me, Member, MyIncentive, NavItem, Notification,
OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMailsResponse, OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMailsResponse,
ProjectMember, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction, ProjectMember, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction,
UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind, UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind,
@ -153,6 +153,12 @@ export const getMailNotes = (id: string) =>
api.get<MailNote[]>(`/projects/${id}/mail-notes`).then((r) => r.data); api.get<MailNote[]>(`/projects/${id}/mail-notes`).then((r) => r.data);
export const putMailNote = (id: string, messageId: string, body: string) => export const putMailNote = (id: string, messageId: string, body: string) =>
api.put<MailNote>(`/projects/${id}/mail-notes`, { messageId, body }).then((r) => r.data); api.put<MailNote>(`/projects/${id}/mail-notes`, { messageId, body }).then((r) => r.data);
export const getMailFull = (id: string, messageId: string) =>
api.get<MailFull>(`/projects/${id}/mails/full`, { params: { messageId } }).then((r) => r.data);
export const mailAttachmentUrl = (
id: string,
p: { messageId: string; gmailMsgId: string; attachmentId: string; filename: string }
) => `/api/projects/${id}/mails/attachment?${new URLSearchParams(p).toString()}`;
export const syncProjectMails = (id: string) => export const syncProjectMails = (id: string) =>
api.post(`/projects/${id}/mails/sync`).then((r) => r.data); api.post(`/projects/${id}/mails/sync`).then((r) => r.data);
export const hideMail = (id: string, messageId: string, hidden: boolean) => export const hideMail = (id: string, messageId: string, hidden: boolean) =>

View File

@ -9,7 +9,7 @@ import {
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles, getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask, getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask,
getTaskComments, createTaskComment, deleteTaskComment, getTaskComments, createTaskComment, deleteTaskComment,
getProjectMails, putMailNote, syncProjectMails, hideMail, getProjectMails, putMailNote, syncProjectMails, hideMail, getMailFull, mailAttachmentUrl,
upsertContact, deleteContact, patchProjectNotes, putContract, uploadContractFile, getFileDownloadUrl, upsertContact, deleteContact, patchProjectNotes, putContract, uploadContractFile, getFileDownloadUrl,
deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject, deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject,
updateProject, updateProject,
@ -596,6 +596,13 @@ 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({
queryKey: ["mail-full", projectId, mail.messageId],
queryFn: () => getMailFull(projectId, mail.messageId),
enabled: open,
staleTime: 5 * 60_000,
});
return ( return (
<Card className={classNames("overflow-hidden", mail.hidden && "opacity-60")}> <Card className={classNames("overflow-hidden", mail.hidden && "opacity-60")}>
@ -640,7 +647,7 @@ function MailRow({ projectId, mail, nameOf }: { projectId: string; mail: Project
</div> </div>
</div> </div>
{open && ( {open && (
<div className="px-4 pb-4 pt-2 border-t border-divider"> <div className="px-4 pb-4 pt-2 border-t border-divider space-y-3">
<div className="text-xs text-ink-muted grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1"> <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.from}</div>
<div className="sm:col-span-2"><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.to || "—"}</div>
@ -648,6 +655,42 @@ function MailRow({ projectId, mail, nameOf }: { projectId: string; mail: Project
<div><span className="text-ink-secondary font-medium"></span> {when}</div> <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><span className="text-ink-secondary font-medium"> </span> {nameOf(mail.mailbox)}</div>
</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-80 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-80 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> </Card>

View File

@ -231,6 +231,20 @@ export interface MailNote {
updatedAt: string; updatedAt: string;
} }
export interface MailAttachment {
filename: string;
mimeType: string;
size: number;
attachmentId: string;
}
export interface MailFull {
gmailId: string;
body: string;
isHtml: boolean;
attachments: MailAttachment[];
}
export interface ProjectMember { export interface ProjectMember {
id: string; id: string;
projectId: string; projectId: string;