diff --git a/client/src/api/contractsRegistry.ts b/client/src/api/contractsRegistry.ts index 7bfbc6b7..7676ebed 100644 --- a/client/src/api/contractsRegistry.ts +++ b/client/src/api/contractsRegistry.ts @@ -1,38 +1,88 @@ -import { apiRequestData } from "./http"; +import { apiRequestBlob, apiRequestData } from "./http"; import type { ConfidentialityLevel, EmployeeBasic } from "./projects"; +export type ContractRegistryType = "purchase" | "sales" | "service" | "framework" | "other"; +export type ContractRegistryPersistedStatus = "draft" | "active" | "archived"; +export type ContractRegistryStatus = ContractRegistryPersistedStatus | "expired"; +export type ContractRegistryExportFormat = "csv" | "xlsx"; + +export type ContractRegistryFilters = { + projectId?: string; + status?: ContractRegistryStatus; + contractType?: ContractRegistryType; + counterpartyName?: string; + expiryDateFrom?: string; + expiryDateTo?: string; +}; + export type ContractRegistryItem = { id: string; contractNo: string; projectId: string | null; + contractType: ContractRegistryType; counterpartyName: string; summary: string; + amount: string | null; + currency: string; + signedDate: string | null; + effectiveDate: string | null; + expiryDate: string | null; confidentialityLevel: ConfidentialityLevel; archiveLocation: string; offlineArchiveNo: string | null; ownerEmployeeId: string; - status: string; + status: ContractRegistryStatus; + persistedStatus: ContractRegistryPersistedStatus; createdAt: string | null; updatedAt: string | null; owner: EmployeeBasic | null; project: { id: string; projectNo: string; name: string } | null; }; -export async function fetchContractsRegistry(projectId?: string) { - const suffix = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; - return apiRequestData<{ items: ContractRegistryItem[] }>(`/api/contracts-registry${suffix}`); +function buildFiltersSearch(filters?: ContractRegistryFilters | string) { + const normalized = + typeof filters === "string" + ? { projectId: filters } + : (filters ?? {}); + const search = new URLSearchParams(); + + if (normalized.projectId) search.set("projectId", normalized.projectId); + if (normalized.status) search.set("status", normalized.status); + if (normalized.contractType) search.set("contractType", normalized.contractType); + if (normalized.counterpartyName) search.set("counterpartyName", normalized.counterpartyName); + if (normalized.expiryDateFrom) search.set("expiryDateFrom", normalized.expiryDateFrom); + if (normalized.expiryDateTo) search.set("expiryDateTo", normalized.expiryDateTo); + + const suffix = search.toString(); + return suffix ? `?${suffix}` : ""; +} + +export async function fetchContractsRegistry(filters?: ContractRegistryFilters | string) { + return apiRequestData<{ items: ContractRegistryItem[] }>(`/api/contracts-registry${buildFiltersSearch(filters)}`); +} + +export async function fetchExpiringContracts(days = 30) { + return apiRequestData<{ days: number; total: number; items: ContractRegistryItem[] }>( + `/api/contracts-registry/expiring?days=${encodeURIComponent(String(days))}`, + ); } export async function createContractRegistry(payload: { contractNo: string; projectId?: string | null; + contractType: ContractRegistryType; counterpartyName: string; summary: string; + amount?: string | null; + currency?: string; + signedDate?: string | null; + effectiveDate?: string | null; + expiryDate?: string | null; confidentialityLevel?: ConfidentialityLevel; archiveLocation: string; offlineArchiveNo?: string | null; ownerEmployeeId?: string; - status?: string; + status?: ContractRegistryPersistedStatus; }) { return apiRequestData<{ item: ContractRegistryItem }>("/api/contracts-registry", { method: "POST", @@ -45,13 +95,19 @@ export async function patchContractRegistry( payload: Partial<{ contractNo: string; projectId: string | null; + contractType: ContractRegistryType; counterpartyName: string; summary: string; + amount: string | null; + currency: string; + signedDate: string | null; + effectiveDate: string | null; + expiryDate: string | null; confidentialityLevel: ConfidentialityLevel; archiveLocation: string; offlineArchiveNo: string | null; ownerEmployeeId: string; - status: string; + status: ContractRegistryPersistedStatus; }>, ) { return apiRequestData<{ item: ContractRegistryItem }>(`/api/contracts-registry/${id}`, { @@ -59,3 +115,22 @@ export async function patchContractRegistry( body: JSON.stringify(payload), }); } + +export async function renewContractRegistry(id: string) { + return apiRequestData<{ item: ContractRegistryItem; sourceItem: ContractRegistryItem }>( + `/api/contracts-registry/${id}/renew`, + { + method: "POST", + }, + ); +} + +export async function exportContractsRegistry(params: ContractRegistryFilters & { format: ContractRegistryExportFormat }) { + return apiRequestBlob("/api/exports/contracts-registry", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(params), + }); +} diff --git a/client/src/pages/ContractsRegistryPage.tsx b/client/src/pages/ContractsRegistryPage.tsx index 55e6a637..562588d7 100644 --- a/client/src/pages/ContractsRegistryPage.tsx +++ b/client/src/pages/ContractsRegistryPage.tsx @@ -1,9 +1,22 @@ -import { useMemo, useState } from "react"; +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 { createContractRegistry, fetchContractsRegistry, patchContractRegistry, type ContractRegistryItem } from "../api/contractsRegistry"; import { fetchProjects, type ConfidentialityLevel } from "../api/projects"; import { isApiError } from "../api/types"; import { PageHeader } from "../components/common/PageHeader"; @@ -12,9 +25,51 @@ 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)) { @@ -30,8 +85,87 @@ function confidentialityLabel(value: ConfidentialityLevel) { return "限制"; } -function nextStatus(item: ContractRegistryItem) { - return item.status === "archived" ? "active" : "archived"; +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) { + 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, + ownerEmployeeId: form.ownerEmployeeId || undefined, + 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() { @@ -39,27 +173,42 @@ export function ContractsRegistryPage() { const [searchParams] = useSearchParams(); const initialProjectId = searchParams.get("projectId") ?? ""; - const [projectId, setProjectId] = useState(initialProjectId); - const [contractNo, setContractNo] = useState(""); - const [counterpartyName, setCounterpartyName] = useState(""); - const [summary, setSummary] = useState(""); - const [archiveLocation, setArchiveLocation] = useState(""); - const [offlineArchiveNo, setOfflineArchiveNo] = useState(""); - const [ownerEmployeeId, setOwnerEmployeeId] = useState(""); - const [confidentialityLevel, setConfidentialityLevel] = useState("internal"); - const [status, setStatus] = useState("active"); + 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", projectId], - queryFn: () => fetchContractsRegistry(projectId || undefined), + 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({ + queryKey: ["employees", "contracts-page"], + queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }), }); - const projectsQuery = useQuery({ queryKey: ["projects", "contracts-page"], queryFn: () => fetchProjects({}) }); - const employeesQuery = useQuery({ queryKey: ["employees", "contracts-page"], queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }) }); const projectOptions = projectsQuery.data?.items ?? []; const employeeOptions = employeesQuery.data?.items ?? []; - - const groupedContracts = useMemo(() => contractsQuery.data?.items ?? [], [contractsQuery.data?.items]); + const contracts = contractsQuery.data?.items ?? []; + const expiringItems = expiringQuery.data?.items ?? []; + const editingItem = editorId ? contracts.find((item) => item.id === editorId) ?? null : null; + const amountInvalid = Boolean(editor.amount.trim()) && !AMOUNT_PATTERN.test(editor.amount.trim()); const refreshAll = async () => { await Promise.all([ @@ -68,62 +217,216 @@ export function ContractsRegistryPage() { ]); }; - const createMutation = useMutation({ - mutationFn: () => - createContractRegistry({ - contractNo: contractNo.trim(), - projectId: projectId || null, - counterpartyName: counterpartyName.trim(), - summary: summary.trim(), - archiveLocation: archiveLocation.trim(), - offlineArchiveNo: offlineArchiveNo.trim() || null, - ownerEmployeeId: ownerEmployeeId || undefined, - confidentialityLevel, - status, - }), - onSuccess: async () => { - setContractNo(""); - setCounterpartyName(""); - setSummary(""); - setArchiveLocation(""); - setOfflineArchiveNo(""); - setOwnerEmployeeId(""); - setConfidentialityLevel("internal"); - setStatus("active"); - await refreshAll(); - toast.success("合同台账已创建"); + const resetEditor = () => { + setEditorId(null); + setEditor(buildEmptyEditor(filters.projectId)); + }; + + const saveMutation = useMutation({ + mutationFn: async () => { + if (editorId) { + return patchContractRegistry(editorId, buildSavePayload(editor)); + } + return createContractRegistry(buildSavePayload(editor)); }, - onError: (error) => toast.error(resolveError(error, "创建合同台账失败")), + 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: nextStatus(item) }), - onSuccess: refreshAll, + 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} +
- setContractNo(event.target.value)} placeholder="例如 HT-2026-001" /> + setEditor((current) => ({ ...current, contractNo: event.target.value }))} + placeholder="例如 HT-2026-001" + />
+ +
+
+ + +
+
+ + +
+
+
- setEditor((current) => ({ ...current, projectId: event.target.value }))} + > {projectOptions.map((item) => (
+
- setCounterpartyName(event.target.value)} placeholder="合同对方名称" /> + setEditor((current) => ({ ...current, counterpartyName: event.target.value }))} + placeholder="合同对方名称" + />
+
-