feat(mail): 메일 리스트에 메모 인라인 표시 + 숨김 토글 + 동기화 버튼
All checks were successful
build-and-push / build (push) Successful in 32s
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:
parent
3382eef010
commit
05eb82c635
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user