feat(mail): 프로젝트 메일 탭 — 고객사 도메인 메일 목록 + 메일별 공동 메모
All checks were successful
build-and-push / build (push) Successful in 31s
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:
parent
2c5078aa2f
commit
3bf2dc0924
@ -2,9 +2,9 @@ import axios from "axios";
|
|||||||
import type {
|
import type {
|
||||||
Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact,
|
Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact,
|
||||||
Company, Contract, ContractFile, Dashboard, Department, DirectoryEntry, IncentiveConfig,
|
Company, Contract, ContractFile, Dashboard, Department, DirectoryEntry, IncentiveConfig,
|
||||||
LeaveBalance, LeaveRequest, Me, Member, MyIncentive, NavItem, Notification,
|
LeaveBalance, LeaveRequest, MailNote, Me, Member, MyIncentive, NavItem, Notification,
|
||||||
OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember,
|
OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMailsResponse,
|
||||||
ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction,
|
ProjectMember, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction,
|
||||||
UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind,
|
UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
@ -142,6 +142,13 @@ export const updateTask = (tId: string, b: Partial<ProjectTask>) =>
|
|||||||
api.patch<ProjectTask>(`/tasks/${tId}`, b).then((r) => r.data);
|
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 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) =>
|
export const getTaskComments = (tId: string) =>
|
||||||
api.get<TaskComment[]>(`/tasks/${tId}/comments`).then((r) => r.data);
|
api.get<TaskComment[]>(`/tasks/${tId}/comments`).then((r) => r.data);
|
||||||
export const createTaskComment = (tId: string, body: string) =>
|
export const createTaskComment = (tId: string, body: string) =>
|
||||||
|
|||||||
@ -3,11 +3,13 @@ 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,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
|
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
|
||||||
getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask,
|
getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask,
|
||||||
getTaskComments, createTaskComment, deleteTaskComment,
|
getTaskComments, createTaskComment, deleteTaskComment,
|
||||||
|
getProjectMails, getMailNotes, putMailNote,
|
||||||
upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl,
|
upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl,
|
||||||
deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject,
|
deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject,
|
||||||
updateProject,
|
updateProject,
|
||||||
@ -26,7 +28,7 @@ import {
|
|||||||
formatDate, formatDateTime, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS,
|
formatDate, formatDateTime, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS,
|
||||||
PRIORITY_META, classNames,
|
PRIORITY_META, classNames,
|
||||||
} from "@/lib/format";
|
} 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() {
|
export function ProjectDetailPage() {
|
||||||
const { id = "" } = useParams();
|
const { id = "" } = useParams();
|
||||||
@ -44,6 +46,7 @@ export function ProjectDetailPage() {
|
|||||||
{ key: "overview", label: "개요" },
|
{ key: "overview", label: "개요" },
|
||||||
{ key: "members", label: "작업자" },
|
{ key: "members", label: "작업자" },
|
||||||
{ key: "timeline", label: "타임라인" },
|
{ key: "timeline", label: "타임라인" },
|
||||||
|
{ key: "mail", label: "메일" },
|
||||||
{ key: "contacts", label: "업체 담당자" },
|
{ key: "contacts", label: "업체 담당자" },
|
||||||
...(isAdmin ? [{ key: "contract", label: "계약 · 정산" }] : []),
|
...(isAdmin ? [{ key: "contract", label: "계약 · 정산" }] : []),
|
||||||
];
|
];
|
||||||
@ -63,6 +66,7 @@ export function ProjectDetailPage() {
|
|||||||
{tab === "overview" && <Overview project={p} />}
|
{tab === "overview" && <Overview project={p} />}
|
||||||
{tab === "members" && <Members projectId={id} isAdmin={isAdmin} />}
|
{tab === "members" && <Members projectId={id} isAdmin={isAdmin} />}
|
||||||
{tab === "timeline" && <Timeline projectId={id} isAdmin={isAdmin} />}
|
{tab === "timeline" && <Timeline projectId={id} isAdmin={isAdmin} />}
|
||||||
|
{tab === "mail" && <MailTab projectId={id} />}
|
||||||
{tab === "contacts" && <Contacts projectId={id} isAdmin={isAdmin} />}
|
{tab === "contacts" && <Contacts projectId={id} isAdmin={isAdmin} />}
|
||||||
{tab === "contract" && isAdmin && <ContractTab projectId={id} />}
|
{tab === "contract" && isAdmin && <ContractTab projectId={id} />}
|
||||||
</div>
|
</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) ---- */
|
/* ---- contract / payments (ADMIN ONLY) ---- */
|
||||||
function ContractTab({ projectId }: { projectId: string }) {
|
function ContractTab({ projectId }: { projectId: string }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@ -604,7 +693,8 @@ function EditProjectModal({ project, onClose }: { project: Project; onClose: ()
|
|||||||
const [f, setF] = useState({
|
const [f, setF] = useState({
|
||||||
name: project.name, consultingType: project.consultingType, country: project.country,
|
name: project.name, consultingType: project.consultingType, country: project.country,
|
||||||
scopeText: project.scopeText, scopeGraphic: project.scopeGraphic, pmEmail: project.pmEmail,
|
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({
|
const save = useMutation({
|
||||||
mutationFn: () => updateProject(project.id, f as Partial<Project>),
|
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.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>
|
<Field label="마감일"><Input type="date" value={f.dueDate} onChange={(e) => setF({ ...f, dueDate: e.target.value })} /></Field>
|
||||||
</div>
|
</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>
|
<Field label="주의사항"><Textarea value={f.cautions} onChange={(e) => setF({ ...f, cautions: e.target.value })} /></Field>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
|||||||
const [productId, setProductId] = useState("");
|
const [productId, setProductId] = useState("");
|
||||||
const verQ = useQuery({ queryKey: ["versions", productId], queryFn: () => getVersions(productId), enabled: !!productId });
|
const verQ = useQuery({ queryKey: ["versions", productId], queryFn: () => getVersions(productId), enabled: !!productId });
|
||||||
const [versionId, setVersionId] = useState("");
|
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
|
// quick-add master data
|
||||||
const [newCompany, setNewCompany] = useState("");
|
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.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>
|
<Field label="마감일"><Input type="date" value={form.dueDate} onChange={(e) => setForm({ ...form, dueDate: e.target.value })} /></Field>
|
||||||
</div>
|
</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>
|
<Field label="주의사항"><Textarea value={form.cautions} onChange={(e) => setForm({ ...form, cautions: e.target.value })} /></Field>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
30
src/types.ts
30
src/types.ts
@ -185,6 +185,7 @@ export interface Project {
|
|||||||
scopeText: string; // 글 계약 범위
|
scopeText: string; // 글 계약 범위
|
||||||
scopeGraphic: string; // 그림 계약 범위
|
scopeGraphic: string; // 그림 계약 범위
|
||||||
pmEmail: string;
|
pmEmail: string;
|
||||||
|
clientDomain: string; // 고객사 메일 도메인(postfix)
|
||||||
cautions: string;
|
cautions: string;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
@ -193,6 +194,35 @@ export interface Project {
|
|||||||
updatedAt: string;
|
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 {
|
export interface ProjectMember {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user