import { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { mapApiErrorMessage } from "../api/errorMessages"; import { assignAsset, createAsset, createAssetCategory, fetchAssetAssignees, fetchAssetCategories, fetchAssetDetail, fetchAssetErpTrack, fetchAssets, patchAsset, patchAssetCategory, requeueAssetErpSync, returnAsset, transferAsset, type AssetCategoryItem, type AssetErpStage, type AssetErpTrack, type AssetStatus, } from "../api/assets"; import { fetchPurchaseRequests } from "../api/purchaseRequests"; import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from "../api/masterData"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission, hasPermission } from "../auth/permissions"; import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; function resolveError(error: unknown) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } if (error instanceof Error) { return error.message; } return "请求失败,请稍后重试。"; } function initialCategoryForm() { return { code: "", name: "", documentCategoryId: "", enabled: true, }; } function initialAssetForm() { return { name: "", categoryId: "", legalEntityId: "", costCenterId: "", purchaseRequestId: "", serialNo: "", location: "", status: "in_stock" as AssetStatus, assigneeEmployeeId: "", activatedAt: "", retiredAt: "", }; } function assetStatusOptions(): Array<{ value: AssetStatus | ""; label: string }> { return [ { value: "", label: "全部状态" }, { value: "in_stock", label: "库存中" }, { value: "in_use", label: "使用中" }, { value: "under_repair", label: "维修中" }, { value: "retired", label: "已退役" }, { value: "archived", label: "已归档" }, ]; } function formatAssetErpReason(reason: string | null) { switch (reason) { case "erp_sync_disabled": case "env_hard_off": return "ERP 总闸当前关闭"; case "flag_force_off": return "ERP 功能开关当前强制关闭"; case "missing_flag_record": return "ERP 功能开关记录缺失"; default: return reason ?? null; } } function resolveAssetErpTimestamp(stage: AssetErpStage) { return stage.outbox?.updatedAt ?? stage.outbox?.processedAt ?? stage.binding?.lastSyncedAt ?? null; } function resolveAssetErpActionLabel(actionHint: AssetErpTrack["stage"]["actionHint"]) { switch (actionHint) { case "backfill": return "补发"; case "retry": return "重试同步"; case "resync": return "重新同步摘要"; default: return null; } } export function AssetRegistryPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const { permissions } = useAuth(); const canManage = hasPermission(permissions, "asset.manage"); const canTransfer = hasPermission(permissions, "asset.transfer"); const canIntegrationManage = hasPermission(permissions, "integration.manage"); const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]); const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]); const [categoryForm, setCategoryForm] = useState(initialCategoryForm); const [editingCategoryId, setEditingCategoryId] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [categoryFilter, setCategoryFilter] = useState(""); const [legalEntityFilter, setLegalEntityFilter] = useState(""); const [costCenterFilter, setCostCenterFilter] = useState(""); const [assigneeFilter, setAssigneeFilter] = useState(""); const [mine, setMine] = useState(!canViewAll); const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? ""); const [isCreatingAsset, setIsCreatingAsset] = useState(false); const [assetForm, setAssetForm] = useState(initialAssetForm); const [assetFormError, setAssetFormError] = useState(""); const [movementEmployeeId, setMovementEmployeeId] = useState(""); const [movementLocation, setMovementLocation] = useState(""); const [movementNote, setMovementNote] = useState(""); const [movementError, setMovementError] = useState(""); const categoriesQuery = useQuery({ queryKey: ["assets", "categories", canManage], queryFn: () => fetchAssetCategories({ includeArchived: canManage, enabled: canManage ? undefined : true }), }); const assetsQuery = useQuery({ queryKey: ["assets", "list", statusFilter, categoryFilter, legalEntityFilter, costCenterFilter, assigneeFilter, mine, canViewAll], queryFn: () => fetchAssets({ status: statusFilter || undefined, categoryId: categoryFilter || undefined, legalEntityId: legalEntityFilter || undefined, costCenterId: costCenterFilter || undefined, assigneeId: assigneeFilter || undefined, mine: canViewAll ? mine : true, }), }); const assetDetailQuery = useQuery({ enabled: Boolean(selectedAssetId) && !isCreatingAsset, queryKey: ["assets", "detail", selectedAssetId], queryFn: () => fetchAssetDetail(selectedAssetId), }); const assetErpTrackQuery = useQuery({ enabled: canIntegrationManage && Boolean(selectedAssetId) && !isCreatingAsset, queryKey: ["assets", "erp-track", selectedAssetId], queryFn: () => fetchAssetErpTrack(selectedAssetId), }); const legalEntitiesQuery = useQuery({ queryKey: ["assets", "legal-entities"], queryFn: () => fetchLegalEntities({ enabled: true }), }); const costCentersQuery = useQuery({ queryKey: ["assets", "cost-centers"], queryFn: () => fetchCostCenters({ enabled: true }), }); const documentCategoriesQuery = useQuery({ queryKey: ["assets", "document-categories"], queryFn: () => fetchDocumentCategories({ entityType: "asset_card", enabled: true }), }); const assigneesQuery = useQuery({ enabled: canManage, queryKey: ["assets", "assignees"], queryFn: () => fetchAssetAssignees(), }); const approvedPurchasesQuery = useQuery({ enabled: 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(() => { if (isCreatingAsset) { return; } if (selectedAssetId) { if (assetDetailQuery.isLoading) { return; } if (!assetDetailQuery.isError) { return; } } const nextId = assetsQuery.data?.items?.[0]?.id ?? ""; if (nextId !== selectedAssetId) { setSelectedAssetId(nextId); } }, [assetDetailQuery.isError, assetDetailQuery.isLoading, assetsQuery.data?.items, isCreatingAsset, 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; } const asset = assetDetailQuery.data.asset; setAssetForm({ name: asset.name, categoryId: asset.categoryId, legalEntityId: asset.legalEntityId, costCenterId: asset.costCenterId, purchaseRequestId: asset.purchaseRequestId ?? "", serialNo: asset.serialNo ?? "", location: asset.location ?? "", status: asset.status, assigneeEmployeeId: asset.assigneeEmployeeId ?? "", activatedAt: asset.activatedAt ?? "", retiredAt: asset.retiredAt ?? "", }); setMovementEmployeeId(""); setMovementLocation(asset.location ?? ""); setMovementNote(""); setMovementError(""); setAssetFormError(""); }, [assetDetailQuery.data, isCreatingAsset]); const filteredAssetCostCenters = useMemo(() => { if (!assetForm.legalEntityId) { return []; } return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === assetForm.legalEntityId); }, [assetForm.legalEntityId, costCentersQuery.data?.items]); const filteredFilterCostCenters = useMemo(() => { if (!legalEntityFilter) { return costCentersQuery.data?.items ?? []; } return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityFilter); }, [costCentersQuery.data?.items, legalEntityFilter]); const currentAsset = assetDetailQuery.data?.asset ?? null; const assetErpTrack = assetErpTrackQuery.data ?? null; const assetErpStage = assetErpTrack?.stage ?? null; const assetErpActionLabel = assetErpStage ? resolveAssetErpActionLabel(assetErpStage.actionHint) : null; const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit); function resetCategoryEditor(category?: AssetCategoryItem) { if (!category) { setEditingCategoryId(""); setCategoryForm(initialCategoryForm()); return; } setEditingCategoryId(category.id); setCategoryForm({ code: category.code, name: category.name, documentCategoryId: category.documentCategoryId ?? "", enabled: category.enabled, }); } function resetAssetEditor() { setIsCreatingAsset(true); setSelectedAssetId(""); setAssetForm(initialAssetForm()); setAssetFormError(""); } function buildCreateAssetPayload() { return { name: assetForm.name.trim(), categoryId: assetForm.categoryId, legalEntityId: assetForm.legalEntityId, costCenterId: assetForm.costCenterId, purchaseRequestId: assetForm.purchaseRequestId || null, serialNo: assetForm.serialNo.trim() || null, location: assetForm.location.trim() || null, status: assetForm.status, assigneeEmployeeId: assetForm.assigneeEmployeeId || null, activatedAt: assetForm.activatedAt || null, retiredAt: assetForm.retiredAt || null, }; } function buildPatchAssetPayload() { return { name: assetForm.name.trim(), categoryId: assetForm.categoryId, legalEntityId: assetForm.legalEntityId, costCenterId: assetForm.costCenterId, serialNo: assetForm.serialNo.trim() || null, location: assetForm.location.trim() || null, status: assetForm.status, activatedAt: assetForm.activatedAt || null, retiredAt: assetForm.retiredAt || null, }; } function validateAssetForm() { if (!assetForm.name.trim()) { throw new Error("请填写资产名称"); } if (!assetForm.categoryId) { throw new Error("请选择资产分类"); } if (!assetForm.legalEntityId) { throw new Error("请选择法体"); } if (!assetForm.costCenterId) { throw new Error("请选择成本中心"); } } const saveCategoryMutation = useMutation({ mutationFn: async () => { if (!categoryForm.code.trim()) { throw new Error("请填写分类编码"); } if (!categoryForm.name.trim()) { throw new Error("请填写分类名称"); } const payload = { code: categoryForm.code.trim(), name: categoryForm.name.trim(), documentCategoryId: categoryForm.documentCategoryId || null, enabled: categoryForm.enabled, }; return editingCategoryId ? patchAssetCategory(editingCategoryId, payload) : createAssetCategory(payload); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["assets", "categories"] }); toast.success(editingCategoryId ? "资产分类已更新" : "资产分类已创建"); resetCategoryEditor(); }, onError: (error) => { toast.error(resolveError(error)); }, }); const saveAssetMutation = useMutation({ mutationFn: async () => { validateAssetForm(); if (isCreatingAsset) { return createAsset(buildCreateAssetPayload()); } if (!selectedAssetId) { throw new Error("请先选择资产卡片"); } return patchAsset(selectedAssetId, buildPatchAssetPayload()); }, onSuccess: async (data) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["assets", "list"] }), queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }), queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }), ]); setAssetFormError(""); setSelectedAssetId(data.asset.id); setIsCreatingAsset(false); toast.success(isCreatingAsset ? "资产卡片已创建" : "资产卡片已更新"); }, onError: (error) => { const message = resolveError(error); setAssetFormError(message); toast.error(message); }, }); const requeueAssetErpMutation = useMutation({ mutationFn: async () => { if (!currentAsset) { throw new Error("请先选择资产卡片"); } return requeueAssetErpSync(currentAsset.id, true); }, onSuccess: async (data) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }), queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }), ]); const message = data.duplicated ? "ERP 摘要已是最新状态" : data.requeued ? "资产 ERP 同步已重新入队" : "资产 ERP 同步已入队"; toast.success(message); }, onError: (error) => { toast.error(resolveError(error)); }, }); const assignAssetMutation = useMutation({ mutationFn: async () => { if (!currentAsset) { throw new Error("请先选择资产卡片"); } if (!movementEmployeeId) { throw new Error("请选择领用人"); } return assignAsset(currentAsset.id, { employeeId: movementEmployeeId, note: movementNote.trim() || null, }); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["assets", "list"] }), queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }), ]); setMovementEmployeeId(""); setMovementNote(""); setMovementError(""); toast.success("资产已领用"); }, onError: (error) => { const message = resolveError(error); setMovementError(message); toast.error(message); }, }); const returnAssetMutation = useMutation({ mutationFn: async () => { if (!currentAsset) { throw new Error("请先选择资产卡片"); } return returnAsset(currentAsset.id, { note: movementNote.trim() || null }); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["assets", "list"] }), queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }), ]); setMovementEmployeeId(""); setMovementNote(""); setMovementError(""); toast.success("资产已归还"); }, onError: (error) => { const message = resolveError(error); setMovementError(message); toast.error(message); }, }); const transferAssetMutation = useMutation({ mutationFn: async () => { if (!currentAsset) { throw new Error("请先选择资产卡片"); } const nextLocation = movementLocation.trim() || null; const nextEmployeeId = movementEmployeeId || undefined; if (!nextEmployeeId && nextLocation === (currentAsset.location ?? null)) { throw new Error("请至少调整归属人或位置"); } return transferAsset(currentAsset.id, { toEmployeeId: nextEmployeeId, toLocation: nextLocation, note: movementNote.trim() || null, }); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["assets", "list"] }), queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }), ]); setMovementEmployeeId(""); setMovementNote(""); setMovementError(""); toast.success("资产调拨已完成"); }, onError: (error) => { const message = resolveError(error); setMovementError(message); toast.error(message); }, }); const purchaseOptions = useMemo(() => { const items = approvedPurchasesQuery.data?.items ?? []; const currentPurchase = currentAsset?.purchaseRequest; if (currentPurchase && !items.some((item) => item.id === currentPurchase.id)) { return [ ...items, { id: currentPurchase.id, requestNo: currentPurchase.requestNo, title: currentPurchase.title, status: currentPurchase.status, applicantId: "", submitterId: null, departmentId: "", legalEntityId: null, costCenterId: null, supplierId: null, documentCategoryId: null, budgetPoolId: null, reason: null, currencyCode: "CNY", totalAmount: "0.00", expectedDeliveryDate: null, createdAt: null, updatedAt: null, applicant: null, submitter: null, department: null, legalEntity: null, costCenter: null, supplier: null, documentCategory: null, budgetPool: null, }, ]; } return items; }, [approvedPurchasesQuery.data?.items, currentAsset?.purchaseRequest]); const categoryItems = categoriesQuery.data?.items ?? []; const assetItems = assetsQuery.data?.items ?? []; const selectAsset = (assetId: string) => { setIsCreatingAsset(false); setSelectedAssetId(assetId); }; return ( {canManage ? ( ) : null} } />

