feat(mail): 프로젝트 메일 탭 — 고객사 도메인 메일 목록 + 메일별 공동 메모
All checks were successful
build-and-push / build (push) Successful in 31s

- 프로젝트 상세에 '메일' 탭: @도메인과 주고받은 팀 메일 집계 목록(펼치면 본문·메모)
- 메일별 공동 메모(MailRow): 프로젝트 구성원 누구나 수정, 마지막 수정자 표시
- 연동 미설정/도메인 미입력 시 안내 EmptyState
- 프로젝트 생성·수정에 '고객사 메일 도메인' 입력 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 10:41:27 +09:00
parent 2c5078aa2f
commit 3bf2dc0924
4 changed files with 135 additions and 6 deletions

View File

@ -2,9 +2,9 @@ import axios from "axios";
import type {
Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact,
Company, Contract, ContractFile, Dashboard, Department, DirectoryEntry, IncentiveConfig,
LeaveBalance, LeaveRequest, Me, Member, MyIncentive, NavItem, Notification,
OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember,
ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction,
LeaveBalance, LeaveRequest, MailNote, Me, Member, MyIncentive, NavItem, Notification,
OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMailsResponse,
ProjectMember, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction,
UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind,
} from "@/types";
@ -142,6 +142,13 @@ export const updateTask = (tId: string, b: Partial<ProjectTask>) =>
api.patch<ProjectTask>(`/tasks/${tId}`, b).then((r) => r.data);
export const deleteTask = (tId: string) => api.delete(`/tasks/${tId}`).then((r) => r.data);
export const getProjectMails = (id: string) =>
api.get<ProjectMailsResponse>(`/projects/${id}/mails`).then((r) => r.data);
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 getTaskComments = (tId: string) =>
api.get<TaskComment[]>(`/tasks/${tId}/comments`).then((r) => r.data);
export const createTaskComment = (tId: string, body: string) =>

View File

