1251 行
71 KiB
TypeScript
1251 行
71 KiB
TypeScript
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 ProjectDetail, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
|
|
import { fetchContractsRegistry } from "../api/contractsRegistry";
|
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
|
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
|
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 { DateTextInput } from "../components/common/DateTextInput";
|
|
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 { formatDisplayDateTime } from "../lib/displayFormat";
|
|
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 projectStatusLabel(value: ProjectStatus) {
|
|
if (value === "draft") return "草稿";
|
|
if (value === "active") return "进行中";
|
|
if (value === "paused") return "已暂停";
|
|
if (value === "completed") return "已完成";
|
|
return "已归档";
|
|
}
|
|
|
|
function memberRoleLabel(value: (typeof MEMBER_ROLE_OPTIONS)[number]) {
|
|
if (value === "owner") return "负责人";
|
|
if (value === "manager") return "项目经理";
|
|
if (value === "observer") return "观察人";
|
|
return "成员";
|
|
}
|
|
|
|
function milestoneStatusLabel(value: ProjectMilestoneStatus) {
|
|
if (value === "pending") return "待处理";
|
|
if (value === "in_progress") return "进行中";
|
|
if (value === "done") return "已完成";
|
|
if (value === "delayed") return "已延期";
|
|
return "已取消";
|
|
}
|
|
|
|
function taskStatusLabel(value: ProjectTaskStatus) {
|
|
if (value === "todo") return "待处理";
|
|
if (value === "doing") return "进行中";
|
|
if (value === "blocked") return "受阻";
|
|
if (value === "done") return "已完成";
|
|
return "已取消";
|
|
}
|
|
|
|
function taskPriorityLabel(value: ProjectTaskPriority) {
|
|
if (value === "low") return "低";
|
|
if (value === "high") return "高";
|
|
if (value === "urgent") return "紧急";
|
|
return "普通";
|
|
}
|
|
|
|
function riskLevelLabel(value: ProjectRiskLevel) {
|
|
if (value === "low") return "低";
|
|
if (value === "high") return "高";
|
|
return "中";
|
|
}
|
|
|
|
function riskStatusLabel(value: string) {
|
|
if (value === "open") return "待处理";
|
|
if (value === "monitoring") return "观察中";
|
|
if (value === "mitigating") return "处理中";
|
|
if (value === "closed") return "已关闭";
|
|
return value || "-";
|
|
}
|
|
|
|
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) {
|
|
return formatDisplayDateTime(value);
|
|
}
|
|
|
|
function resolveProjectNextAction(detail: ProjectDetail) {
|
|
if (detail.status === "archived") {
|
|
return "项目已归档,主要用于查询合同、交付、费用和资料引用。";
|
|
}
|
|
if (detail.closureSummary.pendingCount > 0) {
|
|
return `优先补齐 ${detail.closureSummary.pendingCount} 项收口清单,再执行完成或归档。`;
|
|
}
|
|
if (detail.closureSummary.facts.openRiskCount > 0) {
|
|
return `当前有 ${detail.closureSummary.facts.openRiskCount} 个未关闭风险,建议指定责任人和截止时间。`;
|
|
}
|
|
if (detail.counts.milestones > 0 && detail.status === "active") {
|
|
return "按里程碑和任务推进项目,客户沟通、交付记录和周报都在本页留痕。";
|
|
}
|
|
if (detail.status === "draft") {
|
|
return "先补齐负责人、项目经理、时间范围和摘要,再进入执行。";
|
|
}
|
|
return "当前项目可继续维护执行记录、费用关联和归档资料引用。";
|
|
}
|
|
|
|
function resolveProjectEvidenceText(detail: ProjectDetail) {
|
|
const facts = detail.closureSummary.facts;
|
|
return `交付 ${facts.deliveryRecordCount} · 合同 ${facts.contractCount} · 资料引用 ${facts.confidentialDocRefCount}`;
|
|
}
|
|
|
|
function documentLocationSummary(item: { confidentialityLevel: ConfidentialityLevel; storageType: DocumentStorageType; offlineArchiveNo: string | null }) {
|
|
const archiveNo = item.offlineArchiveNo ? ` · ${item.offlineArchiveNo}` : "";
|
|
if (item.confidentialityLevel === "confidential" || item.confidentialityLevel === "restricted") {
|
|
return `${storageTypeLabel(item.storageType)} · 已登记受控索引${archiveNo}`;
|
|
}
|
|
return `${storageTypeLabel(item.storageType)} · 归档索引${archiveNo || " · 已登记"}`;
|
|
}
|
|
|
|
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<ProjectMilestoneStatus>("pending");
|
|
|
|
const [taskTitle, setTaskTitle] = useState("");
|
|
const [taskDueDate, setTaskDueDate] = useState("");
|
|
const [taskAssigneeId, setTaskAssigneeId] = useState("");
|
|
const [taskPriority, setTaskPriority] = useState<ProjectTaskPriority>("normal");
|
|
|
|
const [riskTitle, setRiskTitle] = useState("");
|
|
const [riskDetail, setRiskDetail] = useState("");
|
|
const [riskLevel, setRiskLevel] = useState<ProjectRiskLevel>("medium");
|
|
const [riskOwnerId, setRiskOwnerId] = useState("");
|
|
|
|
const [deliveryTitle, setDeliveryTitle] = useState("");
|
|
const [deliverySummary, setDeliverySummary] = useState("");
|
|
const [deliveryType, setDeliveryType] = useState<ProjectDeliveryRecordType>("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<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);
|
|
const [waiveChecklistTarget, setWaiveChecklistTarget] = useState<{ itemId: string; currentNote: string | null } | null>(null);
|
|
const [waiveChecklistNote, setWaiveChecklistNote] = useState("");
|
|
const [confirmArchiveProject, setConfirmArchiveProject] = useState(false);
|
|
|
|
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 <div className="card-std p-6 text-sm text-red-600">缺少 projectId 参数。</div>;
|
|
}
|
|
|
|
if (detailQuery.isLoading) {
|
|
return <div className="card-std p-6 text-sm text-zinc-500">加载项目详情中...</div>;
|
|
}
|
|
|
|
if (detailQuery.isError || !detail) {
|
|
return (
|
|
<div className="card-std space-y-3 p-6">
|
|
<div className="text-sm text-red-600">{resolveError(detailQuery.error, "加载项目详情失败")}</div>
|
|
<Button asChild variant="secondary" type="button">
|
|
<Link to="/projects">返回项目列表</Link>
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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;
|
|
const projectNextAction = resolveProjectNextAction(detail);
|
|
const projectEvidenceText = resolveProjectEvidenceText(detail);
|
|
|
|
return (
|
|
<>
|
|
<PageScaffold>
|
|
<EntityOverviewHero
|
|
title={`${detail.projectNo} · ${detail.name}`}
|
|
subtitle="项目状态、下一步、执行留痕和归档边界集中在一个工作台内处理。"
|
|
meta={
|
|
<>
|
|
<StatusBadge status={detail.status} />
|
|
{detail.isConfidential ? <span className="badge-warning">涉密项目</span> : null}
|
|
<span className="info-chip">客户:{detail.clientAlias || detail.clientName}</span>
|
|
<span className="info-chip">负责人:{detail.owner?.name ?? "-"}</span>
|
|
<span className="info-chip">项目经理:{detail.manager?.name ?? "-"}</span>
|
|
</>
|
|
}
|
|
actions={
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button asChild variant="secondary" type="button">
|
|
<Link to="/projects">返回项目</Link>
|
|
</Button>
|
|
<Button asChild variant="secondary" type="button">
|
|
<Link to={`/contracts-registry?projectId=${detail.id}`}>合同</Link>
|
|
</Button>
|
|
<Button type="button" variant="secondary" disabled={archiveProjectMutation.isPending || detail.status === "archived"} onClick={() => setConfirmArchiveProject(true)}>
|
|
{archiveProjectMutation.isPending ? "归档中..." : "归档项目"}
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<section className="detail-command-panel" data-testid="project-current-command">
|
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.1fr),minmax(260px,0.9fr)]">
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-semibold text-zinc-500">当前办理焦点</div>
|
|
<p className="mt-1 text-sm leading-6 text-zinc-700">{projectNextAction}</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<span className="info-chip">状态:{projectStatusLabel(detail.status)}</span>
|
|
<span className="info-chip">证据:{projectEvidenceText}</span>
|
|
{detail.isConfidential ? <span className="badge-warning">涉密资料仅登记引用</span> : null}
|
|
</div>
|
|
</div>
|
|
<div className="detail-fact-grid">
|
|
<div className="detail-fact-card">
|
|
<div className="detail-fact-card__label">收口待处理</div>
|
|
<div className="detail-fact-card__value">{detail.closureSummary.pendingCount}</div>
|
|
</div>
|
|
<div className="detail-fact-card">
|
|
<div className="detail-fact-card__label">未关闭风险</div>
|
|
<div className="detail-fact-card__value">{detail.closureSummary.facts.openRiskCount}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<MetricCard title="成员" value={counts?.members ?? 0} note="执行角色与观察者" />
|
|
<MetricCard title="里程碑" value={counts?.milestones ?? 0} note="用于跟踪关键节点" />
|
|
<MetricCard title="任务" value={counts?.tasks ?? 0} note="按优先级与状态推进" />
|
|
<MetricCard title="风险" value={counts?.risks ?? 0} note="高风险建议指定责任人" tone={(counts?.risks ?? 0) > 0 ? "warning" : "neutral"} />
|
|
<MetricCard title="报销合计" value={expensesQuery.data?.totalAmount ?? "0.00"} note="当前项目已关联报销" />
|
|
<MetricCard title="采购合计" value={purchaseQuery.data?.totalAmount ?? "0.00"} note="当前项目已关联采购" />
|
|
<MetricCard title="差旅合计" value={travelQuery.data?.totalAmount ?? "0.00"} note="当前项目已关联差旅" />
|
|
</section>
|
|
|
|
{(detail.status === "completed" || detail.status === "archived") && detail.closureSummary.pendingCount > 0 ? (
|
|
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800" 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)]">
|
|
<DateTextInput 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="外部单位(可选)" />
|
|
<DateTextInput 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="detail-shell-grid">
|
|
<div className="space-y-6">
|
|
<SectionPanel title="概览与主档" description="先看主信息,再统一维护时间、负责人和涉密属性。">
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>项目名称</Label>
|
|
<Input value={projectForm.name} onChange={(event) => setProjectForm((prev) => ({ ...prev, name: event.target.value }))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>客户名称</Label>
|
|
<Input value={projectForm.clientName} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientName: event.target.value }))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>客户简称</Label>
|
|
<Input value={projectForm.clientAlias} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientAlias: event.target.value }))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>状态</Label>
|
|
<select className="select-std" value={projectForm.status} onChange={(event) => setProjectForm((prev) => ({ ...prev, status: event.target.value as ProjectStatus }))}>
|
|
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{projectStatusLabel(item)}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>开始日期</Label>
|
|
<DateTextInput value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>目标完成</Label>
|
|
<DateTextInput value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>实际完成</Label>
|
|
<DateTextInput value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>项目经理</Label>
|
|
<select className="select-std" value={projectForm.managerEmployeeId} onChange={(event) => setProjectForm((prev) => ({ ...prev, managerEmployeeId: event.target.value }))}>
|
|
<option value="">未指定</option>
|
|
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>项目摘要</Label>
|
|
<Textarea value={projectForm.summary} onChange={(event) => setProjectForm((prev) => ({ ...prev, summary: event.target.value }))} />
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
|
<input type="checkbox" checked={projectForm.isConfidential} onChange={(event) => setProjectForm((prev) => ({ ...prev, isConfidential: event.target.checked }))} />
|
|
项目包含涉密资料,正文与附件保持离线管理
|
|
</label>
|
|
<div className="flex justify-end">
|
|
<Button type="button" disabled={saveProjectMutation.isPending} onClick={() => saveProjectMutation.mutate()}>
|
|
{saveProjectMutation.isPending ? "保存中..." : "保存主档"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</SectionPanel>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-2">
|
|
<SectionPanel title="执行 · 成员" description={`当前 ${memberItems.length} 人`}>
|
|
<div className="space-y-4">
|
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,120px]">
|
|
<select className="select-std" value={memberEmployeeId} onChange={(event) => setMemberEmployeeId(event.target.value)}>
|
|
<option value="">选择成员</option>
|
|
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
|
|
</select>
|
|
<select className="select-std" value={memberRole} onChange={(event) => setMemberRole(event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number])}>
|
|
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{memberRoleLabel(item)}</option>)}
|
|
</select>
|
|
<Button type="button" disabled={addMemberMutation.isPending || !memberEmployeeId} onClick={() => addMemberMutation.mutate()}>
|
|
添加成员
|
|
</Button>
|
|
</div>
|
|
<Table>
|
|
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>角色</TableHead><TableHead>加入时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
|
|
<TableBody>
|
|
{memberItems.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>{item.employee?.name ?? item.employeeId}</TableCell>
|
|
<TableCell>
|
|
<select className="select-std h-9" value={item.role} onChange={(event) => patchMemberMutation.mutate({ memberId: item.id, role: event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number] })}>
|
|
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{memberRoleLabel(role)}</option>)}
|
|
</select>
|
|
</TableCell>
|
|
<TableCell>{item.joinedAt ? item.joinedAt.slice(0, 10) : "-"}</TableCell>
|
|
<TableCell>
|
|
{item.leftAt ? "已移出" : (
|
|
<Button size="sm" variant="secondary" type="button" onClick={() => patchMemberMutation.mutate({ memberId: item.id, leftAt: new Date().toISOString().slice(0, 10) })}>移出</Button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</SectionPanel>
|
|
|
|
<SectionPanel title="执行 · 里程碑" description="逾期请及时调整状态。">
|
|
<div className="space-y-4">
|
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,120px]">
|
|
<Input value={milestoneTitle} onChange={(event) => setMilestoneTitle(event.target.value)} placeholder="里程碑标题" />
|
|
<DateTextInput value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
|
|
<select className="select-std" value={milestoneStatus} onChange={(event) => setMilestoneStatus(event.target.value as ProjectMilestoneStatus)}>
|
|
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{milestoneStatusLabel(item)}</option>)}
|
|
</select>
|
|
<Button type="button" disabled={addMilestoneMutation.isPending || !milestoneTitle.trim()} onClick={() => addMilestoneMutation.mutate()}>新增</Button>
|
|
</div>
|
|
<Table>
|
|
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>截止日期</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
|
<TableBody>
|
|
{milestoneItems.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>{item.title}</TableCell>
|
|
<TableCell>{item.dueDate ?? "-"}</TableCell>
|
|
<TableCell>
|
|
<select className="select-std h-9" value={item.status} onChange={(event) => patchMilestoneMutation.mutate({ milestoneId: item.id, status: event.target.value as ProjectMilestoneStatus })}>
|
|
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{milestoneStatusLabel(option)}</option>)}
|
|
</select>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</SectionPanel>
|
|
</div>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-2">
|
|
<SectionPanel title="执行 · 任务" description="支持高优先级与阻塞状态。">
|
|
<div className="space-y-4">
|
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,160px,120px]">
|
|
<Input value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} placeholder="任务标题" />
|
|
<select className="select-std" value={taskAssigneeId} onChange={(event) => setTaskAssigneeId(event.target.value)}>
|
|
<option value="">未指派</option>
|
|
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
|
</select>
|
|
<select className="select-std" value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as ProjectTaskPriority)}>
|
|
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{taskPriorityLabel(item)}</option>)}
|
|
</select>
|
|
<DateTextInput value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
|
|
<Button type="button" disabled={addTaskMutation.isPending || !taskTitle.trim()} onClick={() => addTaskMutation.mutate()}>新增</Button>
|
|
</div>
|
|
<Table>
|
|
<TableHeader><TableRow><TableHead>任务</TableHead><TableHead>负责人</TableHead><TableHead>优先级</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
|
<TableBody>
|
|
{taskItems.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
|
<div className="text-xs text-zinc-500">截止:{item.dueDate ?? "-"}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<select className="select-std h-9" value={item.assigneeEmployeeId ?? ""} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { assigneeEmployeeId: event.target.value || null } })}>
|
|
<option value="">未指派</option>
|
|
{employeeOptions.map((employee) => <option key={employee.id} value={employee.id}>{employee.name}</option>)}
|
|
</select>
|
|
</TableCell>
|
|
<TableCell>
|
|
<select className="select-std h-9" value={item.priority} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { priority: event.target.value as ProjectTaskPriority } })}>
|
|
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{taskPriorityLabel(option)}</option>)}
|
|
</select>
|
|
</TableCell>
|
|
<TableCell>
|
|
<select className="select-std h-9" value={item.status} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { status: event.target.value as ProjectTaskStatus } })}>
|
|
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{taskStatusLabel(option)}</option>)}
|
|
</select>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</SectionPanel>
|
|
|
|
<SectionPanel title="执行 · 风险" description="问题与阻塞统一留痕。">
|
|
<div className="space-y-4">
|
|
<div className="space-y-3">
|
|
<Input value={riskTitle} onChange={(event) => setRiskTitle(event.target.value)} placeholder="风险标题" />
|
|
<Textarea value={riskDetail} onChange={(event) => setRiskDetail(event.target.value)} placeholder="风险详情、影响与当前处理动作" />
|
|
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr),120px]">
|
|
<select className="select-std" value={riskLevel} onChange={(event) => setRiskLevel(event.target.value as ProjectRiskLevel)}>
|
|
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{riskLevelLabel(item)}</option>)}
|
|
</select>
|
|
<select className="select-std" value={riskOwnerId} onChange={(event) => setRiskOwnerId(event.target.value)}>
|
|
<option value="">未指定责任人</option>
|
|
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
|
</select>
|
|
<div className="text-sm text-zinc-500 flex items-center">高优项目建议每个风险都指定责任人。</div>
|
|
<Button type="button" disabled={addRiskMutation.isPending || !riskTitle.trim() || !riskDetail.trim()} onClick={() => addRiskMutation.mutate()}>登记</Button>
|
|
</div>
|
|
</div>
|
|
<Table>
|
|
<TableHeader><TableRow><TableHead>风险</TableHead><TableHead>等级</TableHead><TableHead>责任人</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
|
<TableBody>
|
|
{riskItems.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
|
<div className="text-xs text-zinc-500">{item.detail}</div>
|
|
</TableCell>
|
|
<TableCell>{riskLevelLabel(item.level)}</TableCell>
|
|
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
|
<TableCell>
|
|
<select className="select-std h-9" value={item.status} onChange={(event) => patchRiskMutation.mutate({ riskId: item.id, status: event.target.value })}>
|
|
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{riskStatusLabel(option)}</option>)}
|
|
</select>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</SectionPanel>
|
|
</div>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-2">
|
|
<SectionPanel title="留痕 · 交付记录" description="现场、验收、交接统一留痕。">
|
|
<div className="space-y-4">
|
|
<div className="space-y-3">
|
|
<Input value={deliveryTitle} onChange={(event) => setDeliveryTitle(event.target.value)} placeholder="记录标题" />
|
|
<Textarea value={deliverySummary} onChange={(event) => setDeliverySummary(event.target.value)} placeholder="记录摘要" />
|
|
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
|
|
<select className="select-std" value={deliveryType} onChange={(event) => setDeliveryType(event.target.value as ProjectDeliveryRecordType)}>
|
|
{DELIVERY_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{recordTypeLabel(item)}</option>)}
|
|
</select>
|
|
<DateTextInput value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
|
|
<Button type="button" disabled={addDeliveryMutation.isPending || !deliveryTitle.trim() || !deliverySummary.trim()} onClick={() => addDeliveryMutation.mutate()}>新增</Button>
|
|
</div>
|
|
</div>
|
|
<Table>
|
|
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>类型</TableHead><TableHead>时间</TableHead><TableHead>责任人</TableHead></TableRow></TableHeader>
|
|
<TableBody>
|
|
{(deliveriesQuery.data?.items ?? []).map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
|
<div className="text-xs text-zinc-500">{item.summary}</div>
|
|
</TableCell>
|
|
<TableCell>{recordTypeLabel(item.recordType)}</TableCell>
|
|
<TableCell>{item.occurredAt ? item.occurredAt.slice(0, 10) : "-"}</TableCell>
|
|
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</SectionPanel>
|
|
|
|
<SectionPanel title="归档与合规 · 资料索引" description="只登记最小必要索引,正文、源码、密钥、完整路径和附件仍走受控归档。">
|
|
<div className="space-y-4">
|
|
<div className="space-y-3">
|
|
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<Input value={docNo} onChange={(event) => setDocNo(event.target.value)} placeholder="文档编号(可选)" />
|
|
<Input value={docOfflineArchiveNo} onChange={(event) => setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
|
|
</div>
|
|
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="归档位置摘要或受控索引,不填写完整路径、密钥或源码地址" />
|
|
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
|
|
<select className="select-std" value={docConfidentiality} onChange={(event) => setDocConfidentiality(event.target.value as ConfidentialityLevel)}>
|
|
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
|
|
</select>
|
|
<select className="select-std" value={docStorageType} onChange={(event) => setDocStorageType(event.target.value as DocumentStorageType)}>
|
|
{STORAGE_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{storageTypeLabel(item)}</option>)}
|
|
</select>
|
|
<Button type="button" disabled={addDocRefMutation.isPending || !docTitle.trim() || !docStoragePath.trim()} onClick={() => addDocRefMutation.mutate()}>登记</Button>
|
|
</div>
|
|
</div>
|
|
<Table>
|
|
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>归档索引</TableHead></TableRow></TableHeader>
|
|
<TableBody>
|
|
{(docsQuery.data?.items ?? []).map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
|
<div className="text-xs text-zinc-500">{item.documentNo ?? item.offlineArchiveNo ?? "无编号"}</div>
|
|
</TableCell>
|
|
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
|
|
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
|
|
<TableCell className="max-w-[280px] truncate">{documentLocationSummary(item)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</SectionPanel>
|
|
</div>
|
|
|
|
<SectionPanel title="留痕 · 项目周报关联" description="按项目查看每周进展、风险与下一步动作。">
|
|
<Table>
|
|
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>周起始</TableHead><TableHead>进展</TableHead><TableHead>下一步</TableHead></TableRow></TableHeader>
|
|
<TableBody>
|
|
{(weeklyQuery.data?.items ?? []).map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>{item.employee?.name ?? "-"}</TableCell>
|
|
<TableCell>{item.weekStart ?? "-"}</TableCell>
|
|
<TableCell>{item.progressText || item.riskText || "-"}</TableCell>
|
|
<TableCell>{item.nextStepText || "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</SectionPanel>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<DetailSideMeta title="项目摘要" description="优先帮助项目负责人判断当前状态、关联量和下一步动作。">
|
|
<div>负责人:{detail.owner?.name ?? "-"}</div>
|
|
<div>项目经理:{detail.manager?.name ?? "-"}</div>
|
|
<div>周报关联:{counts?.weeklyReports ?? 0}</div>
|
|
<div>合同:{counts?.contracts ?? 0}</div>
|
|
<div>交付记录:{counts?.deliveries ?? 0}</div>
|
|
<div>资料引用:{counts?.documentRefs ?? 0}</div>
|
|
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
|
|
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
|
|
</div>
|
|
</DetailSideMeta>
|
|
|
|
<SectionPanel title="执行摘要" description="人、事、费、周报统一查看,帮助快速切回具体页面。">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="rounded-lg border border-zinc-200 p-4">
|
|
<div className="text-sm text-zinc-500">报销</div>
|
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{expensesQuery.data?.totalAmount ?? "0.00"}</div>
|
|
<div className="mt-2 space-y-2 text-sm">
|
|
{(expensesQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
|
<Link key={item.id} to={`/reimbursements/${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
|
{item.formNumber} · {item.totalAmount}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 p-4">
|
|
<div className="text-sm text-zinc-500">差旅</div>
|
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{travelQuery.data?.totalAmount ?? "0.00"}</div>
|
|
<div className="mt-2 space-y-2 text-sm">
|
|
{(travelQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
|
<Link key={item.id} to={`/travel-requests?tab=requests&id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
|
{item.requestNo} · {item.estimatedAmount}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 p-4">
|
|
<div className="text-sm text-zinc-500">采购</div>
|
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{purchaseQuery.data?.totalAmount ?? "0.00"}</div>
|
|
<div className="mt-2 space-y-2 text-sm">
|
|
{purchaseQuery.isLoading ? <div className="text-zinc-500">采购加载中...</div> : null}
|
|
{purchaseQuery.isError ? <div className="text-red-600">{resolveError(purchaseQuery.error, "加载采购摘要失败")}</div> : null}
|
|
{!purchaseQuery.isLoading && !purchaseQuery.isError && !(purchaseQuery.data?.items.length ?? 0) ? (
|
|
<div className="text-zinc-500">暂无关联采购</div>
|
|
) : null}
|
|
{(purchaseQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
|
<Link key={item.id} to={`/purchase-requests?id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="truncate">{item.requestNo || item.title}</span>
|
|
<StatusBadge status={item.status} />
|
|
</div>
|
|
<div className="mt-1 text-xs text-zinc-500">
|
|
{item.totalAmount} · {item.updatedAt ? formatDateTime(item.updatedAt) : "-"}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 p-4">
|
|
<div className="text-sm text-zinc-500">项目周报</div>
|
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{weeklyQuery.data?.items.length ?? 0}</div>
|
|
<div className="mt-2 space-y-2 text-sm">
|
|
{(weeklyQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
|
<Link key={item.id} to="/weekly-reports" className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
|
{item.employee?.name ?? "成员"} · {item.weekStart ?? "-"}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 p-4">
|
|
<div className="text-sm text-zinc-500">合同</div>
|
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
|
|
<div className="mt-2 space-y-2 text-sm">
|
|
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
|
<Link key={item.id} to={`/contracts-registry?projectId=${detail.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
|
{item.contractNo} · {item.counterpartyName}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SectionPanel>
|
|
</div>
|
|
</div>
|
|
</PageScaffold>
|
|
<Dialog
|
|
open={Boolean(waiveChecklistTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setWaiveChecklistTarget(null);
|
|
setWaiveChecklistNote("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-md" data-testid="action-confirm-dialog">
|
|
<DialogHeader>
|
|
<DialogTitle>确认豁免收口项</DialogTitle>
|
|
<DialogDescription>
|
|
豁免后该收口项会标记为已豁免,并写入项目收口记录。必须填写可理解说明。
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-zinc-900" htmlFor="project-closure-waive-note">
|
|
豁免理由
|
|
</label>
|
|
<Textarea
|
|
id="project-closure-waive-note"
|
|
data-testid="project-closure-waive-note"
|
|
value={waiveChecklistNote}
|
|
onChange={(event) => setWaiveChecklistNote(event.target.value)}
|
|
placeholder="说明为什么该收口项可以豁免"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
data-testid="action-confirm-cancel"
|
|
disabled={patchClosureChecklistMutation.isPending}
|
|
onClick={() => {
|
|
setWaiveChecklistTarget(null);
|
|
setWaiveChecklistNote("");
|
|
}}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
data-testid="action-confirm-submit"
|
|
disabled={!waiveChecklistTarget || !waiveChecklistNote.trim() || patchClosureChecklistMutation.isPending}
|
|
onClick={() => {
|
|
if (!waiveChecklistTarget) return;
|
|
patchClosureChecklistMutation.mutate({
|
|
itemId: waiveChecklistTarget.itemId,
|
|
status: "waived",
|
|
note: waiveChecklistNote.trim(),
|
|
});
|
|
setWaiveChecklistTarget(null);
|
|
setWaiveChecklistNote("");
|
|
}}
|
|
>
|
|
{patchClosureChecklistMutation.isPending ? "处理中..." : "确认豁免"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<ActionConfirmDialog
|
|
open={confirmArchiveProject}
|
|
onOpenChange={setConfirmArchiveProject}
|
|
kind="archive"
|
|
targetName={detail.projectNo}
|
|
description="归档后该项目不再作为新的执行项目推进,历史成员、交付、合同、费用和资料索引仍保留。请确认当前收口项、风险和关联业务已经核对。"
|
|
confirmLabel="确认归档"
|
|
onConfirm={() => archiveProjectMutation.mutate()}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default ProjectDetailPage;
|