统一业务台账首屏设计(05-30-unified-page-design-review)
这个提交包含在:
+5
-5
@@ -650,7 +650,7 @@
|
||||
}
|
||||
|
||||
.detail-action-stack {
|
||||
@apply grid gap-3;
|
||||
@apply grid gap-3 overflow-visible;
|
||||
}
|
||||
|
||||
.detail-action-card {
|
||||
@@ -661,7 +661,7 @@
|
||||
}
|
||||
|
||||
.workflow-action-panel {
|
||||
@apply xl:sticky xl:top-20;
|
||||
@apply relative z-30 xl:sticky xl:top-20;
|
||||
}
|
||||
|
||||
.workflow-action-bar {
|
||||
@@ -669,7 +669,7 @@
|
||||
}
|
||||
|
||||
.workflow-action-more {
|
||||
@apply relative;
|
||||
@apply relative z-40;
|
||||
}
|
||||
|
||||
.workflow-action-more > summary {
|
||||
@@ -694,7 +694,7 @@
|
||||
background: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
@apply absolute right-0 top-10 z-20 min-w-28 px-3 py-2 text-left text-sm font-medium text-zinc-800 hover:bg-slate-50;
|
||||
@apply absolute right-0 top-10 z-50 min-w-28 px-3 py-2 text-left text-sm font-medium text-zinc-800 hover:bg-slate-50;
|
||||
}
|
||||
|
||||
.notification-inbox {
|
||||
@@ -889,7 +889,7 @@
|
||||
.portal-task-row {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: var(--color-text-primary);
|
||||
@apply grid min-h-[68px] grid-cols-[auto,minmax(0,1fr)] items-center gap-3 px-3.5 py-3 transition-colors hover:bg-white md:grid-cols-[auto,minmax(0,1fr),auto,auto];
|
||||
@apply grid min-h-[56px] grid-cols-[auto,minmax(0,1fr)] items-center gap-3 px-3.5 py-2.5 transition-colors hover:bg-white md:grid-cols-[auto,minmax(0,1fr),auto,auto];
|
||||
}
|
||||
|
||||
.portal-task-row--warning {
|
||||
|
||||
@@ -229,32 +229,6 @@ function prettyJson(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
title,
|
||||
value,
|
||||
tone = "zinc",
|
||||
}: {
|
||||
title: string;
|
||||
value: number | string;
|
||||
tone?: "zinc" | "amber" | "red" | "emerald";
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "amber"
|
||||
? "text-amber-700"
|
||||
: tone === "red"
|
||||
? "text-red-700"
|
||||
: tone === "emerald"
|
||||
? "text-emerald-700"
|
||||
: "text-zinc-900";
|
||||
|
||||
return (
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">{title}</div>
|
||||
<div className={`finance-metric-card__value ${toneClass}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
@@ -619,7 +593,6 @@ export function ApOperationsPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="应付账款"
|
||||
subtitle="优先处理待付款、异常付款、风险和对账差异;导出和代账交接进入台账工具。"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
@@ -654,30 +627,11 @@ export function ApOperationsPage() {
|
||||
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
|
||||
) : null}
|
||||
|
||||
<div className="finance-status-strip" data-testid="ap-operations-summary">
|
||||
<SummaryCard title="待付款" value={summaryQuery.data?.summary.pendingPaymentCount ?? "-"} />
|
||||
<SummaryCard title="异常" value={summaryQuery.data?.summary.exceptionCount ?? "-"} tone="red" />
|
||||
<SummaryCard
|
||||
title="部分匹配"
|
||||
value={summaryQuery.data?.summary.partialMatchCount ?? "-"}
|
||||
tone="amber"
|
||||
/>
|
||||
<SummaryCard title="台账记录" value={financeLedgerQuery.data?.total ?? "-"} />
|
||||
<SummaryCard
|
||||
title="预算超支"
|
||||
value={summaryQuery.data?.summary.budgetOverspendCount ?? "-"}
|
||||
tone="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showLedgerTools ? (
|
||||
<div className="finance-command-panel space-y-3" data-testid="ap-ledger-tools">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md: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
|
||||
@@ -698,9 +652,6 @@ export function ApOperationsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||
当前工具不会执行真实付款,也不会生成正式总账凭证;付款仍由线下银行或既有财务流程完成。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -364,24 +364,6 @@ export function AssetRegistryPage() {
|
||||
}
|
||||
}, [searchParams, selectedAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreatingAsset) {
|
||||
return;
|
||||
}
|
||||
if (selectedAssetId) {
|
||||
if (assetDetailQuery.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!assetDetailQuery.isError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const nextId = assetsQuery.data?.items?.[0]?.id ?? "";
|
||||
if (nextId !== selectedAssetId) {
|
||||
setSelectedAssetId(nextId);
|
||||
}
|
||||
}, [assetDetailQuery.isError, assetDetailQuery.isLoading, assetsQuery.data?.items, isCreatingAsset, selectedAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = searchParams.get("id") ?? "";
|
||||
if (isCreatingAsset) {
|
||||
@@ -812,20 +794,6 @@ export function AssetRegistryPage() {
|
||||
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
|
||||
});
|
||||
}, [assetItems]);
|
||||
const assetStats = useMemo(() => {
|
||||
return assetItems.reduce(
|
||||
(acc, item) => {
|
||||
acc.total += 1;
|
||||
if (item.assetType === "fixed_asset") acc.fixedAsset += 1;
|
||||
else acc.electronicAsset += 1;
|
||||
if (item.assetType === "fixed_asset" && !item.assignee) acc.unassigned += 1;
|
||||
if (!item.lastVerifiedAt) acc.needsVerification += 1;
|
||||
if ((item.assetType === "fixed_asset" && !item.assignee) || !item.lastVerifiedAt) acc.attention += 1;
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, fixedAsset: 0, electronicAsset: 0, unassigned: 0, needsVerification: 0, attention: 0 },
|
||||
);
|
||||
}, [assetItems]);
|
||||
const selectAsset = (assetId: string) => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId(assetId);
|
||||
@@ -835,7 +803,6 @@ export function AssetRegistryPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="资产台账"
|
||||
subtitle="管理固定资产责任链、位置、盘点和电子资产索引;分类维护和高级筛选收在台账工具。"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
|
||||
@@ -848,35 +815,11 @@ export function AssetRegistryPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4" data-testid="asset-registry-summary">
|
||||
<div className="finance-metric-card">
|
||||
<div className="text-sm text-zinc-500">当前可见</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.total}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{mine ? "默认我相关资产" : "全范围查看"}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="text-sm text-zinc-500">固定资产 FA</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.fixedAsset}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">责任、位置、盘点和交接</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="text-sm text-zinc-500">电子资产索引</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.electronicAsset}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">只登记责任和验证摘要</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="text-sm text-zinc-500">待补责任/验证</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.attention}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">优先补齐保管和最近盘点</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={showLedgerTools ? "grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]" : "grid gap-6"}>
|
||||
{showLedgerTools ? (
|
||||
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
<div className="font-medium text-slate-900">台账工具</div>
|
||||
<p className="mt-1">新建资产卡片、分类维护、全范围与高级筛选放在这里,普通首屏只保留查找和当前资产处理。</p>
|
||||
{canManage ? (
|
||||
<Button type="button" className="mt-3" data-testid="asset-create" onClick={resetAssetEditor}>
|
||||
新建资产卡片
|
||||
@@ -927,7 +870,6 @@ export function AssetRegistryPage() {
|
||||
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">高级筛选</h4>
|
||||
<p className="text-xs text-zinc-500">低频分类、组织、责任人和敏感级别筛选放在工具区。</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
|
||||
@@ -1040,7 +982,7 @@ export function AssetRegistryPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={showLedgerTools ? "grid gap-6 2xl:grid-cols-[minmax(0,0.95fr)_minmax(420px,1.05fr)]" : "grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(420px,1.05fr)]"}>
|
||||
<div className="grid gap-6">
|
||||
<FilterBar className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_190px_auto]">
|
||||
<Input
|
||||
@@ -1124,10 +1066,6 @@ export function AssetRegistryPage() {
|
||||
<div className="text-zinc-400">位置</div>
|
||||
<div className="mt-0.5 truncate text-zinc-700">{item.location ?? "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="text-zinc-400">下一步</div>
|
||||
<div className="mt-0.5 truncate text-zinc-700">{assetNextStep(item)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
@@ -1144,7 +1082,6 @@ export function AssetRegistryPage() {
|
||||
<TableHead>责任链</TableHead>
|
||||
<TableHead>位置</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>下一步</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -1171,12 +1108,11 @@ export function AssetRegistryPage() {
|
||||
<div className="text-xs text-zinc-500">盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}</div>
|
||||
</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>{assetNextStep(item)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-4 text-zinc-500">
|
||||
<TableCell colSpan={5} className="py-4 text-zinc-500">
|
||||
暂无资产卡片
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1188,13 +1124,8 @@ export function AssetRegistryPage() {
|
||||
) : null}
|
||||
</FilterBar>
|
||||
|
||||
{selectedAssetId || isCreatingAsset ? (
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
{!selectedAssetId && !isCreatingAsset ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
|
||||
请先从列表选择资产卡片,或点击“新建资产卡片”。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
|
||||
@@ -1207,9 +1138,11 @@ export function AssetRegistryPage() {
|
||||
<h3 className="text-lg font-semibold text-zinc-900">
|
||||
{isCreatingAsset ? "新建资产卡片" : currentAsset ? <CompactAssetNo value={currentAsset.assetNo} /> : "资产卡片"}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500" title={currentAsset?.name ?? undefined}>
|
||||
{isCreatingAsset ? "创建新的资产台账卡片。" : formatTextWithCompactLongNumbers(currentAsset?.name)}
|
||||
</p>
|
||||
{!isCreatingAsset ? (
|
||||
<p className="text-sm text-zinc-500" title={currentAsset?.name ?? undefined}>
|
||||
{formatTextWithCompactLongNumbers(currentAsset?.name)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{!isCreatingAsset && currentAsset ? <StatusBadge status={currentAsset.status} /> : null}
|
||||
</div>
|
||||
@@ -1626,6 +1559,7 @@ export function AssetRegistryPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -515,11 +515,10 @@ function SummaryStrip({
|
||||
reminderCount: number;
|
||||
}) {
|
||||
const attentionCount = blockerCount + reminderCount;
|
||||
const summaryText = actionCount > 0
|
||||
? `今天有 ${actionCount} 件待处理${progressCount > 0 ? `,${progressCount} 个进度更新` : ""}${attentionCount > 0 ? `,${attentionCount} 个团队或管理提醒` : ""}。`
|
||||
: progressCount > 0 || attentionCount > 0
|
||||
? `今天没有必须处理的事项${progressCount > 0 ? `,有 ${progressCount} 个进度更新` : ""}${attentionCount > 0 ? `,${attentionCount} 个团队或管理提醒` : ""}。`
|
||||
: "今天没有需要你处理的事项。";
|
||||
const summaryParts = [`待处理 ${actionCount}`];
|
||||
if (progressCount > 0) summaryParts.push(`进度 ${progressCount}`);
|
||||
if (attentionCount > 0) summaryParts.push(`提醒 ${attentionCount}`);
|
||||
const summaryText = summaryParts.join(" · ");
|
||||
|
||||
return (
|
||||
<section className="portal-work-summary" data-testid="portal-work-summary">
|
||||
@@ -545,7 +544,6 @@ function WorkItemRow({ item }: { item: WorkItem }) {
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="portal-task-row__title">{item.title}</span>
|
||||
<span className="portal-task-row__reason">{item.reason}</span>
|
||||
</span>
|
||||
<span className="portal-task-row__meta">{item.meta}</span>
|
||||
<span className="portal-task-row__action">{item.actionLabel}</span>
|
||||
@@ -578,7 +576,6 @@ function QuickEntryPanel({ entries }: { entries: QuickEntry[] }) {
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="portal-quick-entry__title">{entry.label}</span>
|
||||
<span className="portal-quick-entry__action">{entry.actionLabel}</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
@@ -601,8 +598,7 @@ function TodayActionPanel({
|
||||
) : (
|
||||
<div className="portal-empty-start">
|
||||
<div>
|
||||
<div className="portal-empty-start__title">今天没有需要你处理的事项。</div>
|
||||
<div className="portal-empty-start__desc">需要发起新申请或回看进度时,从下面的常用动作进入。</div>
|
||||
<div className="portal-empty-start__title">暂无待处理</div>
|
||||
</div>
|
||||
{emptyActions.length ? (
|
||||
<div className="portal-inline-actions">
|
||||
|
||||
@@ -14,8 +14,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { FilterBar, ListWorkspace, StatStrip, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { FilterBar, ListWorkspace, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -123,20 +122,6 @@ export function ProjectsPage() {
|
||||
});
|
||||
}, [projectItems, showTrialData]);
|
||||
const hiddenTrialProjectCount = projectItems.length - visibleProjectItems.length;
|
||||
const projectStats = useMemo(() => {
|
||||
return visibleProjectItems.reduce(
|
||||
(acc, item) => {
|
||||
acc.total += 1;
|
||||
if (item.status === "active") {
|
||||
acc.active += 1;
|
||||
}
|
||||
acc.overdueMilestones += item.counts.overdueMilestones;
|
||||
acc.openRisks += item.counts.openRisks;
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, active: 0, overdueMilestones: 0, openRisks: 0 },
|
||||
);
|
||||
}, [visibleProjectItems]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -188,7 +173,6 @@ export function ProjectsPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="项目"
|
||||
subtitle="轻项目主档、成员、里程碑、任务与风险统一在 OA 内跟进。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="secondary" type="button">
|
||||
@@ -201,15 +185,8 @@ export function ProjectsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<StatStrip columns={4}>
|
||||
<MetricCard title="我相关项目" value={projectStats.total} note={mine ? "默认只看我负责/我参与" : "已切换到全范围"} />
|
||||
<MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" />
|
||||
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="延期事项待处理" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
|
||||
</StatStrip>
|
||||
|
||||
{showLedgerTools ? (
|
||||
<SectionPanel title="台账工具" description="新建项目、显示测试数据和全范围查看放在这里,避免普通首屏变成配置台。">
|
||||
<SectionPanel title="台账工具">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr),260px]">
|
||||
<div>
|
||||
{!canManageProjects ? (
|
||||
@@ -274,14 +251,12 @@ export function ProjectsPage() {
|
||||
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
||||
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
||||
</Button>
|
||||
<p>试运行默认隐藏 TEST/SMOKE 项目。全范围查看请使用筛选区的范围按钮,避免普通项目负责人默认进入公司全量项目。</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<div className="page-shell-grid">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px]">
|
||||
<Input placeholder="搜索项目编号、项目名、客户名" value={q} onChange={(event) => updateSearch({ q: event.target.value })} />
|
||||
<select className="select-std" value={status} onChange={(event) => updateSearch({ status: event.target.value })}>
|
||||
@@ -302,10 +277,7 @@ export function ProjectsPage() {
|
||||
</WorkspaceNotice>
|
||||
) : null}
|
||||
|
||||
<SectionPanel
|
||||
title="项目列表"
|
||||
description={visibleProjectItems.length ? `当前显示 ${visibleProjectItems.length} 个项目` : "按项目名、客户或状态快速缩小范围"}
|
||||
>
|
||||
<SectionPanel title="项目列表">
|
||||
<div className="space-y-4">
|
||||
{listQuery.isLoading ? <div className="text-sm text-zinc-500">项目加载中...</div> : null}
|
||||
{listQuery.isError ? <div className="text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
|
||||
@@ -341,14 +313,6 @@ export function ProjectsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<SectionPanel title="下一步提示" description="项目页只保留轻台账和详情入口。">
|
||||
<div className="space-y-3 text-sm text-zinc-600">
|
||||
<p>项目列表用于看状态、风险、负责人和进入详情;成员、里程碑、风险、交付记录和关联业务在详情页处理。</p>
|
||||
<p>合同、采购、报销和付款只展示摘要与跳转,不在项目列表直接处理对方模块动作。</p>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { Archive, ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
@@ -117,48 +117,6 @@ function statusOptions(): Array<{ value: PurchaseRequestStatus | ""; label: stri
|
||||
];
|
||||
}
|
||||
|
||||
const PURCHASE_TASK_STATUS_OPTIONS = [
|
||||
{
|
||||
value: "rejected",
|
||||
title: "退回待补材料",
|
||||
idleNote: "暂无被退回的采购",
|
||||
activeNote: "按审批意见补材料后重新提交",
|
||||
icon: RotateCcw,
|
||||
accentClass: "border-red-200 bg-red-50 text-red-700",
|
||||
},
|
||||
{
|
||||
value: "draft",
|
||||
title: "草稿待完善",
|
||||
idleNote: "还没有采购草稿",
|
||||
activeNote: "继续补供应商、明细和附件",
|
||||
icon: FilePenLine,
|
||||
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
},
|
||||
{
|
||||
value: "reviewing",
|
||||
title: "审批中进度",
|
||||
idleNote: "暂无审批中的采购",
|
||||
activeNote: "查看当前节点和审批进度",
|
||||
icon: Clock3,
|
||||
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
|
||||
},
|
||||
{
|
||||
value: "approved",
|
||||
title: "待验收 / 归档",
|
||||
idleNote: "暂无已通过待跟进采购",
|
||||
activeNote: "核对到货并登记 PO / 合同摘要",
|
||||
icon: Archive,
|
||||
accentClass: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
},
|
||||
] satisfies Array<{
|
||||
value: PurchaseRequestStatus;
|
||||
title: string;
|
||||
idleNote: string;
|
||||
activeNote: string;
|
||||
icon: typeof RotateCcw;
|
||||
accentClass: string;
|
||||
}>;
|
||||
|
||||
function formatPurchaseCurrency(value: string | number | null | undefined, currencyCode = "CNY") {
|
||||
const amount = Number(value ?? 0);
|
||||
if (!Number.isFinite(amount)) {
|
||||
@@ -486,26 +444,6 @@ export function PurchaseRequestsPage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const draftSummaryQuery = useQuery({
|
||||
queryKey: ["purchase-requests", "task-summary", "mine", "draft"],
|
||||
queryFn: () => fetchPurchaseRequests({ status: "draft", mine: true, page: 1, pageSize: 1 }),
|
||||
});
|
||||
|
||||
const rejectedSummaryQuery = useQuery({
|
||||
queryKey: ["purchase-requests", "task-summary", "mine", "rejected"],
|
||||
queryFn: () => fetchPurchaseRequests({ status: "rejected", mine: true, page: 1, pageSize: 1 }),
|
||||
});
|
||||
|
||||
const reviewingSummaryQuery = useQuery({
|
||||
queryKey: ["purchase-requests", "task-summary", "mine", "reviewing"],
|
||||
queryFn: () => fetchPurchaseRequests({ status: "reviewing", mine: true, page: 1, pageSize: 1 }),
|
||||
});
|
||||
|
||||
const approvedSummaryQuery = useQuery({
|
||||
queryKey: ["purchase-requests", "task-summary", "mine", "approved"],
|
||||
queryFn: () => fetchPurchaseRequests({ status: "approved", mine: true, page: 1, pageSize: 1 }),
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(selectedId),
|
||||
queryKey: ["purchase-requests", "detail", selectedId],
|
||||
@@ -534,22 +472,6 @@ export function PurchaseRequestsPage() {
|
||||
}
|
||||
}, [searchParams, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
if (detailQuery.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!detailQuery.isError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextId = listQuery.data?.items?.[0]?.id ?? "";
|
||||
if (nextId !== selectedId) {
|
||||
setSelectedId(nextId);
|
||||
}
|
||||
}, [detailQuery.isError, detailQuery.isLoading, listQuery.data?.items, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = searchParams.get("id") ?? "";
|
||||
if (!selectedId && !currentId) {
|
||||
@@ -645,12 +567,6 @@ export function PurchaseRequestsPage() {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
const purchaseItems = listQuery.data?.items ?? [];
|
||||
const taskSummaryTotalByStatus = {
|
||||
draft: draftSummaryQuery.data?.total,
|
||||
rejected: rejectedSummaryQuery.data?.total,
|
||||
reviewing: reviewingSummaryQuery.data?.total,
|
||||
approved: approvedSummaryQuery.data?.total,
|
||||
};
|
||||
const hasAdvancedFilters = Boolean(supplierFilter || legalEntityFilter || costCenterFilter || (canViewAll && !mine));
|
||||
const canShowLedgerTools =
|
||||
canViewAll ||
|
||||
@@ -1000,15 +916,6 @@ export function PurchaseRequestsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
function focusPurchaseStatus(nextStatus: PurchaseRequestStatus | "") {
|
||||
setStatus(nextStatus);
|
||||
setMine(true);
|
||||
setSupplierFilter("");
|
||||
setLegalEntityFilter("");
|
||||
setCostCenterFilter("");
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const erpTrack = erpTrackQuery.data ?? null;
|
||||
const canRetryErp = Boolean(canIntegrationManage && erpTrack?.permissions.canRetry);
|
||||
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
|
||||
@@ -1033,9 +940,7 @@ export function PurchaseRequestsPage() {
|
||||
<ListWorkspace>
|
||||
{downloadReasonDialog}
|
||||
<WorkspaceHeader
|
||||
eyebrow="采购"
|
||||
title="采购申请"
|
||||
subtitle={mine ? "先看与你相关的采购任务,再进入单据核对材料和进度。" : "当前为全范围台账视角,处理动作仍回到详情或审批任务。"}
|
||||
actions={
|
||||
canCreate ? (
|
||||
<Button
|
||||
@@ -1053,50 +958,12 @@ export function PurchaseRequestsPage() {
|
||||
{formError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{formError}
|
||||
</p>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4" aria-label="我的采购任务">
|
||||
{PURCHASE_TASK_STATUS_OPTIONS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const count = taskSummaryTotalByStatus[item.value];
|
||||
const active = status === item.value && mine && !hasAdvancedFilters;
|
||||
const displayCount = typeof count === "number" ? count : "-";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`card-std min-h-[124px] p-4 text-left transition hover:border-primary/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
|
||||
active ? "border-primary/40 bg-primary/5" : ""
|
||||
}`}
|
||||
data-testid={`purchase-task-card-${item.value}`}
|
||||
key={item.value}
|
||||
onClick={() => focusPurchaseStatus(item.value)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className={`flex size-9 items-center justify-center rounded-lg border ${item.accentClass}`}>
|
||||
<Icon aria-hidden className="size-4" />
|
||||
</span>
|
||||
<ArrowRight aria-hidden className="mt-1 size-4 text-zinc-300" />
|
||||
</div>
|
||||
<div className="mt-4 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-zinc-500">
|
||||
{typeof count === "number" && count > 0 ? item.activeNote : item.idleNote}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold tabular-nums text-zinc-950">{displayCount}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
||||
<div className="ops-command-bar__row">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="text-xs font-semibold text-zinc-500">采购记录</div>
|
||||
<div className="min-w-0">
|
||||
<Tabs
|
||||
data-testid="purchase-status-tabs"
|
||||
value={status}
|
||||
@@ -1230,7 +1097,7 @@ export function PurchaseRequestsPage() {
|
||||
) : null}
|
||||
</FilterBar>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||
<div className="grid gap-6">
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">采购单列表</h3>
|
||||
@@ -1296,9 +1163,6 @@ export function PurchaseRequestsPage() {
|
||||
</div>
|
||||
<div className={`mt-0.5 ${evidenceHint.className}`}>{evidenceHint.label}</div>
|
||||
</div>
|
||||
<div className="col-span-2 rounded-md bg-slate-50 px-2 py-1.5 text-zinc-700">
|
||||
下一步:{resolvePurchaseNextStep(item)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -1318,7 +1182,7 @@ export function PurchaseRequestsPage() {
|
||||
<TableHead>金额 / 供应商</TableHead>
|
||||
<TableHead>项目 / 合同</TableHead>
|
||||
<TableHead>状态 / 证据</TableHead>
|
||||
<TableHead>下一步</TableHead>
|
||||
<TableHead className="w-28">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -1329,12 +1193,8 @@ export function PurchaseRequestsPage() {
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={item.id === selectedId ? "bg-zinc-50" : undefined}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setHydratedDetailId("");
|
||||
}}
|
||||
>
|
||||
<TableCell className="cursor-pointer">
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">
|
||||
<CompactBusinessCode value={resolvePurchaseDisplayCode(item)} />
|
||||
</div>
|
||||
@@ -1368,11 +1228,7 @@ export function PurchaseRequestsPage() {
|
||||
<div className={`mt-1 text-xs ${evidenceHint.className}`}>{evidenceHint.label}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[160px] text-xs leading-5 text-zinc-600" data-testid="purchase-row-next-step">
|
||||
{resolvePurchaseNextStep(item)}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-2"
|
||||
data-testid="purchase-row-open"
|
||||
size="sm"
|
||||
type="button"
|
||||
@@ -1413,13 +1269,8 @@ export function PurchaseRequestsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedId ? (
|
||||
<div className="card-std space-y-5 p-4 md:p-5">
|
||||
{!selectedId ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
|
||||
请先在左侧选择采购单,或新建一张采购草稿。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={3} rows={8} /> : null}
|
||||
|
||||
{selectedId && detailQuery.isError ? (
|
||||
@@ -2199,6 +2050,7 @@ export function PurchaseRequestsPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ActionConfirmDialog
|
||||
open={Boolean(submitPrecheckConfirm)}
|
||||
|
||||
@@ -83,7 +83,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
|
||||
await login(page, FINANCE_MGR);
|
||||
await (await openNavLink(page, "资产")).click();
|
||||
await expect(page.getByTestId("asset-registry-summary")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "资产台账" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("asset-category-code")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "台账工具" }).click();
|
||||
await expect(page.getByTestId("asset-ledger-tools")).toBeVisible();
|
||||
|
||||
@@ -1085,10 +1085,10 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
|
||||
await page.goto("/projects");
|
||||
await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("默认只看我负责/我参与")).toBeVisible();
|
||||
await expect(page.getByText("新建项目")).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder("搜索项目编号、项目名、客户名")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "创建项目" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "台账工具" }).click();
|
||||
await expect(page.getByText("新建项目")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "创建项目" })).toBeVisible();
|
||||
|
||||
await page.goto("/projects/project-wave3");
|
||||
await expect(page.getByRole("heading", { name: /HYB-W3/ })).toBeVisible();
|
||||
@@ -1099,7 +1099,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
|
||||
await page.goto("/contracts-registry?projectId=project-wave3");
|
||||
await expect(page.getByRole("heading", { name: "合同台账", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("contracts-registry-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("contracts-registry-row")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "台账工具" }).click();
|
||||
await expect(page.getByTestId("contracts-ledger-tools")).toBeVisible();
|
||||
|
||||
@@ -1001,7 +1001,7 @@ async function capture(page: Page, name: string) {
|
||||
|
||||
const pages = [
|
||||
{ path: "/payment-batches", summary: "payment-batches-summary", proof: "text=批次列表", screenshot: "01-payment-batches" },
|
||||
{ path: "/ap-operations", summary: "ap-operations-summary", proof: "text=异常付款", screenshot: "02-ap-operations" },
|
||||
{ path: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false },
|
||||
{ path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool" },
|
||||
{ path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" },
|
||||
{ path: "/people", summary: "people-summary", proof: "data-testid=people-table", screenshot: "05-people" },
|
||||
@@ -1023,9 +1023,13 @@ test("PC 财务与管理页 Wave4-5 视觉冒烟", async ({ page }) => {
|
||||
|
||||
for (const target of pages) {
|
||||
await page.goto(target.path);
|
||||
await expect(page.getByTestId(target.summary)).toBeVisible();
|
||||
if ("summary" in target) {
|
||||
await expect(page.getByTestId(target.summary)).toBeVisible();
|
||||
}
|
||||
await expect(page.locator(target.proof).first()).toBeVisible();
|
||||
await expect(page.locator(".finance-status-strip").first()).toBeVisible();
|
||||
if (!("expectStatusStrip" in target) || target.expectStatusStrip !== false) {
|
||||
await expect(page.locator(".finance-status-strip").first()).toBeVisible();
|
||||
}
|
||||
await capture(page, target.screenshot);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await page.getByRole("combobox").first().selectOption("approved");
|
||||
const purchaseRow = page.getByRole("row").filter({ hasText: requestNo }).first();
|
||||
await expect(purchaseRow).toBeVisible({ timeout: 30_000 });
|
||||
await purchaseRow.click();
|
||||
await purchaseRow.getByTestId("purchase-row-open").click();
|
||||
|
||||
await expect(page.getByTestId("purchase-archive-po-no")).toBeVisible();
|
||||
await expect(page.getByTestId("purchase-status-hint")).toContainText("已通过", { timeout: 30_000 });
|
||||
|
||||
@@ -348,9 +348,9 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
|
||||
await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购申请")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "采购申请" })).toBeVisible();
|
||||
await expect(page.getByTestId("purchase-task-card-draft")).toBeVisible();
|
||||
await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
|
||||
await expect(page.getByTestId("purchase-ledger-tools")).toBeVisible();
|
||||
await expect(page.getByTestId("purchase-row-next-step")).toContainText("继续完善材料并提交");
|
||||
await expect(page.getByTestId("purchase-row-next-step")).toHaveCount(0);
|
||||
await expect(page.getByText("付款处理")).toHaveCount(0);
|
||||
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
|
||||
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
|
||||
|
||||
在新工单中引用
屏蔽一个用户