@ -3,11 +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,
} from "lucide-react";
import {
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask,
getTaskComments, createTaskComment, deleteTaskComment,
getProjectMails, getMailNotes, putMailNote,
upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl,
deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject,
updateProject,
@ -26,7 +28,7 @@ import {
formatDate, formatDateTime, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS,
PRIORITY_META, classNames,
} from "@/lib/format";
import type { Lane, PaymentSplit, Project, ProjectTask, TaskPriority } from "@/types";
import type { Lane, MailNote, PaymentSplit, Project, ProjectMail, ProjectTask, TaskPriority } from "@/types";
export function ProjectDetailPage() {
const { id = "" } = useParams();
@ -44,6 +46,7 @@ export function ProjectDetailPage() {
{ key: "overview", label: "개요" },
{ key: "members", label: "작업자" },
{ key: "timeline", label: "타임라인" },
{ key: "mail", label: "메일" },
{ key: "contacts", label: "업체 담당자" },
...(isAdmin ? [{ key: "contract", label: "계약 · 정산" }] : []),
];
@ -63,6 +66,7 @@ export function ProjectDetailPage() {
{tab === "overview" && <Overview project={p} />}
{tab === "members" && <Members projectId={id} isAdmin={isAdmin} />}
{tab === "timeline" && <Timeline projectId={id} isAdmin={isAdmin} />}
{tab === "mail" && <MailTab projectId={id} />}
{tab === "contacts" && <Contacts projectId={id} isAdmin={isAdmin} />}
{tab === "contract" && isAdmin && <ContractTab projectId={id} />}
</div>
@ -478,6 +482,91 @@ function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean
);
}
/* ---- project mail (Google Workspace) + 공동 메모 ---- */
function MailTab({ projectId }: { projectId: string }) {
const { nameOf } = useDirectory();
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]));
if (q.isLoading) return <LoadingState />;
const data = q.data;
if (!data?.enabled) {
return <EmptyState title="메일 연동이 설정되지 않았습니다" icon={<Mail size={28} />}
description="관리자가 Google Workspace 서비스계정(도메인 위임)을 연결하면, 이 프로젝트 고객사 도메인과 주고받은 메일이 팀 메일함에서 모여 표시됩니다." />;
}
if (!data.domain) {
return <EmptyState title="고객사 메일 도메인이 없습니다" icon={<Mail size={28} />}
description="‘프로젝트 수정’에서 고객사 메일 도메인(예: meditech.com)을 입력하면 관련 메일을 모아 봅니다." />;
}
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> () ·
</p>
{data.error && <span className="text-xs text-status-pending-fg"> </span>}
</div>
{data.messages.length === 0 ? (
<EmptyState title="해당 도메인 메일이 없습니다" icon={<Mail size={28} />} description="팀 구성원이 이 고객사 도메인과 주고받은 메일이 없습니다." />
) : (
<div className="space-y-2">
{data.messages.map((m) => (
<MailRow key={m.id} projectId={projectId} mail={m} note={noteByMsg.get(m.id)} nameOf={nameOf} />
))}
</div>
)}
</div>
);
}
function MailRow({ projectId, mail, note, nameOf }: { projectId: string; mail: ProjectMail; note?: MailNote; 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 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">
<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>}
</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>
{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>
</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>}
</div>
<Textarea value={memo} onChange={(e) => setMemo(e.target.value)}
onBlur={() => { if (memo !== (note?.body ?? "")) save.mutate(); }}
placeholder="이 메일에 대한 팀 메모를 남기세요. (대응 방향·할 일·합의 내용 등)" style={{ minHeight: 80 }} />
</div>
</div>
)}
</Card>
);
}
/* ---- contract / payments (ADMIN ONLY) ---- */
function ContractTab({ projectId }: { projectId: string }) {
const qc = useQueryClient();
@ -604,7 +693,8 @@ function EditProjectModal({ project, onClose }: { project: Project; onClose: ()
const [f, setF] = useState({
name: project.name, consultingType: project.consultingType, country: project.country,
scopeText: project.scopeText, scopeGraphic: project.scopeGraphic, pmEmail: project.pmEmail,
cautions: project.cautions, status: project.status, startDate: project.startDate, dueDate: project.dueDate,
clientDomain: project.clientDomain, cautions: project.cautions, status: project.status,
startDate: project.startDate, dueDate: project.dueDate,
});
const save = useMutation({
mutationFn: () => updateProject(project.id, f as Partial<Project>),
@ -631,6 +721,7 @@ function EditProjectModal({ project, onClose }: { project: Project; onClose: ()
<Field label="시작일"><Input type="date" value={f.startDate} onChange={(e) => setF({ ...f, startDate: e.target.value })} /></Field>
<Field label="마감일"><Input type="date" value={f.dueDate} onChange={(e) => setF({ ...f, dueDate: e.target.value })} /></Field>
</div>
<Field label="고객사 메일 도메인" hint="이 도메인과 주고받은 메일을 '메일' 탭에 모아 봅니다. 예: meditech.com"><Input value={f.clientDomain} onChange={(e) => setF({ ...f, clientDomain: e.target.value })} placeholder="meditech.com" /></Field>
<Field label="주의사항"><Textarea value={f.cautions} onChange={(e) => setF({ ...f, cautions: e.target.value })} /></Field>
</div>
</Modal>

View File

@ -74,7 +74,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const [productId, setProductId] = useState("");
const verQ = useQuery({ queryKey: ["versions", productId], queryFn: () => getVersions(productId), enabled: !!productId });
const [versionId, setVersionId] = useState("");
const [form, setForm] = useState({ name: "", consultingType: "", country: "", scopeText: "", scopeGraphic: "", pmEmail: "", cautions: "", startDate: "", dueDate: "" });
const [form, setForm] = useState({ name: "", consultingType: "", country: "", scopeText: "", scopeGraphic: "", pmEmail: "", clientDomain: "", cautions: "", startDate: "", dueDate: "" });
// quick-add master data
const [newCompany, setNewCompany] = useState("");
@ -149,6 +149,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<Field label="시작일"><Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} /></Field>
<Field label="마감일"><Input type="date" value={form.dueDate} onChange={(e) => setForm({ ...form, dueDate: e.target.value })} /></Field>
</div>
<Field label="고객사 메일 도메인" hint="이 도메인과 주고받은 메일을 '메일' 탭에 모아 봅니다. 예: meditech.com"><Input value={form.clientDomain} onChange={(e) => setForm({ ...form, clientDomain: e.target.value })} placeholder="meditech.com" /></Field>
<Field label="주의사항"><Textarea value={form.cautions} onChange={(e) => setForm({ ...form, cautions: e.target.value })} /></Field>
</div>
</Modal>

View File

@ -185,6 +185,7 @@ export interface Project {
scopeText: string; // 글 계약 범위
scopeGraphic: string; // 그림 계약 범위
pmEmail: string;
clientDomain: string; // 고객사 메일 도메인(postfix)
cautions: string;
status: ProjectStatus;
startDate: string;
@ -193,6 +194,35 @@ export interface Project {
updatedAt: string;
}
export interface ProjectMail {
id: string;
threadId: string;
from: string;
to: string;
subject: string;
date: string;
snippet: string;
mailbox: string;
ts: number;
}
export interface ProjectMailsResponse {
enabled: boolean;
domain: string;
messages: ProjectMail[];
error?: string;
}
export interface MailNote {
id: string;
projectId: string;
messageId: string;
body: string;
lastEditedBy: string;
createdAt: string;
updatedAt: string;
}
export interface ProjectMember {
id: string;
projectId: string;