import { useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createContractRegistry, exportContractsRegistry, fetchContractsRegistry, fetchExpiringContracts, patchContractRegistry, renewContractRegistry, type ContractRegistryExportFormat, type ContractRegistryFilters, type ContractRegistryItem, type ContractRegistryPersistedStatus, type ContractRegistryStatus, type ContractRegistryType, } from "../api/contractsRegistry"; import { fetchEmployees } from "../api/employees"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchProjects, type ConfidentialityLevel } from "../api/projects"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission } from "../auth/permissions"; import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard"; import { PageHeader } from "../components/common/PageHeader"; import { StatusBadge } from "../components/common/StatusBadge"; import { DateTextInput } from "../components/common/DateTextInput"; import { HeaderActionGroup, WorkspaceNotice } from "../components/patterns/ListWorkspace"; import { BusinessListToolbar, BusinessMobileList, BusinessTable, } from "../components/patterns/BusinessListTemplate"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { Button } from "../components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../components/ui/dropdown-menu"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Textarea } from "../components/ui/textarea"; import { downloadBlob } from "../lib/download"; import { formatDisplayDate } from "../lib/displayFormat"; import { hasTrialNoise } from "../lib/trialNoise"; import { toast } from "sonner"; const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"]; const CONTRACT_TYPE_OPTIONS: ContractRegistryType[] = ["purchase", "sales", "service", "framework", "other"]; const FILTER_STATUS_OPTIONS: Array<{ value: ContractRegistryStatus; label: string }> = [ { value: "active", label: "生效中" }, { value: "expired", label: "已过期" }, { value: "draft", label: "草稿" }, { value: "archived", label: "已归档" }, ]; const EDITOR_STATUS_OPTIONS: Array<{ value: ContractRegistryPersistedStatus; label: string }> = [ { value: "active", label: "生效中" }, { value: "draft", label: "草稿" }, { value: "archived", label: "已归档" }, ]; const AMOUNT_PATTERN = /^\d{1,12}(\.\d{1,2})?$/; const EXPIRING_DAYS = 30; type FiltersState = { projectId: string; status: "" | ContractRegistryStatus; contractType: "" | ContractRegistryType; counterpartyName: string; expiryDateFrom: string; expiryDateTo: string; }; type EditorState = { contractNo: string; projectId: string; contractType: ContractRegistryType; counterpartyName: string; summary: string; amount: string; currency: string; signedDate: string; effectiveDate: string; expiryDate: string; confidentialityLevel: ConfidentialityLevel; archiveLocation: string; offlineArchiveNo: string; ownerEmployeeId: string; status: ContractRegistryPersistedStatus; }; 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 contractTypeLabel(value: ContractRegistryType) { if (value === "purchase") return "采购合同"; if (value === "sales") return "销售合同"; if (value === "service") return "服务合同"; if (value === "framework") return "框架协议"; return "其他"; } function contractStatusLabel(value: ContractRegistryStatus | ContractRegistryPersistedStatus) { if (value === "active") return "生效中"; if (value === "expired") return "已过期"; if (value === "draft") return "草稿"; if (value === "archived") return "已归档"; return value || "-"; } function buildEmptyEditor(projectId = ""): EditorState { return { contractNo: "", projectId, contractType: "other", counterpartyName: "", summary: "", amount: "", currency: "CNY", signedDate: "", effectiveDate: "", expiryDate: "", confidentialityLevel: "internal", archiveLocation: "", offlineArchiveNo: "", ownerEmployeeId: "", status: "active", }; } function buildFiltersPayload(filters: FiltersState): ContractRegistryFilters { return { ...(filters.projectId ? { projectId: filters.projectId } : {}), ...(filters.status ? { status: filters.status } : {}), ...(filters.contractType ? { contractType: filters.contractType } : {}), ...(filters.counterpartyName.trim() ? { counterpartyName: filters.counterpartyName.trim() } : {}), ...(filters.expiryDateFrom ? { expiryDateFrom: filters.expiryDateFrom } : {}), ...(filters.expiryDateTo ? { expiryDateTo: filters.expiryDateTo } : {}), }; } function buildSavePayload(form: EditorState, options: { includeOwnerEmployeeId?: boolean } = {}) { const includeOwnerEmployeeId = options.includeOwnerEmployeeId ?? true; return { contractNo: form.contractNo.trim(), projectId: form.projectId || null, contractType: form.contractType, counterpartyName: form.counterpartyName.trim(), summary: form.summary.trim(), amount: form.amount.trim() ? form.amount.trim() : null, currency: form.currency.trim().toUpperCase(), signedDate: form.signedDate || null, effectiveDate: form.effectiveDate || null, expiryDate: form.expiryDate || null, confidentialityLevel: form.confidentialityLevel, archiveLocation: form.archiveLocation.trim(), offlineArchiveNo: form.offlineArchiveNo.trim() || null, ...(includeOwnerEmployeeId && form.ownerEmployeeId ? { ownerEmployeeId: form.ownerEmployeeId } : {}), status: form.status, }; } function loadEditorFromItem(item: ContractRegistryItem): EditorState { return { contractNo: item.contractNo, projectId: item.projectId ?? "", contractType: item.contractType, counterpartyName: item.counterpartyName, summary: item.summary, amount: item.amount ?? "", currency: item.currency, signedDate: item.signedDate ?? "", effectiveDate: item.effectiveDate ?? "", expiryDate: item.expiryDate ?? "", confidentialityLevel: item.confidentialityLevel, archiveLocation: item.archiveLocation, offlineArchiveNo: item.offlineArchiveNo ?? "", ownerEmployeeId: item.ownerEmployeeId, status: item.persistedStatus, }; } function nextPersistedStatus(item: ContractRegistryItem): ContractRegistryPersistedStatus { return item.persistedStatus === "archived" ? "active" : "archived"; } export function ContractsRegistryPage() { const queryClient = useQueryClient(); const { permissions } = useAuth(); const [searchParams] = useSearchParams(); const initialProjectId = searchParams.get("projectId") ?? ""; const canBrowseEmployees = hasAnyPermission(permissions, ["system.users", "report.dashboard.view"]); const canManageContracts = hasAnyPermission(permissions, ["project.manage", "project.view_all", "system.settings"]); const [filters, setFilters] = useState({ projectId: initialProjectId, status: "", contractType: "", counterpartyName: "", expiryDateFrom: "", expiryDateTo: "", }); const [editorId, setEditorId] = useState(null); const [editor, setEditor] = useState(() => buildEmptyEditor(initialProjectId)); const [savedEditorSignature, setSavedEditorSignature] = useState(() => JSON.stringify(buildEmptyEditor(initialProjectId))); const [showExpiring, setShowExpiring] = useState(false); const [showTrialData, setShowTrialData] = useState(false); const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [showEditor, setShowEditor] = useState(false); const [confirmExportFormat, setConfirmExportFormat] = useState(null); const [confirmStatusTarget, setConfirmStatusTarget] = useState(null); const filterPayload = buildFiltersPayload(filters); const contractsQuery = useQuery({ queryKey: ["contracts-registry", filterPayload], queryFn: () => fetchContractsRegistry(filterPayload), }); const expiringQuery = useQuery({ queryKey: ["contracts-registry", "expiring", EXPIRING_DAYS], queryFn: () => fetchExpiringContracts(EXPIRING_DAYS), }); const projectsQuery = useQuery({ queryKey: ["projects", "contracts-page"], queryFn: () => fetchProjects({}), }); const employeesQuery = useQuery({ enabled: canBrowseEmployees, queryKey: ["employees", "contracts-page"], queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }), }); const projectOptions = projectsQuery.data?.items ?? []; const employeeOptions = canBrowseEmployees ? employeesQuery.data?.items ?? [] : []; const contracts = contractsQuery.data?.items ?? []; const visibleContracts = useMemo( () => showTrialData ? contracts : contracts.filter( (item) => !hasTrialNoise(item.contractNo, item.counterpartyName, item.summary, item.project?.name), ), [contracts, showTrialData], ); const hiddenTrialContractCount = contracts.length - visibleContracts.length; const expiringItems = expiringQuery.data?.items ?? []; const editingItem = editorId ? contracts.find((item) => item.id === editorId) ?? null : null; const readonlyOwnerLabel = editingItem?.owner ? `${editingItem.owner.name} · ${editingItem.owner.employeeNo}` : editor.ownerEmployeeId ? "保留现有责任人" : "默认当前用户"; const amountInvalid = Boolean(editor.amount.trim()) && !AMOUNT_PATTERN.test(editor.amount.trim()); const expiringContractCount = expiringQuery.data?.total ?? expiringItems.length; const runGuarded = useUnsavedChangesGuard( showEditor && JSON.stringify(editor) !== savedEditorSignature, "合同表单", ); const refreshAll = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }), queryClient.invalidateQueries({ queryKey: ["projects"] }), ]); }; const resetEditor = () => { const nextEditor = buildEmptyEditor(filters.projectId); setEditorId(null); setEditor(nextEditor); setSavedEditorSignature(JSON.stringify(nextEditor)); }; const openEditor = (item: ContractRegistryItem) => { runGuarded(() => { const nextEditor = loadEditorFromItem(item); setEditorId(item.id); setEditor(nextEditor); setSavedEditorSignature(JSON.stringify(nextEditor)); setShowEditor(true); }); }; const saveMutation = useMutation({ mutationFn: async () => { const payload = buildSavePayload(editor, { includeOwnerEmployeeId: canBrowseEmployees }); if (editorId) { return patchContractRegistry(editorId, payload); } return createContractRegistry(payload); }, onSuccess: async () => { await refreshAll(); setShowEditor(false); resetEditor(); toast.success(editorId ? "合同已更新" : "合同已创建"); }, onError: (error) => toast.error(resolveError(error, editorId ? "更新合同失败" : "创建合同失败")), }); const toggleStatusMutation = useMutation({ mutationFn: (item: ContractRegistryItem) => patchContractRegistry(item.id, { status: nextPersistedStatus(item) }), onSuccess: async () => { await refreshAll(); toast.success("合同状态已更新"); }, onError: (error) => toast.error(resolveError(error, "更新合同状态失败")), }); const renewMutation = useMutation({ mutationFn: (item: ContractRegistryItem) => renewContractRegistry(item.id), onSuccess: async () => { await refreshAll(); toast.success("合同已续约,新的草稿合同已创建"); }, onError: (error) => toast.error(resolveError(error, "续约合同失败")), }); const exportMutation = useMutation({ mutationFn: (format: ContractRegistryExportFormat) => exportContractsRegistry({ ...filterPayload, format, }), onSuccess: (response, format) => { downloadBlob(response.blob, response.filename ?? `contracts-registry.${format}`); toast.success(`合同已导出为 ${format.toUpperCase()}`); }, onError: (error) => toast.error(resolveError(error, "导出合同失败")), }); return ( {canManageContracts ? ( ) : null} } /> {expiringQuery.isError ? (
{resolveError(expiringQuery.error, "加载到期预警失败")}
) : null} {showExpiring ? (
{expiringItems.map((item) => (
{item.contractNo} · {item.counterpartyName}
到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}
{canManageContracts ? ( ) : null}
))} {!expiringQuery.isLoading && !expiringItems.length ? (
暂无到期合同
) : null}
) : null}
{showEditor ? (

{editorId ? "编辑合同" : "新建合同"}

补齐合同类型、金额、签署/生效/到期日期,续约将自动创建新的草稿合同。

{editorId ? ( ) : null}
{editingItem?.status === "expired" ? (
当前合同因到期日已过,列表状态显示为“已过期”;保存时仍按“{contractStatusLabel(editingItem.persistedStatus)}”留存。
) : null}

基础信息

填写编号、类型、相对方、摘要和金额。

setEditor((current) => ({ ...current, contractNo: event.target.value }))} placeholder="例如 HT-2026-001" />
setEditor((current) => ({ ...current, counterpartyName: event.target.value }))} placeholder="合同对方名称" />