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

这个提交包含在:
2026-08-07 11:00:56 +08:00
父节点 969b426e64
当前提交 1118bfadd7
共修改 24 个文件,包含 891 行新增和 196 行删除
+12 -1
查看文件
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { searchGlobal, type GlobalSearchHit } from "../api/search";
import { isApiError } from "../api/types";
import { buildAssetDetailPath, buildPurchaseRequestDetailPath } from "../lib/businessDetailRoutes";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
@@ -88,6 +89,16 @@ function resolveBrowseHref(groupKey: SearchGroupKey, items: GlobalSearchHit[]) {
return null;
}
function resolveSearchHitHref(item: GlobalSearchHit) {
if (item.entityType === "purchase_request") {
return buildPurchaseRequestDetailPath(item.entityId);
}
if (item.entityType === "asset") {
return buildAssetDetailPath(item.entityId);
}
return item.href;
}
export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const navigate = useNavigate();
const [keyword, setKeyword] = useState("");
@@ -158,7 +169,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
key={`${item.entityType}-${item.entityId}`}
type="button"
className="w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"
onClick={() => closeAndNavigate(item.href)}
onClick={() => closeAndNavigate(resolveSearchHitHref(item))}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.title}</div>
+3
查看文件
@@ -15,6 +15,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/wecom-approval-archive", title: "企微审批历史", group: "业务" },
{ path: "/projects", title: "项目", group: "业务" },
{ path: "/contracts-registry", title: "合同", group: "业务" },
{ path: "/purchase-requests/", title: "采购详情", group: "业务" },
{ path: "/purchase-requests", title: "采购", group: "业务" },
{ path: "/travel-requests", title: "差旅申请", group: "更多" },
{ path: "/budgets", title: "预算管理", group: "财务" },
@@ -27,6 +28,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/reimbursements/", title: "报销详情", group: "工作" },
{ path: "/reimbursements", title: "报销", group: "工作" },
{ path: "/leave-requests", title: "请假记录", group: "业务" },
{ path: "/assets/new", title: "新增资产", group: "业务" },
{ path: "/assets/", title: "资产详情", group: "业务" },
{ path: "/assets", title: "资产", group: "业务" },
{ path: "/workflow/tasks/", title: "审批详情", group: "工作" },
{ path: "/workflow/tasks", title: "待我审批", group: "工作" },
+200
查看文件
@@ -0,0 +1,200 @@
import type { PurchaseRequestStatus } from "../api/purchaseRequests";
import type { AssetSensitivityLevel, AssetStatus, AssetType } from "../api/assets";
const PURCHASE_STATUS_VALUES = new Set<PurchaseRequestStatus>([
"draft",
"reviewing",
"approved",
"rejected",
"cancelled",
]);
const ASSET_STATUS_VALUES = new Set<AssetStatus>([
"in_stock",
"in_use",
"under_repair",
"retired",
"archived",
]);
const ASSET_TYPE_VALUES = new Set<AssetType>([
"fixed_asset",
"electronic_document",
"electronic_system",
"code_repository",
"backup_media",
]);
const ASSET_SENSITIVITY_VALUES = new Set<AssetSensitivityLevel>([
"public_candidate",
"internal",
"confidential",
"strictly_confidential",
]);
export const ASSET_CREATE_PATH = "/assets/new";
function normalizedEntityId(value: string) {
return value.trim();
}
function optionalSearchValue(searchParams: URLSearchParams, key: string) {
return (searchParams.get(key) ?? "").trim();
}
function positivePage(value: string | null) {
if (!value || !/^[1-9]\d*$/.test(value)) {
return 1;
}
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : 1;
}
function appendOptional(target: URLSearchParams, key: string, value: string) {
if (value) {
target.set(key, value);
}
}
export function buildPurchaseRequestDetailPath(purchaseRequestId: string) {
const normalized = normalizedEntityId(purchaseRequestId);
return normalized ? `/purchase-requests/${encodeURIComponent(normalized)}` : "/purchase-requests";
}
export function buildAssetDetailPath(assetId: string) {
const normalized = normalizedEntityId(assetId);
return normalized ? `/assets/${encodeURIComponent(normalized)}` : "/assets";
}
export function readPurchaseListFilters(
searchParams: URLSearchParams,
canViewAll: boolean,
): {
status: PurchaseRequestStatus | "";
mine: boolean;
supplierId: string;
legalEntityId: string;
costCenterId: string;
page: number;
} {
const rawStatus = searchParams.get("status");
const status = rawStatus === "all"
? ""
: rawStatus && PURCHASE_STATUS_VALUES.has(rawStatus as PurchaseRequestStatus)
? rawStatus as PurchaseRequestStatus
: "draft";
const scope = searchParams.get("scope");
return {
status,
mine: !(canViewAll && scope === "all"),
supplierId: optionalSearchValue(searchParams, "supplierId"),
legalEntityId: optionalSearchValue(searchParams, "legalEntityId"),
costCenterId: optionalSearchValue(searchParams, "costCenterId"),
page: positivePage(searchParams.get("page")),
};
}
export function normalizePurchaseListSearchParams(searchParams: URLSearchParams) {
const normalized = new URLSearchParams();
const rawStatus = searchParams.get("status");
if (rawStatus === "all" || (rawStatus && PURCHASE_STATUS_VALUES.has(rawStatus as PurchaseRequestStatus))) {
normalized.set("status", rawStatus);
}
const rawScope = searchParams.get("scope");
if (rawScope === "own" || rawScope === "all") {
normalized.set("scope", rawScope);
}
appendOptional(normalized, "supplierId", optionalSearchValue(searchParams, "supplierId"));
appendOptional(normalized, "legalEntityId", optionalSearchValue(searchParams, "legalEntityId"));
appendOptional(normalized, "costCenterId", optionalSearchValue(searchParams, "costCenterId"));
const page = positivePage(searchParams.get("page"));
if (page > 1) {
normalized.set("page", String(page));
}
return normalized;
}
export function readAssetListFilters(
searchParams: URLSearchParams,
canViewAll: boolean,
): {
q: string;
status: AssetStatus | "";
assetType: AssetType | "";
sensitivity: AssetSensitivityLevel | "";
categoryId: string;
legalEntityId: string;
costCenterId: string;
assigneeId: string;
mine: boolean;
} {
const rawStatus = searchParams.get("status");
const rawAssetType = searchParams.get("assetType");
const rawSensitivity = searchParams.get("sensitivity");
const scope = searchParams.get("scope");
return {
q: searchParams.get("q") ?? "",
status: rawStatus && ASSET_STATUS_VALUES.has(rawStatus as AssetStatus) ? rawStatus as AssetStatus : "",
assetType: rawAssetType && ASSET_TYPE_VALUES.has(rawAssetType as AssetType) ? rawAssetType as AssetType : "",
sensitivity: rawSensitivity && ASSET_SENSITIVITY_VALUES.has(rawSensitivity as AssetSensitivityLevel)
? rawSensitivity as AssetSensitivityLevel
: "",
categoryId: optionalSearchValue(searchParams, "categoryId"),
legalEntityId: optionalSearchValue(searchParams, "legalEntityId"),
costCenterId: optionalSearchValue(searchParams, "costCenterId"),
assigneeId: optionalSearchValue(searchParams, "assigneeId"),
mine: scope === "own" || !(canViewAll && (scope === "all" || scope == null)),
};
}
export function normalizeAssetListSearchParams(searchParams: URLSearchParams) {
const normalized = new URLSearchParams();
appendOptional(normalized, "q", optionalSearchValue(searchParams, "q"));
const rawStatus = searchParams.get("status");
if (rawStatus && ASSET_STATUS_VALUES.has(rawStatus as AssetStatus)) {
normalized.set("status", rawStatus);
}
const rawAssetType = searchParams.get("assetType");
if (rawAssetType && ASSET_TYPE_VALUES.has(rawAssetType as AssetType)) {
normalized.set("assetType", rawAssetType);
}
const rawSensitivity = searchParams.get("sensitivity");
if (rawSensitivity && ASSET_SENSITIVITY_VALUES.has(rawSensitivity as AssetSensitivityLevel)) {
normalized.set("sensitivity", rawSensitivity);
}
appendOptional(normalized, "categoryId", optionalSearchValue(searchParams, "categoryId"));
appendOptional(normalized, "legalEntityId", optionalSearchValue(searchParams, "legalEntityId"));
appendOptional(normalized, "costCenterId", optionalSearchValue(searchParams, "costCenterId"));
appendOptional(normalized, "assigneeId", optionalSearchValue(searchParams, "assigneeId"));
const rawScope = searchParams.get("scope");
if (rawScope === "own" || rawScope === "all") {
normalized.set("scope", rawScope);
}
return normalized;
}
function listSource(pathname: string, searchParams: URLSearchParams) {
const search = searchParams.toString();
return search ? `${pathname}?${search}` : pathname;
}
export function resolvePurchaseRequestLegacyRedirect(searchParams: URLSearchParams) {
const purchaseRequestId = normalizedEntityId(searchParams.get("id") ?? "");
if (!purchaseRequestId) {
return null;
}
return {
to: buildPurchaseRequestDetailPath(purchaseRequestId),
from: listSource("/purchase-requests", normalizePurchaseListSearchParams(searchParams)),
};
}
export function resolveAssetLegacyRedirect(searchParams: URLSearchParams) {
const assetId = normalizedEntityId(searchParams.get("id") ?? "");
if (!assetId) {
return null;
}
return {
to: buildAssetDetailPath(assetId),
from: listSource("/assets", normalizeAssetListSearchParams(searchParams)),
};
}
+4 -2
查看文件
@@ -1,3 +1,5 @@
import { buildAssetDetailPath, buildPurchaseRequestDetailPath } from "./businessDetailRoutes";
type NotificationTarget = {
targetType: string | null;
targetId: string | null;
@@ -31,7 +33,7 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
}
if (item.targetType === "purchase_request") {
return `/purchase-requests?highlight=${item.targetId}`;
return buildPurchaseRequestDetailPath(item.targetId);
}
if (item.targetType === "travel_request") {
@@ -39,7 +41,7 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
}
if (item.targetType === "asset" || item.targetType === "asset_card") {
return `/assets?highlight=${item.targetId}`;
return buildAssetDetailPath(item.targetId);
}
if (item.targetType === "inventory_check") {
+17 -5
查看文件
@@ -2,22 +2,34 @@ export type PcDetailNavigationState = {
from?: string;
};
export type PcDetailSourcePolicy = "reimbursement" | "purchase-request" | "asset";
export function buildPcDetailSource(pathname: string, search: string) {
return `${pathname}${search}`;
}
export function resolvePcDetailSource(value: unknown, currentPath: string) {
export function resolvePcDetailSource(
value: unknown,
currentPath: string,
policy: PcDetailSourcePolicy = "reimbursement",
) {
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.includes("#")) {
return null;
}
const url = new URL(value, "https://hyb-oa.invalid");
const isReimbursementList = url.pathname === "/reimbursements";
const isWorkflowTask = /^\/workflow\/tasks\/[^/]+$/.test(url.pathname);
if (!isReimbursementList && !isWorkflowTask) {
const baseUrl = new URL("https://hyb-oa.invalid");
const url = new URL(value, baseUrl);
if (url.origin !== baseUrl.origin) {
return null;
}
const allowed = policy === "purchase-request"
? url.pathname === "/purchase-requests" || /^\/projects\/[^/]+$/.test(url.pathname)
: policy === "asset"
? url.pathname === "/assets"
: url.pathname === "/reimbursements" || /^\/workflow\/tasks\/[^/]+$/.test(url.pathname);
if (!allowed) return null;
const source = `${url.pathname}${url.search}`;
return source === currentPath ? null : source;
}
+1 -1
查看文件
@@ -100,7 +100,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
return "/mobile/approvals";
}
if (normalizedPath === "/assets" || normalizedPath === "/inventory-checks") {
if (normalizedPath === "/assets" || normalizedPath.startsWith("/assets/") || normalizedPath === "/inventory-checks") {
return "/mobile/assets/scan";
}
+124 -87
查看文件
@@ -1,8 +1,9 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import {
assignAsset,
createAsset,
@@ -48,6 +49,17 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
import {
ASSET_CREATE_PATH,
buildAssetDetailPath,
readAssetListFilters,
resolveAssetLegacyRedirect,
} from "../lib/businessDetailRoutes";
import {
buildPcDetailSource,
resolvePcDetailSource,
type PcDetailNavigationState,
} from "../lib/pcDetailNavigation";
function resolveError(error: unknown) {
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
@@ -199,8 +211,33 @@ function unsafeAssetTextReason(value: string) {
}
export function AssetRegistryPage() {
const [searchParams] = useSearchParams();
const legacyRedirect = resolveAssetLegacyRedirect(searchParams);
if (legacyRedirect) {
return <Navigate to={legacyRedirect.to} replace state={{ from: legacyRedirect.from }} />;
}
return <AssetRegistryWorkspace mode="list" />;
}
export function AssetCreatePage() {
return <AssetRegistryWorkspace mode="create" />;
}
export function AssetDetailPage() {
const { assetId = "" } = useParams();
return <AssetRegistryWorkspace mode="detail" assetId={assetId} />;
}
function AssetRegistryWorkspace({
mode,
assetId = "",
}: {
mode: "list" | "create" | "detail";
assetId?: string;
}) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const queryClient = useQueryClient();
const { permissions } = useAuth();
@@ -208,24 +245,43 @@ export function AssetRegistryPage() {
const canTransfer = hasPermission(permissions, "asset.transfer");
const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
const isCreatingAsset = mode === "create";
const isAssetListMode = mode === "list";
const selectedAssetId = mode === "detail" ? assetId.trim() : "";
const listFilters = useMemo(
() => readAssetListFilters(searchParams, canViewAll),
[canViewAll, searchParams],
);
const {
q: assetSearch,
status: statusFilter,
assetType: assetTypeFilter,
sensitivity: sensitivityFilter,
categoryId: categoryFilter,
legalEntityId: legalEntityFilter,
costCenterId: costCenterFilter,
assigneeId: assigneeFilter,
mine,
} = listFilters;
const deferredAssetSearch = useDeferredValue(assetSearch.trim());
const currentSource = buildPcDetailSource(location.pathname, location.search);
const navigationState = location.state as PcDetailNavigationState | null;
const backSource = resolvePcDetailSource(navigationState?.from, currentSource, "asset") ?? "/assets";
const listSource = buildPcDetailSource(location.pathname, location.search);
function updateListSearch(updates: Record<string, string | null>) {
const next = new URLSearchParams(searchParams);
for (const [key, value] of Object.entries(updates)) {
if (value) next.set(key, value);
else next.delete(key);
}
setSearchParams(next, { replace: true });
}
const [categoryForm, setCategoryForm] = useState(initialCategoryForm);
const [editingCategoryId, setEditingCategoryId] = useState("");
const [statusFilter, setStatusFilter] = useState<AssetStatus | "">("");
const [assetTypeFilter, setAssetTypeFilter] = useState<AssetType | "">("");
const [sensitivityFilter, setSensitivityFilter] = useState<AssetSensitivityLevel | "">("");
const [categoryFilter, setCategoryFilter] = useState("");
const [legalEntityFilter, setLegalEntityFilter] = useState("");
const [costCenterFilter, setCostCenterFilter] = useState("");
const [assigneeFilter, setAssigneeFilter] = useState("");
const [assetSearch, setAssetSearch] = useState("");
const [mine, setMine] = useState(!canViewAll);
const [showCategoryManager, setShowCategoryManager] = useState(false);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
const [assetForm, setAssetForm] = useState(initialAssetForm);
const [savedAssetFormSignature, setSavedAssetFormSignature] = useState(() => JSON.stringify(initialAssetForm()));
const [assetFormError, setAssetFormError] = useState("");
@@ -240,7 +296,8 @@ export function AssetRegistryPage() {
});
const assetsQuery = useQuery({
queryKey: ["assets", "list", statusFilter, assetTypeFilter, sensitivityFilter, categoryFilter, legalEntityFilter, costCenterFilter, assigneeFilter, assetSearch, mine, canViewAll],
enabled: isAssetListMode,
queryKey: ["assets", "list", statusFilter, assetTypeFilter, sensitivityFilter, categoryFilter, legalEntityFilter, costCenterFilter, assigneeFilter, deferredAssetSearch, mine, canViewAll],
queryFn: () =>
fetchAssets({
status: statusFilter || undefined,
@@ -250,13 +307,13 @@ export function AssetRegistryPage() {
legalEntityId: legalEntityFilter || undefined,
costCenterId: costCenterFilter || undefined,
assigneeId: assigneeFilter || undefined,
q: assetSearch.trim() || undefined,
q: deferredAssetSearch || undefined,
mine: canViewAll ? mine : true,
}),
});
const assetDetailQuery = useQuery({
enabled: Boolean(selectedAssetId) && !isCreatingAsset,
enabled: mode === "detail" && Boolean(selectedAssetId),
queryKey: ["assets", "detail", selectedAssetId],
queryFn: () => fetchAssetDetail(selectedAssetId),
});
@@ -272,6 +329,7 @@ export function AssetRegistryPage() {
});
const documentCategoriesQuery = useQuery({
enabled: isAssetListMode && canManage,
queryKey: ["assets", "document-categories"],
queryFn: () => fetchDocumentCategories({ entityType: "asset_card", enabled: true }),
});
@@ -283,44 +341,11 @@ export function AssetRegistryPage() {
});
const approvedPurchasesQuery = useQuery({
enabled: canReadProcurementSources,
enabled: isCreatingAsset && canReadProcurementSources,
queryKey: ["assets", "approved-purchases"],
queryFn: () => fetchPurchaseRequests({ status: "approved", mine: false, page: 1, pageSize: 100 }),
});
useEffect(() => {
const searchSelectedId = searchParams.get("id") ?? "";
if (searchSelectedId && searchSelectedId !== selectedAssetId) {
setSelectedAssetId(searchSelectedId);
}
}, [searchParams, selectedAssetId]);
useEffect(() => {
const currentId = searchParams.get("id") ?? "";
if (isCreatingAsset) {
if (!currentId) {
return;
}
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
return;
}
if (!selectedAssetId && !currentId) {
return;
}
if (selectedAssetId === currentId) {
return;
}
const next = new URLSearchParams(searchParams);
if (selectedAssetId) {
next.set("id", selectedAssetId);
} else {
next.delete("id");
}
setSearchParams(next, { replace: true });
}, [isCreatingAsset, searchParams, selectedAssetId, setSearchParams]);
useEffect(() => {
if (isCreatingAsset || !assetDetailQuery.data?.asset) {
return;
@@ -406,12 +431,7 @@ export function AssetRegistryPage() {
function resetAssetEditor() {
runGuarded(() => {
const nextAssetForm = initialAssetForm();
setIsCreatingAsset(true);
setSelectedAssetId("");
setAssetForm(nextAssetForm);
setSavedAssetFormSignature(JSON.stringify(nextAssetForm));
setAssetFormError("");
navigate(ASSET_CREATE_PATH, { state: { from: listSource } });
});
}
@@ -561,9 +581,13 @@ export function AssetRegistryPage() {
]);
setAssetFormError("");
setSavedAssetFormSignature(JSON.stringify(assetForm));
setSelectedAssetId(data.asset.id);
setIsCreatingAsset(false);
toast.success(isCreatingAsset ? "资产卡片已创建" : "资产卡片已更新");
if (isCreatingAsset) {
navigate(buildAssetDetailPath(data.asset.id), {
replace: true,
state: { from: backSource },
});
}
},
onError: (error) => {
const message = resolveError(error);
@@ -718,20 +742,16 @@ export function AssetRegistryPage() {
}, [assetItems]);
const selectAsset = (assetId: string) => {
runGuarded(() => {
setIsCreatingAsset(false);
setSelectedAssetId(assetId);
navigate(buildAssetDetailPath(assetId), { state: { from: listSource } });
});
};
const returnToAssetList = () => {
runGuarded(() => {
setIsCreatingAsset(false);
setSelectedAssetId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
navigate(backSource, { replace: true });
});
};
const isAssetDetailMode = isCreatingAsset || Boolean(selectedAssetId);
const isAssetDetailMode = !isAssetListMode;
const detailAccessDenied = assetDetailQuery.isError && isApiError(assetDetailQuery.error) && [403, 404].includes(assetDetailQuery.error.status);
return (
<ListWorkspace>
@@ -741,7 +761,7 @@ export function AssetRegistryPage() {
<HeaderActionGroup>
{isAssetDetailMode ? (
<Button type="button" variant="secondary" onClick={returnToAssetList}>
返回列表
返回资产列表
</Button>
) : (
<>
@@ -888,10 +908,10 @@ export function AssetRegistryPage() {
<>
{canViewAll ? (
<>
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => updateListSearch({ scope: "own" })}>
我相关
</Button>
<Button type="button" size="sm" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
<Button type="button" size="sm" variant={!mine ? "default" : "secondary"} onClick={() => updateListSearch({ scope: "all" })}>
全范围
</Button>
</>
@@ -909,14 +929,14 @@ export function AssetRegistryPage() {
)}
advanced={showAssetAdvancedFilters ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
<select className="select-std" value={sensitivityFilter} onChange={(event) => updateListSearch({ sensitivity: event.target.value || null })}>
{sensitivityOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
{item.label}
</option>
))}
</select>
<select className="select-std" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
<select className="select-std" value={categoryFilter} onChange={(event) => updateListSearch({ categoryId: event.target.value || null })}>
<option value="">全部分类</option>
{(categoriesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
@@ -925,8 +945,14 @@ export function AssetRegistryPage() {
))}
</select>
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
setLegalEntityFilter(event.target.value);
setCostCenterFilter("");
const nextLegalEntityId = event.target.value;
const keepCostCenter = costCenterFilter && (costCentersQuery.data?.items ?? []).some(
(item) => item.id === costCenterFilter && item.legalEntityId === nextLegalEntityId,
);
updateListSearch({
legalEntityId: nextLegalEntityId || null,
costCenterId: keepCostCenter ? costCenterFilter : null,
});
}}>
<option value="">全部公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
@@ -935,7 +961,7 @@ export function AssetRegistryPage() {
</option>
))}
</select>
<select className="select-std" value={costCenterFilter} onChange={(event) => setCostCenterFilter(event.target.value)}>
<select className="select-std" value={costCenterFilter} onChange={(event) => updateListSearch({ costCenterId: event.target.value || null })}>
<option value="">全部成本中心</option>
{filteredFilterCostCenters.map((item) => (
<option key={item.id} value={item.id}>
@@ -943,7 +969,7 @@ export function AssetRegistryPage() {
</option>
))}
</select>
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
<select className="select-std" value={assigneeFilter} onChange={(event) => updateListSearch({ assigneeId: event.target.value || null })} disabled={!canViewAll}>
<option value="">全部归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
@@ -957,11 +983,13 @@ export function AssetRegistryPage() {
size="sm"
variant="secondary"
onClick={() => {
setSensitivityFilter("");
setCategoryFilter("");
setLegalEntityFilter("");
setCostCenterFilter("");
setAssigneeFilter("");
updateListSearch({
sensitivity: null,
categoryId: null,
legalEntityId: null,
costCenterId: null,
assigneeId: null,
});
setShowAdvancedFilters(false);
}}
>
@@ -975,16 +1003,16 @@ export function AssetRegistryPage() {
<Input
placeholder="搜索编号/名称/hash/VER"
value={assetSearch}
onChange={(event) => setAssetSearch(event.target.value)}
onChange={(event) => updateListSearch({ q: event.target.value || null })}
/>
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as AssetStatus | "") }>
<select className="select-std" value={statusFilter} onChange={(event) => updateListSearch({ status: event.target.value || null })}>
{assetStatusOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
{item.label}
</option>
))}
</select>
<select className="select-std" value={assetTypeFilter} onChange={(event) => setAssetTypeFilter(event.target.value as AssetType | "") }>
<select className="select-std" value={assetTypeFilter} onChange={(event) => updateListSearch({ assetType: event.target.value || null })}>
{assetTypeOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
{item.label}
@@ -1111,8 +1139,17 @@ export function AssetRegistryPage() {
<BusinessDetailMain>
<div className="card-std space-y-4 p-4 md:p-5">
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
{mode === "detail" && (!selectedAssetId || assetDetailQuery.isError) ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700">
<p>
{!selectedAssetId || detailAccessDenied
? "无法查看该记录,记录可能不存在或你无权访问。"
: resolveError(assetDetailQuery.error)}
</p>
<Button className="mt-3" type="button" variant="secondary" onClick={returnToAssetList}>
返回资产列表
</Button>
</div>
) : null}
{(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? (
+11 -2
查看文件
@@ -1,6 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Link, useLocation, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchEmployees } from "../api/employees";
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectDetail, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
@@ -20,6 +20,8 @@ import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea";
import { formatDisplayDateTime } from "../lib/displayFormat";
import { buildPurchaseRequestDetailPath } from "../lib/businessDetailRoutes";
import { buildPcDetailSource } from "../lib/pcDetailNavigation";
import { toast } from "sonner";
const PROJECT_STATUS_OPTIONS: ProjectStatus[] = ["draft", "active", "paused", "completed", "archived"];
@@ -167,7 +169,9 @@ function documentLocationSummary(item: { confidentialityLevel: ConfidentialityLe
export function ProjectDetailPage() {
const { projectId = "" } = useParams();
const location = useLocation();
const queryClient = useQueryClient();
const currentProjectSource = buildPcDetailSource(location.pathname, location.search);
const detailQuery = useQuery({
enabled: Boolean(projectId),
@@ -1128,7 +1132,12 @@ export function ProjectDetailPage() {
<div className="text-zinc-500">暂无关联采购</div>
) : null}
{(purchaseQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to={`/purchase-requests?id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
<Link
key={item.id}
to={buildPurchaseRequestDetailPath(item.id)}
state={{ from: currentProjectSource }}
className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50"
>
<div className="flex items-center justify-between gap-3">
<span className="truncate">{item.requestNo || item.title}</span>
<StatusBadge status={item.status} />
+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 ? (
+32
查看文件
@@ -19,6 +19,8 @@ import {
ApprovalRulesPage,
AnnouncementsPage,
AttendancePage,
AssetCreatePage,
AssetDetailPage,
AssetRegistryPage,
AuditLogsPage,
CutoverConsolePage,
@@ -61,6 +63,7 @@ import {
TeamPresencePage,
TodosPage,
PurchaseRequestsPage,
PurchaseRequestDetailPage,
ReimbursementDetailPage,
ReimbursementDashboardPage,
ReimbursementPrintPage,
@@ -254,6 +257,15 @@ const routeElements = (
}
/>
<Route
path="purchase-requests/:purchaseRequestId"
element={
<RequirePermission anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}>
{withRouteFallback(<PurchaseRequestDetailPage />)}
</RequirePermission>
}
/>
<Route
path="travel-requests"
element={
@@ -301,6 +313,26 @@ const routeElements = (
}
/>
<Route
path="assets/new"
element={
<RequirePermission anyOf={["asset.manage"]}>
{withRouteFallback(<AssetCreatePage />)}
</RequirePermission>
}
/>
<Route
path="assets/:assetId"
element={
<RequirePermission
anyOf={["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]}
>
{withRouteFallback(<AssetDetailPage />)}
</RequirePermission>
}
/>
<Route
path="inventory-checks"
element={
@@ -0,0 +1 @@
export { AssetCreatePage as default } from "../../pages/AssetRegistryPage";
@@ -0,0 +1 @@
export { AssetDetailPage as default } from "../../pages/AssetRegistryPage";
@@ -0,0 +1 @@
export { PurchaseRequestDetailPage as default } from "../../pages/PurchaseRequestsPage";
+3
查看文件
@@ -5,6 +5,8 @@ export const AttendancePage = lazy(() => import("./AttendancePage"));
export const AuditLogsPage = lazy(() => import("./AuditLogsPage"));
export const ApprovalRulesPage = lazy(() => import("./ApprovalRulesPage"));
export const AssetRegistryPage = lazy(() => import("./AssetRegistryPage"));
export const AssetCreatePage = lazy(() => import("./AssetCreatePage"));
export const AssetDetailPage = lazy(() => import("./AssetDetailPage"));
export const CutoverConsolePage = lazy(() => import("./CutoverConsolePage"));
export const DelegationsPage = lazy(() => import("./DelegationsPage"));
export const DepartmentsPage = lazy(() => import("./DepartmentsPage"));
@@ -49,6 +51,7 @@ export const ProjectsPage = lazy(() => import("./ProjectsPage"));
export const ProjectDetailPage = lazy(() => import("./ProjectDetailPage"));
export const ContractsRegistryPage = lazy(() => import("./ContractsRegistryPage"));
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
export const PurchaseRequestDetailPage = lazy(() => import("./PurchaseRequestDetailPage"));
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage"));