重构采购申请任务化页面(purchase-request-task-layout)

这个提交包含在:
2026-05-30 10:29:49 +08:00
父节点 e0aa2ead30
当前提交 b1121c14fe
共修改 6 个文件,包含 727 行新增和 267 行删除
@@ -3,7 +3,7 @@
"name": "purchase-request-task-layout",
"title": "采购申请任务化页面重构",
"description": "按全页面设计规则重构采购申请页:任务卡、状态/证据/动作顺序、跨模块摘要跳转和审批动作归位。",
"status": "planning",
"status": "in_progress",
"dev_type": null,
"scope": "frontend-page-redesign",
"package": null,
@@ -43,6 +43,22 @@ function purchaseProgressFallback(status: string | undefined) {
return "/mobile/approvals?progress=reviewing";
}
function resolveMobilePurchaseEvidence(detail: {
supplierId: string | null;
legalEntityId: string | null;
costCenterId: string | null;
documentCategoryId: string | null;
budgetPoolId: string | null;
projectId: string | null;
}) {
if (!detail.supplierId) return "待补供应商";
if (!detail.legalEntityId || !detail.costCenterId) return "待补法体 / 成本中心";
if (!detail.documentCategoryId) return "待补单据分类";
if (!detail.budgetPoolId) return "预算池未匹配";
if (!detail.projectId) return "未关联项目";
return "基础依据已齐,附件在材料区核对";
}
export function MobilePurchaseRequestDetailPage() {
const { purchaseRequestId = "" } = useParams();
@@ -59,6 +75,7 @@ export function MobilePurchaseRequestDetailPage() {
? `当前在 ${detail.pendingWorkflow.task.stepName},可从审批详情处理;本页用于核对采购依据。`
: resolvePurchaseStatusHint(detail)
: "";
const assetCandidateCount = detail?.items.filter((item) => item.isAssetCandidate).length ?? 0;
return (
<PageScaffold className="space-y-3 pb-4">
@@ -105,6 +122,10 @@ export function MobilePurchaseRequestDetailPage() {
<div className="text-xs text-zinc-500">当前节点</div>
<div className="mt-1 truncate font-medium text-zinc-900">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
</div>
<div>
<div className="text-xs text-zinc-500">材料摘要</div>
<div className="mt-1 truncate font-medium text-zinc-900">{resolveMobilePurchaseEvidence(detail)}</div>
</div>
</div>
<div className="mobile-subcard bg-white text-sm">
<div className="text-xs font-semibold text-zinc-500">下一步</div>
@@ -112,6 +133,28 @@ export function MobilePurchaseRequestDetailPage() {
</div>
</section>
<section className="mobile-card" data-testid="mobile-purchase-detail-related">
<h3 className="text-base font-semibold text-zinc-900">关联与后续</h3>
<div className="mobile-field-list mt-3">
<div className="mobile-field-row">
<div className="mobile-field-label">关联项目</div>
<div className="mobile-field-value">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}
</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">资产候选</div>
<div className="mobile-field-value">
{assetCandidateCount > 0 ? `${assetCandidateCount} 项,后续由管理员核对后手动建卡` : "无"}
</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">付款 / 应付</div>
<div className="mobile-field-value">仅查看状态,付款状态回到财务页面查看</div>
</div>
</div>
</section>
<section className="mobile-card" data-testid="mobile-purchase-detail-fields">
<h3 className="text-base font-semibold text-zinc-900">业务信息</h3>
<div className="mobile-field-list mt-3">
@@ -38,6 +38,31 @@ function formatDate(value: string | null | undefined) {
return value ? value.slice(0, 10) : "-";
}
function resolvePurchaseListNextStep(status: PurchaseRequestStatus) {
if (status === "draft") return "继续完善采购材料";
if (status === "reviewing") return "查看审批进度";
if (status === "rejected") return "按意见补材料";
if (status === "approved") return "核对到货 / 归档";
return "查看取消记录";
}
function resolvePurchaseListEvidence(item: PurchaseRequestSummary) {
if (!item.supplierId) return "待补供应商";
if (!item.legalEntityId || !item.costCenterId) return "待补法体 / 成本中心";
if (!item.documentCategoryId) return "待补单据分类";
if (!item.budgetPoolId) return "预算池未匹配";
if (!item.projectId) return "未关联项目";
return "基础依据已齐";
}
function resolveMobileEmptyState(status: PurchaseRequestStatus) {
if (status === "draft") return "暂无采购草稿";
if (status === "reviewing") return "暂无审批中的采购";
if (status === "rejected") return "暂无需要补材料的采购";
if (status === "approved") return "暂无已通过采购";
return "暂无已取消采购";
}
function PurchaseRequestCard({
item,
currentMobileSource,
@@ -74,6 +99,10 @@ function PurchaseRequestCard({
item.expectedDeliveryDate ? `到货:${formatDate(item.expectedDeliveryDate)}` : null,
])}
</div>
<div className="mt-3 rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-zinc-600">
<div>材料:{resolvePurchaseListEvidence(item)}</div>
<div>下一步:{resolvePurchaseListNextStep(item.status)}</div>
</div>
</Link>
);
}
@@ -116,9 +145,11 @@ export function MobilePurchaseRequestsPage() {
return (
<PageScaffold className="space-y-4">
<PageHeader title="采购" />
<PageHeader title="我的采购" />
<section className="mobile-card">
<div className="text-sm font-semibold text-zinc-900">当前采购任务</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">手机端只看状态、材料摘要和下一步;台账筛选、全范围和导出留在电脑端。</div>
<div className="mobile-info-grid">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
@@ -150,7 +181,7 @@ export function MobilePurchaseRequestsPage() {
</div>
) : null}
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
<div className="mobile-empty-state">暂无采购申请</div>
<div className="mobile-empty-state">{resolveMobileEmptyState(status)}</div>
) : null}
{items.map((item) => (
<PurchaseRequestCard currentMobileSource={currentMobileSource} item={item} key={item.id} />
+464 -86
查看文件
@@ -1,6 +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 { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
@@ -51,6 +52,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { Textarea } from "../components/ui/textarea";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
import { buildPrecheckDialogMessage } from "../lib/precheck";
@@ -115,6 +117,161 @@ 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)) {
return "-";
}
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: currencyCode }).format(amount);
}
function formatDate(value: string | null | undefined) {
return value ? value.slice(0, 10) : "-";
}
function resolvePurchaseEvidenceHint(item: {
supplierId: string | null;
legalEntityId: string | null;
costCenterId: string | null;
documentCategoryId: string | null;
budgetPoolId: string | null;
projectId: string | null;
status: PurchaseRequestStatus;
}) {
const missingFields = [
item.supplierId ? null : "供应商",
item.legalEntityId ? null : "法体",
item.costCenterId ? null : "成本中心",
item.documentCategoryId ? null : "单据分类",
].filter(Boolean);
if (missingFields.length) {
return {
label: `待补:${missingFields.slice(0, 2).join("、")}${missingFields.length > 2 ? "等" : ""}`,
className: "text-amber-700",
};
}
if (!item.budgetPoolId) {
return { label: "预算池未匹配", className: "text-amber-700" };
}
if (!item.projectId) {
return { label: "未关联项目", className: "text-zinc-500" };
}
if (item.status === "approved") {
return { label: "可核对验收 / 归档", className: "text-emerald-700" };
}
return { label: "基础依据已齐,附件进详情核对", className: "text-emerald-700" };
}
function resolvePurchaseNextStep(item: { status: PurchaseRequestStatus; pendingWorkflow?: PurchaseRequestDetail["pendingWorkflow"] }) {
if (item.status === "draft") {
return "继续完善材料并提交";
}
if (item.status === "reviewing") {
return item.pendingWorkflow?.task?.stepName
? `看审批进度:${item.pendingWorkflow.task.stepName}`
: "查看审批进度";
}
if (item.status === "rejected") {
return "按意见补材料后重交";
}
if (item.status === "approved") {
return "核对到货 / 归档 PO";
}
return "查看取消记录";
}
function resolvePurchaseRowActionLabel(status: PurchaseRequestStatus) {
if (status === "draft") return "继续编辑";
if (status === "rejected") return "补材料";
if (status === "reviewing") return "看进度";
if (status === "approved") return "验收/归档";
return "查看记录";
}
function resolvePurchaseEmptyStateCopy(status: PurchaseRequestStatus | "") {
if (status === "draft") {
return {
title: "还没有采购草稿",
description: "需要采购时先新建草稿,再补供应商、明细、预算和附件。",
};
}
if (status === "reviewing") {
return {
title: "暂无审批中的采购",
description: "提交后的采购会在这里显示当前审批节点。",
};
}
if (status === "rejected") {
return {
title: "暂无需要补材料的采购",
description: "被退回或驳回的采购会在这里继续修改后重新提交。",
};
}
if (status === "approved") {
return {
title: "暂无已通过待跟进采购",
description: "采购通过后,在这里核对到货、PO/合同归档和付款关联状态。",
};
}
if (status === "cancelled") {
return {
title: "暂无已取消采购",
description: "取消的采购记录会在这里留痕。",
};
}
return {
title: "暂无采购申请",
description: "当前筛选范围内没有采购记录。",
};
}
function CompactBusinessCode({ value, copyable = true }: { value: string; copyable?: boolean }) {
const displayValue = formatCompactIdentifier(value, { head: 12, tail: 4, minLength: 18 });
const compacted = isCompactedIdentifier(value, displayValue);
@@ -267,6 +424,7 @@ export function PurchaseRequestsPage() {
const [supplierFilter, setSupplierFilter] = useState("");
const [legalEntityFilter, setLegalEntityFilter] = useState("");
const [costCenterFilter, setCostCenterFilter] = useState("");
const [showLedgerTools, setShowLedgerTools] = useState(false);
const [page, setPage] = useState(1);
const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? "");
const [hydratedDetailId, setHydratedDetailId] = useState("");
@@ -328,6 +486,26 @@ 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],
@@ -467,10 +645,28 @@ 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 ||
Boolean((suppliersQuery.data?.items ?? []).length) ||
Boolean((legalEntitiesQuery.data?.items ?? []).length) ||
Boolean((costCentersQuery.data?.items ?? []).length);
const showAdvancedTools = canShowLedgerTools && (showLedgerTools || hasAdvancedFilters);
const emptyState = resolvePurchaseEmptyStateCopy(status);
const totalAmountPreview = useMemo(
() => items.reduce((sum, item) => sum + Number(moneyPreview(item.quantity, item.unitPrice)), 0).toFixed(2),
[items],
);
const assetCandidateCount = useMemo(
() => items.filter((item) => item.isAssetCandidate).length,
[items],
);
const createMutation = useMutation({
mutationFn: () => createPurchaseRequest(),
@@ -803,6 +999,16 @@ export function PurchaseRequestsPage() {
toast.error(message);
}
};
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));
@@ -820,13 +1026,16 @@ export function PurchaseRequestsPage() {
isPurchaseParticipant &&
detail?.pendingWorkflow?.task?.assigneeId !== me?.id,
);
const detailEvidenceHint = detail ? resolvePurchaseEvidenceHint(detail) : null;
const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : "";
return (
<ListWorkspace>
{downloadReasonDialog}
<WorkspaceHeader
eyebrow="采购"
title="采购申请"
subtitle="草稿、附件、审批。"
subtitle={mine ? "先看与你相关的采购任务,再进入单据核对材料和进度。" : "当前为全范围台账视角,处理动作仍回到详情或审批任务。"}
actions={
canCreate ? (
<Button
@@ -847,23 +1056,91 @@ export function PurchaseRequestsPage() {
</p>
) : null}
<FilterBar className="space-y-4">
<div className="grid gap-3 md:grid-cols-5">
<select
className="select-std"
<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>
<Tabs
data-testid="purchase-status-tabs"
value={status}
onChange={(event) => {
setStatus(event.target.value as PurchaseRequestStatus | "");
onValueChange={(value) => {
setStatus(value as PurchaseRequestStatus | "");
setPage(1);
}}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{statusOptions().map((item) => (
<option key={item.label} value={item.value}>
<TabsTrigger
data-testid={`purchase-tab-${item.value || "all"}`}
key={item.label}
value={item.value}
>
{item.label}
</option>
</TabsTrigger>
))}
</select>
</TabsList>
</Tabs>
</div>
<div className="ops-command-bar__actions">
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
{mine ? "我相关" : "全范围"}
</span>
{canShowLedgerTools ? (
<Button
data-testid="purchase-ledger-tools"
size="sm"
type="button"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
>
<Wrench aria-hidden className="mr-1 size-4" />
台账工具
</Button>
) : null}
</div>
</div>
{showAdvancedTools ? (
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3" data-testid="purchase-ledger-tools-panel">
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
<select
className="select-std"
value={supplierFilter}
@@ -919,6 +1196,8 @@ export function PurchaseRequestsPage() {
{canViewAll ? (
<div className="flex items-center gap-2">
<Button
data-testid="purchase-scope-mine"
size="sm"
type="button"
variant={mine ? "default" : "secondary"}
onClick={() => {
@@ -926,9 +1205,11 @@ export function PurchaseRequestsPage() {
setPage(1);
}}
>
仅自己
我相关
</Button>
<Button
data-testid="purchase-scope-all"
size="sm"
type="button"
variant={!mine ? "default" : "secondary"}
onClick={() => {
@@ -939,8 +1220,14 @@ export function PurchaseRequestsPage() {
全范围
</Button>
</div>
) : null}
) : (
<div className="rounded-lg bg-white px-3 py-2 text-sm text-zinc-500">
当前仅显示与你相关的采购。
</div>
)}
</div>
</div>
) : null}
</FilterBar>
<div className="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]">
@@ -962,7 +1249,9 @@ export function PurchaseRequestsPage() {
<>
<div className="space-y-3 md:hidden">
{purchaseItems.length ? (
purchaseItems.map((item) => (
purchaseItems.map((item) => {
const evidenceHint = resolvePurchaseEvidenceHint(item);
return (
<button
data-testid="purchase-request-mobile-row"
key={item.id}
@@ -988,42 +1277,55 @@ export function PurchaseRequestsPage() {
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-zinc-600">
<div>
<div className="text-zinc-400">申请人</div>
<div className="mt-0.5 truncate text-zinc-700">{item.applicant?.name ?? "-"}</div>
<div className="text-zinc-400">金额 / 到货</div>
<div className="mt-0.5 font-medium text-zinc-900">
{formatPurchaseCurrency(item.totalAmount, item.currencyCode)}
</div>
<div className="mt-0.5 text-zinc-500">{formatDate(item.expectedDeliveryDate)}</div>
</div>
<div>
<div className="text-zinc-400">金额</div>
<div className="mt-0.5 font-medium text-zinc-900">{item.totalAmount}</div>
<div className="text-zinc-400">供应商</div>
<div className="mt-0.5 truncate text-zinc-700">{item.supplier?.name ?? "待补供应商"}</div>
</div>
<div className="col-span-2">
<div className="text-zinc-400">关联项目</div>
<div className="text-zinc-400">项目 / 证据</div>
<div className="mt-0.5 truncate text-zinc-700">
{item.project
? `${formatTextWithCompactLongNumbers(item.project.projectNo)} · ${item.project.name}`
: "未关联项目"}
</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>
))
);
})
) : (
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无采购申请</div>
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
<div className="mt-1 leading-6">{emptyState.description}</div>
</div>
)}
</div>
<PrimaryTable className="hidden md:block">
<Table data-testid="purchase-requests-table">
<TableHeader>
<tr>
<TableHead>单据</TableHead>
<TableHead>申请人</TableHead>
<TableHead>项目</TableHead>
<TableHead>状态</TableHead>
<TableHead>金额</TableHead>
<TableHead>单据 / 对象</TableHead>
<TableHead>金额 / 供应商</TableHead>
<TableHead>项目 / 合同</TableHead>
<TableHead>状态 / 证据</TableHead>
<TableHead>下一步</TableHead>
</tr>
</TableHeader>
<TableBody>
{purchaseItems.length ? (
purchaseItems.map((item) => (
purchaseItems.map((item) => {
const evidenceHint = resolvePurchaseEvidenceHint(item);
return (
<TableRow
key={item.id}
className={item.id === selectedId ? "bg-zinc-50" : undefined}
@@ -1039,8 +1341,15 @@ export function PurchaseRequestsPage() {
<div className="text-xs text-zinc-500" title={item.title || undefined}>
{formatTextWithCompactLongNumbers(item.title || "未命名采购申请")}
</div>
<div className="mt-1 text-xs text-zinc-500">申请人:{item.applicant?.name ?? "-"}</div>
</TableCell>
<TableCell>
<div className="font-semibold text-zinc-900">
{formatPurchaseCurrency(item.totalAmount, item.currencyCode)}
</div>
<div className="text-xs text-zinc-500">{item.supplier?.name ?? "待补供应商"}</div>
<div className="text-xs text-zinc-500">到货:{formatDate(item.expectedDeliveryDate)}</div>
</TableCell>
<TableCell>{item.applicant?.name ?? "-"}</TableCell>
<TableCell>
{item.project ? (
<div>
@@ -1052,15 +1361,41 @@ export function PurchaseRequestsPage() {
) : (
<span className="text-zinc-500">未关联项目</span>
)}
<div className="mt-1 text-xs text-zinc-500">合同:详情页登记归档摘要</div>
</TableCell>
<TableCell>
<StatusBadge status={item.status} />
<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"
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
onClick={(event) => {
event.stopPropagation();
setSelectedId(item.id);
setHydratedDetailId("");
}}
>
{resolvePurchaseRowActionLabel(item.status)}
</Button>
</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell>{item.totalAmount}</TableCell>
</TableRow>
))
);
})
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={5}>
暂无采购申请
<div className="py-3 text-center">
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
</div>
</TableCell>
</TableRow>
)}
@@ -1101,38 +1436,54 @@ export function PurchaseRequestsPage() {
<CompactBusinessCode value={resolvePurchaseDisplayCode(detail)} />
</h3>
<p className="text-sm text-zinc-500">申请人:{detail.applicant?.name ?? "-"}</p>
<p className="mt-1 text-sm text-zinc-500" data-testid="purchase-status-hint">
</div>
<StatusBadge data-testid="purchase-detail-status" status={detail.status} />
</div>
<section className="detail-command-panel space-y-4" data-testid="purchase-next-step-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="text-xs font-semibold text-zinc-500">当前状态与下一步</div>
<p className="mt-1 text-sm leading-6 text-zinc-700" data-testid="purchase-status-hint">
{resolvePurchaseStatusHint(detail)}
</p>
<p className="mt-1 text-sm font-medium text-zinc-900">
下一步:{detailNextStepText}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge data-testid="purchase-detail-status" status={detail.status} />
<div className="flex shrink-0 flex-wrap gap-2">
{canEditDraft ? (
<Button
data-testid="purchase-save"
type="button"
variant="secondary"
disabled={!canEditDraft || saveMutation.isPending}
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? "保存中..." : "保存草稿"}
</Button>
) : null}
{canSubmit ? (
<Button
data-testid="purchase-submit"
type="button"
disabled={!canSubmit || submitMutation.isPending}
disabled={submitMutation.isPending}
onClick={() => void handleSubmitAction()}
>
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
) : null}
{canWithdraw ? (
<Button
data-testid="purchase-withdraw"
type="button"
variant="secondary"
disabled={!canWithdraw || withdrawMutation.isPending}
disabled={withdrawMutation.isPending}
onClick={() => withdrawMutation.mutate()}
>
{withdrawMutation.isPending ? "撤回中..." : "撤回"}
</Button>
) : null}
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
data-testid="purchase-remind-workflow"
@@ -1147,63 +1498,33 @@ export function PurchaseRequestsPage() {
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
<div className="text-zinc-500">采购状态</div>
<div className="mt-2"><StatusBadge status={detail.status} /></div>
<div className="detail-fact-grid xl:grid-cols-4">
<div className="detail-fact-card">
<div className="detail-fact-card__label">总金额</div>
<div className="detail-fact-card__value">{formatPurchaseCurrency(totalAmountPreview, currencyCode)}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
<div className="text-zinc-500">总金额</div>
<div className="mt-2 font-semibold text-zinc-900">{totalAmountPreview}</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">当前节点</div>
<div className="detail-fact-card__value">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
<div className="text-zinc-500">当前节点</div>
<div className="mt-2 font-medium text-zinc-900">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
<div className="text-zinc-500">附件</div>
<div className="mt-2 font-medium text-zinc-900">{detailAttachments.length} 个</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">材料摘要</div>
<div className={`detail-fact-card__value ${detailEvidenceHint?.className ?? ""}`}>
{detailEvidenceHint?.label ?? "-"} · 附件 {detailAttachments.length} 个
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">关联项目</div>
<div className="text-xs text-zinc-500">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}
<div className="detail-fact-card">
<div className="detail-fact-card__label">资产候选</div>
<div className="detail-fact-card__value">
{assetCandidateCount > 0 ? `${assetCandidateCount} 项需后续人工建卡判断` : "无资产候选"}
</div>
</div>
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
查看项目
</Link>
) : null}
</div>
</div>
</section>
<RiskFlagsCard items={detailRiskFlags} title="待处理风险项" />
<SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。">
{detailApprovalHistory.length ? (
<div className="space-y-2" data-testid="purchase-approval-history">
{detailApprovalHistory.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium text-zinc-900">
{workflowActionLabel(item.action)} · {item.actorName ?? item.actorId}
</span>
<span className="text-xs text-zinc-500">{formatApprovalHistoryTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-zinc-600">{item.comment || "无补充说明"}</div>
</div>
))}
</div>
) : (
<div className="text-sm text-zinc-500">暂无审批记录。</div>
)}
</SectionPanel>
<SectionPanel title="业务摘要" description="只保留提交和审批需要核对的采购事实。">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">标题</label>
@@ -1313,6 +1634,7 @@ export function PurchaseRequestsPage() {
{resolvePurchaseBudgetHint(detail)}
</div>
</div>
</SectionPanel>
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -1541,8 +1863,8 @@ export function PurchaseRequestsPage() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-base font-semibold text-zinc-900">附件</h4>
<p className="text-sm text-zinc-500">用于订单、报价、验收或情况说明;草稿状态可增删,下载会留痕。</p>
<h4 className="text-base font-semibold text-zinc-900">材料证据</h4>
<p className="text-sm text-zinc-500">用于报价、订单、验收或情况说明;草稿状态可增删,下载会留痕。</p>
</div>
<div className="flex min-w-0 items-center gap-3">
<input
@@ -1670,6 +1992,42 @@ export function PurchaseRequestsPage() {
</div>
</div>
<SectionPanel title="关联摘要" description="跨模块只展示摘要和入口,采购页不直接执行付款或资产建卡。">
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="detail-evidence-card">
<div className="font-medium text-zinc-900">关联项目</div>
<div className="mt-1 text-zinc-600">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}
</div>
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="mt-2 inline-flex text-sm text-primary hover:underline">
查看项目
</Link>
) : null}
</div>
<div className="detail-evidence-card">
<div className="font-medium text-zinc-900">资产候选</div>
<div className="mt-1 text-zinc-600">
{assetCandidateCount > 0
? `${assetCandidateCount} 项可能后续建卡;需管理员到货核对后手动创建资产卡片。`
: "当前明细未标记资产候选。"}
</div>
</div>
<div className="detail-evidence-card">
<div className="font-medium text-zinc-900">付款 / 应付</div>
<div className="mt-1 text-zinc-600">本页只展示采购依据;付款状态和回单后续在付款或应付账款页面处理。</div>
</div>
<div className="detail-evidence-card">
<div className="font-medium text-zinc-900">PO / 合同</div>
<div className="mt-1 text-zinc-600">
{archiveOrder
? `已登记 PO ${archiveOrder.poNo}${archiveOrder.contractNo ? ` / 合同 ${archiveOrder.contractNo}` : ""}`
: "已通过后由采购/行政登记归档摘要,不在列表下载合同正文。"}
</div>
</div>
</div>
</SectionPanel>
{showArchiveSection ? (
<div className="space-y-3 rounded-lg border border-zinc-200 p-4">
<div className="flex items-center justify-between">
@@ -1817,6 +2175,26 @@ export function PurchaseRequestsPage() {
</SectionPanel>
) : null}
<SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。">
{detailApprovalHistory.length ? (
<div className="space-y-2" data-testid="purchase-approval-history">
{detailApprovalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium text-zinc-900">
{workflowActionLabel(item.action)} · {item.actorName ?? item.actorId}
</span>
<span className="text-xs text-zinc-500">{formatApprovalHistoryTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-zinc-600">{item.comment || "无补充说明"}</div>
</div>
))}
</div>
) : (
<div className="text-sm text-zinc-500">暂无审批记录。</div>
)}
</SectionPanel>
<AiSuggestionCard targetType="purchase_request" targetId={detail.id} />
</>
) : null}
@@ -1145,6 +1145,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.goto("/mobile/purchase-requests/purchase-wave3");
await expect(page.getByTestId("mobile-purchase-detail-summary")).toBeVisible();
await expect(page.getByTestId("mobile-purchase-detail-related")).toBeVisible();
await expect(page.getByTestId("mobile-purchase-detail-items")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
@@ -287,7 +287,10 @@ async function mockApis(page: Page) {
}
if (pathName === "/api/purchase-requests" && request.method() === "GET") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, mine: true, items: [purchaseSummary()] });
const summary = purchaseSummary();
const queryStatus = url.searchParams.get("status");
const items = !queryStatus || queryStatus === summary.status ? [summary] : [];
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, mine: true, items });
return;
}
@@ -345,6 +348,10 @@ 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-ledger-tools")).toBeVisible();
await expect(page.getByTestId("purchase-row-next-step")).toContainText("继续完善材料并提交");
await expect(page.getByText("付款处理")).toHaveCount(0);
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
await expect(page.getByText("Choose File")).toHaveCount(0);
@@ -362,7 +369,7 @@ test.describe("R103 cross page UI consistency", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/invoice-pool");
await expect(page.locator("header").filter({ hasText: "财务" }).getByText("票据中心")).toBeVisible();
await expect(page.getByRole("heading", { name: "票据中心" })).toBeVisible();
await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06");
await expect(page.getByTestId("invoice-bind-reimbursement")).not.toContainText(reimbursementId);
await expect(page.getByText("/invoice-pool")).toHaveCount(0);