diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx index 17b9bdf..1fc8472 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; 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, RefreshCw, EyeOff, Eye, ExternalLink, + Mail, ChevronDown, ChevronRight, RefreshCw, EyeOff, Eye, ExternalLink, Search, + ArrowLeftToLine, ArrowLeft as ArrowLeftIcon, ArrowRight, } from "lucide-react"; import { getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles, @@ -496,11 +497,40 @@ function Contacts({ projectId }: { projectId: string }) { } /* ---- 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" → 표시용 짧은 이름(이름 또는 @앞) +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 }) { const { nameOf } = useDirectory(); const qc = useQueryClient(); const [showHidden, setShowHidden] = useState(false); const [polling, setPolling] = useState(false); + const [query, setQuery] = useState(""); + const [openId, setOpenId] = useState(null); + const rowRefs = useRef>({}); const q = useQuery({ queryKey: ["mails", projectId], queryFn: () => getProjectMails(projectId), @@ -512,7 +542,7 @@ function MailTab({ projectId }: { projectId: string }) { onError: () => { setPolling(false); alert("동기화 요청에 실패했습니다. 잠시 후 다시 시도해 주세요."); }, onSettled: () => { qc.invalidateQueries({ queryKey: ["mails", projectId] }); - setTimeout(() => setPolling(false), 30000); // 백필 동안 자동 폴링 후 중단 + setTimeout(() => setPolling(false), 30000); }, }); const busy = sync.isPending || polling || !!q.data?.syncing; @@ -527,8 +557,26 @@ function MailTab({ projectId }: { projectId: string }) { return } 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; + // 같은 스레드(보이는 것만, ts 오름차순) — 원문/이전/다음 이동용 + const threadMap = new Map(); + 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 (
@@ -552,43 +600,39 @@ function MailTab({ projectId }: { projectId: string }) { onClick={() => sync.mutate()} disabled={sync.isPending}>{busy ? "동기화 중" : "동기화"}
- {visible.length === 0 ? ( - } + + {/* 검색 */} +
+ + setQuery(e.target.value)} /> +
+ + {listed.length === 0 ? ( + } description={data.syncing ? "잠시 후 자동으로 표시됩니다. (처음 동기화는 시간이 걸릴 수 있어요)" : "내가 참여한 이 고객사 도메인 메일이 없습니다."} /> ) : (
- {visible.map((m) => )} + {listed.map((m) => ( + setOpenId((id) => (id === m.messageId ? null : m.messageId))} + siblings={threadMap.get(m.threadId) ?? [m]} + onNavigate={navigate} + innerRef={(el) => { rowRefs.current[m.messageId] = el; }} /> + ))}
)} ); } -// RFC822 Message-ID로 보는 사람 본인 Gmail에서 해당 메일을 연다. -function gmailUrl(messageId: string): string { - return `https://mail.google.com/mail/u/0/#search/rfc822msgid:${encodeURIComponent(messageId)}`; -} - -// "Name " 또는 "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 }) { +function MailRow({ projectId, mail, nameOf, open, onToggle, siblings, onNavigate, innerRef }: { + projectId: string; mail: ProjectMail; nameOf: (e?: string | null) => string; + open: boolean; onToggle: () => void; + siblings: ProjectMail[]; onNavigate: (messageId: string) => void; + innerRef: (el: HTMLDivElement | null) => void; +}) { const qc = useQueryClient(); - const [open, setOpen] = useState(false); const [memo, setMemo] = useState(mail.note ?? ""); useEffect(() => { setMemo(mail.note ?? ""); }, [mail.note]); 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 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, }); + 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 ( - -
- {/* 메일 본문 요약 (제목·보낸이→받는이·스니펫) */} -
- - {/* 메일 누르면 본인 Gmail에서 바로 열림 */} - - - {mail.subject || "(제목 없음)"} - - {mail.hidden && 숨김} - - - {addrName(mail.from)} {" "} - {rcpt.short} - - {mail.snippet} - -
- {when} - -
-
- {/* 메모 열 — 클릭 전에도 항상 보이고 바로 요약을 적을 수 있다 */} -
-