资产分类

{canManage ? ( ) : null}
setCategoryForm((current) => ({ ...current, code: event.target.value }))} />
setCategoryForm((current) => ({ ...current, name: event.target.value }))} />
{canManage ? ( ) : null}
{categoriesQuery.isLoading ? : null} {categoriesQuery.isError ? (

{resolveError(categoriesQuery.error)}

) : null} {!categoriesQuery.isLoading && !categoriesQuery.isError ? ( <>
{categoryItems.length ? ( categoryItems.map((item) => ( )) ) : (
暂无资产分类
)}
编码名称状态 {categoryItems.map((item) => ( resetCategoryEditor(item)} className="cursor-pointer"> {item.code}
{item.name}
{item.documentCategory?.name ?? "系统默认"}
{item.enabled ? "启用" : "停用"}
))}
) : null}
{canViewAll ? (
) : null}
{assetsQuery.isLoading ? : null} {assetsQuery.isError ? (

{resolveError(assetsQuery.error)}

) : null} {!assetsQuery.isLoading && !assetsQuery.isError ? ( <>
{assetItems.length ? ( assetItems.map((item) => ( )) ) : (
暂无资产卡片
)}
资产分类状态归属人 {assetItems.length ? ( assetItems.map((item) => ( { selectAsset(item.id); }} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
{item.assetNo}
{item.name}
{item.category?.name ?? "-"} {item.assignee?.name ?? "-"}
)) ) : ( 暂无资产卡片 )}
) : null}
{!selectedAssetId && !isCreatingAsset ? (
请先从列表选择资产卡片,或点击“新建资产卡片”。
) : null} {selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? : null} {selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (

{resolveError(assetDetailQuery.error)}

) : null} {(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? ( <>

{isCreatingAsset ? "新建资产卡片" : currentAsset?.assetNo ?? "资产卡片"}

{isCreatingAsset ? "创建新的资产台账卡片。" : currentAsset?.name}

{!isCreatingAsset && currentAsset ? : null}
{assetFormError ? (

{assetFormError}

) : null}
setAssetForm((current) => ({ ...current, name: event.target.value }))} />
setAssetForm((current) => ({ ...current, serialNo: event.target.value }))} />
setAssetForm((current) => ({ ...current, location: event.target.value }))} />
setAssetForm((current) => ({ ...current, activatedAt: event.target.value }))} />
setAssetForm((current) => ({ ...current, retiredAt: event.target.value }))} />
{currentAsset && canTransfer && !isCreatingAsset ? (

资产流转

通过受控动作执行领用、归还和调拨,并写入审计记录。

当前归属人:{currentAsset.assignee?.name ?? "未分配"} · 位置:{currentAsset.location ?? "未设置"}
{movementError ? (

{movementError}

) : null}
setMovementLocation(event.target.value)} />
setMovementNote(event.target.value)} />
) : null} {selectedAssetId && !isCreatingAsset && canIntegrationManage ? ( {assetErpTrackQuery.isLoading ? (

ERP 同步轨道加载中...

) : assetErpTrackQuery.isError ? (

{resolveError(assetErpTrackQuery.error)}

) : assetErpStage ? (
最近更新时间:{resolveAssetErpTimestamp(assetErpStage) ? new Date(resolveAssetErpTimestamp(assetErpStage) as string).toLocaleString() : "尚无同步记录"}
变更来源:{assetErpStage.preview.sourceEventName ?? "-"}
资产编号:{assetErpTrack?.asset.assetNo ?? currentAsset?.assetNo ?? "-"}
最近 changeType:{assetErpStage.preview.changeType ?? "-"}
分类:{assetErpStage.preview.categoryCode ?? "-"}
资产状态:{assetErpStage.preview.status ?? assetErpTrack?.asset.status ?? "-"}
法体:{assetErpStage.preview.legalEntityCode ?? "-"}
成本中心:{assetErpStage.preview.costCenterCode ?? "-"}
{formatAssetErpReason(assetErpStage.reason) ? (
{formatAssetErpReason(assetErpStage.reason)}
) : null} {assetErpStage.outbox?.lastError ? (
{assetErpStage.outbox.lastError}
) : null} {assetErpTrack?.permissions.canSeeExternalRefs && (assetErpStage.binding?.externalCode || assetErpStage.binding?.externalId) ? (
ERP 资产编号:{assetErpStage.binding?.externalCode ?? "-"} {assetErpStage.binding?.externalId ? ` · ${assetErpStage.binding.externalId}` : ""}
) : null} {assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
) : null}
) : (

暂无 ERP 轨道信息

)}
) : null} {selectedAssetId && !isCreatingAsset && currentAsset ? ( ) : null}
{canManage ? ( ) : null} {currentAsset?.purchaseRequest ? ( 采购来源:{currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id} ) : null}
) : null}
); }