2015 行
86 KiB
TypeScript
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>
|
|
);
|
|
}
|