R23-3:完成项目页与Portal收口面板

这个提交包含在:
2026-03-22 11:31:22 +08:00
父节点 9939657790
当前提交 a136000797
共修改 6 个文件,包含 407 行新增和 7 行删除
+1 -1
查看文件
@@ -1,6 +1,6 @@
import { apiRequestData } from "./http";
export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar" | "budget_precheck_v1" | "payment_batch_v1" | "portal_home_v1" | "global_search_v1" | "announcements_v1" | "workflow_definition_admin_v1" | "cutover_console_v1" | "presence_board_v1" | "weekly_report_v1" | "employee_lifecycle_v1" | "todo_board_v1";
export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar" | "budget_precheck_v1" | "payment_batch_v1" | "portal_home_v1" | "global_search_v1" | "announcements_v1" | "workflow_definition_admin_v1" | "cutover_console_v1" | "presence_board_v1" | "weekly_report_v1" | "employee_lifecycle_v1" | "todo_board_v1" | "project_domain_v1" | "project_linking_v1" | "project_communication_log_v1" | "project_closure_checklist_v1" | "confidential_doc_refs_v1" | "public_security_hardening_v1";
export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
export type FeatureFlagRolloutType = "global" | "department_allowlist";
+2
查看文件
@@ -34,6 +34,8 @@ export type PortalHomePayload = {
overdueMilestonesCount: number;
highPriorityTaskCount: number;
openRiskCount: number;
followUpDueTodayCount: number;
closurePendingProjectCount: number;
href: string;
}
| null;
+120 -2
查看文件
@@ -7,6 +7,8 @@ export type ProjectTaskStatus = "todo" | "doing" | "blocked" | "done" | "cancell
export type ProjectTaskPriority = "low" | "normal" | "high" | "urgent";
export type ProjectRiskLevel = "low" | "medium" | "high";
export type ProjectDeliveryRecordType = "kickoff" | "onsite" | "stage_acceptance" | "final_acceptance" | "handover";
export type ProjectCommunicationChannel = "wechat" | "phone" | "meeting" | "wecom" | "email" | "onsite" | "other";
export type ProjectClosureChecklistStatus = "pending" | "done" | "waived";
export type ConfidentialityLevel = "public" | "internal" | "confidential" | "restricted";
export type DocumentStorageType = "oa_link" | "nas" | "internal_share" | "offline_archive";
@@ -90,6 +92,35 @@ export type ProjectRisk = {
updatedAt: string | null;
};
export type ProjectCommunicationLog = {
id: string;
happenedAt: string | null;
channel: ProjectCommunicationChannel;
externalContactName: string | null;
externalOrgName: string | null;
summary: string;
nextAction: string | null;
nextFollowUpDate: string | null;
createdBy: string;
createdAt: string | null;
updatedAt: string | null;
canEdit: boolean;
creator: EmployeeBasic | null;
};
export type ProjectClosureChecklistItem = {
id: string;
itemKey: string;
title: string;
status: ProjectClosureChecklistStatus;
note: string | null;
sortOrder: number;
completedAt: string | null;
completedBy: string | null;
canEdit: boolean;
completer: EmployeeBasic | null;
};
export type ProjectDetail = {
id: string;
projectNo: string;
@@ -117,6 +148,8 @@ export type ProjectDetail = {
travelRequests: number;
todoItems: number;
weeklyReports: number;
communicationLogs: number;
closureChecklistItems: number;
contracts: number;
deliveries: number;
documentRefs: number;
@@ -125,6 +158,30 @@ export type ProjectDetail = {
milestones: ProjectMilestone[];
tasks: ProjectTask[];
risks: ProjectRisk[];
communicationSummary: {
totalCount: number;
latestHappenedAt: string | null;
followUpDueTodayCount: number;
overdueFollowUpCount: number;
};
closureSummary: {
totalCount: number;
pendingCount: number;
waivedCount: number;
warned: boolean;
facts: {
openRiskCount: number;
openReimbursementCount: number;
openTravelCount: number;
deliveryRecordCount: number;
contractCount: number;
confidentialDocRefCount: number;
};
};
capabilities: {
communicationLogEnabled: boolean;
closureChecklistEnabled: boolean;
};
};
export type ProjectExpenseSummary = {
@@ -229,6 +286,11 @@ export async function fetchProject(projectId: string) {
return apiRequestData<{ item: ProjectDetail }>(`/api/projects/${projectId}`);
}
export type ProjectClosureWarning = {
pendingCount: number;
message: string;
} | null;
export async function patchProject(
projectId: string,
payload: Partial<{
@@ -246,14 +308,14 @@ export async function patchProject(
isConfidential: boolean;
}>,
) {
return apiRequestData<{ item: ProjectDetail }>(`/api/projects/${projectId}`, {
return apiRequestData<{ item: ProjectDetail; closureWarning: ProjectClosureWarning }>(`/api/projects/${projectId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function archiveProject(projectId: string) {
return apiRequestData<{ item: { id: string; status: ProjectStatus } }>(`/api/projects/${projectId}/archive`, {
return apiRequestData<{ item: ProjectDetail; closureWarning: ProjectClosureWarning }>(`/api/projects/${projectId}/archive`, {
method: "POST",
body: JSON.stringify({}),
});
@@ -400,6 +462,62 @@ export async function fetchProjectWeeklyReports(projectId: string) {
return apiRequestData<{ items: ProjectWeeklyReportLink[] }>(`/api/projects/${projectId}/weekly-reports`);
}
export async function fetchProjectCommunicationLogs(projectId: string) {
return apiRequestData<{ items: ProjectCommunicationLog[] }>(`/api/projects/${projectId}/communication-logs`);
}
export async function createProjectCommunicationLog(
projectId: string,
payload: {
happenedAt: string;
channel: ProjectCommunicationChannel;
externalContactName?: string | null;
externalOrgName?: string | null;
summary: string;
nextAction?: string | null;
nextFollowUpDate?: string | null;
},
) {
return apiRequestData<{ item: ProjectCommunicationLog }>(`/api/projects/${projectId}/communication-logs`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchProjectCommunicationLog(
projectId: string,
logId: string,
payload: Partial<{
happenedAt: string;
channel: ProjectCommunicationChannel;
externalContactName: string | null;
externalOrgName: string | null;
summary: string;
nextAction: string | null;
nextFollowUpDate: string | null;
}>,
) {
return apiRequestData<{ item: ProjectCommunicationLog }>(`/api/projects/${projectId}/communication-logs/${logId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function fetchProjectClosureChecklist(projectId: string) {
return apiRequestData<{ items: ProjectClosureChecklistItem[] }>(`/api/projects/${projectId}/closure-checklist`);
}
export async function patchProjectClosureChecklistItem(
projectId: string,
itemId: string,
payload: Partial<{ status: ProjectClosureChecklistStatus; note: string | null }>,
) {
return apiRequestData<{ item: ProjectClosureChecklistItem }>(`/api/projects/${projectId}/closure-checklist/${itemId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function fetchProjectDeliveries(projectId: string) {
return apiRequestData<{ items: ProjectDeliveryRecord[] }>(`/api/projects/${projectId}/deliveries`);
}
+6
查看文件
@@ -44,6 +44,12 @@ function toDisplayName(flagKey: string) {
if (flagKey === "weekly_report_v1") return "周报工作台";
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
if (flagKey === "todo_board_v1") return "待办工作台";
if (flagKey === "project_domain_v1") return "项目主域";
if (flagKey === "project_linking_v1") return "项目联动";
if (flagKey === "project_communication_log_v1") return "项目沟通回填";
if (flagKey === "project_closure_checklist_v1") return "项目收口清单";
if (flagKey === "confidential_doc_refs_v1") return "涉密资料引用";
if (flagKey === "public_security_hardening_v1") return "公网安全强化";
return flagKey;
}
+1 -1
查看文件
@@ -187,7 +187,7 @@ export function PortalHomePage() {
) : null}
{payload.projectSummary ? (
<Link to={payload.projectSummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
项目摘要:我的项目 {payload.projectSummary.myCount},逾期里程碑 {payload.projectSummary.overdueMilestonesCount},风险 {payload.projectSummary.openRiskCount}
项目摘要:我的项目 {payload.projectSummary.myCount},逾期里程碑 {payload.projectSummary.overdueMilestonesCount},风险 {payload.projectSummary.openRiskCount},今日待跟进 {payload.projectSummary.followUpDueTodayCount},待收口项目 {payload.projectSummary.closurePendingProjectCount}
</Link>
) : null}
{payload.peopleLifecycle ? (
+277 -3
查看文件
@@ -2,7 +2,7 @@ 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 { createConfidentialDocumentRef, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, archiveProject, type ConfidentialityLevel, type DocumentStorageType, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, 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 { isApiError } from "../api/types";
@@ -23,6 +23,8 @@ const TASK_PRIORITY_OPTIONS: ProjectTaskPriority[] = ["low", "normal", "high", "
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"];
@@ -55,6 +57,27 @@ function recordTypeLabel(value: ProjectDeliveryRecordType) {
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();
@@ -79,6 +102,16 @@ export function ProjectDetailPage() {
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;
@@ -125,6 +158,17 @@ export function ProjectDetailPage() {
const [docConfidentiality, setDocConfidentiality] = useState<ConfidentialityLevel>("internal");
const [docStorageType, setDocStorageType] = useState<DocumentStorageType>("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<string | null>(null);
useEffect(() => {
if (!detail) return;
setProjectForm({
@@ -141,11 +185,26 @@ export function ProjectDetailPage() {
});
}, [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] }),
@@ -165,18 +224,24 @@ export function ProjectDetailPage() {
summary: projectForm.summary.trim() || null,
isConfidential: projectForm.isConfidential,
}),
onSuccess: async () => {
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 () => {
onSuccess: async (payload) => {
await refreshProject();
toast.success("项目已归档");
if (payload.closureWarning) {
toast.warning(payload.closureWarning.message);
}
},
onError: (error) => toast.error(resolveError(error, "归档项目失败")),
});
@@ -326,7 +391,75 @@ export function ProjectDetailPage() {
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") {
const note = window.prompt("请填写豁免理由", currentNote ?? "");
if (note == null) {
return;
}
if (!note.trim()) {
toast.error("豁免必须填写说明");
return;
}
patchClosureChecklistMutation.mutate({ itemId, status, note: note.trim() });
return;
}
patchClosureChecklistMutation.mutate({ itemId, status });
};
if (!projectId) {
return <div className="card-std p-6 text-sm text-red-600">缺少 projectId 参数。</div>;
@@ -381,6 +514,147 @@ export function ProjectDetailPage() {
<div className="card-std p-4"><div className="text-sm text-zinc-500">差旅合计</div><div className="mt-2 text-2xl font-bold">{travelQuery.data?.totalAmount ?? "0.00"}</div></div>
</div>
{(detail.status === "completed" || detail.status === "archived") && detail.closureSummary.pendingCount > 0 ? (
<div className="card-std border-amber-200 bg-amber-50 p-4" data-testid="project-closure-warning">
<div className="font-medium text-zinc-900">项目已进入收口状态,但仍有 {detail.closureSummary.pendingCount} 项待处理</div>
<div className="mt-1 text-sm text-zinc-600">本轮是软约束:允许完成或归档,但请尽快补齐交付、费用、合同与交接说明。</div>
</div>
) : null}
{detail.capabilities.communicationLogEnabled || detail.capabilities.closureChecklistEnabled ? (
<div className="grid gap-6 xl:grid-cols-2">
{detail.capabilities.communicationLogEnabled ? (
<div className="card-std space-y-4 p-6" data-testid="project-communication-panel">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">客户沟通</h3>
<div className="text-sm text-zinc-500">只记录结果摘要,不同步聊天记录。</div>
</div>
<div className="text-right text-xs text-zinc-500">
<div>最近沟通:{detail.communicationSummary.latestHappenedAt ? detail.communicationSummary.latestHappenedAt.slice(0, 10) : "-"}</div>
<div>今日待跟进 {detail.communicationSummary.followUpDueTodayCount} · 已逾期 {detail.communicationSummary.overdueFollowUpCount}</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
<div className="text-zinc-500">沟通总数</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.communicationSummary.totalCount}</div>
</div>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm">
<div className="text-zinc-500">今日待跟进</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.communicationSummary.followUpDueTodayCount}</div>
</div>
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-3 text-sm">
<div className="text-zinc-500">已逾期</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.communicationSummary.overdueFollowUpCount}</div>
</div>
</div>
<div className="space-y-3 rounded-lg border border-zinc-200 p-4">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{editingCommunicationLogId ? "编辑沟通回填" : "新增沟通回填"}</div>
{editingCommunicationLogId ? (
<Button type="button" variant="secondary" size="sm" onClick={resetCommunicationForm}>取消编辑</Button>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr)]">
<Input type="date" value={communicationForm.happenedAt} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, happenedAt: event.target.value }))} />
<select className="select-std" value={communicationForm.channel} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, channel: event.target.value as ProjectCommunicationChannel }))}>
{COMMUNICATION_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{communicationChannelLabel(item)}</option>)}
</select>
<Input value={communicationForm.externalContactName} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, externalContactName: event.target.value }))} placeholder="外部联系人(可选)" />
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px]">
<Input value={communicationForm.externalOrgName} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, externalOrgName: event.target.value }))} placeholder="外部单位(可选)" />
<Input type="date" value={communicationForm.nextFollowUpDate} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, nextFollowUpDate: event.target.value }))} />
</div>
<Textarea value={communicationForm.summary} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, summary: event.target.value }))} placeholder="沟通结果摘要(1000字内)" data-testid="project-communication-summary-input" />
<Textarea value={communicationForm.nextAction} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, nextAction: event.target.value }))} placeholder="下一步动作(可选)" />
<div className="flex justify-end">
<Button type="button" data-testid="project-communication-submit" disabled={saveCommunicationLogMutation.isPending || !communicationForm.summary.trim()} onClick={() => saveCommunicationLogMutation.mutate()}>
{saveCommunicationLogMutation.isPending ? "提交中..." : editingCommunicationLogId ? "保存修改" : "新增回填"}
</Button>
</div>
</div>
<div className="space-y-3">
{(communicationLogsQuery.isLoading && !communicationItems.length) ? <div className="text-sm text-zinc-500">加载沟通记录中...</div> : null}
{!communicationItems.length && !communicationLogsQuery.isLoading ? <div className="text-sm text-zinc-500">暂无沟通回填,建议在 24 小时内补充客户沟通结果。</div> : null}
{communicationItems.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4" data-testid="project-communication-item">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{communicationChannelLabel(item.channel)} · {item.externalContactName ?? item.externalOrgName ?? "外部沟通"}</div>
<div className="mt-1 text-xs text-zinc-500">{item.happenedAt ? item.happenedAt.slice(0, 10) : "-"} · 创建人 {item.creator?.name ?? item.createdBy}</div>
</div>
{item.canEdit ? <Button type="button" size="sm" variant="secondary" onClick={() => handleEditCommunicationLog(item)}>编辑</Button> : null}
</div>
<div className="mt-3 text-sm text-zinc-700 whitespace-pre-wrap">{item.summary}</div>
<div className="mt-2 grid gap-2 text-xs text-zinc-500 md:grid-cols-2">
<div>下一步:{item.nextAction ?? "-"}</div>
<div>下次跟进:{item.nextFollowUpDate ?? "-"}</div>
</div>
</div>
))}
</div>
</div>
) : null}
{detail.capabilities.closureChecklistEnabled ? (
<div className="card-std space-y-4 p-6" data-testid="project-closure-panel">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">项目收口</h3>
<div className="text-sm text-zinc-500">软约束模式:允许完成或归档,但会给出红黄灯提醒。</div>
</div>
<div className="text-right text-xs text-zinc-500">
<div>待处理 {detail.closureSummary.pendingCount} / {detail.closureSummary.totalCount}</div>
<div>已豁免 {detail.closureSummary.waivedCount}</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm">
<div className="text-zinc-500">待收口项</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.closureSummary.pendingCount}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
<div className="text-zinc-500">已豁免</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.closureSummary.waivedCount}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
<div className="text-zinc-500">交付记录</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.closureSummary.facts.deliveryRecordCount}</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 text-sm">
<div className="rounded-lg border border-zinc-200 px-3 py-3">未关闭风险:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.openRiskCount}</span></div>
<div className="rounded-lg border border-zinc-200 px-3 py-3">未收口报销:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.openReimbursementCount}</span></div>
<div className="rounded-lg border border-zinc-200 px-3 py-3">未收口差旅:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.openTravelCount}</span></div>
<div className="rounded-lg border border-zinc-200 px-3 py-3">合同数:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.contractCount}</span></div>
<div className="rounded-lg border border-zinc-200 px-3 py-3">资料引用:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.confidentialDocRefCount}</span></div>
</div>
<div className="space-y-3">
{(closureChecklistQuery.isLoading && !closureChecklistItems.length) ? <div className="text-sm text-zinc-500">加载收口清单中...</div> : null}
{closureChecklistItems.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4" data-testid="project-closure-item">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-xs text-zinc-500">状态:{closureChecklistStatusLabel(item.status)}{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}</div>
</div>
{item.canEdit ? (
<select className="select-std h-9 w-[120px]" value={item.status} onChange={(event) => handleChecklistStatusChange(item.id, event.target.value as ProjectClosureChecklistStatus, item.note)}>
{CLOSURE_CHECKLIST_STATUS_OPTIONS.map((status) => <option key={status} value={status}>{closureChecklistStatusLabel(status)}</option>)}
</select>
) : null}
</div>
{item.note ? <div className="mt-2 text-sm text-zinc-600">说明:{item.note}</div> : null}
</div>
))}
</div>
</div>
) : null}
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
<div className="card-std space-y-4 p-6">
<div className="flex items-center gap-3">