import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchEmployees } from "../api/employees"; import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects"; import { fetchContractsRegistry } from "../api/contractsRegistry"; import { mapApiErrorMessage } from "../api/errorMessages"; import { DetailSideMeta } from "../components/patterns/DetailSideMeta"; import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; import { MetricCard } from "../components/patterns/MetricCard"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { isApiError } from "../api/types"; import { StatusBadge } from "../components/common/StatusBadge"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Textarea } from "../components/ui/textarea"; import { toast } from "sonner"; const PROJECT_STATUS_OPTIONS: ProjectStatus[] = ["draft", "active", "paused", "completed", "archived"]; const MEMBER_ROLE_OPTIONS = ["owner", "manager", "member", "observer"] as const; const MILESTONE_STATUS_OPTIONS: ProjectMilestoneStatus[] = ["pending", "in_progress", "done", "delayed", "cancelled"]; const TASK_STATUS_OPTIONS: ProjectTaskStatus[] = ["todo", "doing", "blocked", "done", "cancelled"]; const TASK_PRIORITY_OPTIONS: ProjectTaskPriority[] = ["low", "normal", "high", "urgent"]; const RISK_LEVEL_OPTIONS: ProjectRiskLevel[] = ["low", "medium", "high"]; const RISK_STATUS_OPTIONS = ["open", "monitoring", "mitigating", "closed"]; const DELIVERY_TYPE_OPTIONS: ProjectDeliveryRecordType[] = ["kickoff", "onsite", "stage_acceptance", "final_acceptance", "handover"]; const COMMUNICATION_CHANNEL_OPTIONS: ProjectCommunicationChannel[] = ["wechat", "phone", "meeting", "wecom", "email", "onsite", "other"]; const CLOSURE_CHECKLIST_STATUS_OPTIONS: ProjectClosureChecklistStatus[] = ["pending", "done", "waived"]; const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"]; const STORAGE_TYPE_OPTIONS: DocumentStorageType[] = ["oa_link", "nas", "internal_share", "offline_archive"]; function resolveError(error: unknown, fallback = "操作失败") { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } return error instanceof Error ? error.message : fallback; } function confidentialityLabel(value: ConfidentialityLevel) { if (value === "public") return "公开"; if (value === "internal") return "内部"; if (value === "confidential") return "机密"; return "限制"; } function storageTypeLabel(value: DocumentStorageType) { if (value === "oa_link") return "OA 链接"; if (value === "nas") return "NAS"; if (value === "internal_share") return "内网共享"; return "离线档案"; } function recordTypeLabel(value: ProjectDeliveryRecordType) { if (value === "kickoff") return "启动"; if (value === "onsite") return "现场"; if (value === "stage_acceptance") return "阶段验收"; if (value === "final_acceptance") return "最终验收"; return "交接"; } function communicationChannelLabel(value: ProjectCommunicationChannel) { if (value === "wechat") return "微信"; if (value === "phone") return "电话"; if (value === "meeting") return "会议"; if (value === "wecom") return "企业微信"; if (value === "email") return "邮件"; if (value === "onsite") return "现场"; return "其他"; } function closureChecklistStatusLabel(value: ProjectClosureChecklistStatus) { if (value === "done") return "已完成"; if (value === "waived") return "已豁免"; return "待处理"; } function formatDateTime(value: string | null) { if (!value) return "-"; return value.slice(0, 16).replace("T", " "); } export function ProjectDetailPage() { const { projectId = "" } = useParams(); const queryClient = useQueryClient(); const detailQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", "detail", projectId], queryFn: () => fetchProject(projectId), }); const employeesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["employees", "project-detail"], queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }), }); const expensesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "expenses"], queryFn: () => fetchProjectExpensesSummary(projectId) }); const travelQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "travel"], queryFn: () => fetchProjectTravelSummary(projectId) }); const purchaseQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "purchase-requests"], queryFn: () => fetchProjectPurchaseSummary(projectId) }); const weeklyQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "weekly"], queryFn: () => fetchProjectWeeklyReports(projectId) }); const deliveriesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "deliveries"], queryFn: () => fetchProjectDeliveries(projectId) }); const docsQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "document-refs"], queryFn: () => fetchProjectDocumentRefs(projectId) }); const contractsQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["contracts-registry", projectId], queryFn: () => fetchContractsRegistry(projectId) }); const membersQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "members"], queryFn: () => fetchProjectMembers(projectId) }); const milestonesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "milestones"], queryFn: () => fetchProjectMilestones(projectId) }); const tasksQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "tasks"], queryFn: () => fetchProjectTasks(projectId) }); const risksQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "risks"], queryFn: () => fetchProjectRisks(projectId) }); const communicationLogsQuery = useQuery({ enabled: Boolean(projectId) && Boolean(detailQuery.data?.item?.capabilities.communicationLogEnabled), queryKey: ["projects", projectId, "communication-logs"], queryFn: () => fetchProjectCommunicationLogs(projectId), }); const closureChecklistQuery = useQuery({ enabled: Boolean(projectId) && Boolean(detailQuery.data?.item?.capabilities.closureChecklistEnabled), queryKey: ["projects", projectId, "closure-checklist"], queryFn: () => fetchProjectClosureChecklist(projectId), }); const employeeOptions = employeesQuery.data?.items ?? []; const detail = detailQuery.data?.item ?? null; const [projectForm, setProjectForm] = useState({ name: "", clientName: "", clientAlias: "", status: "draft" as ProjectStatus, startsAt: "", targetEndAt: "", actualEndAt: "", managerEmployeeId: "", summary: "", isConfidential: false, }); const [memberEmployeeId, setMemberEmployeeId] = useState(""); const [memberRole, setMemberRole] = useState<(typeof MEMBER_ROLE_OPTIONS)[number]>("member"); const [milestoneTitle, setMilestoneTitle] = useState(""); const [milestoneDueDate, setMilestoneDueDate] = useState(""); const [milestoneStatus, setMilestoneStatus] = useState("pending"); const [taskTitle, setTaskTitle] = useState(""); const [taskDueDate, setTaskDueDate] = useState(""); const [taskAssigneeId, setTaskAssigneeId] = useState(""); const [taskPriority, setTaskPriority] = useState("normal"); const [riskTitle, setRiskTitle] = useState(""); const [riskDetail, setRiskDetail] = useState(""); const [riskLevel, setRiskLevel] = useState("medium"); const [riskOwnerId, setRiskOwnerId] = useState(""); const [deliveryTitle, setDeliveryTitle] = useState(""); const [deliverySummary, setDeliverySummary] = useState(""); const [deliveryType, setDeliveryType] = useState("onsite"); const [deliveryOccurredAt, setDeliveryOccurredAt] = useState(""); const [docTitle, setDocTitle] = useState(""); const [docNo, setDocNo] = useState(""); const [docStoragePath, setDocStoragePath] = useState(""); const [docOfflineArchiveNo, setDocOfflineArchiveNo] = useState(""); const [docConfidentiality, setDocConfidentiality] = useState("internal"); const [docStorageType, setDocStorageType] = useState("nas"); const [communicationForm, setCommunicationForm] = useState({ happenedAt: new Date().toISOString().slice(0, 10), channel: "wechat" as ProjectCommunicationChannel, externalContactName: "", externalOrgName: "", summary: "", nextAction: "", nextFollowUpDate: "", }); const [editingCommunicationLogId, setEditingCommunicationLogId] = useState(null); const [waiveChecklistTarget, setWaiveChecklistTarget] = useState<{ itemId: string; currentNote: string | null } | null>(null); const [waiveChecklistNote, setWaiveChecklistNote] = useState(""); useEffect(() => { if (!detail) return; setProjectForm({ name: detail.name, clientName: detail.clientName, clientAlias: detail.clientAlias ?? "", status: detail.status, startsAt: detail.startsAt ?? "", targetEndAt: detail.targetEndAt ?? "", actualEndAt: detail.actualEndAt ?? "", managerEmployeeId: detail.managerEmployeeId ?? "", summary: detail.summary ?? "", isConfidential: detail.isConfidential, }); }, [detail]); const resetCommunicationForm = () => { setCommunicationForm({ happenedAt: new Date().toISOString().slice(0, 10), channel: "wechat", externalContactName: "", externalOrgName: "", summary: "", nextAction: "", nextFollowUpDate: "", }); setEditingCommunicationLogId(null); }; const refreshProject = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["projects"] }), queryClient.invalidateQueries({ queryKey: ["projects", "detail", projectId] }), queryClient.invalidateQueries({ queryKey: ["projects", projectId] }), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "communication-logs"] }), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "closure-checklist"] }), queryClient.invalidateQueries({ queryKey: ["portal-home"] }), queryClient.invalidateQueries({ queryKey: ["global-search"] }), queryClient.invalidateQueries({ queryKey: ["contracts-registry", projectId] }), ]); }; const saveProjectMutation = useMutation({ mutationFn: () => patchProject(projectId, { name: projectForm.name.trim(), clientName: projectForm.clientName.trim(), clientAlias: projectForm.clientAlias.trim() || null, status: projectForm.status, startsAt: projectForm.startsAt || null, targetEndAt: projectForm.targetEndAt || null, actualEndAt: projectForm.actualEndAt || null, managerEmployeeId: projectForm.managerEmployeeId || null, summary: projectForm.summary.trim() || null, isConfidential: projectForm.isConfidential, }), onSuccess: async (payload) => { await refreshProject(); toast.success("项目主档已更新"); if (payload.closureWarning) { toast.warning(payload.closureWarning.message); } }, onError: (error) => toast.error(resolveError(error, "保存项目失败")), }); const archiveProjectMutation = useMutation({ mutationFn: () => archiveProject(projectId), onSuccess: async (payload) => { await refreshProject(); toast.success("项目已归档"); if (payload.closureWarning) { toast.warning(payload.closureWarning.message); } }, onError: (error) => toast.error(resolveError(error, "归档项目失败")), }); const addMemberMutation = useMutation({ mutationFn: () => createProjectMember(projectId, { employeeId: memberEmployeeId, role: memberRole }), onSuccess: async () => { setMemberEmployeeId(""); setMemberRole("member"); await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "members"] }), ]); toast.success("项目成员已添加"); }, onError: (error) => toast.error(resolveError(error, "添加成员失败")), }); const patchMemberMutation = useMutation({ mutationFn: ({ memberId, role, leftAt }: { memberId: string; role?: (typeof MEMBER_ROLE_OPTIONS)[number]; leftAt?: string | null }) => patchProjectMember(projectId, memberId, { ...(role ? { role } : {}), ...(leftAt !== undefined ? { leftAt } : {}) }), onSuccess: async () => { await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "members"] }), ]); toast.success("成员已更新"); }, onError: (error) => toast.error(resolveError(error, "更新成员失败")), }); const addMilestoneMutation = useMutation({ mutationFn: () => createProjectMilestone(projectId, { title: milestoneTitle.trim(), dueDate: milestoneDueDate || null, status: milestoneStatus }), onSuccess: async () => { setMilestoneTitle(""); setMilestoneDueDate(""); setMilestoneStatus("pending"); await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "milestones"] }), ]); toast.success("里程碑已创建"); }, onError: (error) => toast.error(resolveError(error, "创建里程碑失败")), }); const patchMilestoneMutation = useMutation({ mutationFn: ({ milestoneId, status }: { milestoneId: string; status: ProjectMilestoneStatus }) => patchProjectMilestone(projectId, milestoneId, { status }), onSuccess: async () => { await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "milestones"] }), ]); toast.success("里程碑状态已更新"); }, onError: (error) => toast.error(resolveError(error, "更新里程碑失败")), }); const addTaskMutation = useMutation({ mutationFn: () => createProjectTask(projectId, { title: taskTitle.trim(), dueDate: taskDueDate || null, assigneeEmployeeId: taskAssigneeId || null, priority: taskPriority, status: "todo" }), onSuccess: async () => { setTaskTitle(""); setTaskDueDate(""); setTaskAssigneeId(""); setTaskPriority("normal"); await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }), ]); toast.success("项目任务已创建"); }, onError: (error) => toast.error(resolveError(error, "创建任务失败")), }); const patchTaskMutation = useMutation({ mutationFn: ({ taskId, payload }: { taskId: string; payload: Partial<{ status: ProjectTaskStatus; priority: ProjectTaskPriority; assigneeEmployeeId: string | null }> }) => patchProjectTask(projectId, taskId, payload), onSuccess: async () => { await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }), ]); toast.success("任务已更新"); }, onError: (error) => toast.error(resolveError(error, "更新任务失败")), }); const addRiskMutation = useMutation({ mutationFn: () => createProjectRisk(projectId, { title: riskTitle.trim(), detail: riskDetail.trim(), level: riskLevel, ownerEmployeeId: riskOwnerId || null, status: "open" }), onSuccess: async () => { setRiskTitle(""); setRiskDetail(""); setRiskLevel("medium"); setRiskOwnerId(""); await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "risks"] }), ]); toast.success("项目风险已登记"); }, onError: (error) => toast.error(resolveError(error, "创建风险失败")), }); const patchRiskMutation = useMutation({ mutationFn: ({ riskId, status }: { riskId: string; status: string }) => patchProjectRisk(projectId, riskId, { status }), onSuccess: async () => { await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "risks"] }), ]); toast.success("风险状态已更新"); }, onError: (error) => toast.error(resolveError(error, "更新风险失败")), }); const addDeliveryMutation = useMutation({ mutationFn: () => createProjectDelivery(projectId, { title: deliveryTitle.trim(), summary: deliverySummary.trim(), recordType: deliveryType, occurredAt: `${deliveryOccurredAt || new Date().toISOString().slice(0, 10)}T00:00:00.000Z` }), onSuccess: async () => { setDeliveryTitle(""); setDeliverySummary(""); setDeliveryType("onsite"); setDeliveryOccurredAt(""); await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "deliveries"] }), ]); toast.success("交付记录已创建"); }, onError: (error) => toast.error(resolveError(error, "创建交付记录失败")), }); const addDocRefMutation = useMutation({ mutationFn: () => createConfidentialDocumentRef({ projectId, title: docTitle.trim(), documentNo: docNo.trim() || null, confidentialityLevel: docConfidentiality, storageType: docStorageType, storagePath: docStoragePath.trim(), offlineArchiveNo: docOfflineArchiveNo.trim() || null }), onSuccess: async () => { setDocTitle(""); setDocNo(""); setDocStoragePath(""); setDocOfflineArchiveNo(""); setDocConfidentiality("internal"); setDocStorageType("nas"); await Promise.all([ refreshProject(), queryClient.invalidateQueries({ queryKey: ["projects", projectId, "document-refs"] }), ]); toast.success("涉密资料引用已登记"); }, onError: (error) => toast.error(resolveError(error, "登记资料引用失败")), }); const saveCommunicationLogMutation = useMutation({ mutationFn: async () => { const payload = { happenedAt: `${communicationForm.happenedAt || new Date().toISOString().slice(0, 10)}T00:00:00.000Z`, channel: communicationForm.channel, externalContactName: communicationForm.externalContactName.trim() || null, externalOrgName: communicationForm.externalOrgName.trim() || null, summary: communicationForm.summary.trim(), nextAction: communicationForm.nextAction.trim() || null, nextFollowUpDate: communicationForm.nextFollowUpDate || null, }; if (editingCommunicationLogId) { return patchProjectCommunicationLog(projectId, editingCommunicationLogId, payload); } return createProjectCommunicationLog(projectId, payload); }, onSuccess: async () => { await refreshProject(); resetCommunicationForm(); toast.success(editingCommunicationLogId ? "沟通回填已更新" : "沟通回填已新增"); }, onError: (error) => toast.error(resolveError(error, editingCommunicationLogId ? "更新沟通回填失败" : "新增沟通回填失败")), }); const patchClosureChecklistMutation = useMutation({ mutationFn: ({ itemId, status, note }: { itemId: string; status: ProjectClosureChecklistStatus; note?: string | null }) => patchProjectClosureChecklistItem(projectId, itemId, { status, ...(note !== undefined ? { note } : {}), }), onSuccess: async () => { await refreshProject(); toast.success("项目收口项已更新"); }, onError: (error) => toast.error(resolveError(error, "更新项目收口项失败")), }); const counts = detail?.counts; const communicationItems = (communicationLogsQuery.data?.items ?? []).slice(0, 10); const closureChecklistItems = closureChecklistQuery.data?.items ?? []; const handleEditCommunicationLog = (item: typeof communicationItems[number]) => { setEditingCommunicationLogId(item.id); setCommunicationForm({ happenedAt: item.happenedAt?.slice(0, 10) ?? new Date().toISOString().slice(0, 10), channel: item.channel, externalContactName: item.externalContactName ?? "", externalOrgName: item.externalOrgName ?? "", summary: item.summary, nextAction: item.nextAction ?? "", nextFollowUpDate: item.nextFollowUpDate ?? "", }); }; const handleChecklistStatusChange = (itemId: string, status: ProjectClosureChecklistStatus, currentNote: string | null) => { if (status === "waived") { setWaiveChecklistTarget({ itemId, currentNote }); setWaiveChecklistNote(currentNote ?? ""); return; } patchClosureChecklistMutation.mutate({ itemId, status }); }; if (!projectId) { return
缺少 projectId 参数。
; } if (detailQuery.isLoading) { return
加载项目详情中...
; } if (detailQuery.isError || !detail) { return (
{resolveError(detailQuery.error, "加载项目详情失败")}
); } const memberItems = membersQuery.data?.items ?? detail.members; const milestoneItems = milestonesQuery.data?.items ?? detail.milestones; const taskItems = tasksQuery.data?.items ?? detail.tasks; const riskItems = risksQuery.data?.items ?? detail.risks; return ( <> {detail.isConfidential ? 涉密项目 : null} 客户:{detail.clientAlias || detail.clientName} 负责人:{detail.owner?.name ?? "-"} 项目经理:{detail.manager?.name ?? "-"} } actions={
} />
0 ? "warning" : "neutral"} />
{(detail.status === "completed" || detail.status === "archived") && detail.closureSummary.pendingCount > 0 ? (
项目已进入收口状态,但仍有 {detail.closureSummary.pendingCount} 项待处理
本轮是软约束:允许完成或归档,但请尽快补齐交付、费用、合同与交接说明。
) : null} {detail.capabilities.communicationLogEnabled || detail.capabilities.closureChecklistEnabled ? (
{detail.capabilities.communicationLogEnabled ? (

客户沟通

只记录结果摘要,不同步聊天记录。
最近沟通:{detail.communicationSummary.latestHappenedAt ? detail.communicationSummary.latestHappenedAt.slice(0, 10) : "-"}
今日待跟进 {detail.communicationSummary.followUpDueTodayCount} · 已逾期 {detail.communicationSummary.overdueFollowUpCount}
沟通总数
{detail.communicationSummary.totalCount}
今日待跟进
{detail.communicationSummary.followUpDueTodayCount}
已逾期
{detail.communicationSummary.overdueFollowUpCount}
{editingCommunicationLogId ? "编辑沟通回填" : "新增沟通回填"}
{editingCommunicationLogId ? ( ) : null}
setCommunicationForm((prev) => ({ ...prev, happenedAt: event.target.value }))} /> setCommunicationForm((prev) => ({ ...prev, externalContactName: event.target.value }))} placeholder="外部联系人(可选)" />
setCommunicationForm((prev) => ({ ...prev, externalOrgName: event.target.value }))} placeholder="外部单位(可选)" /> setCommunicationForm((prev) => ({ ...prev, nextFollowUpDate: event.target.value }))} />