From b14be976d23f9d42b3f39994354533a9b545e574 Mon Sep 17 00:00:00 2001 From: yuqei Date: Fri, 19 Jun 2026 09:26:10 +0800 Subject: [PATCH] feat: improve contract ledger tracking --- frontend/src/app/contract-ledger/page.tsx | 227 ++++++++++++++++++++-- frontend/src/app/globals.css | 11 ++ 2 files changed, 218 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/contract-ledger/page.tsx b/frontend/src/app/contract-ledger/page.tsx index dd907973..e3a3f81b 100644 --- a/frontend/src/app/contract-ledger/page.tsx +++ b/frontend/src/app/contract-ledger/page.tsx @@ -1,27 +1,31 @@ "use client"; import { + DownloadOutlined, FileDoneOutlined, ReloadOutlined, SearchOutlined, WarningOutlined, } from "@ant-design/icons"; -import { Button, Empty, Input, Space, Table, Tag, Typography, message } from "antd"; +import { Button, Empty, Input, Select, Space, Table, Tag, Typography, message } from "antd"; import type { ColumnsType } from "antd/es/table"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { formatAutoReviewStatus, + formatContractApprovalStatus, formatContractType, formatDateTime, formatDueStatus, formatLifecycleStatus, formatRisk, getAutoReviewStatusColor, + getContractApprovalStatusColor, getDueStatusColor, getLifecycleStatusColor, getRiskColor, + lifecycleStatusOptions, } from "@/lib/format"; import { listGeneratedContracts, type GeneratedContractListItem } from "@/services/contract"; @@ -33,6 +37,35 @@ type LedgerMetric = { tone?: "warning" | "danger" | "success"; }; +const ALL_FILTER = "__all__"; + +const dueStatusOptions = [ + { value: ALL_FILTER, label: "全部期限" }, + { value: "not_set", label: "未设置期限" }, + { value: "normal", label: "期限正常" }, + { value: "due_soon", label: "即将到期" }, + { value: "overdue", label: "已逾期" }, + { value: "closed", label: "已关闭" }, +]; + +const approvalStatusOptions = [ + { value: ALL_FILTER, label: "全部审批" }, + { value: "not_submitted", label: "未提交" }, + { value: "pending", label: "审批中" }, + { value: "approved", label: "已通过" }, + { value: "rejected", label: "已退回" }, + { value: "cancelled", label: "已取消" }, +]; + +const riskStatusOptions = [ + { value: ALL_FILTER, label: "全部风险" }, + { value: "critical", label: "重大风险" }, + { value: "high", label: "高风险" }, + { value: "medium", label: "中风险" }, + { value: "low", label: "低风险" }, + { value: "unrated", label: "未评级" }, +]; + function getLifecycleStatus(item: GeneratedContractListItem) { return item.lifecycle?.status ?? "generated"; } @@ -41,6 +74,14 @@ function getDueStatus(item: GeneratedContractListItem) { return item.lifecycle?.due_status ?? "not_set"; } +function getApprovalStatus(item: GeneratedContractListItem) { + return item.approval?.status ?? "not_submitted"; +} + +function getRiskStatus(item: GeneratedContractListItem) { + return item.auto_review?.risk_level ?? "unrated"; +} + function isHighRisk(item: GeneratedContractListItem) { return item.auto_review?.risk_level === "critical" || item.auto_review?.risk_level === "high"; } @@ -57,6 +98,7 @@ function getPriorityWeight(item: GeneratedContractListItem) { if (dueStatus === "overdue") return 0; if (dueStatus === "due_soon") return 1; + if (getApprovalStatus(item) === "pending") return 2; if (isHighRisk(item)) return 2; if (lifecycleStatus === "internal_review" || lifecycleStatus === "external_review") return 3; return 99; @@ -68,6 +110,7 @@ function getPriorityLabel(item: GeneratedContractListItem) { if (dueStatus === "overdue") return "逾期处理"; if (dueStatus === "due_soon") return "到期跟进"; + if (getApprovalStatus(item) === "pending") return "审批跟进"; if (isHighRisk(item)) return "高风险预审"; if (lifecycleStatus === "internal_review" || lifecycleStatus === "external_review") return "审查推进"; return "合同跟进"; @@ -79,6 +122,7 @@ function getPriorityDescription(item: GeneratedContractListItem) { if (dueStatus === "overdue") return "合同期限已经越过,建议补齐续约、终止或归档动作。"; if (dueStatus === "due_soon") return "合同即将到期,建议提前确认续约、结算和履约节点。"; + if (getApprovalStatus(item) === "pending") return "合同正在审批中,建议跟进处理人和审批意见。"; if (isHighRisk(item)) return "自动预审提示高风险,建议先处理关键条款再推进签署。"; if (lifecycleStatus === "internal_review" || lifecycleStatus === "external_review") return "合同仍处于审查流转中,需要继续跟进修改和确认。"; return "继续完善合同状态和后续处理记录。"; @@ -90,10 +134,14 @@ function buildMetrics(items: GeneratedContractListItem[]): LedgerMetric[] { const dueSoon = items.filter((item) => getDueStatus(item) === "due_soon").length; const overdue = items.filter((item) => getDueStatus(item) === "overdue").length; const highRisk = items.filter(isHighRisk).length; + const pendingApproval = items.filter((item) => getApprovalStatus(item) === "pending").length; + const approved = items.filter((item) => getApprovalStatus(item) === "approved").length; return [ { label: "已入台账", value: items.length }, { label: "审查流转中", value: inReview }, + { label: "待审批", value: pendingApproval, tone: "warning" }, + { label: "已审批", value: approved, tone: "success" }, { label: "签署/履约中", value: active, tone: "success" }, { label: "即将到期", value: dueSoon, tone: "warning" }, { label: "已逾期", value: overdue, tone: "danger" }, @@ -114,6 +162,9 @@ function matchesKeyword(item: GeneratedContractListItem, keyword: string) { formatLifecycleStatus(lifecycle?.status), formatDueStatus(lifecycle), formatAutoReviewStatus(item.auto_review?.status), + formatContractApprovalStatus(item.approval?.status), + item.approval?.latest?.assignee_name, + item.approval?.latest?.submitter_name, formatRisk(item.auto_review?.risk_level), ] .filter(Boolean) @@ -123,11 +174,27 @@ function matchesKeyword(item: GeneratedContractListItem, keyword: string) { return searchText.includes(keyword.toLowerCase()); } +function matchesSelectFilter(value: string, selected: string) { + return selected === ALL_FILTER || value === selected; +} + +function csvEscape(value: unknown) { + const text = String(value ?? ""); + if (/[",\n\r]/.test(text)) { + return `"${text.replaceAll('"', '""')}"`; + } + return text; +} + export default function ContractLedgerPage() { const [messageApi, contextHolder] = message.useMessage(); const [contracts, setContracts] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); + const [lifecycleFilter, setLifecycleFilter] = useState(ALL_FILTER); + const [dueFilter, setDueFilter] = useState(ALL_FILTER); + const [approvalFilter, setApprovalFilter] = useState(ALL_FILTER); + const [riskFilter, setRiskFilter] = useState(ALL_FILTER); const loadContracts = useCallback(async () => { setLoading(true); @@ -152,9 +219,26 @@ export default function ContractLedgerPage() { return () => window.clearTimeout(timer); }, [loadContracts]); - const metrics = useMemo(() => buildMetrics(contracts), [contracts]); + const filteredContracts = useMemo(() => { + const keyword = searchQuery.trim(); + return contracts.filter((item) => { + if (keyword && !matchesKeyword(item, keyword)) return false; + if (!matchesSelectFilter(getLifecycleStatus(item), lifecycleFilter)) return false; + if (!matchesSelectFilter(getDueStatus(item), dueFilter)) return false; + if (!matchesSelectFilter(getApprovalStatus(item), approvalFilter)) return false; + if (!matchesSelectFilter(getRiskStatus(item), riskFilter)) return false; + return true; + }); + }, [approvalFilter, contracts, dueFilter, lifecycleFilter, riskFilter, searchQuery]); + + const metrics = useMemo(() => buildMetrics(filteredContracts), [filteredContracts]); + const recentContracts = useMemo(() => { + return [...filteredContracts] + .sort((a, b) => getTimeValue(b.lifecycle?.updated_at ?? b.generated_at) - getTimeValue(a.lifecycle?.updated_at ?? a.generated_at)) + .slice(0, 4); + }, [filteredContracts]); const priorityContracts = useMemo(() => { - return contracts + return filteredContracts .filter((item) => getPriorityWeight(item) < 99) .sort((a, b) => { const weightDiff = getPriorityWeight(a) - getPriorityWeight(b); @@ -162,12 +246,33 @@ export default function ContractLedgerPage() { return getTimeValue(b.lifecycle?.updated_at ?? b.generated_at) - getTimeValue(a.lifecycle?.updated_at ?? a.generated_at); }) .slice(0, 6); - }, [contracts]); - const filteredContracts = useMemo(() => { - const keyword = searchQuery.trim(); - if (!keyword) return contracts; - return contracts.filter((item) => matchesKeyword(item, keyword)); - }, [contracts, searchQuery]); + }, [filteredContracts]); + + const handleExportCsv = () => { + const headers = ["合同名称", "文件名", "类型", "相对方", "负责人", "生命周期", "期限状态", "审批状态", "审批处理人", "自动预审", "风险等级", "最近更新"]; + const rows = filteredContracts.map((item) => [ + item.template_name, + item.file_name, + formatContractType(item.document_type_code ?? item.contract_type), + item.lifecycle?.counterparty_name ?? "", + item.lifecycle?.owner_name ?? item.metadata?.generated_by_name ?? "", + formatLifecycleStatus(item.lifecycle?.status), + formatDueStatus(item.lifecycle), + formatContractApprovalStatus(item.approval?.status), + item.approval?.pending?.assignee_name ?? item.approval?.latest?.assignee_name ?? "", + formatAutoReviewStatus(item.auto_review?.status), + item.auto_review?.risk_level ? formatRisk(item.auto_review.risk_level) : "未评级", + formatDateTime(item.lifecycle?.updated_at ?? item.generated_at), + ]); + const csv = [headers, ...rows].map((row) => row.map(csvEscape).join(",")).join("\n"); + const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `contract-ledger-${new Date().toISOString().slice(0, 10)}.csv`; + anchor.click(); + URL.revokeObjectURL(url); + }; const columns = useMemo>(() => [ { @@ -218,6 +323,25 @@ export default function ContractLedgerPage() { ), }, + { + title: "审批状态", + key: "approval", + width: 210, + render: (_, record) => ( + + + {formatContractApprovalStatus(record.approval?.status)} + + + {record.approval?.pending?.assignee_name + ? `处理人:${record.approval.pending.assignee_name}` + : record.approval?.latest?.assignee_name + ? `最近处理人:${record.approval.latest.assignee_name}` + : "未提交审批"} + + + ), + }, { title: "自动预审", key: "auto_review", @@ -261,12 +385,15 @@ export default function ContractLedgerPage() {
Contract Ledger

合同台账

-

生成后的合同进入统一台账,优先暴露到期、逾期和高风险事项。

+

统一跟踪审批状态、生命周期、到期状态和最近合同,优先暴露需要跟进的事项。

+
+
+
+

最近合同

+

按生命周期更新时间和起草时间排序,快速回到最近推进的合同。

+
+ {recentContracts.length === 0 ? ( + + ) : ( +
+ {recentContracts.map((item) => ( + + {formatDateTime(item.lifecycle?.updated_at ?? item.generated_at)} + {item.template_name} + {item.file_name} + + + {formatLifecycleStatus(item.lifecycle?.status)} + + + {formatContractApprovalStatus(item.approval?.status)} + + + {formatDueStatus(item.lifecycle)} + + + + ))} +
+ )} +
+

优先处理

-

聚焦逾期、即将到期、高风险预审和仍在审查流转中的合同。

+

聚焦逾期、即将到期、待审批、高风险预审和仍在审查流转中的合同。

{priorityContracts.length === 0 ? ( @@ -306,6 +464,9 @@ export default function ContractLedgerPage() { {formatDueStatus(item.lifecycle)} + + {formatContractApprovalStatus(item.approval?.status)} + {isHighRisk(item) ? ( }> {formatRisk(item.auto_review?.risk_level)} @@ -324,14 +485,40 @@ export default function ContractLedgerPage() {

合同列表

共 {contracts.length} 份合同,当前显示 {filteredContracts.length} 份。

- setSearchQuery(event.target.value)} - placeholder="搜索合同、相对方、负责人或状态" - prefix={} - value={searchQuery} - /> + + setSearchQuery(event.target.value)} + placeholder="搜索合同、相对方、负责人或状态" + prefix={} + value={searchQuery} + /> + + +
@@ -341,7 +528,7 @@ export default function ContractLedgerPage() { loading={loading} pagination={{ pageSize: 10, showSizeChanger: true }} rowKey="id" - scroll={{ x: 1360 }} + scroll={{ x: 1540 }} />
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1bf72ec9..be0c5c94 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1719,6 +1719,11 @@ a { width: min(360px, 100%); } +.contract-ledger-filters { + justify-content: flex-end; + max-width: 860px; +} + .contract-ledger-table-shell { overflow: hidden; border-radius: 8px; @@ -1745,6 +1750,12 @@ a { .contract-ledger-search { width: 100%; } + + .contract-ledger-filters, + .contract-ledger-filters .ant-select, + .contract-ledger-filters .ant-input-affix-wrapper { + width: 100% !important; + } } .review-page-grid,