feat(08-05-business-detail-routing): 完成采购与资产独立路由
这个提交包含在:
@@ -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 ? (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
在新工单中引用
屏蔽一个用户