文件
hyb-oa/client/src/pages/PurchaseRequestsPage.tsx
T

2015 行
86 KiB
TypeScript

import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import {
archivePurchaseOrder,
createPurchaseRequest,
deletePurchaseAttachment,
downloadPurchaseAttachment,
fetchPurchaseOrderArchive,
fetchPurchaseRequestDetail,
fetchPurchaseRequests,
precheckPurchaseRequest,
submitPurchaseRequest,
updatePurchaseRequest,
uploadPurchaseAttachment,
withdrawPurchaseRequest,
type PurchaseRequestDetail,
type PurchaseRequestItemPayload,
type PurchaseRequestStatus,
} from "../api/purchaseRequests";
import {
fetchCostCenters,
fetchDocumentCategories,
fetchLegalEntities,
fetchSuppliers,
} from "../api/masterData";
import { fetchProjects } from "../api/projects";
import { remindWorkflowTask } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { DateTextInput } from "../components/common/DateTextInput";
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea";
import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import {
BusinessListToolbar,
BusinessMobileList,
BusinessTableFrame,
} from "../components/patterns/BusinessListTemplate";
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 { downloadBlob } from "../lib/download";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
buildPurchaseRequestDetailPath,
readPurchaseListFilters,
resolvePurchaseRequestLegacyRedirect,
} from "../lib/businessDetailRoutes";
import {
buildPcDetailSource,
resolvePcDetailSource,
type PcDetailNavigationState,
} from "../lib/pcDetailNavigation";
import {
formatApprovalHistoryTime,
resolvePurchaseBudgetHint,
resolvePurchaseDisplayCode,
resolvePurchaseStatusHint,
resolvePurchaseSubmitWarning,
workflowActionLabel,
} from "../lib/approvalDisplay";
const PAGE_SIZE = 20;
const MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH = 180;
type DraftItem = {
id: string;
description: string;
specification: string;
quantity: string;
unitPrice: string;
isAssetCandidate: boolean;
};
function purchaseDraftSignature(value: {
title: string;
reason: string;
currencyCode: string;
legalEntityId: string;
costCenterId: string;
supplierId: string;
documentCategoryId: string;
projectId: string;
expectedDeliveryDate: string;
items: DraftItem[];
}) {
return JSON.stringify(value);
}
function resolveError(error: unknown) {
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
}
function sanitizePurchaseAttachmentDownloadName(value?: string) {
const cleaned = (value ?? "")
.normalize("NFC")
.replace(/[\u0000-\u001f\u007f-\u009f/\\]+/g, "_")
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]+/g, "_")
.replace(/\s+/g, " ")
.replace(/^[\s._]+|[\s._]+$/g, "")
.trim();
const fallback = cleaned || "采购附件";
const characters = Array.from(fallback);
if (characters.length <= MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH) {
return fallback;
}
const extension = fallback.match(/\.[a-z0-9]{1,10}$/i)?.[0] ?? "";
const stem = extension ? fallback.slice(0, -extension.length) : fallback;
const stemLength = Math.max(1, MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH - Array.from(extension).length);
return `${Array.from(stem).slice(0, stemLength).join("")}${extension}`;
}
function moneyPreview(quantity: string, unitPrice: string) {
const quantityNumber = Number(quantity);
const unitPriceNumber = Number(unitPrice);
if (!Number.isFinite(quantityNumber) || !Number.isFinite(unitPriceNumber)) {
return "0.00";
}
return (quantityNumber * unitPriceNumber).toFixed(2);
}
function emptyDraftItem(): DraftItem {
return {
id: `draft-${crypto.randomUUID()}`,
description: "",
specification: "",
quantity: "1.00",
unitPrice: "0.00",
isAssetCandidate: false,
};
}
function statusOptions(): Array<{ value: PurchaseRequestStatus | ""; label: string }> {
return [
{ value: "", label: "全部状态" },
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
];
}
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);
async function copyBusinessCode() {
if (!compacted) return;
try {
await navigator.clipboard.writeText(value);
toast.success("已复制完整单据编号");
} catch {
toast.error("复制失败,可在详情页查看完整编号");
}
}
return (
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5">
<span className="truncate" title={compacted ? value : undefined}>
{displayValue}
</span>
{compacted && copyable ? (
<button
className="shrink-0 rounded border border-zinc-200 px-1.5 py-0.5 text-[11px] font-medium text-zinc-500 hover:border-zinc-300 hover:text-zinc-700"
onClick={(event) => {
event.stopPropagation();
void copyBusinessCode();
}}
type="button"
>
复制
</button>
) : null}
</span>
);
}
export function PurchaseRequestsPage() {
const [searchParams] = useSearchParams();
const legacyRedirect = resolvePurchaseRequestLegacyRedirect(searchParams);
if (legacyRedirect) {
return <Navigate to={legacyRedirect.to} replace state={{ from: legacyRedirect.from }} />;
}
return <PurchaseRequestsWorkspace mode="list" />;
}
export function PurchaseRequestDetailPage() {
const { purchaseRequestId = "" } = useParams();
return <PurchaseRequestsWorkspace mode="detail" purchaseRequestId={purchaseRequestId} />;
}
function PurchaseRequestsWorkspace({
mode,
purchaseRequestId = "",
}: {
mode: "list" | "detail";
purchaseRequestId?: string;
}) {
const queryClient = useQueryClient();
const { me, permissions } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const canCreate = hasPermission(permissions, "procurement.create");
const canManage = hasPermission(permissions, "procurement.manage");
const canViewAll =
hasPermission(permissions, "procurement.view_all") ||
canManage;
const isPurchaseDetailMode = mode === "detail";
const selectedId = isPurchaseDetailMode ? purchaseRequestId.trim() : "";
const listFilters = useMemo(
() => readPurchaseListFilters(searchParams, canViewAll),
[canViewAll, searchParams],
);
const {
status,
mine,
supplierId: supplierFilter,
legalEntityId: legalEntityFilter,
costCenterId: costCenterFilter,
page,
} = listFilters;
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [hydratedDetailId, setHydratedDetailId] = useState("");
const [formError, setFormError] = useState("");
const [title, setTitle] = useState("");
const [reason, setReason] = useState("");
const [currencyCode, setCurrencyCode] = useState("CNY");
const [legalEntityId, setLegalEntityId] = useState("");
const [costCenterId, setCostCenterId] = useState("");
const [supplierId, setSupplierId] = useState("");
const [documentCategoryId, setDocumentCategoryId] = useState("");
const [projectId, setProjectId] = useState("");
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState("");
const [items, setItems] = useState<DraftItem[]>([]);
const [savedDraftSignature, setSavedDraftSignature] = useState(() =>
purchaseDraftSignature({
title: "",
reason: "",
currencyCode: "CNY",
legalEntityId: "",
costCenterId: "",
supplierId: "",
documentCategoryId: "",
projectId: "",
expectedDeliveryDate: "",
items: [],
}),
);
const [archivePoNo, setArchivePoNo] = useState("");
const [archiveContractNo, setArchiveContractNo] = useState("");
const [selectedAttachmentName, setSelectedAttachmentName] = useState("");
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const currentSource = buildPcDetailSource(location.pathname, location.search);
const navigationState = location.state as PcDetailNavigationState | null;
const backSource = resolvePcDetailSource(navigationState?.from, currentSource, "purchase-request") ?? "/purchase-requests";
const backLabel = backSource.startsWith("/projects/") ? "返回项目详情" : "返回采购列表";
const listSource = buildPcDetailSource(location.pathname, location.search);
function updateListSearch(
updates: Record<string, string | null>,
options: { resetPage?: boolean } = {},
) {
const next = new URLSearchParams(searchParams);
for (const [key, value] of Object.entries(updates)) {
if (value) next.set(key, value);
else next.delete(key);
}
if (options.resetPage) {
next.delete("page");
}
setSearchParams(next, { replace: true });
}
const suppliersQuery = useQuery({
queryKey: ["purchase-requests", "suppliers"],
queryFn: () => fetchSuppliers({ enabled: true }),
});
const legalEntitiesQuery = useQuery({
queryKey: ["purchase-requests", "legal-entities"],
queryFn: () => fetchLegalEntities({ enabled: true }),
});
const costCentersQuery = useQuery({
queryKey: ["purchase-requests", "cost-centers"],
queryFn: () => fetchCostCenters({ enabled: true }),
});
const categoriesQuery = useQuery({
enabled: isPurchaseDetailMode,
queryKey: ["purchase-requests", "document-categories"],
queryFn: () => fetchDocumentCategories({ entityType: "purchase_request", enabled: true }),
});
const projectsQuery = useQuery({
enabled: isPurchaseDetailMode,
queryKey: ["projects", "purchase-request-link"],
queryFn: () => fetchProjects({ mine: true }),
});
const listQuery = useQuery({
enabled: !isPurchaseDetailMode,
queryKey: ["purchase-requests", "list", status, mine, supplierFilter, legalEntityFilter, costCenterFilter, page],
queryFn: () =>
fetchPurchaseRequests({
status: status || undefined,
mine: canViewAll ? mine : true,
supplierId: supplierFilter || undefined,
legalEntityId: legalEntityFilter || undefined,
costCenterId: costCenterFilter || undefined,
page,
pageSize: PAGE_SIZE,
}),
});
const detailQuery = useQuery({
enabled: isPurchaseDetailMode && Boolean(selectedId),
queryKey: ["purchase-requests", "detail", selectedId],
queryFn: () => fetchPurchaseRequestDetail(selectedId),
});
const archiveOrderQuery = useQuery({
enabled: isPurchaseDetailMode && Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
queryKey: ["purchase-requests", "archive-order", selectedId],
queryFn: () => fetchPurchaseOrderArchive(selectedId),
});
useEffect(() => {
if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) {
return;
}
const nextDraft = {
title: detailQuery.data.title || "",
reason: detailQuery.data.reason || "",
currencyCode: detailQuery.data.currencyCode || "CNY",
legalEntityId: detailQuery.data.legalEntityId || "",
costCenterId: detailQuery.data.costCenterId || "",
supplierId: detailQuery.data.supplierId || "",
documentCategoryId: detailQuery.data.documentCategoryId || "",
projectId: detailQuery.data.projectId || "",
expectedDeliveryDate: detailQuery.data.expectedDeliveryDate || "",
items: detailQuery.data.items.map((item) => ({
id: item.id,
description: item.description,
specification: item.specification || "",
quantity: item.quantity,
unitPrice: item.unitPrice,
isAssetCandidate: item.isAssetCandidate,
})),
};
setTitle(nextDraft.title);
setReason(nextDraft.reason);
setCurrencyCode(nextDraft.currencyCode);
setLegalEntityId(nextDraft.legalEntityId);
setCostCenterId(nextDraft.costCenterId);
setSupplierId(nextDraft.supplierId);
setDocumentCategoryId(nextDraft.documentCategoryId);
setProjectId(nextDraft.projectId);
setExpectedDeliveryDate(nextDraft.expectedDeliveryDate);
setItems(nextDraft.items);
setSavedDraftSignature(purchaseDraftSignature(nextDraft));
setArchivePoNo(detailQuery.data.archiveOrder?.poNo || "");
setArchiveContractNo(detailQuery.data.archiveOrder?.contractNo || "");
setFormError("");
setHydratedDetailId(detailQuery.data.id);
}, [detailQuery.data, hydratedDetailId, selectedId]);
useEffect(() => {
if (!archiveOrderQuery.data) {
return;
}
setArchivePoNo((current) => current || archiveOrderQuery.data?.archiveOrder?.poNo || "");
setArchiveContractNo((current) => current || archiveOrderQuery.data?.archiveOrder?.contractNo || "");
}, [archiveOrderQuery.data]);
const filteredCostCenters = useMemo(() => {
if (!legalEntityId) {
return [];
}
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityId);
}, [costCentersQuery.data?.items, legalEntityId]);
const filterCostCenters = useMemo(() => {
if (!legalEntityFilter) {
return costCentersQuery.data?.items ?? [];
}
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityFilter);
}, [costCentersQuery.data?.items, legalEntityFilter]);
const projectOptions = useMemo(() => {
const activeItems = (projectsQuery.data?.items ?? [])
.filter((item) => item.status !== "archived")
.map((item) => ({
id: item.id,
projectNo: item.projectNo,
name: item.name,
status: item.status,
}));
const currentProject = detailQuery.data?.project;
if (currentProject && currentProject.id === projectId && !activeItems.some((item) => item.id === currentProject.id)) {
return [
{
id: currentProject.id,
projectNo: currentProject.projectNo,
name: currentProject.name,
status: currentProject.status || "archived",
},
...activeItems,
];
}
return activeItems;
}, [detailQuery.data?.project, projectId, projectsQuery.data?.items]);
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
const purchaseItems = listQuery.data?.items ?? [];
const hasAdvancedFilters = Boolean(supplierFilter || legalEntityFilter || costCenterFilter || (canViewAll && !mine));
const canShowAdvancedFilters =
canViewAll ||
Boolean((suppliersQuery.data?.items ?? []).length) ||
Boolean((legalEntitiesQuery.data?.items ?? []).length) ||
Boolean((costCentersQuery.data?.items ?? []).length);
const showAdvancedFilterPanel = canShowAdvancedFilters && (showAdvancedFilters || 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(),
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] });
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail"] });
toast.success("采购草稿已创建");
navigate(buildPurchaseRequestDetailPath(data.purchaseRequest.id), {
state: { from: listSource },
});
},
onError: (error) => {
const message = resolveError(error);
setFormError(message);
toast.error(message);
},
});
function buildPayload(): {
title: string;
reason: string | null;
currencyCode: string;
legalEntityId: string | null;
costCenterId: string | null;
supplierId: string | null;
documentCategoryId: string | null;
projectId: string | null;
expectedDeliveryDate: string | null;
items: PurchaseRequestItemPayload[];
} {
return {
title: title.trim(),
reason: reason.trim() || null,
currencyCode: currencyCode.trim().toUpperCase() || "CNY",
legalEntityId: legalEntityId || null,
costCenterId: costCenterId || null,
supplierId: supplierId || null,
documentCategoryId: documentCategoryId || null,
projectId: projectId || null,
expectedDeliveryDate: expectedDeliveryDate || null,
items: items.map((item) => ({
description: item.description.trim(),
specification: item.specification.trim() || null,
quantity: item.quantity || "0.00",
unitPrice: item.unitPrice || "0.00",
isAssetCandidate: item.isAssetCandidate,
})),
};
}
function validateBeforeSubmit() {
if (!supplierId) {
throw new Error("提交前请选择供应商");
}
if (!legalEntityId) {
throw new Error("提交前请选择公司");
}
if (!costCenterId) {
throw new Error("提交前请选择成本中心");
}
if (!documentCategoryId) {
throw new Error("提交前请选择单据分类");
}
if (!items.length) {
throw new Error("提交前至少添加一条明细");
}
if (items.some((item) => !item.description.trim())) {
throw new Error("请补齐所有明细描述");
}
}
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return updatePurchaseRequest(selectedId, buildPayload());
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
]);
setFormError("");
setSavedDraftSignature(currentDraftSignature);
toast.success("采购草稿已保存");
},
onError: (error) => {
const message = resolveError(error);
setFormError(message);
toast.error(message);
},
});
const submitMutation = useMutation({
mutationFn: async () => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return submitPurchaseRequest(selectedId);
},
onSuccess: async (data) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
]);
setFormError("");
toast.success(
data.precheckDecision === "manual_review"
? `采购申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险`
: resolvePurchaseSubmitWarning(data.warnings),
);
},
onError: (error) => {
const message = resolveError(error);
setFormError(message);
toast.error(message);
},
});
const withdrawMutation = useMutation({
mutationFn: async () => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return withdrawPurchaseRequest(selectedId);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
]);
setFormError("");
toast.success("采购申请已撤回到草稿");
},
onError: (error) => {
const message = resolveError(error);
setFormError(message);
toast.error(message);
},
});
const remindMutation = useMutation({
mutationFn: (taskId: string) => remindWorkflowTask(taskId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks", "summary"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
toast.success("已催办当前审批人");
},
onError: (error) => {
const message = resolveError(error);
setFormError(message);
toast.error(message);
},
});
const archiveMutation = useMutation({
mutationFn: async () => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
if (!archivePoNo.trim()) {
throw new Error("请填写 PO 号");
}
return archivePurchaseOrder(selectedId, {
poNo: archivePoNo.trim(),
contractNo: archiveContractNo.trim() || null,
archivedFiles: archiveOrderQuery.data?.archiveOrder?.archivedFiles ?? [],
});
},
onSuccess: async (data) => {
if (selectedId) {
setArchivePoNo(data.archiveOrder.poNo ?? "");
setArchiveContractNo(data.archiveOrder.contractNo ?? "");
queryClient.setQueryData<{ archiveOrder: Awaited<ReturnType<typeof fetchPurchaseOrderArchive>>["archiveOrder"] }>(
["purchase-requests", "archive-order", selectedId],
{ archiveOrder: data.archiveOrder },
);
queryClient.setQueryData<PurchaseRequestDetail | undefined>(["purchase-requests", "detail", selectedId], (current) =>
current
? {
...current,
archiveOrder: data.archiveOrder,
}
: current,
);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
]);
toast.success("PO/合同归档已保存");
},
onError: (error) => {
const message = resolveError(error);
toast.error(message);
},
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return uploadPurchaseAttachment(selectedId, file);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] });
toast.success("附件已上传");
},
onError: (error) => {
const message = resolveError(error);
setFormError(message);
toast.error(message);
},
});
const deleteAttachmentMutation = useMutation({
mutationFn: async ({ attachmentId, reason }: { attachmentId: string; reason: string }) => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return deletePurchaseAttachment(selectedId, attachmentId, reason);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] });
setDeleteAttachmentTarget(null);
toast.success("附件已删除");
},
onError: (error) => {
const message = resolveError(error);
setFormError(message);
toast.error(message);
},
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
async function handleDownloadAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
if (!selectedId) {
return;
}
if (attachment.governanceStatus === "quarantined") {
toast.error("附件已隔离,无法下载,请联系管理员。");
return;
}
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
try {
const response = await downloadPurchaseAttachment(selectedId, attachment.id, reason);
downloadBlob(response.blob, sanitizePurchaseAttachmentDownloadName(attachment.originalName));
} catch (error) {
const message = resolveError(error);
setFormError(message);
toast.error(message);
}
}
const detail = detailQuery.data;
const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null;
const detailAttachments = detail?.attachments ?? [];
const detailApprovalHistory = detail?.approvalHistory ?? [];
const detailRiskFlags = detail?.riskFlags ?? [];
const handleSubmitAction = async () => {
if (!selectedId) {
return;
}
try {
setFormError("");
validateBeforeSubmit();
await updatePurchaseRequest(selectedId, buildPayload());
setSavedDraftSignature(currentDraftSignature);
const precheck = await precheckPurchaseRequest(selectedId);
const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") {
setFormError(message);
toast.error("提交前校验未通过");
return;
}
if (precheck.decision === "warn" || precheck.decision === "manual_review") {
setSubmitPrecheckConfirm(message);
return;
}
submitMutation.mutate();
} catch (error) {
const message = resolveError(error);
setFormError(message);
toast.error(message);
}
};
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
const currentDraftSignature = useMemo(
() =>
purchaseDraftSignature({
title,
reason,
currencyCode,
legalEntityId,
costCenterId,
supplierId,
documentCategoryId,
projectId,
expectedDeliveryDate,
items,
}),
[
costCenterId,
currencyCode,
documentCategoryId,
expectedDeliveryDate,
items,
legalEntityId,
projectId,
reason,
supplierId,
title,
],
);
const runGuarded = useUnsavedChangesGuard(
Boolean(selectedId) && canEditDraft && currentDraftSignature !== savedDraftSignature,
"采购表单",
);
const canSubmit = Boolean(detail?.permissions.canSubmit);
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage);
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
const isPurchaseParticipant = Boolean(
detail && me?.id && [detail.applicantId, detail.submitterId].includes(me.id),
);
const canRemindPendingWorkflow = Boolean(
detail?.status === "reviewing" &&
pendingTaskId &&
isPurchaseParticipant &&
detail?.pendingWorkflow?.task?.assigneeId !== me?.id,
);
const detailEvidenceHint = detail ? resolvePurchaseEvidenceHint(detail) : null;
const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : "";
const openPurchaseDetail = (id: string) => {
runGuarded(() => {
navigate(buildPurchaseRequestDetailPath(id), {
state: { from: listSource },
});
});
};
const returnToPurchaseList = () => {
runGuarded(() => {
navigate(backSource, { replace: true });
});
};
const detailAccessDenied = detailQuery.isError && isApiError(detailQuery.error) && [403, 404].includes(detailQuery.error.status);
return (
<ListWorkspace>
{downloadReasonDialog}
<WorkspaceHeader
title="采购"
actions={
<HeaderActionGroup>
{isPurchaseDetailMode ? (
<Button type="button" variant="secondary" onClick={returnToPurchaseList}>
{backLabel}
</Button>
) : (
<>
{canCreate ? (
<Button
data-testid="purchase-requests-create"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建采购"}
</Button>
) : null}
</>
)}
</HeaderActionGroup>
}
/>
{formError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</p>
) : null}
{!isPurchaseDetailMode ? (
<>
<BusinessListToolbar
actions={
<>
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
{mine ? "我相关" : "全范围"}
</span>
{canShowAdvancedFilters ? (
<Button
data-testid="purchase-advanced-filters"
type="button"
size="sm"
variant={showAdvancedFilterPanel ? "default" : "secondary"}
onClick={() => setShowAdvancedFilters((current) => !current)}
>
筛选
</Button>
) : null}
</>
}
advanced={showAdvancedFilterPanel ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4" data-testid="purchase-advanced-filters-panel">
<select
className="select-std"
value={supplierFilter}
onChange={(event) => {
updateListSearch({ supplierId: event.target.value || null }, { resetPage: true });
}}
>
<option value="">全部供应商</option>
{(suppliersQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select
className="select-std"
value={legalEntityFilter}
onChange={(event) => {
const nextValue = event.target.value;
const keepCostCenter = costCenterFilter && (costCentersQuery.data?.items ?? []).some(
(item) => item.id === costCenterFilter && item.legalEntityId === nextValue,
);
updateListSearch(
{
legalEntityId: nextValue || null,
costCenterId: keepCostCenter ? costCenterFilter : null,
},
{ resetPage: true },
);
}}
>
<option value="">全部公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select
className="select-std"
value={costCenterFilter}
onChange={(event) => {
updateListSearch({ costCenterId: event.target.value || null }, { resetPage: true });
}}
>
<option value="">全部成本中心</option>
{filterCostCenters.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
{canViewAll ? (
<div className="flex items-center gap-2">
<Button
data-testid="purchase-scope-mine"
size="sm"
type="button"
variant={mine ? "default" : "secondary"}
onClick={() => {
updateListSearch({ scope: null }, { resetPage: true });
}}
>
我相关
</Button>
<Button
data-testid="purchase-scope-all"
size="sm"
type="button"
variant={!mine ? "default" : "secondary"}
onClick={() => {
updateListSearch({ scope: "all" }, { resetPage: true });
}}
>
全范围
</Button>
</div>
) : null}
</div>
) : null}
>
<Tabs
data-testid="purchase-status-tabs"
value={status}
onValueChange={(value) => {
updateListSearch(
{ status: value === "draft" ? null : value || "all" },
{ resetPage: true },
);
}}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{statusOptions().map((item) => (
<TabsTrigger
data-testid={`purchase-tab-${item.value || "all"}`}
key={item.label}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</BusinessListToolbar>
{listQuery.isLoading ? <TableSkeleton columns={4} rows={6} /> : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(listQuery.error)}
</p>
) : null}
{!listQuery.isLoading && !listQuery.isError ? (
<>
<BusinessMobileList>
{purchaseItems.length ? (
purchaseItems.map((item) => {
const evidenceHint = resolvePurchaseEvidenceHint(item);
return (
<button
data-testid="purchase-request-mobile-row"
key={item.id}
type="button"
className="w-full rounded-lg border border-zinc-200 bg-white p-3 text-left"
onClick={() => openPurchaseDetail(item.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">
<CompactBusinessCode copyable={false} value={resolvePurchaseDisplayCode(item)} />
</div>
<div className="mt-1 truncate text-xs text-zinc-500" title={item.title || undefined}>
{formatTextWithCompactLongNumbers(item.title || "未命名采购申请")}
</div>
</div>
<StatusBadge status={item.status} />
</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 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 truncate text-zinc-700">{item.supplier?.name ?? "待补供应商"}</div>
</div>
<div className="col-span-2">
<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>
<div className="mt-3 text-sm font-medium text-primary">{resolvePurchaseRowActionLabel(item.status)}</div>
</button>
);
})
) : (
<div className="rounded-lg border border-zinc-200 bg-white 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>
)}
</BusinessMobileList>
<BusinessTableFrame>
<Table data-testid="purchase-requests-table">
<TableHeader>
<tr>
<TableHead>单据 / 对象</TableHead>
<TableHead>金额 / 供应商</TableHead>
<TableHead>项目 / 合同</TableHead>
<TableHead>状态 / 证据</TableHead>
<TableHead className="w-28">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{purchaseItems.length ? (
purchaseItems.map((item) => {
const evidenceHint = resolvePurchaseEvidenceHint(item);
return (
<TableRow
key={item.id}
className="cursor-pointer"
onClick={() => openPurchaseDetail(item.id)}
>
<TableCell>
<div className="font-medium text-zinc-900">
<CompactBusinessCode value={resolvePurchaseDisplayCode(item)} />
</div>
<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.project ? (
<div>
<div className="font-medium text-zinc-900" title={item.project.projectNo}>
{formatTextWithCompactLongNumbers(item.project.projectNo)}
</div>
<div className="text-xs text-zinc-500">{item.project.name}</div>
</div>
) : (
<span className="text-zinc-500">未关联项目</span>
)}
</TableCell>
<TableCell>
<StatusBadge status={item.status} />
<div className={`mt-1 text-xs ${evidenceHint.className}`}>{evidenceHint.label}</div>
</TableCell>
<TableCell>
<Button
data-testid="purchase-row-open"
size="sm"
type="button"
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
onClick={(event) => {
event.stopPropagation();
openPurchaseDetail(item.id);
}}
>
{resolvePurchaseRowActionLabel(item.status)}
</Button>
</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>
)}
</TableBody>
</Table>
</BusinessTableFrame>
</>
) : null}
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(nextPage) => updateListSearch({ page: nextPage > 1 ? String(nextPage) : null })}
testIdPrefix="purchase-requests"
/>
</>
) : (
<div className="card-std space-y-5 p-4 md:p-5">
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={3} rows={8} /> : null}
{isPurchaseDetailMode && (!selectedId || detailQuery.isError) ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700">
<p>
{!selectedId || detailAccessDenied
? "无法查看该记录,记录可能不存在或你无权访问。"
: resolveError(detailQuery.error)}
</p>
<Button className="mt-3" type="button" variant="secondary" onClick={returnToPurchaseList}>
{backLabel}
</Button>
</div>
) : null}
{selectedId && detail ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900">
<CompactBusinessCode value={resolvePurchaseDisplayCode(detail)} />
</h3>
<p className="text-sm text-zinc-500">申请人:{detail.applicant?.name ?? "-"}</p>
</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 shrink-0 flex-wrap gap-2">
{canEditDraft ? (
<Button
data-testid="purchase-save"
type="button"
variant="secondary"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? "保存中..." : "保存草稿"}
</Button>
) : null}
{canSubmit ? (
<Button
data-testid="purchase-submit"
type="button"
disabled={submitMutation.isPending}
onClick={() => void handleSubmitAction()}
>
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
) : null}
{canWithdraw ? (
<Button
data-testid="purchase-withdraw"
type="button"
variant="secondary"
disabled={withdrawMutation.isPending}
onClick={() => withdrawMutation.mutate()}
>
{withdrawMutation.isPending ? "撤回中..." : "撤回"}
</Button>
) : null}
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
data-testid="purchase-remind-workflow"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办审批"}
</Button>
) : null}
</div>
</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="detail-fact-card">
<div className="detail-fact-card__label">当前节点</div>
<div className="detail-fact-card__value">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
</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="detail-fact-card">
<div className="detail-fact-card__label">资产候选</div>
<div className="detail-fact-card__value">
{assetCandidateCount > 0 ? `${assetCandidateCount} 项需后续人工建卡判断` : "无资产候选"}
</div>
</div>
</div>
</section>
<RiskFlagsCard items={detailRiskFlags} title="待处理风险项" />
<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>
<Input data-testid="purchase-title" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canEditDraft} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">币种</label>
<Input value={currencyCode} onChange={(event) => setCurrencyCode(event.target.value.toUpperCase())} disabled={!canEditDraft} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">公司</label>
<select
data-testid="purchase-legal-entity"
className="select-std"
value={legalEntityId}
disabled={!canEditDraft}
onChange={(event) => {
const nextValue = event.target.value;
setLegalEntityId(nextValue);
if (costCenterId && !filteredCostCenters.some((item) => item.id === costCenterId && item.legalEntityId === nextValue)) {
setCostCenterId("");
}
}}
>
<option value="">请选择公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">成本中心</label>
<select data-testid="purchase-cost-center" className="select-std" value={costCenterId} disabled={!canEditDraft} onChange={(event) => setCostCenterId(event.target.value)}>
<option value="">请选择成本中心</option>
{filteredCostCenters.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">供应商</label>
<select data-testid="purchase-supplier" className="select-std" value={supplierId} disabled={!canEditDraft} onChange={(event) => setSupplierId(event.target.value)}>
<option value="">请选择供应商</option>
{(suppliersQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">单据分类</label>
<select data-testid="purchase-document-category" className="select-std" value={documentCategoryId} disabled={!canEditDraft} onChange={(event) => setDocumentCategoryId(event.target.value)}>
<option value="">请选择单据分类</option>
{(categoriesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.code})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">关联项目</label>
<select
data-testid="purchase-project"
className="select-std"
value={projectId}
disabled={!canEditDraft || projectsQuery.isLoading}
onChange={(event) => setProjectId(event.target.value)}
>
<option value="">不关联项目</option>
{projectOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}{item.status === "archived" ? "(已归档)" : ""}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">期望到货日期</label>
<DateTextInput data-testid="purchase-expected-delivery-date" value={expectedDeliveryDate} disabled={!canEditDraft} onChange={(event) => setExpectedDeliveryDate(event.target.value)} />
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">申请说明</label>
<Textarea rows={4} value={reason} disabled={!canEditDraft} onChange={(event) => setReason(event.target.value)} />
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-600">
<div>预算池:{detail.budgetPool?.name ?? "未匹配"}</div>
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}</div>
<div>当前审批节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
<div>总金额:<span className="font-semibold text-zinc-900">{totalAmountPreview}</span></div>
<div
className={`mt-2 rounded-md border px-2 py-1 ${
detail.budgetPool
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}`}
data-testid="purchase-budget-hint"
>
{resolvePurchaseBudgetHint(detail)}
</div>
</div>
</SectionPanel>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-zinc-900">采购明细</h4>
<Button data-testid="purchase-add-item" type="button" variant="secondary" disabled={!canEditDraft} onClick={() => setItems((current) => [...current, emptyDraftItem()])}>
新增明细
</Button>
</div>
<div className="space-y-3 md:hidden">
{items.length ? (
items.map((item, index) => (
<div key={item.id} className="rounded-lg border border-zinc-200 bg-white p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">明细 {index + 1}</div>
<div className="text-sm font-medium text-zinc-900">{moneyPreview(item.quantity, item.unitPrice)}</div>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs text-zinc-500">描述</label>
<Input
data-testid={`purchase-item-description-mobile-${item.id}`}
value={item.description}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, description: event.target.value } : entry,
),
)
}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-zinc-500">规格</label>
<Input
value={item.specification}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, specification: event.target.value } : entry,
),
)
}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs text-zinc-500">数量</label>
<Input
data-testid={`purchase-item-quantity-mobile-${item.id}`}
value={item.quantity}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, quantity: event.target.value } : entry,
),
)
}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-zinc-500">单价</label>
<Input
data-testid={`purchase-item-unit-price-mobile-${item.id}`}
value={item.unitPrice}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, unitPrice: event.target.value } : entry,
),
)
}
/>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input
type="checkbox"
checked={item.isAssetCandidate}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, isAssetCandidate: event.target.checked } : entry,
),
)
}
/>
资产候选
</label>
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditDraft}
onClick={() => setItems((current) => current.filter((entry) => entry.id !== item.id))}
>
删除
</Button>
</div>
</div>
</div>
))
) : (
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">
暂无明细,请先新增至少一条采购项。
</div>
)}
</div>
<div className="hidden rounded-lg border border-zinc-200 md:block">
<Table>
<TableHeader>
<tr>
<TableHead>描述</TableHead>
<TableHead>规格</TableHead>
<TableHead>数量</TableHead>
<TableHead>单价</TableHead>
<TableHead>金额</TableHead>
<TableHead>资产候选</TableHead>
<TableHead className="w-20">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{items.length ? (
items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<Input
data-testid={`purchase-item-description-${item.id}`}
value={item.description}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, description: event.target.value } : entry,
),
)
}
/>
</TableCell>
<TableCell>
<Input
value={item.specification}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, specification: event.target.value } : entry,
),
)
}
/>
</TableCell>
<TableCell>
<Input
data-testid={`purchase-item-quantity-${item.id}`}
value={item.quantity}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, quantity: event.target.value } : entry,
),
)
}
/>
</TableCell>
<TableCell>
<Input
data-testid={`purchase-item-unit-price-${item.id}`}
value={item.unitPrice}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, unitPrice: event.target.value } : entry,
),
)
}
/>
</TableCell>
<TableCell>{moneyPreview(item.quantity, item.unitPrice)}</TableCell>
<TableCell>
<input
type="checkbox"
checked={item.isAssetCandidate}
disabled={!canEditDraft}
onChange={(event) =>
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, isAssetCandidate: event.target.checked } : entry,
),
)
}
/>
</TableCell>
<TableCell>
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditDraft}
onClick={() => setItems((current) => current.filter((entry) => entry.id !== item.id))}
>
删除
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={7}>
暂无明细,请先新增至少一条采购项。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-base font-semibold text-zinc-900">材料证据</h4>
<p id="purchase-attachment-format-hint" className="text-sm text-zinc-500">
用于报价、订单、验收或情况说明;支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。
</p>
</div>
<div className="flex min-w-0 items-center gap-3">
<input
data-testid="purchase-attachment-file-input"
hidden
ref={attachmentInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp"
aria-describedby="purchase-attachment-format-hint"
disabled={!canEditDraft || uploadMutation.isPending}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
setSelectedAttachmentName(file.name);
uploadMutation.mutate(file);
}
event.currentTarget.value = "";
}}
/>
<span
className="hidden max-w-[220px] truncate text-xs text-zinc-500 sm:inline"
data-testid="purchase-attachment-selected-name"
>
{uploadMutation.isPending
? `正在上传:${uploadMutation.variables?.name ?? selectedAttachmentName}`
: selectedAttachmentName || "未选择文件"}
</span>
<Button
data-testid="purchase-attachment-upload"
type="button"
size="sm"
variant="secondary"
disabled={!canEditDraft || uploadMutation.isPending}
onClick={() => attachmentInputRef.current?.click()}
>
{uploadMutation.isPending ? "上传中" : "上传附件"}
</Button>
</div>
</div>
<div className="space-y-3 md:hidden">
{detailAttachments.length ? (
detailAttachments.map((attachment) => (
<div key={attachment.id} className="rounded-lg border border-zinc-200 bg-white p-4">
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
<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">{attachment.uploader?.name ?? "-"}</div>
</div>
<div>
<div className="text-zinc-400">时间</div>
<div className="mt-0.5 text-zinc-700">
{formatDisplayDateTime(attachment.createdAt)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
data-testid={`purchase-attachment-download-mobile-${attachment.id}`}
type="button"
size="sm"
variant="secondary"
disabled={attachment.governanceStatus === "quarantined"}
title={attachment.governanceStatus === "quarantined" ? "附件已隔离,无法下载" : undefined}
onClick={() => void handleDownloadAttachment(attachment)}
>
下载
</Button>
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditDraft || deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
删除
</Button>
</div>
</div>
))
) : (
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无附件</div>
)}
</div>
<div className="hidden rounded-lg border border-zinc-200 md:block">
<Table>
<TableHeader>
<tr>
<TableHead>文件名</TableHead>
<TableHead>上传人</TableHead>
<TableHead>时间</TableHead>
<TableHead className="w-40">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{detailAttachments.length ? (
detailAttachments.map((attachment) => (
<TableRow key={attachment.id}>
<TableCell>
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
<div className="text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</TableCell>
<TableCell>{attachment.uploader?.name ?? "-"}</TableCell>
<TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
data-testid={`purchase-attachment-download-desktop-${attachment.id}`}
type="button"
size="sm"
variant="secondary"
disabled={attachment.governanceStatus === "quarantined"}
title={attachment.governanceStatus === "quarantined" ? "附件已隔离,无法下载" : undefined}
onClick={() => void handleDownloadAttachment(attachment)}
>
下载
</Button>
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditDraft || deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={4}>
暂无附件
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</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">
<div>
<h4 className="text-base font-semibold text-zinc-900">PO/合同归档</h4>
<p className="text-sm text-zinc-500">仅已通过采购单可归档,当前阶段保留编号与文件元数据锚点。</p>
</div>
{archiveOrder ? (
<div className="text-right text-xs text-zinc-500">
<div>归档时间:{formatDisplayDateTime(archiveOrder.archivedAt)}</div>
<div>归档人:{archiveOrder.archivedByEmployee?.name ?? "-"}</div>
</div>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">PO号</label>
<Input
data-testid="purchase-archive-po-no"
value={archivePoNo}
disabled={!canArchiveOrder || archiveMutation.isPending}
onChange={(event) => setArchivePoNo(event.target.value)}
placeholder="请输入 PO 号"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">合同号</label>
<Input
data-testid="purchase-archive-contract-no"
value={archiveContractNo}
disabled={!canArchiveOrder || archiveMutation.isPending}
onChange={(event) => setArchiveContractNo(event.target.value)}
placeholder="可选"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
<span>已记录归档文件:{archiveOrder?.archivedFiles.length ?? 0} 个</span>
<Button
type="button"
size="sm"
data-testid="purchase-archive-save"
disabled={!canArchiveOrder || archiveMutation.isPending}
onClick={() => archiveMutation.mutate()}
>
{archiveMutation.isPending ? "保存中..." : "保存归档"}
</Button>
</div>
{archiveOrder?.archivedFiles.length ? (
<div className="space-y-2 text-sm text-zinc-600">
{archiveOrder.archivedFiles.map((file, index) => (
<div key={`${file.storagePath ?? file.originalName ?? index}`} className="rounded border border-zinc-200 px-3 py-2">
<div className="font-medium text-zinc-900">{file.originalName ?? `归档文件 ${index + 1}`}</div>
<div className="text-xs text-zinc-500">{file.mimeType ?? "application/octet-stream"} / {file.fileSize} bytes</div>
</div>
))}
</div>
) : null}
</div>
) : 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}
</div>
)}
<ActionConfirmDialog
open={Boolean(submitPrecheckConfirm)}
onOpenChange={(open) => {
if (!open) setSubmitPrecheckConfirm(null);
}}
kind="precheck"
targetName={detail ? resolvePurchaseDisplayCode(detail) : "采购申请"}
description={submitPrecheckConfirm}
onConfirm={async () => {
submitMutation.mutate();
}}
/>
<AttachmentDeleteConfirmDialog
open={Boolean(deleteAttachmentTarget)}
targetName={deleteAttachmentTarget?.originalName}
pending={deleteAttachmentMutation.isPending}
onOpenChange={(open) => {
if (!open) setDeleteAttachmentTarget(null);
}}
onConfirm={(deleteReason) => {
if (!deleteAttachmentTarget) return;
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason });
}}
/>
</ListWorkspace>
);
}