feat(mail): 메일 리스트에 메모 인라인 표시 + 숨김 토글 + 동기화 버튼
All checks were successful
build-and-push / build (push) Successful in 32s

- 각 메일 행에 공동 메모 미리보기(📝), 펼치면 편집
- 메일 숨기기/다시보기, '숨긴 메일 보기(N)' 토글
- 마지막 동기화 시각 + 수동 '동기화' 버튼, 참조(CC) 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 12:44:58 +09:00
parent 3382eef010
commit 05eb82c635
3 changed files with 79 additions and 32 deletions

View File

@ -148,6 +148,10 @@ 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 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<TaskComment[]>(`/tasks/${tId}/comments`).then((r) => r.data);

View File

@ -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 <LoadingState />;
const data = q.data;
@ -499,66 +503,97 @@ function MailTab({ projectId }: { projectId: string }) {
return <EmptyState title="고객사 메일 도메인이 없습니다" icon={<Mail size={28} />}
description="‘프로젝트 수정’에서 고객사 메일 도메인(예: meditech.com)을 입력하면 관련 메일을 모아 봅니다." />;
}
const visible = data.messages.filter((m) => showHidden || !m.hidden);
const hiddenCount = data.messages.filter((m) => m.hidden).length;
return (
<div>
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
<p className="text-sm text-ink-secondary">
<span className="font-medium text-ink">@{data.domain}</span> () · <span className="text-ink-muted"> ·(CC) </span>
</p>
{data.error && <span className="text-xs text-status-pending-fg"> </span>}
<div className="min-w-0">
<p className="text-sm text-ink-secondary">
<span className="font-medium text-ink">@{data.domain}</span> () · <span className="text-ink-muted"> ·(CC) </span>
</p>
<p className="text-[11px] text-ink-muted mt-0.5">
{data.lastSyncedAt ? `마지막 동기화 ${formatDateTime(data.lastSyncedAt)}` : data.syncing ? "처음 동기화 중…" : "아직 동기화 안 됨"}
{data.error && <span className="text-status-pending-fg"> · </span>}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{hiddenCount > 0 && (
<button onClick={() => setShowHidden((v) => !v)} className="text-xs text-ink-secondary hover:text-ink px-2 py-1 rounded-control hover:bg-canvas">
{showHidden ? "숨긴 메일 가리기" : `숨긴 메일 보기 (${hiddenCount})`}
</button>
)}
<Button size="sm" variant="secondary" icon={<RefreshCw size={14} className={sync.isPending ? "animate-spin" : ""} />}
onClick={() => sync.mutate()} disabled={sync.isPending}></Button>
</div>
</div>
{data.messages.length === 0 ? (
<EmptyState title="해당 도메인 메일이 없습니다" icon={<Mail size={28} />} description="팀 구성원이 이 고객사 도메인과 주고받은 메일이 없습니다." />
{visible.length === 0 ? (
<EmptyState title={data.syncing ? "메일을 불러오는 중입니다" : "표시할 메일이 없습니다"} icon={<Mail size={28} />}
description={data.syncing ? "잠시 후 자동으로 표시됩니다. (처음 동기화는 시간이 걸릴 수 있어요)" : "내가 참여한 이 고객사 도메인 메일이 없습니다."} />
) : (
<div className="space-y-2">
{data.messages.map((m) => (
<MailRow key={m.id} projectId={projectId} mail={m} note={noteByMsg.get(m.id)} nameOf={nameOf} />
))}
{visible.map((m) => <MailRow key={m.messageId} projectId={projectId} mail={m} nameOf={nameOf} />)}
</div>
)}
</div>
);
}
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 (
<Card className="overflow-hidden">
<button onClick={() => setOpen((o) => !o)} className="w-full text-left flex items-start gap-3 px-4 py-3 hover:bg-canvas transition-colors">
<span className="mt-0.5 text-ink-muted shrink-0">{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</span>
<span className="min-w-0 flex-1">
<Card className={classNames("overflow-hidden", mail.hidden && "opacity-60")}>
<div className="flex items-start gap-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">
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<button onClick={() => setOpen((o) => !o)} className="min-w-0 flex-1 text-left">
<span className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink truncate">{mail.subject || "(제목 없음)"}</span>
{note?.body && <span className="text-[10px] bg-chip-bg text-navy rounded-pill px-1.5 py-0.5 shrink-0"></span>}
{mail.note && <span className="text-[10px] bg-chip-bg text-navy rounded-pill px-1.5 py-0.5 shrink-0"></span>}
{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">{mail.from}</span>
<span className="block text-sm text-ink-secondary truncate mt-1">{mail.snippet}</span>
</span>
<span className="text-[11px] text-ink-muted tabular shrink-0">{when}</span>
</button>
{/* 메일 리스트에 메모도 함께 표시 */}
{mail.note && (
<span className="mt-2 block text-xs text-ink bg-navy-subtle/40 border border-navy-subtle rounded-control px-2.5 py-1.5 line-clamp-2">
📝 {mail.note}
</span>
)}
</button>
<div className="flex items-center gap-1 shrink-0">
<span className="text-[11px] text-ink-muted tabular hidden sm: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>
</div>
</div>
{open && (
<div className="px-4 pb-4 pt-1 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><span className="text-ink-secondary font-medium"></span> {mail.from}</div>
<div><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> {nameOf(mail.mailbox)} <span className="text-ink-muted">({mail.mailbox})</span></div>
{mail.cc && <div className="sm:col-span-2"><span className="text-ink-secondary font-medium"></span> {mail.cc}</div>}
<div className="sm:col-span-2 sm:hidden"><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 className="text-ink-muted font-normal">· </span></span>
{note?.lastEditedBy && <span className="text-[11px] text-ink-muted"> {nameOf(note.lastEditedBy)}</span>}
{mail.noteEditedBy && <span className="text-[11px] text-ink-muted"> {nameOf(mail.noteEditedBy)}</span>}
</div>
<Textarea value={memo} onChange={(e) => setMemo(e.target.value)}
onBlur={() => { if (memo !== (note?.body ?? "")) save.mutate(); }}
onBlur={() => { if (memo !== (mail.note ?? "")) save.mutate(); }}
placeholder="이 메일에 대한 팀 메모를 남기세요. (대응 방향·할 일·합의 내용 등)" style={{ minHeight: 80 }} />
</div>
</div>

View File

@ -196,14 +196,20 @@ export interface Project {
export interface ProjectMail {
id: string;
messageId: string;
threadId: string;
from: string;
to: string;
cc: string;
subject: string;
date: string;
snippet: string;
mailbox: string;
ts: number;
hidden: boolean;
hiddenBy: string;
note: string; // 공동 메모 본문 (인라인 표시)
noteEditedBy: string; // 메모 마지막 수정자
}
export interface ProjectMailsResponse {
@ -211,6 +217,8 @@ export interface ProjectMailsResponse {
domain: string;
messages: ProjectMail[];
error?: string;
lastSyncedAt?: string;
syncing?: boolean;
}
export interface MailNote {