From 52a676ff19f8d1d22e78da31e2ec037c61bcf1e9 Mon Sep 17 00:00:00 2001 From: cyuqei Date: Fri, 19 Jun 2026 13:31:49 +0800 Subject: [PATCH] feat: enhance ledger archive and approval sla --- frontend/src/app/contract-approvals/page.tsx | 245 ++++++++++++- frontend/src/app/contract-ledger/page.tsx | 341 ++++++++++++++++++- 2 files changed, 578 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/contract-approvals/page.tsx b/frontend/src/app/contract-approvals/page.tsx index 7fc3b8dd..75d6594f 100644 --- a/frontend/src/app/contract-approvals/page.tsx +++ b/frontend/src/app/contract-approvals/page.tsx @@ -2,7 +2,9 @@ import { CheckCircleOutlined, + ClockCircleOutlined, CloseCircleOutlined, + ExclamationCircleOutlined, FileSearchOutlined, ReloadOutlined, } from "@ant-design/icons"; @@ -43,6 +45,100 @@ const statusOptions = [ { label: "已退回", value: "rejected" }, ]; +const slaOptions = [ + { label: "全部时效", value: "all" }, + { label: "已超时", value: "overdue" }, + { label: "临近超时", value: "due_soon" }, + { label: "SLA 内", value: "normal" }, + { label: "已处理", value: "closed" }, +]; + +const APPROVAL_SLA_HOURS = 48; +const APPROVAL_SLA_WARNING_HOURS = 24; + +type ApprovalSlaStatus = "normal" | "due_soon" | "overdue" | "closed"; + +function getTimeValue(value: string | null | undefined) { + if (!value) return 0; + const time = new Date(value).getTime(); + return Number.isNaN(time) ? 0 : time; +} + +function formatDurationHours(hours: number) { + if (!Number.isFinite(hours) || hours <= 0) return "不足1小时"; + const rounded = Math.floor(hours); + if (rounded < 24) return `${Math.max(1, rounded)}小时`; + const days = Math.floor(rounded / 24); + const restHours = rounded % 24; + return restHours > 0 ? `${days}天${restHours}小时` : `${days}天`; +} + +function buildApprovalSla(record: ContractApprovalStep): { + status: ApprovalSlaStatus; + label: string; + color: string; + elapsedHours: number; + dueAt: string | null; + description: string; +} { + const createdAt = getTimeValue(record.created_at); + const finishedAt = getTimeValue(record.decided_at); + const endAt = record.status === "pending" ? Date.now() : finishedAt || Date.now(); + const elapsedHours = createdAt ? Math.max((endAt - createdAt) / 3_600_000, 0) : 0; + const dueAt = createdAt ? new Date(createdAt + APPROVAL_SLA_HOURS * 3_600_000).toISOString() : null; + + if (record.status !== "pending") { + return { + status: "closed", + label: "已处理", + color: "default", + elapsedHours, + dueAt, + description: `处理耗时 ${formatDurationHours(elapsedHours)}`, + }; + } + + if (elapsedHours >= APPROVAL_SLA_HOURS) { + return { + status: "overdue", + label: "已超时", + color: "red", + elapsedHours, + dueAt, + description: `已等待 ${formatDurationHours(elapsedHours)}`, + }; + } + + if (elapsedHours >= APPROVAL_SLA_WARNING_HOURS) { + const remainingHours = APPROVAL_SLA_HOURS - elapsedHours; + return { + status: "due_soon", + label: "临近超时", + color: "orange", + elapsedHours, + dueAt, + description: `剩余 ${formatDurationHours(remainingHours)}`, + }; + } + + return { + status: "normal", + label: "SLA 内", + color: "green", + elapsedHours, + dueAt, + description: `已等待 ${formatDurationHours(elapsedHours)}`, + }; +} + +function getApprovalPriorityWeight(record: ContractApprovalStep) { + const sla = buildApprovalSla(record); + if (record.status !== "pending") return 99; + if (sla.status === "overdue") return 0; + if (sla.status === "due_soon") return 1; + return 2; +} + export default function ContractApprovalsPage() { const [messageApi, contextHolder] = message.useMessage(); const [items, setItems] = useState([]); @@ -50,6 +146,7 @@ export default function ContractApprovalsPage() { const [loading, setLoading] = useState(true); const [scope, setScope] = useState("assigned_to_me"); const [statusValue, setStatusValue] = useState(""); + const [slaFilter, setSlaFilter] = useState("all"); const [decidingId, setDecidingId] = useState(null); const loadApprovals = useCallback(async () => { @@ -81,13 +178,41 @@ export default function ContractApprovalsPage() { return () => window.clearTimeout(timer); }, [loadApprovals]); + const filteredItems = useMemo(() => { + if (slaFilter === "all") return items; + return items.filter((item) => buildApprovalSla(item).status === slaFilter); + }, [items, slaFilter]); + + const priorityItems = useMemo(() => { + return items + .filter((item) => item.status === "pending") + .sort((a, b) => { + const weightDiff = getApprovalPriorityWeight(a) - getApprovalPriorityWeight(b); + if (weightDiff !== 0) return weightDiff; + return getTimeValue(a.created_at) - getTimeValue(b.created_at); + }) + .slice(0, 5); + }, [items]); + + const slaSummary = useMemo(() => { + const pendingItems = items.filter((item) => item.status === "pending"); + return { + overdue: pendingItems.filter((item) => buildApprovalSla(item).status === "overdue").length, + dueSoon: pendingItems.filter((item) => buildApprovalSla(item).status === "due_soon").length, + normal: pendingItems.filter((item) => buildApprovalSla(item).status === "normal").length, + }; + }, [items]); + const metrics = useMemo(() => [ { label: "待我审批", value: summary.assigned_to_me ?? 0, tone: "warning" }, { label: "全部待审批", value: summary.pending_all ?? 0, tone: "warning" }, + { label: "已超时", value: slaSummary.overdue, tone: "danger" }, + { label: "临近超时", value: slaSummary.dueSoon, tone: "warning" }, + { label: "SLA 内", value: slaSummary.normal, tone: "success" }, { label: "我提交的", value: summary.submitted_by_me ?? 0 }, { label: "已通过", value: summary.approved ?? 0, tone: "success" }, { label: "已退回", value: summary.rejected ?? 0, tone: "danger" }, - ], [summary]); + ], [slaSummary.dueSoon, slaSummary.normal, slaSummary.overdue, summary]); const handleDecision = useCallback((item: ContractApprovalStep, decision: "approve" | "reject") => { Modal.confirm({ @@ -179,6 +304,21 @@ export default function ContractApprovalsPage() { render: (value: string | null) => formatDateTime(value), sorter: (a, b) => new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime(), }, + { + title: "审批时效", + key: "sla", + width: 190, + render: (_, record) => { + const sla = buildApprovalSla(record); + return ( + + {sla.label} + {sla.description} + + ); + }, + sorter: (a, b) => getApprovalPriorityWeight(a) - getApprovalPriorityWeight(b), + }, { title: "操作", key: "actions", @@ -244,26 +384,123 @@ export default function ContractApprovalsPage() { +
+
+
+

审批时效队列

+

按 SLA 优先展示已超时和临近超时的待处理审批。

+
+
+ {priorityItems.length === 0 ? ( + + ) : ( +
+ ( + + + {record.contract?.template_name ?? record.title} + + {record.contract?.file_name ?? "合同文件"} + + ), + }, + { + title: "时效", + key: "sla", + width: 180, + render: (_, record) => { + const sla = buildApprovalSla(record); + return ( + + : }> + {sla.label} + + {sla.description} + + ); + }, + }, + { + title: "提交/处理人", + key: "people", + width: 210, + render: (_, record) => ( + + {record.submitter_name || "提交人未记录"} + 处理人:{record.assignee_name || "-"} + + ), + }, + { + title: "截止参考", + key: "due", + width: 180, + render: (_, record) => {formatDateTime(buildApprovalSla(record).dueAt)}, + }, + { + title: "操作", + key: "actions", + width: 210, + render: (_, record) => ( + + 详情 + + + + ), + }, + ]} + /> + + )} + +

审批任务

-

当前显示 {items.length} 条审批记录。

+

当前显示 {filteredItems.length} 条审批记录,共加载 {items.length} 条。

+
}} pagination={{ pageSize: 10 }} rowKey="id" - scroll={{ x: 1330 }} + scroll={{ x: 1520 }} /> diff --git a/frontend/src/app/contract-ledger/page.tsx b/frontend/src/app/contract-ledger/page.tsx index cb10112b..e4c25cff 100644 --- a/frontend/src/app/contract-ledger/page.tsx +++ b/frontend/src/app/contract-ledger/page.tsx @@ -1,13 +1,17 @@ "use client"; import { + CheckCircleOutlined, DownloadOutlined, FileDoneOutlined, + InboxOutlined, + ClockCircleOutlined, ReloadOutlined, SearchOutlined, + StopOutlined, WarningOutlined, } from "@ant-design/icons"; -import { Button, Empty, Input, Select, Space, Table, Tag, Typography, message } from "antd"; +import { Button, Empty, Input, Popconfirm, 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"; @@ -36,6 +40,9 @@ import { import { listContractLifecycleTaskWorkbench, listGeneratedContracts, + createContractLifecycleTask, + updateGeneratedContractLifecycle, + updateContractLifecycleTask, type ContractLifecycleTask, type GeneratedContractListItem, } from "@/services/contract"; @@ -48,6 +55,17 @@ type LedgerMetric = { tone?: "warning" | "danger" | "success"; }; +type ArchiveReadinessStatus = "needs_task" | "in_progress" | "needs_evidence" | "ready_to_archive" | "archived" | "monitoring"; + +type ArchiveReadinessItem = { + contract: GeneratedContractListItem; + task?: ContractLifecycleTask; + status: ArchiveReadinessStatus; + label: string; + color: string; + description: string; +}; + const ALL_FILTER = "__all__"; const dueStatusOptions = [ @@ -77,6 +95,27 @@ const riskStatusOptions = [ { value: "unrated", label: "未评级" }, ]; +const lifecycleTaskActionMessages: Record = { + in_progress: "提醒已设为跟进中", + completed: "提醒已标记完成", + skipped: "提醒已跳过", + archived: "提醒已归档", +}; + +const archiveReadinessMeta: Record = { + needs_task: { label: "需建归档任务", color: "gold", description: "合同已接近关闭节点,需要补齐终止、结算或签署归档任务。" }, + in_progress: { label: "归档处理中", color: "blue", description: "已有归档任务,继续收集签署文本、验收或终止确认材料。" }, + needs_evidence: { label: "待补证据", color: "orange", description: "归档任务已完成,但还没有绑定归档证据文件。" }, + ready_to_archive: { label: "可归档", color: "green", description: "归档任务与证据已就绪,可将合同生命周期更新为已归档。" }, + archived: { label: "已归档", color: "default", description: "合同已完成归档。" }, + monitoring: { label: "持续跟踪", color: "default", description: "合同仍在履约或签署阶段,暂不需要归档处理。" }, +}; + +const archiveLifecycleStatuses = new Set(["signed", "effective", "expiring", "expired", "terminated", "archived"]); +const archiveTaskRequiredLifecycleStatuses = new Set(["signed", "expired", "terminated"]); +const urgentArchiveLifecycleStatuses = new Set(["expired", "terminated"]); +const closedLifecycleTaskStatuses = new Set(["completed", "skipped", "archived"]); + function getLifecycleStatus(item: GeneratedContractListItem) { return item.lifecycle?.status ?? "generated"; } @@ -139,6 +178,66 @@ function getPriorityDescription(item: GeneratedContractListItem) { return "继续完善合同状态和后续处理记录。"; } +function isArchiveCandidate(item: GeneratedContractListItem) { + return archiveLifecycleStatuses.has(getLifecycleStatus(item)) || ["due_soon", "overdue"].includes(getDueStatus(item)); +} + +function getLatestArchiveTask(tasks: ContractLifecycleTask[], contractId: string) { + return tasks + .filter((task) => task.contract_id === contractId && task.task_type === "termination_archive") + .sort((a, b) => getTimeValue(b.updated_at ?? b.created_at ?? b.due_at) - getTimeValue(a.updated_at ?? a.created_at ?? a.due_at))[0]; +} + +function buildArchiveReadinessItem(contract: GeneratedContractListItem, tasks: ContractLifecycleTask[]): ArchiveReadinessItem { + const task = getLatestArchiveTask(tasks, contract.id); + const lifecycleStatus = getLifecycleStatus(contract); + const dueStatus = getDueStatus(contract); + + let status: ArchiveReadinessStatus = "monitoring"; + if (lifecycleStatus === "archived") { + status = "archived"; + } else if (task?.status === "completed") { + status = task.evidence_file_id || task.evidence_file ? "ready_to_archive" : "needs_evidence"; + } else if (task && !closedLifecycleTaskStatuses.has(task.status)) { + status = "in_progress"; + } else if (archiveTaskRequiredLifecycleStatuses.has(lifecycleStatus) || dueStatus === "overdue" || dueStatus === "due_soon") { + status = "needs_task"; + } + + const meta = archiveReadinessMeta[status]; + return { contract, task, status, ...meta }; +} + +function buildArchiveReadinessItems(contracts: GeneratedContractListItem[], tasks: ContractLifecycleTask[]) { + return contracts + .filter(isArchiveCandidate) + .map((contract) => buildArchiveReadinessItem(contract, tasks)) + .filter((item) => item.status !== "monitoring" && item.status !== "archived") + .sort((a, b) => { + const weightMap: Record = { + needs_task: 0, + needs_evidence: 1, + ready_to_archive: 2, + in_progress: 3, + monitoring: 9, + archived: 10, + }; + const weightDiff = weightMap[a.status] - weightMap[b.status]; + if (weightDiff !== 0) return weightDiff; + return getTimeValue(a.contract.lifecycle?.expires_at ?? a.contract.generated_at) - getTimeValue(b.contract.lifecycle?.expires_at ?? b.contract.generated_at); + }); +} + +function buildArchiveMetrics(contracts: GeneratedContractListItem[], tasks: ContractLifecycleTask[]): LedgerMetric[] { + const items = contracts.filter(isArchiveCandidate).map((contract) => buildArchiveReadinessItem(contract, tasks)); + return [ + { label: "归档关注", value: items.filter((item) => !["archived", "monitoring"].includes(item.status)).length, tone: "warning" }, + { label: "归档处理中", value: items.filter((item) => item.status === "in_progress").length, tone: "warning" }, + { label: "证据待补", value: items.filter((item) => item.status === "needs_evidence").length, tone: "danger" }, + { label: "可归档", value: items.filter((item) => item.status === "ready_to_archive").length, tone: "success" }, + ]; +} + function buildMetrics(items: GeneratedContractListItem[]): LedgerMetric[] { const inReview = items.filter((item) => ["internal_review", "external_review"].includes(getLifecycleStatus(item))).length; const active = items.filter((item) => ["signed", "effective", "expiring"].includes(getLifecycleStatus(item))).length; @@ -203,6 +302,8 @@ export default function ContractLedgerPage() { const [lifecycleTasks, setLifecycleTasks] = useState([]); const [loading, setLoading] = useState(true); const [taskLoading, setTaskLoading] = useState(true); + const [updatingTaskAction, setUpdatingTaskAction] = useState(null); + const [archiveActionKey, setArchiveActionKey] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [lifecycleFilter, setLifecycleFilter] = useState(ALL_FILTER); const [dueFilter, setDueFilter] = useState(ALL_FILTER); @@ -262,6 +363,8 @@ export default function ContractLedgerPage() { }, [approvalFilter, contracts, dueFilter, lifecycleFilter, riskFilter, searchQuery]); const metrics = useMemo(() => buildMetrics(filteredContracts), [filteredContracts]); + const archiveMetrics = useMemo(() => buildArchiveMetrics(filteredContracts, lifecycleTasks), [filteredContracts, lifecycleTasks]); + const archiveReadinessItems = useMemo(() => buildArchiveReadinessItems(filteredContracts, lifecycleTasks).slice(0, 6), [filteredContracts, lifecycleTasks]); const recentContracts = useMemo(() => { return [...filteredContracts] .sort((a, b) => getTimeValue(b.lifecycle?.updated_at ?? b.generated_at) - getTimeValue(a.lifecycle?.updated_at ?? a.generated_at)) @@ -309,6 +412,86 @@ export default function ContractLedgerPage() { URL.revokeObjectURL(url); }; + const handleUpdateLifecycleTaskStatus = useCallback( + async (task: ContractLifecycleTask, statusValue: string) => { + const actionKey = `${task.id}:${statusValue}`; + setUpdatingTaskAction(actionKey); + try { + const result = await updateContractLifecycleTask(task.contract_id, task.id, { status_value: statusValue }); + if (result.code !== 0) { + messageApi.error(result.message || "提醒状态更新失败"); + return; + } + messageApi.success(lifecycleTaskActionMessages[statusValue] ?? "提醒状态已更新"); + await Promise.all([loadLifecycleTasks(), loadContracts()]); + } catch { + messageApi.error("提醒状态更新失败,请稍后重试"); + } finally { + setUpdatingTaskAction(null); + } + }, + [loadContracts, loadLifecycleTasks, messageApi], + ); + + const handleCreateArchiveTask = useCallback( + async (contract: GeneratedContractListItem) => { + const actionKey = `${contract.id}:create-archive-task`; + setArchiveActionKey(actionKey); + try { + const result = await createContractLifecycleTask(contract.id, { + task_type: "termination_archive", + title: `归档准备:${contract.template_name}`, + description: "收集签署文件、验收材料、终止确认、结算记录或续约判断依据。", + priority: getDueStatus(contract) === "overdue" || urgentArchiveLifecycleStatuses.has(getLifecycleStatus(contract)) ? "high" : "medium", + status_value: "open", + due_at: contract.lifecycle?.expires_at ?? null, + }); + if (result.code !== 0) { + messageApi.error(result.message || "归档任务创建失败"); + return; + } + messageApi.success("归档任务已创建"); + await loadLifecycleTasks(); + } catch { + messageApi.error("归档任务创建失败,请稍后重试"); + } finally { + setArchiveActionKey(null); + } + }, + [loadLifecycleTasks, messageApi], + ); + + const handleArchiveContract = useCallback( + async (item: ArchiveReadinessItem) => { + const actionKey = `${item.contract.id}:archive-contract`; + setArchiveActionKey(actionKey); + try { + const result = await updateGeneratedContractLifecycle(item.contract.id, { + lifecycle_status: "archived", + counterparty_name: item.contract.lifecycle?.counterparty_name ?? null, + owner_name: item.contract.lifecycle?.owner_name ?? null, + effective_at: item.contract.lifecycle?.effective_at ?? null, + expires_at: item.contract.lifecycle?.expires_at ?? null, + lifecycle_note: item.task?.evidence_note ?? item.contract.lifecycle?.note ?? "归档任务和证据已完成,合同已归档。", + }); + if (result.code !== 0) { + messageApi.error(result.message || "合同归档失败"); + return; + } + if (item.task && item.task.status !== "archived") { + await updateContractLifecycleTask(item.contract.id, item.task.id, { status_value: "archived" }); + } + messageApi.success("合同已归档"); + await Promise.all([loadContracts(), loadLifecycleTasks()]); + } catch { + messageApi.error("合同归档失败,请稍后重试"); + } finally { + setArchiveActionKey(null); + } + }, + [loadContracts, loadLifecycleTasks, messageApi], + ); + const columns = useMemo>(() => [ { title: "合同", @@ -445,7 +628,7 @@ export default function ContractLedgerPage() {
- {metrics.map((metric) => ( + {[...metrics, ...archiveMetrics].map((metric) => (
{metric.label} {metric.value} @@ -526,8 +709,158 @@ export default function ContractLedgerPage() { { title: "操作", key: "actions", - width: 110, - render: (_, record) => 处理, + width: 260, + render: (_, record) => ( + + + + + void handleUpdateLifecycleTaskStatus(record, "archived")} + title="归档后该提醒将从待处理列表移除,确认归档?" + > + + + 详情 + + ), + }, + ]} + /> +
+ )} + + +
+
+

归档准备

+

跟踪已签署、履约、到期和终止合同的归档任务与证据完整度,减少合同关闭前的遗漏。

+
+ {archiveReadinessItems.length === 0 ? ( + + ) : ( +
+
`${item.contract.id}:${item.task?.id ?? item.status}`} + dataSource={archiveReadinessItems} + loading={loading || taskLoading} + pagination={false} + columns={[ + { + title: "合同", + key: "contract", + render: (_, item) => ( + + + {item.contract.template_name} + + {item.contract.lifecycle?.counterparty_name || item.contract.file_name} + + ), + }, + { + title: "归档状态", + key: "status", + width: 160, + render: (_, item) => {item.label}, + }, + { + title: "任务/证据", + key: "task", + width: 260, + render: (_, item) => ( + + {item.task ? ( + <> + {item.task.title} + + + {formatLifecycleTaskStatus(item.task.status)} + + + {item.task.evidence_file_id || item.task.evidence_file ? "已绑定证据" : "未绑定证据"} + + + + ) : ( + 尚未创建终止归档任务 + )} + + ), + }, + { + title: "建议动作", + key: "description", + render: (_, item) => {item.description}, + }, + { + title: "操作", + key: "actions", + width: 240, + render: (_, item) => ( + + {item.status === "needs_task" ? ( + + ) : null} + {item.status === "ready_to_archive" ? ( + void handleArchiveContract(item)} + title="确认将该合同生命周期更新为已归档?" + > + + + ) : null} + 详情 + + ), }, ]} />