feat: improve contract ledger tracking
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user