统一业务台账首屏设计(05-30-unified-page-design-review)
这个提交包含在:
@@ -26,7 +26,6 @@ import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -250,9 +249,6 @@ export function ContractsRegistryPage() {
|
||||
? "保留现有责任人"
|
||||
: "默认当前用户";
|
||||
const amountInvalid = Boolean(editor.amount.trim()) && !AMOUNT_PATTERN.test(editor.amount.trim());
|
||||
const activeContractCount = visibleContracts.filter((item) => item.status === "active").length;
|
||||
const draftContractCount = visibleContracts.filter((item) => item.status === "draft").length;
|
||||
const archivedContractCount = visibleContracts.filter((item) => item.status === "archived").length;
|
||||
const expiringContractCount = expiringQuery.data?.total ?? expiringItems.length;
|
||||
|
||||
const refreshAll = async () => {
|
||||
@@ -323,21 +319,14 @@ export function ContractsRegistryPage() {
|
||||
return (
|
||||
<PageScaffold>
|
||||
<PageHeader
|
||||
eyebrow="合同"
|
||||
title="合同台账"
|
||||
subtitle="维护合同元数据、生命周期、到期预警与导出,不在 OA 存放涉密正文附件。"
|
||||
meta={
|
||||
<>
|
||||
<span className="info-chip">可见 {visibleContracts.length} 份</span>
|
||||
<span className="info-chip">30 天到期 {expiringContractCount} 份</span>
|
||||
<span className="info-chip">私密正文不入库</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{initialProjectId ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/projects">返回项目</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
|
||||
台账工具
|
||||
</Button>
|
||||
@@ -345,82 +334,21 @@ export function ContractsRegistryPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4" data-testid="contracts-registry-summary">
|
||||
<MetricCard title="可见合同" value={visibleContracts.length} note={filters.projectId ? "已按项目过滤" : "当前筛选结果"} />
|
||||
<MetricCard title="生效中" value={activeContractCount} note="需要关注到期和责任人" />
|
||||
<MetricCard title="草稿" value={draftContractCount} note="补齐摘要、金额和归档位置" tone={draftContractCount > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="已归档" value={archivedContractCount} note="只保留台账和线下档案索引" />
|
||||
</section>
|
||||
|
||||
<div className="detail-command-panel space-y-4" data-testid="contracts-registry-command">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">生命周期焦点</div>
|
||||
<div className="mt-1 text-3xl font-semibold text-zinc-900">{expiringQuery.data?.total ?? 0}</div>
|
||||
<div className="text-sm text-zinc-600">
|
||||
份合同将在 {EXPIRING_DAYS} 天内到期;续约会生成草稿合同,导出仅用于授权复核。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={expiringQuery.isLoading}
|
||||
onClick={() => setShowExpiring((value) => !value)}
|
||||
>
|
||||
{showExpiring ? "收起到期列表" : "展开到期列表"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expiringQuery.isError ? (
|
||||
<div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
|
||||
) : null}
|
||||
|
||||
{showExpiring ? (
|
||||
<div className="space-y-3 border-t border-zinc-200 pt-4">
|
||||
{expiringItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-amber-50/70 p-4 lg:flex-row lg:items-center lg:justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{item.contractNo} · {item.counterpartyName}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">
|
||||
{contractTypeLabel(item.contractType)} · 到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => openEditor(item)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!expiringQuery.isLoading && !expiringItems.length ? (
|
||||
<div className="text-sm text-zinc-500">未来 {EXPIRING_DAYS} 天内暂无即将到期合同。</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showLedgerTools ? (
|
||||
<div className="detail-command-panel space-y-4" data-testid="contracts-ledger-tools">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-500">
|
||||
新建/编辑、导出和高级到期范围放在这里。导出文件可能包含合同金额、相对方、责任人和归档摘要。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={expiringQuery.isLoading}
|
||||
onClick={() => setShowExpiring((value) => !value)}
|
||||
>
|
||||
到期列表 {expiringContractCount}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={exportMutation.isPending} onClick={() => setConfirmExportFormat("csv")}>
|
||||
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
|
||||
</Button>
|
||||
@@ -446,10 +374,31 @@ export function ContractsRegistryPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{expiringQuery.isError ? (
|
||||
<div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
|
||||
) : null}
|
||||
{showExpiring ? (
|
||||
<div className="space-y-2 border-t border-zinc-200 pt-3">
|
||||
{expiringItems.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-zinc-900">{item.contractNo} · {item.counterpartyName}</div>
|
||||
<div className="text-xs text-zinc-500">到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}</div>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!expiringQuery.isLoading && !expiringItems.length ? (
|
||||
<div className="text-sm text-zinc-500">暂无到期合同</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={showLedgerTools || editorId ? "grid gap-6 xl:grid-cols-[400px,minmax(0,1fr)]" : "grid gap-6"}>
|
||||
<div className="grid gap-6">
|
||||
{showLedgerTools || editorId ? (
|
||||
<div className="card-std space-y-4 p-6" data-testid="contracts-editor-panel">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -735,11 +684,11 @@ export function ContractsRegistryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>项目</Label>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(180px,1fr)_160px_160px_minmax(220px,1fr)]">
|
||||
<div>
|
||||
<select
|
||||
className="select-std"
|
||||
aria-label="项目"
|
||||
value={filters.projectId}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, projectId: event.target.value }))}
|
||||
>
|
||||
@@ -752,10 +701,10 @@ export function ContractsRegistryPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>状态</Label>
|
||||
<div>
|
||||
<select
|
||||
className="select-std"
|
||||
aria-label="状态"
|
||||
value={filters.status}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value as FiltersState["status"] }))}
|
||||
>
|
||||
@@ -768,10 +717,10 @@ export function ContractsRegistryPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>合同类型</Label>
|
||||
<div>
|
||||
<select
|
||||
className="select-std"
|
||||
aria-label="合同类型"
|
||||
value={filters.contractType}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, contractType: event.target.value as FiltersState["contractType"] }))}
|
||||
>
|
||||
@@ -784,12 +733,12 @@ export function ContractsRegistryPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>相对方模糊筛选</Label>
|
||||
<div>
|
||||
<Input
|
||||
aria-label="相对方"
|
||||
value={filters.counterpartyName}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, counterpartyName: event.target.value }))}
|
||||
placeholder="输入相对方名称关键字"
|
||||
placeholder="搜索相对方"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户