From 05eb82c635707cbad0dbad88bdd0e8c5d8e5792e Mon Sep 17 00:00:00 2001 From: theorose49 Date: Tue, 30 Jun 2026 12:44:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=EB=A9=94=EC=9D=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=A9=94=EB=AA=A8=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=ED=91=9C=EC=8B=9C=20+=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=20=ED=86=A0=EA=B8=80=20+=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B2=84=ED=8A=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 각 메일 행에 공동 메모 미리보기(📝), 펼치면 편집 - 메일 숨기기/다시보기, '숨긴 메일 보기(N)' 토글 - 마지막 동기화 시각 + 수동 '동기화' 버튼, 참조(CC) 표시 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/api.ts | 4 ++ src/pages/ProjectDetail.tsx | 99 +++++++++++++++++++++++++------------ src/types.ts | 8 +++ 3 files changed, 79 insertions(+), 32 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 26cf0a4..b3a995f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -148,6 +148,10 @@ export const getMailNotes = (id: string) => api.get(`/projects/${id}/mail-notes`).then((r) => r.data); export const putMailNote = (id: string, messageId: string, body: string) => api.put(`/projects/${id}/mail-notes`, { messageId, body }).then((r) => r.data); +export const syncProjectMails = (id: string) => + api.post(`/projects/${id}/mails/sync`).then((r) => r.data); +export const hideMail = (id: string, messageId: string, hidden: boolean) => + api.put(`/projects/${id}/mail-hide`, { messageId, hidden }).then((r) => r.data); export const getTaskComments = (tId: string) => api.get(`/tasks/${tId}/comments`).then((r) => r.data); diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx index 0fb69ab..6e33db8 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -3,13 +3,13 @@ import { useParams, Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock, Pencil, - Mail, ChevronDown, ChevronRight, + Mail, ChevronDown, ChevronRight, RefreshCw, EyeOff, Eye, } from "lucide-react"; import { getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles, getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask, getTaskComments, createTaskComment, deleteTaskComment, - getProjectMails, getMailNotes, putMailNote, + getProjectMails, putMailNote, syncProjectMails, hideMail, upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl, deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject, updateProject, @@ -28,7 +28,7 @@ import { formatDate, formatDateTime, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS, PRIORITY_META, classNames, } from "@/lib/format"; -import type { Lane, MailNote, PaymentSplit, Project, ProjectMail, ProjectTask, TaskPriority } from "@/types"; +import type { Lane, PaymentSplit, Project, ProjectMail, ProjectTask, TaskPriority } from "@/types"; export function ProjectDetailPage() { const { id = "" } = useParams(); @@ -485,9 +485,13 @@ function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean /* ---- project mail (Google Workspace) + 공동 메모 ---- */ function MailTab({ projectId }: { projectId: string }) { const { nameOf } = useDirectory(); + const qc = useQueryClient(); const q = useQuery({ queryKey: ["mails", projectId], queryFn: () => getProjectMails(projectId) }); - const notesQ = useQuery({ queryKey: ["mail-notes", projectId], queryFn: () => getMailNotes(projectId) }); - const noteByMsg = new Map((notesQ.data ?? []).map((n) => [n.messageId, n])); + const [showHidden, setShowHidden] = useState(false); + const sync = useMutation({ + mutationFn: () => syncProjectMails(projectId), + onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["mails", projectId] }), 2500), + }); if (q.isLoading) return ; const data = q.data; @@ -499,66 +503,97 @@ function MailTab({ projectId }: { projectId: string }) { return } description="‘프로젝트 수정’에서 고객사 메일 도메인(예: meditech.com)을 입력하면 관련 메일을 모아 봅니다." />; } + const visible = data.messages.filter((m) => showHidden || !m.hidden); + const hiddenCount = data.messages.filter((m) => m.hidden).length; + return (
-

- @{data.domain} 와(과) 주고받은 메일 · 내가 수신·참조(CC)된 메일만 표시 -

- {data.error && 일부 메일함을 읽지 못했습니다} +
+

+ @{data.domain} 와(과) 주고받은 메일 · 내가 수신·참조(CC)된 메일만 +

+

+ {data.lastSyncedAt ? `마지막 동기화 ${formatDateTime(data.lastSyncedAt)}` : data.syncing ? "처음 동기화 중…" : "아직 동기화 안 됨"} + {data.error && · 일부 메일함 오류} +

+
+
+ {hiddenCount > 0 && ( + + )} + +
- {data.messages.length === 0 ? ( - } description="팀 구성원이 이 고객사 도메인과 주고받은 메일이 없습니다." /> + {visible.length === 0 ? ( + } + description={data.syncing ? "잠시 후 자동으로 표시됩니다. (처음 동기화는 시간이 걸릴 수 있어요)" : "내가 참여한 이 고객사 도메인 메일이 없습니다."} /> ) : (
- {data.messages.map((m) => ( - - ))} + {visible.map((m) => )}
)}
); } -function MailRow({ projectId, mail, note, nameOf }: { projectId: string; mail: ProjectMail; note?: MailNote; nameOf: (e?: string | null) => string }) { +function MailRow({ projectId, mail, nameOf }: { projectId: string; mail: ProjectMail; nameOf: (e?: string | null) => string }) { const qc = useQueryClient(); const [open, setOpen] = useState(false); - const [memo, setMemo] = useState(note?.body ?? ""); - useEffect(() => { setMemo(note?.body ?? ""); }, [note?.updatedAt]); - const save = useMutation({ - mutationFn: () => putMailNote(projectId, mail.id, memo), - onSuccess: () => qc.invalidateQueries({ queryKey: ["mail-notes", projectId] }), - }); + const [memo, setMemo] = useState(mail.note ?? ""); + useEffect(() => { setMemo(mail.note ?? ""); }, [mail.note]); + const invalidate = () => qc.invalidateQueries({ queryKey: ["mails", projectId] }); + 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; return ( - - + + {/* 메일 리스트에 메모도 함께 표시 */} + {mail.note && ( + + 📝 {mail.note} + + )} + +
+ {when} + +
+ {open && (
보낸사람 {mail.from}
받는사람 {mail.to}
-
발견된 메일함 {nameOf(mail.mailbox)} ({mail.mailbox})
+ {mail.cc &&
참조 {mail.cc}
} +
시각 {when}
공동 메모 · 프로젝트 구성원 누구나 수정 - {note?.lastEditedBy && 마지막 수정 {nameOf(note.lastEditedBy)}} + {mail.noteEditedBy && 마지막 수정 {nameOf(mail.noteEditedBy)}}