feat(mail): 검색·스레드 이동(원문/이전/다음)·제목 정리·발신 아바타·HTML 본문
All checks were successful
build-and-push / build (push) Successful in 31s
All checks were successful
build-and-push / build (push) Successful in 31s
- 메일 검색(제목·보낸이·내용·메모) - 제목에서 Re:/Fwd: 제거, 대신 '원문/답장 N' 배지 + 상세에 '대화 i/n' 행 - 상세에 원문·이전·다음 이동 버튼(같은 스레드의 보이는 메일로 스크롤 이동) - 행에 발신자 아바타(이니셜)로 한눈에 파악, HTML 본문은 sandbox iframe로 크게 렌더 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
50081a4142
commit
a42318fc4c
@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useParams, Link } from "react-router-dom";
|
import { useParams, Link } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock, Pencil,
|
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";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
|
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
|
||||||
@ -496,11 +497,40 @@ function Contacts({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- project mail (Google Workspace) + 공동 메모 ---- */
|
/* ---- 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>" 또는 "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 }) {
|
function MailTab({ projectId }: { projectId: string }) {
|
||||||
const { nameOf } = useDirectory();
|
const { nameOf } = useDirectory();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [showHidden, setShowHidden] = useState(false);
|
const [showHidden, setShowHidden] = useState(false);
|
||||||
const [polling, setPolling] = useState(false);
|
const [polling, setPolling] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const rowRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["mails", projectId],
|
queryKey: ["mails", projectId],
|
||||||
queryFn: () => getProjectMails(projectId),
|
queryFn: () => getProjectMails(projectId),
|
||||||
@ -512,7 +542,7 @@ function MailTab({ projectId }: { projectId: string }) {
|
|||||||
onError: () => { setPolling(false); alert("동기화 요청에 실패했습니다. 잠시 후 다시 시도해 주세요."); },
|
onError: () => { setPolling(false); alert("동기화 요청에 실패했습니다. 잠시 후 다시 시도해 주세요."); },
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["mails", projectId] });
|
qc.invalidateQueries({ queryKey: ["mails", projectId] });
|
||||||
setTimeout(() => setPolling(false), 30000); // 백필 동안 자동 폴링 후 중단
|
setTimeout(() => setPolling(false), 30000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const busy = sync.isPending || polling || !!q.data?.syncing;
|
const busy = sync.isPending || polling || !!q.data?.syncing;
|
||||||
@ -527,8 +557,26 @@ function MailTab({ projectId }: { projectId: string }) {
|
|||||||
return <EmptyState title="고객사 메일 도메인이 없습니다" icon={<Mail size={28} />}
|
return <EmptyState title="고객사 메일 도메인이 없습니다" icon={<Mail size={28} />}
|
||||||
description="‘프로젝트 수정’에서 고객사 메일 도메인(예: meditech.com)을 입력하면 관련 메일을 모아 봅니다." />;
|
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;
|
const hiddenCount = data.messages.filter((m) => m.hidden).length;
|
||||||
|
// 같은 스레드(보이는 것만, ts 오름차순) — 원문/이전/다음 이동용
|
||||||
|
const threadMap = new Map<string, ProjectMail[]>();
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -552,43 +600,39 @@ function MailTab({ projectId }: { projectId: string }) {
|
|||||||
onClick={() => sync.mutate()} disabled={sync.isPending}>{busy ? "동기화 중" : "동기화"}</Button>
|
onClick={() => sync.mutate()} disabled={sync.isPending}>{busy ? "동기화 중" : "동기화"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{visible.length === 0 ? (
|
|
||||||
<EmptyState title={data.syncing ? "메일을 불러오는 중입니다" : "표시할 메일이 없습니다"} icon={<Mail size={28} />}
|
{/* 검색 */}
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-muted pointer-events-none" />
|
||||||
|
<input className="form-input pl-9" placeholder="제목·보낸이·내용·메모 검색" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listed.length === 0 ? (
|
||||||
|
<EmptyState title={data.syncing ? "메일을 불러오는 중입니다" : ql ? "검색 결과가 없습니다" : "표시할 메일이 없습니다"} icon={<Mail size={28} />}
|
||||||
description={data.syncing ? "잠시 후 자동으로 표시됩니다. (처음 동기화는 시간이 걸릴 수 있어요)" : "내가 참여한 이 고객사 도메인 메일이 없습니다."} />
|
description={data.syncing ? "잠시 후 자동으로 표시됩니다. (처음 동기화는 시간이 걸릴 수 있어요)" : "내가 참여한 이 고객사 도메인 메일이 없습니다."} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{visible.map((m) => <MailRow key={m.messageId} projectId={projectId} mail={m} nameOf={nameOf} />)}
|
{listed.map((m) => (
|
||||||
|
<MailRow key={m.messageId} projectId={projectId} mail={m} nameOf={nameOf}
|
||||||
|
open={openId === m.messageId}
|
||||||
|
onToggle={() => setOpenId((id) => (id === m.messageId ? null : m.messageId))}
|
||||||
|
siblings={threadMap.get(m.threadId) ?? [m]}
|
||||||
|
onNavigate={navigate}
|
||||||
|
innerRef={(el) => { rowRefs.current[m.messageId] = el; }} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC822 Message-ID로 보는 사람 본인 Gmail에서 해당 메일을 연다.
|
function MailRow({ projectId, mail, nameOf, open, onToggle, siblings, onNavigate, innerRef }: {
|
||||||
function gmailUrl(messageId: string): string {
|
projectId: string; mail: ProjectMail; nameOf: (e?: string | null) => string;
|
||||||
return `https://mail.google.com/mail/u/0/#search/rfc822msgid:${encodeURIComponent(messageId)}`;
|
open: boolean; onToggle: () => void;
|
||||||
}
|
siblings: ProjectMail[]; onNavigate: (messageId: string) => void;
|
||||||
|
innerRef: (el: HTMLDivElement | null) => void;
|
||||||
// "Name <a@b>" 또는 "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 }) {
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [memo, setMemo] = useState(mail.note ?? "");
|
const [memo, setMemo] = useState(mail.note ?? "");
|
||||||
useEffect(() => { setMemo(mail.note ?? ""); }, [mail.note]);
|
useEffect(() => { setMemo(mail.note ?? ""); }, [mail.note]);
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["mails", projectId] });
|
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 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({
|
const fullQ = useQuery({
|
||||||
queryKey: ["mail-full", projectId, mail.messageId],
|
queryKey: ["mail-full", projectId, mail.messageId],
|
||||||
queryFn: () => getMailFull(projectId, mail.messageId),
|
queryFn: () => getMailFull(projectId, mail.messageId),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
staleTime: 5 * 60_000,
|
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 (
|
return (
|
||||||
<Card className={classNames("overflow-hidden", mail.hidden && "opacity-60")}>
|
<div ref={innerRef}>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-stretch">
|
<Card className={classNames("overflow-hidden", mail.hidden && "opacity-60", open && "ring-1 ring-navy/30")}>
|
||||||
{/* 메일 본문 요약 (제목·보낸이→받는이·스니펫) */}
|
<div className="flex flex-col sm:flex-row sm:items-stretch">
|
||||||
<div className="flex items-start gap-1 min-w-0 flex-1 px-2 sm:px-3 py-3">
|
<div className="flex items-start gap-2 min-w-0 flex-1 px-2 sm:px-3 py-3">
|
||||||
<button onClick={() => setOpen((o) => !o)} className="mt-0.5 text-ink-muted shrink-0 p-1 rounded hover:bg-canvas">
|
<button onClick={onToggle} title="자세히" className="mt-0.5 text-ink-muted shrink-0 p-1 rounded hover:bg-canvas">
|
||||||
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
</button>
|
|
||||||
{/* 메일 누르면 본인 Gmail에서 바로 열림 */}
|
|
||||||
<a href={gmailUrl(mail.messageId)} target="_blank" rel="noopener noreferrer" title="Gmail에서 열기" className="min-w-0 flex-1 group">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="text-sm font-semibold text-ink truncate group-hover:text-navy">{mail.subject || "(제목 없음)"}</span>
|
|
||||||
<ExternalLink size={12} className="shrink-0 text-ink-muted opacity-0 group-hover:opacity-100" />
|
|
||||||
{mail.hidden && <span className="text-[10px] bg-divider text-ink-muted rounded-pill px-1.5 py-0.5 shrink-0">숨김</span>}
|
|
||||||
</span>
|
|
||||||
<span className="block text-xs text-ink-muted truncate mt-0.5">
|
|
||||||
{addrName(mail.from)} <span className="text-ink-muted/70">→</span>{" "}
|
|
||||||
<span title={rcpt.full} className="cursor-help underline decoration-dotted decoration-ink-muted/40 underline-offset-2">{rcpt.short}</span>
|
|
||||||
</span>
|
|
||||||
<span className="block text-sm text-ink-secondary truncate mt-1">{mail.snippet}</span>
|
|
||||||
</a>
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<span className="text-[11px] text-ink-muted tabular hidden md:block">{when}</span>
|
|
||||||
<button onClick={() => hide.mutate()} title={mail.hidden ? "다시 보이기" : "숨기기"}
|
|
||||||
className="p-1.5 rounded text-ink-muted hover:text-ink hover:bg-canvas">
|
|
||||||
{mail.hidden ? <Eye size={15} /> : <EyeOff size={15} />}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<span className="mt-0.5 w-8 h-8 rounded-full bg-navy text-white text-[12px] font-bold flex items-center justify-center shrink-0">
|
||||||
</div>
|
{(sender.slice(0, 1) || "?").toUpperCase()}
|
||||||
{/* 메모 열 — 클릭 전에도 항상 보이고 바로 요약을 적을 수 있다 */}
|
</span>
|
||||||
<div className="sm:w-72 shrink-0 border-t sm:border-t-0 sm:border-l border-divider px-3 py-2 flex flex-col bg-canvas/30">
|
{/* 메일 누르면 본인 Gmail에서 바로 열림 */}
|
||||||
<textarea
|
<a href={gmailUrl(mail.messageId)} target="_blank" rel="noopener noreferrer" title="Gmail에서 열기" className="min-w-0 flex-1 group">
|
||||||
value={memo}
|
<span className="flex items-center gap-1.5">
|
||||||
onChange={(e) => setMemo(e.target.value)}
|
{threadLabel && (
|
||||||
onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }}
|
<span className={classNames("text-[10px] rounded-pill px-1.5 py-0.5 shrink-0 font-medium", isOriginal ? "bg-chip-bg text-navy" : "bg-[#EBE9FE] text-[#5925DC]")}>{threadLabel}</span>
|
||||||
placeholder="요약 메모 입력…"
|
)}
|
||||||
rows={2}
|
<span className="text-sm font-semibold text-ink truncate group-hover:text-navy">{cleanSubject(mail.subject)}</span>
|
||||||
className="w-full text-xs leading-snug text-ink bg-transparent rounded-control px-2 py-1.5 resize-none border border-transparent hover:border-border focus:border-navy focus:bg-surface focus:outline-none min-h-[44px]"
|
<ExternalLink size={12} className="shrink-0 text-ink-muted opacity-0 group-hover:opacity-100" />
|
||||||
/>
|
{mail.hidden && <span className="text-[10px] bg-divider text-ink-muted rounded-pill px-1.5 py-0.5 shrink-0">숨김</span>}
|
||||||
{mail.noteEditedBy && <span className="text-[10px] text-ink-muted mt-1 px-2 truncate">✎ {nameOf(mail.noteEditedBy)}</span>}
|
</span>
|
||||||
</div>
|
<span className="block text-xs text-ink-muted truncate mt-0.5">
|
||||||
</div>
|
<span className="text-ink-secondary font-medium">{sender}</span> <span className="text-ink-muted/70">→</span>{" "}
|
||||||
{open && (
|
<span title={rcpt.full} className="cursor-help underline decoration-dotted decoration-ink-muted/40 underline-offset-2">{rcpt.short}</span>
|
||||||
<div className="px-4 pb-4 pt-2 border-t border-divider space-y-3">
|
</span>
|
||||||
<div className="text-xs text-ink-muted grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
|
<span className="block text-sm text-ink-secondary truncate mt-1">{mail.snippet}</span>
|
||||||
<div className="sm:col-span-2"><span className="text-ink-secondary font-medium">보낸사람</span> {mail.from}</div>
|
</a>
|
||||||
<div className="sm:col-span-2"><span className="text-ink-secondary font-medium">받는사람</span> {mail.to || "—"}</div>
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{mail.cc && <div className="sm:col-span-2"><span className="text-ink-secondary font-medium">참조</span> {mail.cc}</div>}
|
<span className="text-[11px] text-ink-muted tabular hidden md:block">{when}</span>
|
||||||
<div><span className="text-ink-secondary font-medium">시각</span> {when}</div>
|
<button onClick={() => hide.mutate()} title={mail.hidden ? "다시 보이기" : "숨기기"}
|
||||||
<div><span className="text-ink-secondary font-medium">발견 메일함</span> {nameOf(mail.mailbox)}</div>
|
className="p-1.5 rounded text-ink-muted hover:text-ink hover:bg-canvas">
|
||||||
</div>
|
{mail.hidden ? <Eye size={15} /> : <EyeOff size={15} />}
|
||||||
|
</button>
|
||||||
{/* 메일 전문 (본문) */}
|
|
||||||
<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>
|
</div>
|
||||||
{fullQ.isLoading ? (
|
</div>
|
||||||
<div className="text-sm text-ink-muted py-4">본문 불러오는 중…</div>
|
{/* 메모 열 */}
|
||||||
) : fullQ.isError ? (
|
<div className="sm:w-72 shrink-0 border-t sm:border-t-0 sm:border-l border-divider px-3 py-2 flex flex-col bg-canvas/30">
|
||||||
<div className="text-sm text-status-pending-fg py-4">본문을 불러오지 못했습니다. (메일 연동/권한 확인)</div>
|
<textarea value={memo} onChange={(e) => setMemo(e.target.value)}
|
||||||
) : fullQ.data?.isHtml ? (
|
onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }}
|
||||||
<iframe title="메일 본문" sandbox="" srcDoc={fullQ.data.body}
|
placeholder="요약 메모 입력…" rows={2}
|
||||||
className="w-full h-80 border border-divider rounded-control bg-white" />
|
className="w-full text-xs leading-snug text-ink bg-transparent rounded-control px-2 py-1.5 resize-none border border-transparent hover:border-border focus:border-navy focus:bg-surface focus:outline-none min-h-[44px]" />
|
||||||
) : (
|
{mail.noteEditedBy && <span className="text-[10px] text-ink-muted mt-1 px-2 truncate">✎ {nameOf(mail.noteEditedBy)}</span>}
|
||||||
<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>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 pb-4 pt-2 border-t border-divider space-y-3">
|
||||||
|
{/* 스레드 순번 + 원문/이전/다음 이동 */}
|
||||||
|
{mail.threadCount > 1 && (
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap bg-canvas/60 rounded-control px-3 py-2">
|
||||||
|
<span className="text-xs text-ink-secondary">대화 {mail.threadIndex}/{mail.threadCount} · {isOriginal ? "원문" : `${mail.threadIndex - 1}번째 답장`}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<NavBtn label="원문" icon={<ArrowLeftToLine size={13} />} disabled={idx <= 0} onClick={() => first && onNavigate(first.messageId)} />
|
||||||
|
<NavBtn label="이전" icon={<ArrowLeftIcon size={13} />} disabled={!prev} onClick={() => prev && onNavigate(prev.messageId)} />
|
||||||
|
<NavBtn label="다음" icon={<ArrowRight size={13} />} disabled={!next} onClick={() => next && onNavigate(next.messageId)} iconRight />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
{mail.cc && <div className="sm:col-span-2"><span className="text-ink-secondary font-medium">참조</span> {mail.cc}</div>}
|
||||||
|
<div><span className="text-ink-secondary font-medium">시각</span> {when}</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-96 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-96 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* 첨부파일 리스트 */}
|
function NavBtn({ label, icon, disabled, onClick, iconRight }: { label: string; icon: React.ReactNode; disabled?: boolean; onClick: () => void; iconRight?: boolean }) {
|
||||||
{(fullQ.data?.attachments?.length ?? 0) > 0 && (
|
return (
|
||||||
<div>
|
<button onClick={onClick} disabled={disabled}
|
||||||
<span className="form-label">첨부파일 ({fullQ.data!.attachments.length})</span>
|
className={classNames("inline-flex items-center gap-1 text-xs px-2 py-1 rounded-control border border-border", disabled ? "opacity-40 cursor-not-allowed" : "text-ink hover:border-navy hover:bg-navy-subtle/30")}>
|
||||||
<div className="flex flex-wrap gap-2">
|
{!iconRight && icon}{label}{iconRight && icon}
|
||||||
{fullQ.data!.attachments.map((a) => (
|
</button>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -210,6 +210,8 @@ export interface ProjectMail {
|
|||||||
hiddenBy: string;
|
hiddenBy: string;
|
||||||
note: string; // 공동 메모 본문 (인라인 표시)
|
note: string; // 공동 메모 본문 (인라인 표시)
|
||||||
noteEditedBy: string; // 메모 마지막 수정자
|
noteEditedBy: string; // 메모 마지막 수정자
|
||||||
|
threadIndex: number; // 스레드 내 순번 (1=원문)
|
||||||
|
threadCount: number; // 스레드 총 메일 수
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectMailsResponse {
|
export interface ProjectMailsResponse {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user