feat(mail): 메일 상세에 전문(본문)+첨부파일 리스트 표시
All checks were successful
build-and-push / build (push) Successful in 31s
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:
parent
effd72761e
commit
50081a4142
@ -2,7 +2,7 @@ import axios from "axios";
|
||||
import type {
|
||||
Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact,
|
||||
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,
|
||||
ProjectMember, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction,
|
||||
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);
|
||||
export const putMailNote = (id: string, messageId: string, body: string) =>
|
||||
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) =>
|
||||
api.post(`/projects/${id}/mails/sync`).then((r) => r.data);
|
||||
export const hideMail = (id: string, messageId: string, hidden: boolean) =>
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
|
||||
getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask,
|
||||
getTaskComments, createTaskComment, deleteTaskComment,
|
||||
getProjectMails, putMailNote, syncProjectMails, hideMail,
|
||||
getProjectMails, putMailNote, syncProjectMails, hideMail, getMailFull, mailAttachmentUrl,
|
||||
upsertContact, deleteContact, patchProjectNotes, putContract, uploadContractFile, getFileDownloadUrl,
|
||||
deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject,
|
||||
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 when = mail.ts ? formatDateTime(new Date(mail.ts).toISOString()) : mail.date;
|
||||
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 (
|
||||
<Card className={classNames("overflow-hidden", mail.hidden && "opacity-60")}>
|
||||
@ -640,7 +647,7 @@ function MailRow({ projectId, mail, nameOf }: { projectId: string; mail: Project
|
||||
</div>
|
||||
</div>
|
||||
{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="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>
|
||||
@ -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> {nameOf(mail.mailbox)}</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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
14
src/types.ts
14
src/types.ts
@ -231,6 +231,20 @@ export interface MailNote {
|
||||
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 {
|
||||
id: string;
|
||||
projectId: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user