feat: improve contract ledger tracking

This commit is contained in:
yuqei
2026-06-19 09:26:10 +08:00
parent 5bb330b610
commit b14be976d2
2 changed files with 218 additions and 20 deletions
+207 -20
View File
@@ -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<GeneratedContractListItem[]>([]);
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<ColumnsType<GeneratedContractListItem>>(() => [
{
@@ -218,6 +323,25 @@ export default function ContractLedgerPage() {
</Space>
),
},
{
title: "审批状态",
key: "approval",
width: 210,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Tag color={getContractApprovalStatusColor(record.approval?.status)}>
{formatContractApprovalStatus(record.approval?.status)}
</Tag>
<Text type="secondary">
{record.approval?.pending?.assignee_name
? `处理人:${record.approval.pending.assignee_name}`
: record.approval?.latest?.assignee_name
? `最近处理人:${record.approval.latest.assignee_name}`
: "未提交审批"}
</Text>
</Space>
),
},
{
title: "自动预审",
key: "auto_review",
@@ -261,12 +385,15 @@ export default function ContractLedgerPage() {
<div className="contract-ledger-title">
<span className="landing-badge">Contract Ledger</span>
<h1>合同台账</h1>
<p>生成后的合同进入统一台账,优先暴露到期、逾期和高风险事项。</p>
<p>统一跟踪审批状态、生命周期、到期状态和最近合同,优先暴露需要跟进的事项。</p>
</div>
<div className="contract-ledger-actions">
<Button icon={<ReloadOutlined />} onClick={() => void loadContracts()} loading={loading}>
刷新
</Button>
<Button icon={<DownloadOutlined />} disabled={!filteredContracts.length} onClick={handleExportCsv}>
导出当前视图
</Button>
<Link href="/contracts">
<Button type="primary" icon={<FileDoneOutlined />}>
新建合同
@@ -285,10 +412,41 @@ export default function ContractLedgerPage() {
</div>
</section>
<section className="landing-section">
<div className="landing-section-header">
<h2>最近合同</h2>
<p>按生命周期更新时间和起草时间排序,快速回到最近推进的合同。</p>
</div>
{recentContracts.length === 0 ? (
<Empty description="当前筛选条件下没有最近合同。" />
) : (
<div className="landing-grid">
{recentContracts.map((item) => (
<Link className="landing-card contract-ledger-priority-card" href={`/contracts/${item.id}`} key={item.id}>
<span className="landing-card-eyebrow">{formatDateTime(item.lifecycle?.updated_at ?? item.generated_at)}</span>
<strong>{item.template_name}</strong>
<span>{item.file_name}</span>
<Space wrap>
<Tag color={getLifecycleStatusColor(item.lifecycle?.status)}>
{formatLifecycleStatus(item.lifecycle?.status)}
</Tag>
<Tag color={getContractApprovalStatusColor(item.approval?.status)}>
{formatContractApprovalStatus(item.approval?.status)}
</Tag>
<Tag color={getDueStatusColor(item.lifecycle?.due_status)}>
{formatDueStatus(item.lifecycle)}
</Tag>
</Space>
</Link>
))}
</div>
)}
</section>
<section className="landing-section">
<div className="landing-section-header">
<h2>优先处理</h2>
<p>聚焦逾期、即将到期、高风险预审和仍在审查流转中的合同。</p>
<p>聚焦逾期、即将到期、待审批、高风险预审和仍在审查流转中的合同。</p>
</div>
{priorityContracts.length === 0 ? (
<Empty description="当前没有高优先级合同事项。" />
@@ -306,6 +464,9 @@ export default function ContractLedgerPage() {
<Tag color={getDueStatusColor(item.lifecycle?.due_status)}>
{formatDueStatus(item.lifecycle)}
</Tag>
<Tag color={getContractApprovalStatusColor(item.approval?.status)}>
{formatContractApprovalStatus(item.approval?.status)}
</Tag>
{isHighRisk(item) ? (
<Tag color="red" icon={<WarningOutlined />}>
{formatRisk(item.auto_review?.risk_level)}
@@ -324,14 +485,40 @@ export default function ContractLedgerPage() {
<h2>合同列表</h2>
<p>共 {contracts.length} 份合同,当前显示 {filteredContracts.length} 份。</p>
</div>
<Input
allowClear
className="contract-ledger-search"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索合同、相对方、负责人或状态"
prefix={<SearchOutlined />}
value={searchQuery}
/>
<Space wrap className="contract-ledger-filters">
<Input
allowClear
className="contract-ledger-search"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索合同、相对方、负责人或状态"
prefix={<SearchOutlined />}
value={searchQuery}
/>
<Select
options={[{ value: ALL_FILTER, label: "全部生命周期" }, ...lifecycleStatusOptions]}
value={lifecycleFilter}
onChange={setLifecycleFilter}
style={{ width: 150 }}
/>
<Select
options={dueStatusOptions}
value={dueFilter}
onChange={setDueFilter}
style={{ width: 140 }}
/>
<Select
options={approvalStatusOptions}
value={approvalFilter}
onChange={setApprovalFilter}
style={{ width: 140 }}
/>
<Select
options={riskStatusOptions}
value={riskFilter}
onChange={setRiskFilter}
style={{ width: 130 }}
/>
</Space>
</div>
<div className="contract-ledger-table-shell">
@@ -341,7 +528,7 @@ export default function ContractLedgerPage() {
loading={loading}
pagination={{ pageSize: 10, showSizeChanger: true }}
rowKey="id"
scroll={{ x: 1360 }}
scroll={{ x: 1540 }}
/>
</div>
</section>
+11
View File
@@ -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,