|
|
|
@@ -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">
|
|
|
|
|