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

1548 行
65 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
archivePurchaseOrder,
createPurchaseRequest,
deletePurchaseAttachment,
fetchPurchaseOrderArchive,
fetchPurchaseRequestDetail,
fetchPurchaseRequestErpTrack,
fetchPurchaseRequests,
issuePurchaseAttachmentSignedUrl,
precheckPurchaseRequest,
requeuePurchaseRequestErpSync,
submitPurchaseRequest,
updatePurchaseRequest,
uploadPurchaseAttachment,
withdrawPurchaseRequest,
type PurchaseErpBinding,
type PurchaseRequestDetail,
type PurchaseRequestErpRequeueTarget,
type PurchaseRequestErpTrack,
type PurchaseRequestItemPayload,
type PurchaseRequestStatus,
} from "../api/purchaseRequests";
import {
fetchCostCenters,
fetchDocumentCategories,
fetchLegalEntities,
fetchSuppliers,
} from "../api/masterData";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
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 { SectionPanel } from "../components/patterns/SectionPanel";
import { buildPrecheckDialogMessage } from "../lib/precheck";
const PAGE_SIZE = 20;
type DraftItem = {
id: string;
description: string;
specification: string;
quantity: string;
unitPrice: string;
isAssetCandidate: boolean;
};
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
}
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: "已取消" },
];
}
type PurchaseErpStageKey = keyof PurchaseRequestErpTrack["stages"];
const PURCHASE_ERP_STAGE_META: Record<
PurchaseErpStageKey,
{ title: string; target: PurchaseRequestErpRequeueTarget }
> = {
commitmentJournal: { title: "Commitment", target: "commitment_journal" },
accrualJournal: { title: "Accrual", target: "accrual_journal" },
archiveSummary: { title: "PO/合同同步", target: "archive_summary" },
};
function formatStageReason(reason: string | null) {
switch (reason) {
case "purchase_request_not_approved":
return "当前采购单尚未处于已通过状态";
case "purchase_order_archive_missing":
return "尚未完成 PO/合同归档";
default:
return reason ?? null;
}
}
function resolveStageTimestamp(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
const poBinding = stage.bindings?.po ?? null;
const contractBinding = stage.bindings?.contract ?? null;
return (
stage.outbox?.updatedAt ??
stage.outbox?.processedAt ??
binding?.lastSyncedAt ??
poBinding?.lastSyncedAt ??
contractBinding?.lastSyncedAt ??
null
);
}
function resolveStageExternalCode(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
return binding?.externalCode ?? stage.bindings?.po?.externalCode ?? stage.bindings?.contract?.externalCode ?? null;
}
function resolveStageExternalId(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
return binding?.externalId ?? stage.bindings?.po?.externalId ?? stage.bindings?.contract?.externalId ?? null;
}
function resolveStageSummary(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const preview = stage.preview as {
documentNo?: string | null;
requestNo?: string | null;
poNo?: string | null;
contractNo?: string | null;
totalAmount?: string | null;
supplier?: {
code?: string | null;
};
dimensions?: {
legalEntityCode?: string | null;
costCenterCode?: string | null;
supplierCode?: string | null;
};
};
return {
documentNo:
preview.documentNo ??
preview.requestNo ??
(stageKey === "archiveSummary" ? preview.poNo ?? null : null),
totalAmount: preview.totalAmount ?? null,
legalEntityCode: preview.dimensions?.legalEntityCode ?? null,
costCenterCode: preview.dimensions?.costCenterCode ?? null,
supplierCode: preview.dimensions?.supplierCode ?? preview.supplier?.code ?? null,
poNo: preview.poNo ?? null,
contractNo: preview.contractNo ?? null,
};
}
function resolveStageAction(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
if (!stage.eligible) {
return null;
}
if (stage.status === "failed" || stage.status === "dead_letter") {
return {
label: stageKey === "archiveSummary" ? "重试同步摘要" : "重试同步",
target: PURCHASE_ERP_STAGE_META[stageKey].target,
};
}
if (stage.status === "not_triggered" || stage.status === "skipped") {
return {
label: stageKey === "archiveSummary" ? "补发摘要" : "补发",
target: PURCHASE_ERP_STAGE_META[stageKey].target,
};
}
return null;
}
export function PurchaseRequestsPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const canCreate = hasPermission(permissions, "procurement.create");
const canManage = hasPermission(permissions, "procurement.manage");
const canIntegrationManage = hasPermission(permissions, "integration.manage");
const canViewAll =
hasPermission(permissions, "procurement.view_all") ||
canManage;
const [status, setStatus] = useState<PurchaseRequestStatus | "">("draft");
const [mine, setMine] = useState(true);
const [supplierFilter, setSupplierFilter] = useState("");
const [legalEntityFilter, setLegalEntityFilter] = useState("");
const [costCenterFilter, setCostCenterFilter] = useState("");
const [page, setPage] = useState(1);
const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? "");
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 [expectedDeliveryDate, setExpectedDeliveryDate] = useState("");
const [items, setItems] = useState<DraftItem[]>([]);
const [archivePoNo, setArchivePoNo] = useState("");
const [archiveContractNo, setArchiveContractNo] = useState("");
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({
queryKey: ["purchase-requests", "document-categories"],
queryFn: () => fetchDocumentCategories({ entityType: "purchase_request", enabled: true }),
});
const listQuery = useQuery({
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: Boolean(selectedId),
queryKey: ["purchase-requests", "detail", selectedId],
queryFn: () => fetchPurchaseRequestDetail(selectedId),
});
const archiveOrderQuery = useQuery({
enabled: Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
queryKey: ["purchase-requests", "archive-order", selectedId],
queryFn: () => fetchPurchaseOrderArchive(selectedId),
});
const erpTrackQuery = useQuery({
enabled:
canIntegrationManage &&
Boolean(selectedId) &&
(detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
queryKey: ["purchase-requests", "erp-track", selectedId],
queryFn: () => fetchPurchaseRequestErpTrack(selectedId),
});
useEffect(() => {
const searchSelectedId = searchParams.get("id") ?? "";
if (searchSelectedId && searchSelectedId !== selectedId) {
setSelectedId(searchSelectedId);
}
}, [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) {
return;
}
if (selectedId === currentId) {
return;
}
const next = new URLSearchParams(searchParams);
if (selectedId) {
next.set("id", selectedId);
} else {
next.delete("id");
}
setSearchParams(next, { replace: true });
}, [searchParams, selectedId, setSearchParams]);
useEffect(() => {
if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) {
return;
}
setTitle(detailQuery.data.title || "");
setReason(detailQuery.data.reason || "");
setCurrencyCode(detailQuery.data.currencyCode || "CNY");
setLegalEntityId(detailQuery.data.legalEntityId || "");
setCostCenterId(detailQuery.data.costCenterId || "");
setSupplierId(detailQuery.data.supplierId || "");
setDocumentCategoryId(detailQuery.data.documentCategoryId || "");
setExpectedDeliveryDate(detailQuery.data.expectedDeliveryDate || "");
setItems(
detailQuery.data.items.map((item) => ({
id: item.id,
description: item.description,
specification: item.specification || "",
quantity: item.quantity,
unitPrice: item.unitPrice,
isAssetCandidate: item.isAssetCandidate,
})),
);
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 totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
const purchaseItems = listQuery.data?.items ?? [];
const totalAmountPreview = useMemo(
() => items.reduce((sum, item) => sum + Number(moneyPreview(item.quantity, item.unitPrice)), 0).toFixed(2),
[items],
);
const createMutation = useMutation({
mutationFn: () => createPurchaseRequest(),
onSuccess: async (data) => {
setSelectedId(data.purchaseRequest.id);
setHydratedDetailId("");
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] });
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail"] });
toast.success("采购草稿已创建");
},
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;
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,
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("");
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"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
setFormError("");
toast.success(
data.precheckDecision === "manual_review"
? `采购申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险`
: data.warnings.includes("missing_budget_pool")
? "采购申请已提交(未匹配预算池)"
: "采购申请已提交",
);
},
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] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
setFormError("");
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] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
toast.success("PO/合同归档已保存");
},
onError: (error) => {
const message = resolveError(error);
toast.error(message);
},
});
const requeueErpMutation = useMutation({
mutationFn: async (payload: { target: PurchaseRequestErpRequeueTarget; label: string }) => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return {
label: payload.label,
response: await requeuePurchaseRequestErpSync(selectedId, { targets: [payload.target] }),
};
},
onSuccess: async ({ label, response }) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
const firstItem = response.items[0];
if (firstItem?.duplicated) {
toast.success(`${label} 已存在待处理记录`);
return;
}
if (firstItem?.requeued) {
toast.success(`${label} 已重新入队`);
return;
}
toast.success(`${label} 已加入同步队列`);
},
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: string) => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return deletePurchaseAttachment(selectedId, attachmentId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] });
toast.success("附件已删除");
},
onError: (error) => {
const message = resolveError(error);
setFormError(message);
toast.error(message);
},
});
async function handleDownloadAttachment(attachmentId: string) {
if (!selectedId) {
return;
}
try {
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachmentId);
window.open(response.signedUrl, "_blank", "noopener,noreferrer");
} catch (error) {
const message = resolveError(error);
setFormError(message);
toast.error(message);
}
}
const detail = detailQuery.data;
const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null;
const handleSubmitAction = async () => {
if (!selectedId) {
return;
}
try {
setFormError("");
validateBeforeSubmit();
await updatePurchaseRequest(selectedId, buildPayload());
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") && !window.confirm(message)) {
return;
}
submitMutation.mutate();
} catch (error) {
const message = resolveError(error);
setFormError(message);
toast.error(message);
}
};
const erpTrack = erpTrackQuery.data ?? null;
const canRetryErp = Boolean(canIntegrationManage && erpTrack?.permissions.canRetry);
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
const canSubmit = Boolean(detail?.permissions.canSubmit);
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage);
return (
<div className="space-y-6">
<PageHeader
title="采购申请"
subtitle="支持草稿、附件、提交、撤回与经理单步审批的轻采购流程。"
actions={
canCreate ? (
<Button
data-testid="purchase-requests-create"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建采购"}
</Button>
) : undefined
}
/>
{formError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</p>
) : null}
<div className="card-std space-y-4 p-6">
<div className="grid gap-3 md:grid-cols-5">
<select
className="select-std"
value={status}
onChange={(event) => {
setStatus(event.target.value as PurchaseRequestStatus | "");
setPage(1);
}}
>
{statusOptions().map((item) => (
<option key={item.label} value={item.value}>
{item.label}
</option>
))}
</select>
<select
className="select-std"
value={supplierFilter}
onChange={(event) => {
setSupplierFilter(event.target.value);
setPage(1);
}}
>
<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;
setLegalEntityFilter(nextValue);
if (costCenterFilter && !filterCostCenters.some((item) => item.id === costCenterFilter && item.legalEntityId === nextValue)) {
setCostCenterFilter("");
}
setPage(1);
}}
>
<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) => {
setCostCenterFilter(event.target.value);
setPage(1);
}}
>
<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
type="button"
variant={mine ? "default" : "secondary"}
onClick={() => {
setMine(true);
setPage(1);
}}
>
仅自己
</Button>
<Button
type="button"
variant={!mine ? "default" : "secondary"}
onClick={() => {
setMine(false);
setPage(1);
}}
>
全范围
</Button>
</div>
) : null}
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">采购单列表</h3>
<span className="text-sm text-zinc-500">共 {listQuery.data?.total ?? 0} 条</span>
</div>
{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 ? (
<>
<div className="space-y-3 md:hidden">
{purchaseItems.length ? (
purchaseItems.map((item) => (
<button
data-testid="purchase-request-mobile-row"
key={item.id}
type="button"
className={`w-full rounded-lg border p-3 text-left ${
item.id === selectedId ? "border-zinc-400 bg-zinc-50" : "border-zinc-200 bg-white"
}`}
onClick={() => {
setSelectedId(item.id);
setHydratedDetailId("");
}}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">
{item.requestNo || item.title || item.id}
</div>
<div className="mt-1 truncate text-xs text-zinc-500">{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 truncate text-zinc-700">{item.applicant?.name ?? "-"}</div>
</div>
<div>
<div className="text-zinc-400">金额</div>
<div className="mt-0.5 font-medium text-zinc-900">{item.totalAmount}</div>
</div>
</div>
</button>
))
) : (
<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 data-testid="purchase-requests-table">
<TableHeader>
<tr>
<TableHead>单据</TableHead>
<TableHead>申请人</TableHead>
<TableHead>状态</TableHead>
<TableHead>金额</TableHead>
</tr>
</TableHeader>
<TableBody>
{purchaseItems.length ? (
purchaseItems.map((item) => (
<TableRow
key={item.id}
className={item.id === selectedId ? "bg-zinc-50" : undefined}
onClick={() => {
setSelectedId(item.id);
setHydratedDetailId("");
}}
>
<TableCell className="cursor-pointer">
<div className="font-medium text-zinc-900">{item.requestNo || item.title || item.id}</div>
<div className="text-xs text-zinc-500">{item.title || "未命名采购申请"}</div>
</TableCell>
<TableCell>{item.applicant?.name ?? "-"}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell>{item.totalAmount}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={4}>
暂无采购申请
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
) : null}
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
testIdPrefix="purchase-requests"
/>
</div>
<div className="card-std space-y-5 p-6">
{!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 ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(detailQuery.error)}
</p>
) : null}
{selectedId && detail ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900">{detail.requestNo ?? "采购草稿"}</h3>
<p className="text-sm text-zinc-500">申请人:{detail.applicant?.name ?? "-"}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge data-testid="purchase-detail-status" status={detail.status} />
<Button
data-testid="purchase-save"
type="button"
variant="secondary"
disabled={!canEditDraft || saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? "保存中..." : "保存草稿"}
</Button>
<Button
data-testid="purchase-submit"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => void handleSubmitAction()}
>
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
<Button
data-testid="purchase-withdraw"
type="button"
variant="secondary"
disabled={!canWithdraw || withdrawMutation.isPending}
onClick={() => withdrawMutation.mutate()}
>
{withdrawMutation.isPending ? "撤回中..." : "撤回"}
</Button>
</div>
</div>
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<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>
<Input data-testid="purchase-expected-delivery-date" type="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 p-4 text-sm text-zinc-600">
<div>预算池:{detail.budgetPool?.name ?? "待提交时自动解析"}</div>
<div>待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
<div>总金额:<span className="font-semibold text-zinc-900">{totalAmountPreview}</span></div>
</div>
<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">
<h4 className="text-base font-semibold text-zinc-900">附件</h4>
<input
type="file"
disabled={!canEditDraft || uploadMutation.isPending}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
uploadMutation.mutate(file);
}
event.currentTarget.value = "";
}}
/>
</div>
<div className="space-y-3 md:hidden">
{detail.attachments.length ? (
detail.attachments.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>
<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">
{attachment.createdAt ? new Date(attachment.createdAt).toLocaleString() : "-"}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
下载
</Button>
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditDraft || deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
>
删除
</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>
{detail.attachments.length ? (
detail.attachments.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>
</TableCell>
<TableCell>{attachment.uploader?.name ?? "-"}</TableCell>
<TableCell>{attachment.createdAt ? new Date(attachment.createdAt).toLocaleString() : "-"}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
下载
</Button>
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditDraft || deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={4}>
暂无附件
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{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>归档时间:{archiveOrder.archivedAt ? new Date(archiveOrder.archivedAt).toLocaleString() : "-"}</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}
{showArchiveSection && canIntegrationManage ? (
<SectionPanel
title="ERP 同步状态"
description="展示 Commitment / Accrual / PO/合同摘要三段同步状态,失败或未触发时可在此补发或重试。"
>
{erpTrackQuery.isLoading ? (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 同步轨道加载中...</p>
) : erpTrackQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(erpTrackQuery.error)}</p>
) : erpTrack ? (
<div className="grid gap-3 xl:grid-cols-3">
{(Object.entries(PURCHASE_ERP_STAGE_META) as Array<
[PurchaseErpStageKey, (typeof PURCHASE_ERP_STAGE_META)[PurchaseErpStageKey]]
>).map(([stageKey, meta]) => {
const stage = erpTrack.stages[stageKey];
const summary = resolveStageSummary(stageKey, stage);
const action = canRetryErp ? resolveStageAction(stageKey, stage) : null;
const stageTime = resolveStageTimestamp(stage);
const externalCode = resolveStageExternalCode(stage);
const externalId = resolveStageExternalId(stage);
const reasonText = formatStageReason(stage.reason);
const isSubmittingAction = requeueErpMutation.isPending && requeueErpMutation.variables?.target === action?.target;
return (
<div key={stageKey} className="space-y-3 rounded-lg border border-zinc-200 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">{meta.title}</div>
<div className="text-xs text-zinc-500">
{stageTime ? `最近更新时间:${new Date(stageTime).toLocaleString()}` : "尚无同步记录"}
</div>
</div>
<StatusBadge status={stage.status} />
</div>
<div className="space-y-1 text-xs text-zinc-600">
<div>单号:{summary.documentNo ?? "-"}</div>
<div>金额:{summary.totalAmount ?? "-"}</div>
<div>法体 / 成本中心:{summary.legalEntityCode ?? "-"} / {summary.costCenterCode ?? "-"}</div>
<div>供应商:{summary.supplierCode ?? "-"}</div>
{stageKey !== "commitmentJournal" ? (
<div>PO / 合同:{summary.poNo ?? "-"} / {summary.contractNo ?? "-"}</div>
) : null}
</div>
{reasonText ? (
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-500">{reasonText}</div>
) : null}
{stage.outbox?.lastError ? (
<div className="rounded bg-red-50 px-2 py-1 text-xs text-red-600">{stage.outbox.lastError}</div>
) : null}
{externalCode || externalId ? (
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-600">
ERP 编号:{externalCode ?? "-"}
{externalId ? ` · ${externalId}` : ""}
</div>
) : null}
{action ? (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="secondary"
disabled={requeueErpMutation.isPending}
onClick={() => requeueErpMutation.mutate({ target: action.target, label: meta.title })}
>
{isSubmittingAction ? "处理中..." : action.label}
</Button>
</div>
) : null}
</div>
);
})}
</div>
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
)}
</SectionPanel>
) : null}
<AiSuggestionCard targetType="purchase_request" targetId={detail.id} />
</>
) : null}
</div>
</div>
</div>
);
}