import { 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 { PageHeader } from "../components/common/PageHeader"; import { StatusBadge } from "../components/common/StatusBadge"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Textarea } from "../components/ui/textarea"; import { downloadBlob } from "../lib/download"; 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: "active" }, { value: "draft", label: "draft" }, { value: "archived", label: "archived" }, ]; 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 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 [filters, setFilters] = useState({ projectId: initialProjectId, status: "", contractType: "", counterpartyName: "", expiryDateFrom: "", expiryDateTo: "", }); const [editorId, setEditorId] = useState(null); const [editor, setEditor] = useState(() => buildEmptyEditor(initialProjectId)); const [showExpiring, setShowExpiring] = useState(false); 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 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 refreshAll = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }), queryClient.invalidateQueries({ queryKey: ["projects"] }), ]); }; const resetEditor = () => { setEditorId(null); setEditor(buildEmptyEditor(filters.projectId)); }; const saveMutation = useMutation({ mutationFn: async () => { const payload = buildSavePayload(editor, { includeOwnerEmployeeId: canBrowseEmployees }); if (editorId) { return patchContractRegistry(editorId, payload); } return createContractRegistry(payload); }, onSuccess: async () => { await refreshAll(); 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 (
} />
到期预警
{expiringQuery.data?.total ?? 0}
份合同将在 {EXPIRING_DAYS} 天内到期
{expiringQuery.isError ? (
{resolveError(expiringQuery.error, "加载到期预警失败")}
) : null} {showExpiring ? (
{expiringItems.map((item) => (
{item.contractNo} · {item.counterpartyName}
{contractTypeLabel(item.contractType)} · 到期日 {item.expiryDate ?? "-"} · 责任人 {item.owner?.name ?? "-"}
))} {!expiringQuery.isLoading && !expiringItems.length ? (
未来 {EXPIRING_DAYS} 天内暂无即将到期合同。
) : null}
) : null}

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

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

{editorId ? ( ) : null}
{editingItem?.status === "expired" ? (
当前合同因到期日已过,列表状态显示为“已过期”;保存时仍以数据库状态 {editingItem.persistedStatus} 持久化。
) : null}
setEditor((current) => ({ ...current, contractNo: event.target.value }))} placeholder="例如 HT-2026-001" />
setEditor((current) => ({ ...current, counterpartyName: event.target.value }))} placeholder="合同对方名称" />