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 {
|
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) =>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
14
src/types.ts
14
src/types.ts
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user