feat: enhance ledger archive and approval sla
This commit is contained in:
@@ -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<ContractApprovalStep[]>([]);
|
||||
@@ -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<string | null>(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 (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Tag color={sla.color}>{sla.label}</Tag>
|
||||
<Text type="secondary">{sla.description}</Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
sorter: (a, b) => getApprovalPriorityWeight(a) - getApprovalPriorityWeight(b),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
@@ -244,26 +384,123 @@ export default function ContractApprovalsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-section">
|
||||
<div className="landing-section-header contract-ledger-list-header">
|
||||
<div>
|
||||
<h2>审批时效队列</h2>
|
||||
<p>按 SLA 优先展示已超时和临近超时的待处理审批。</p>
|
||||
</div>
|
||||
</div>
|
||||
{priorityItems.length === 0 ? (
|
||||
<Empty description={loading ? "审批队列加载中..." : "当前没有待处理审批。"} />
|
||||
) : (
|
||||
<div className="contract-ledger-table-shell">
|
||||
<Table
|
||||
dataSource={priorityItems}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
scroll={{ x: 980 }}
|
||||
columns={[
|
||||
{
|
||||
title: "合同",
|
||||
key: "contract",
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Link href={`/contracts/${record.contract_id}`}>
|
||||
<Text strong>{record.contract?.template_name ?? record.title}</Text>
|
||||
</Link>
|
||||
<Text type="secondary">{record.contract?.file_name ?? "合同文件"}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "时效",
|
||||
key: "sla",
|
||||
width: 180,
|
||||
render: (_, record) => {
|
||||
const sla = buildApprovalSla(record);
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Tag color={sla.color} icon={sla.status === "overdue" ? <ExclamationCircleOutlined /> : <ClockCircleOutlined />}>
|
||||
{sla.label}
|
||||
</Tag>
|
||||
<Text type="secondary">{sla.description}</Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "提交/处理人",
|
||||
key: "people",
|
||||
width: 210,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text>{record.submitter_name || "提交人未记录"}</Text>
|
||||
<Text type="secondary">处理人:{record.assignee_name || "-"}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "截止参考",
|
||||
key: "due",
|
||||
width: 180,
|
||||
render: (_, record) => <Text>{formatDateTime(buildApprovalSla(record).dueAt)}</Text>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 210,
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Link href={`/contracts/${record.contract_id}`}>详情</Link>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={decidingId === record.id}
|
||||
onClick={() => handleDecision(record, "approve")}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<CloseCircleOutlined />}
|
||||
loading={decidingId === record.id}
|
||||
onClick={() => handleDecision(record, "reject")}
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="landing-section">
|
||||
<div className="landing-section-header contract-ledger-list-header">
|
||||
<div>
|
||||
<h2>审批任务</h2>
|
||||
<p>当前显示 {items.length} 条审批记录。</p>
|
||||
<p>当前显示 {filteredItems.length} 条审批记录,共加载 {items.length} 条。</p>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Select options={scopeOptions} value={scope} onChange={setScope} style={{ width: 140 }} />
|
||||
<Select options={statusOptions} value={statusValue} onChange={setStatusValue} style={{ width: 140 }} />
|
||||
<Select options={slaOptions} value={slaFilter} onChange={setSlaFilter} style={{ width: 140 }} />
|
||||
</Space>
|
||||
</div>
|
||||
<div className="contract-ledger-table-shell">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
dataSource={filteredItems}
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无审批任务。" /> }}
|
||||
pagination={{ pageSize: 10 }}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1330 }}
|
||||
scroll={{ x: 1520 }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
in_progress: "提醒已设为跟进中",
|
||||
completed: "提醒已标记完成",
|
||||
skipped: "提醒已跳过",
|
||||
archived: "提醒已归档",
|
||||
};
|
||||
|
||||
const archiveReadinessMeta: Record<ArchiveReadinessStatus, { label: string; color: string; description: string }> = {
|
||||
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<ArchiveReadinessStatus, number> = {
|
||||
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<ContractLifecycleTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskLoading, setTaskLoading] = useState(true);
|
||||
const [updatingTaskAction, setUpdatingTaskAction] = useState<string | null>(null);
|
||||
const [archiveActionKey, setArchiveActionKey] = useState<string | null>(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<ColumnsType<GeneratedContractListItem>>(() => [
|
||||
{
|
||||
title: "合同",
|
||||
@@ -445,7 +628,7 @@ export default function ContractLedgerPage() {
|
||||
</div>
|
||||
|
||||
<div className="contract-ledger-metric-grid">
|
||||
{metrics.map((metric) => (
|
||||
{[...metrics, ...archiveMetrics].map((metric) => (
|
||||
<div className={`contract-ledger-metric contract-ledger-metric-${metric.tone ?? "default"}`} key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>{metric.value}</strong>
|
||||
@@ -526,8 +709,158 @@ export default function ContractLedgerPage() {
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_, record) => <Link href={`/contracts/${record.contract_id}`}>处理</Link>,
|
||||
width: 260,
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Button
|
||||
disabled={record.status === "in_progress"}
|
||||
icon={<ClockCircleOutlined />}
|
||||
loading={updatingTaskAction === `${record.id}:in_progress`}
|
||||
onClick={() => void handleUpdateLifecycleTaskStatus(record, "in_progress")}
|
||||
size="small"
|
||||
>
|
||||
跟进
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={updatingTaskAction === `${record.id}:completed`}
|
||||
onClick={() => void handleUpdateLifecycleTaskStatus(record, "completed")}
|
||||
size="small"
|
||||
type="primary"
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
<Button
|
||||
icon={<StopOutlined />}
|
||||
loading={updatingTaskAction === `${record.id}:skipped`}
|
||||
onClick={() => void handleUpdateLifecycleTaskStatus(record, "skipped")}
|
||||
size="small"
|
||||
>
|
||||
跳过
|
||||
</Button>
|
||||
<Popconfirm
|
||||
cancelText="取消"
|
||||
okText="归档"
|
||||
onConfirm={() => void handleUpdateLifecycleTaskStatus(record, "archived")}
|
||||
title="归档后该提醒将从待处理列表移除,确认归档?"
|
||||
>
|
||||
<Button
|
||||
icon={<InboxOutlined />}
|
||||
loading={updatingTaskAction === `${record.id}:archived`}
|
||||
size="small"
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Link href={`/contracts/${record.contract_id}`}>详情</Link>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="landing-section">
|
||||
<div className="landing-section-header">
|
||||
<h2>归档准备</h2>
|
||||
<p>跟踪已签署、履约、到期和终止合同的归档任务与证据完整度,减少合同关闭前的遗漏。</p>
|
||||
</div>
|
||||
{archiveReadinessItems.length === 0 ? (
|
||||
<Empty description={taskLoading ? "归档状态加载中..." : "当前没有需要归档处理的合同。"} />
|
||||
) : (
|
||||
<div className="contract-ledger-table-shell">
|
||||
<Table
|
||||
rowKey={(item) => `${item.contract.id}:${item.task?.id ?? item.status}`}
|
||||
dataSource={archiveReadinessItems}
|
||||
loading={loading || taskLoading}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: "合同",
|
||||
key: "contract",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Link href={`/contracts/${item.contract.id}`}>
|
||||
<Text strong>{item.contract.template_name}</Text>
|
||||
</Link>
|
||||
<Text type="secondary">{item.contract.lifecycle?.counterparty_name || item.contract.file_name}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "归档状态",
|
||||
key: "status",
|
||||
width: 160,
|
||||
render: (_, item) => <Tag color={item.color}>{item.label}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "任务/证据",
|
||||
key: "task",
|
||||
width: 260,
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
{item.task ? (
|
||||
<>
|
||||
<Text>{item.task.title}</Text>
|
||||
<Space wrap>
|
||||
<Tag color={getLifecycleTaskStatusColor(item.task.status)}>
|
||||
{formatLifecycleTaskStatus(item.task.status)}
|
||||
</Tag>
|
||||
<Tag color={item.task.evidence_file_id || item.task.evidence_file ? "green" : "default"}>
|
||||
{item.task.evidence_file_id || item.task.evidence_file ? "已绑定证据" : "未绑定证据"}
|
||||
</Tag>
|
||||
</Space>
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary">尚未创建终止归档任务</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "建议动作",
|
||||
key: "description",
|
||||
render: (_, item) => <Text type="secondary">{item.description}</Text>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 240,
|
||||
render: (_, item) => (
|
||||
<Space wrap>
|
||||
{item.status === "needs_task" ? (
|
||||
<Button
|
||||
icon={<FileDoneOutlined />}
|
||||
loading={archiveActionKey === `${item.contract.id}:create-archive-task`}
|
||||
onClick={() => void handleCreateArchiveTask(item.contract)}
|
||||
size="small"
|
||||
type="primary"
|
||||
>
|
||||
建归档任务
|
||||
</Button>
|
||||
) : null}
|
||||
{item.status === "ready_to_archive" ? (
|
||||
<Popconfirm
|
||||
cancelText="取消"
|
||||
okText="归档"
|
||||
onConfirm={() => void handleArchiveContract(item)}
|
||||
title="确认将该合同生命周期更新为已归档?"
|
||||
>
|
||||
<Button
|
||||
icon={<InboxOutlined />}
|
||||
loading={archiveActionKey === `${item.contract.id}:archive-contract`}
|
||||
size="small"
|
||||
type="primary"
|
||||
>
|
||||
合同归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<Link href={`/contracts/${item.contract.id}`}>详情</Link>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user