feat(08-05-business-detail-routing): 完成采购与资产独立路由

这个提交包含在:
2026-08-07 11:00:56 +08:00
父节点 969b426e64
当前提交 1118bfadd7
共修改 24 个文件,包含 891 行新增和 196 行删除
+112 -66
查看文件
@@ -1,8 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import {
archivePurchaseOrder,
createPurchaseRequest,
@@ -57,6 +58,16 @@ 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,
@@ -297,24 +308,52 @@ function CompactBusinessCode({ value, copyable = true }: { value: string; copyab
}
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 [status, setStatus] = useState<PurchaseRequestStatus | "">("draft");
const [mine, setMine] = useState(true);
const [supplierFilter, setSupplierFilter] = useState("");
const [legalEntityFilter, setLegalEntityFilter] = useState("");
const [costCenterFilter, setCostCenterFilter] = useState("");
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 [page, setPage] = useState(1);
const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? "");
const [hydratedDetailId, setHydratedDetailId] = useState("");
const [formError, setFormError] = useState("");
@@ -348,6 +387,26 @@ export function PurchaseRequestsPage() {
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"],
@@ -365,16 +424,19 @@ export function PurchaseRequestsPage() {
});
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({
@@ -389,41 +451,17 @@ export function PurchaseRequestsPage() {
});
const detailQuery = useQuery({
enabled: Boolean(selectedId),
enabled: isPurchaseDetailMode && Boolean(selectedId),
queryKey: ["purchase-requests", "detail", selectedId],
queryFn: () => fetchPurchaseRequestDetail(selectedId),
});
const archiveOrderQuery = useQuery({
enabled: Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
enabled: isPurchaseDetailMode && Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
queryKey: ["purchase-requests", "archive-order", selectedId],
queryFn: () => fetchPurchaseOrderArchive(selectedId),
});
useEffect(() => {
const searchSelectedId = searchParams.get("id") ?? "";
if (searchSelectedId && searchSelectedId !== selectedId) {
setSelectedId(searchSelectedId);
}
}, [searchParams, 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;
@@ -533,11 +571,12 @@ export function PurchaseRequestsPage() {
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("采购草稿已创建");
navigate(buildPurchaseRequestDetailPath(data.purchaseRequest.id), {
state: { from: listSource },
});
},
onError: (error) => {
const message = resolveError(error);
@@ -881,22 +920,19 @@ export function PurchaseRequestsPage() {
);
const detailEvidenceHint = detail ? resolvePurchaseEvidenceHint(detail) : null;
const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : "";
const isPurchaseDetailMode = Boolean(selectedId);
const openPurchaseDetail = (id: string) => {
runGuarded(() => {
setSelectedId(id);
setHydratedDetailId("");
navigate(buildPurchaseRequestDetailPath(id), {
state: { from: listSource },
});
});
};
const returnToPurchaseList = () => {
runGuarded(() => {
setSelectedId("");
setHydratedDetailId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
navigate(backSource, { replace: true });
});
};
const detailAccessDenied = detailQuery.isError && isApiError(detailQuery.error) && [403, 404].includes(detailQuery.error.status);
return (
<ListWorkspace>
@@ -907,7 +943,7 @@ export function PurchaseRequestsPage() {
<HeaderActionGroup>
{isPurchaseDetailMode ? (
<Button type="button" variant="secondary" onClick={returnToPurchaseList}>
返回列表
{backLabel}
</Button>
) : (
<>
@@ -960,8 +996,7 @@ export function PurchaseRequestsPage() {
className="select-std"
value={supplierFilter}
onChange={(event) => {
setSupplierFilter(event.target.value);
setPage(1);
updateListSearch({ supplierId: event.target.value || null }, { resetPage: true });
}}
>
<option value="">全部供应商</option>
@@ -977,11 +1012,16 @@ export function PurchaseRequestsPage() {
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);
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>
@@ -996,8 +1036,7 @@ export function PurchaseRequestsPage() {
className="select-std"
value={costCenterFilter}
onChange={(event) => {
setCostCenterFilter(event.target.value);
setPage(1);
updateListSearch({ costCenterId: event.target.value || null }, { resetPage: true });
}}
>
<option value="">全部成本中心</option>
@@ -1016,8 +1055,7 @@ export function PurchaseRequestsPage() {
type="button"
variant={mine ? "default" : "secondary"}
onClick={() => {
setMine(true);
setPage(1);
updateListSearch({ scope: null }, { resetPage: true });
}}
>
我相关
@@ -1028,8 +1066,7 @@ export function PurchaseRequestsPage() {
type="button"
variant={!mine ? "default" : "secondary"}
onClick={() => {
setMine(false);
setPage(1);
updateListSearch({ scope: "all" }, { resetPage: true });
}}
>
全范围
@@ -1043,8 +1080,10 @@ export function PurchaseRequestsPage() {
data-testid="purchase-status-tabs"
value={status}
onValueChange={(value) => {
setStatus(value as PurchaseRequestStatus | "");
setPage(1);
updateListSearch(
{ status: value === "draft" ? null : value || "all" },
{ resetPage: true },
);
}}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
@@ -1216,7 +1255,7 @@ export function PurchaseRequestsPage() {
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
onPageChange={(nextPage) => updateListSearch({ page: nextPage > 1 ? String(nextPage) : null })}
testIdPrefix="purchase-requests"
/>
</>
@@ -1224,10 +1263,17 @@ export function PurchaseRequestsPage() {
<div className="card-std space-y-5 p-4 md:p-5">
{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>
{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 ? (