修正 ERP 边界与资产页面一致性(05-30-fix-erp-boundary-asset-consistency)

这个提交包含在:
2026-05-30 23:06:02 +08:00
父节点 16fd9bdf52
当前提交 f299ceeb74
共修改 47 个文件,包含 476 行新增和 1633 行删除
+192 -353
查看文件
@@ -10,16 +10,12 @@ import {
fetchAssetAssignees,
fetchAssetCategories,
fetchAssetDetail,
fetchAssetErpTrack,
fetchAssets,
patchAsset,
patchAssetCategory,
requeueAssetErpSync,
returnAsset,
transferAsset,
type AssetCategoryItem,
type AssetErpStage,
type AssetErpTrack,
type AssetItem,
type AssetSensitivityLevel,
type AssetStatus,
@@ -30,11 +26,12 @@ import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from ".
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
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";
@@ -117,16 +114,6 @@ function assetStatusLabel(value: AssetStatus | string | null | undefined) {
return assetStatusOptions().find((item) => item.value === value)?.label ?? "未知状态";
}
function assetErpChangeTypeLabel(value: string | null | undefined) {
const labels: Record<string, string> = {
created: "新建资产卡",
updated: "资产信息更新",
retired: "资产退役",
archived: "资产归档",
};
return value ? labels[value] ?? "资产变更" : "-";
}
function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label: string }> {
return [
{ value: "", label: "全部敏感级别" },
@@ -137,38 +124,12 @@ function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label:
];
}
function typeHint(assetType: AssetType) {
switch (assetType) {
case "electronic_document":
return "电子资料只登记摘要、hash、敏感级别和归档位置;S3/S4 原文和完整路径不上传公网 OA。";
case "electronic_system":
return "电子系统登记负责人、入口、到期和备份策略;账号密码、token、Secret 不进 OA。";
case "code_repository":
return "代码仓库继续由 Git 管理,OA 只登记仓库元数据、负责人和备份策略。";
case "backup_media":
return "备份介质记录保管人、加密状态、备份范围和最近恢复验证时间。";
default:
return "固定资产支持领用、归还、调拨、盘点和移动扫码。";
}
}
function isElectronicAssetType(assetType: AssetType) {
return assetType !== "fixed_asset";
}
function assetNextStep(item: Pick<AssetItem, "assetType" | "assignee" | "owner" | "status" | "lastVerifiedAt" | "expiresAt">) {
if (item.status === "archived") return "已归档留存";
if (item.status === "retired") return "核对处置记录";
if (item.status === "under_repair") return "跟进维修结果";
if (item.assetType === "fixed_asset") {
if (!item.assignee) return "待领用或确认保管";
if (!item.lastVerifiedAt) return "待首次盘点";
return "按季度盘点";
}
if (!item.owner) return "补负责人";
if (!item.lastVerifiedAt) return "待验证责任/备份";
if (item.expiresAt && item.expiresAt < new Date().toISOString().slice(0, 10)) return "检查到期续费";
return "按期验证权限";
function formatEmployeeSummary(employee: { name: string; employeeNo: string } | null | undefined) {
return employee ? `${employee.name} · ${employee.employeeNo}` : "-";
}
function CompactAssetNo({ value, copyable = true }: { value: string; copyable?: boolean }) {
@@ -231,37 +192,6 @@ function unsafeAssetTextReason(value: string) {
return "";
}
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();
@@ -270,7 +200,6 @@ export function AssetRegistryPage() {
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"]);
@@ -324,12 +253,6 @@ export function AssetRegistryPage() {
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 }),
@@ -442,9 +365,6 @@ export function AssetRegistryPage() {
}, [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) {
@@ -612,7 +532,6 @@ export function AssetRegistryPage() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }),
]);
setAssetFormError("");
setSelectedAssetId(data.asset.id);
@@ -627,30 +546,6 @@ export function AssetRegistryPage() {
});
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) {
@@ -819,6 +714,11 @@ export function AssetRegistryPage() {
</Button>
) : (
<>
{canManage ? (
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
新增资产
</Button>
) : null}
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
移动扫码查找
</Button>
@@ -835,15 +735,6 @@ export function AssetRegistryPage() {
<div className="grid gap-6">
{showLedgerTools ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
<div className="font-medium text-slate-900">台账工具</div>
{canManage ? (
<Button type="button" className="mt-3" data-testid="asset-create" onClick={resetAssetEditor}>
新建资产卡片
</Button>
) : null}
</div>
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
{canManage ? (
@@ -1087,58 +978,61 @@ export function AssetRegistryPage() {
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div>
)}
</div>
<PrimaryTable className="hidden md:block">
<Table>
<TableHeader>
<tr>
<TableHead>资产</TableHead>
<TableHead>类型</TableHead>
<TableHead>责任链</TableHead>
<TableHead>位置</TableHead>
<TableHead>状态</TableHead>
<TableHead className="w-24 text-right">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{orderedAssetItems.length ? (
orderedAssetItems.map((item) => (
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
selectAsset(item.id);
}} className="cursor-pointer">
<TableCell>
<div className="font-medium text-zinc-900">
<CompactAssetNo value={item.assetNo} />
</div>
<div className="text-xs text-zinc-500" title={item.name}>
{formatTextWithCompactLongNumbers(item.name)}
</div>
</TableCell>
<TableCell>{assetTypeLabel(item.assetType)}</TableCell>
<TableCell>
<div>{item.owner?.name ?? item.assignee?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">保管 {item.keeper?.name ?? "-"}</div>
</TableCell>
<TableCell title={item.location ?? undefined}>
<div>{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
<div className="text-xs text-zinc-500">盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}</div>
</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell className="text-right">
<Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}>
查看详情
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="py-4 text-zinc-500">
暂无资产卡片
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<PrimaryTable className="hidden md:block ops-table-focus">
<table className="w-full border-collapse text-left" data-testid="assets-table">
<thead className="table-header">
<tr>
<th className="table-th">资产</th>
<th className="table-th">状态</th>
<th className="table-th">责任人</th>
<th className="table-th">保管/位置</th>
<th className="table-th">盘点</th>
<th className="table-th w-24">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-50">
{orderedAssetItems.length ? (
orderedAssetItems.map((item) => (
<tr className="table-row" data-testid="asset-row" key={item.id}>
<td className="table-td">
<div className="font-semibold text-zinc-900">
<CompactAssetNo value={item.assetNo} />
</div>
<div className="text-xs text-zinc-500" title={item.name}>
{formatTextWithCompactLongNumbers(item.name)}
</div>
<div className="text-xs text-zinc-500">{assetTypeLabel(item.assetType)}</div>
</td>
<td className="table-td">
<StatusBadge status={item.status} />
</td>
<td className="table-td">
<div className="font-medium text-zinc-900">{formatEmployeeSummary(item.owner ?? item.assignee)}</div>
<div className="text-xs text-zinc-500">归属 {formatEmployeeSummary(item.assignee)}</div>
</td>
<td className="table-td" title={item.location ?? undefined}>
<div className="font-medium text-zinc-900">{formatEmployeeSummary(item.keeper)}</div>
<div className="text-xs text-zinc-500">{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
</td>
<td className="table-td">
{item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}
</td>
<td className="table-td">
<Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}>
查看详情
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="py-8 text-center text-sm text-zinc-500">
暂无资产卡片
</td>
</tr>
)}
</tbody>
</table>
</PrimaryTable>
</>
) : null}
@@ -1146,7 +1040,8 @@ export function AssetRegistryPage() {
</div>
</div>
) : (
<div className="card-std space-y-4 p-4 md:p-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
<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>
@@ -1182,19 +1077,19 @@ export function AssetRegistryPage() {
<div className="grid gap-3 md:grid-cols-4" data-testid="asset-detail-summary">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">责任人</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.owner?.name ?? currentAsset.assignee?.name ?? "未指定"}</div>
<div className="mt-1 font-medium text-zinc-900">{formatEmployeeSummary(currentAsset.owner ?? currentAsset.assignee)}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">保管/位置</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.keeper?.name ?? currentAsset.location ?? "待确认"}</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.keeper ? formatEmployeeSummary(currentAsset.keeper) : currentAsset.location ?? "待确认"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">{isElectronicAssetType(currentAsset.assetType) ? "最近验证" : "最近盘点"}</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : "未登记"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">下一步</div>
<div className="mt-1 font-medium text-zinc-900">{assetNextStep(currentAsset)}</div>
<div className="text-xs text-zinc-500">状态</div>
<div className="mt-1"><StatusBadge status={currentAsset.status} /></div>
</div>
</div>
) : null}
@@ -1203,7 +1098,6 @@ export function AssetRegistryPage() {
<section className="space-y-4">
<div>
<h4 className="text-sm font-semibold text-zinc-900">基础信息</h4>
<p className="text-xs text-zinc-500">维护资产主标识、类型、分类和组织归属。</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -1219,7 +1113,6 @@ export function AssetRegistryPage() {
</option>
))}
</select>
<p className="text-xs text-zinc-500">{typeHint(assetForm.assetType)}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">资产状态</label>
@@ -1300,7 +1193,6 @@ export function AssetRegistryPage() {
<section className="space-y-4 border-t border-zinc-200 pt-5">
<div>
<h4 className="text-sm font-semibold text-zinc-900">责任与归档</h4>
<p className="text-xs text-zinc-500">明确领用、负责、保管、位置和生命周期日期。</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -1371,7 +1263,7 @@ export function AssetRegistryPage() {
<section className="space-y-4 border-t border-zinc-200 pt-5">
<div>
<h4 className="text-sm font-semibold text-zinc-900">电子资产安全字段</h4>
<p className="text-xs text-zinc-500">只登记必要摘要,密码、密钥、源码包、原始正文和完整路径不得进入 OA。</p>
<p className="text-xs text-zinc-500">不填写密码、密钥、源码包、原始正文或完整路径。</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -1403,183 +1295,130 @@ export function AssetRegistryPage() {
</section>
) : null}
</div>
{currentAsset && canTransfer && !isCreatingAsset && currentAsset.assetType === "fixed_asset" ? (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-zinc-900">资产流转</h4>
<p className="text-xs text-zinc-500">通过受控动作执行领用、归还和调拨,并写入审计记录。</p>
</div>
<div className="text-xs text-zinc-500">
当前归属人:{currentAsset.assignee?.name ?? "未分配"} · 位置:{currentAsset.location ?? "未设置"}
</div>
</div>
{movementError ? (
<p className="mb-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{movementError}</p>
) : null}
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标归属人</label>
<select
data-testid="asset-movement-assignee"
className="select-std"
value={movementEmployeeId}
onChange={(event) => setMovementEmployeeId(event.target.value)}
>
<option value="">保持当前归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.employeeNo})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标位置</label>
<Input
data-testid="asset-movement-location"
value={movementLocation}
onChange={(event) => setMovementLocation(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">备注</label>
<Input
data-testid="asset-movement-note"
value={movementNote}
onChange={(event) => setMovementNote(event.target.value)}
/>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button
type="button"
data-testid="asset-assign-submit"
disabled={assignAssetMutation.isPending || Boolean(currentAsset.assigneeEmployeeId) || !movementEmployeeId}
onClick={() => assignAssetMutation.mutate()}
>
{assignAssetMutation.isPending ? "领用中..." : "资产领用"}
</Button>
<Button
type="button"
variant="secondary"
data-testid="asset-return-submit"
disabled={returnAssetMutation.isPending || !currentAsset.assigneeEmployeeId}
onClick={() => returnAssetMutation.mutate()}
>
{returnAssetMutation.isPending ? "归还中..." : "资产归还"}
</Button>
<Button
type="button"
variant="secondary"
data-testid="asset-transfer-submit"
disabled={
transferAssetMutation.isPending ||
(!movementEmployeeId && movementLocation.trim() === (currentAsset.location ?? ""))
}
onClick={() => transferAssetMutation.mutate()}
>
{transferAssetMutation.isPending ? "调拨中..." : "资产调拨"}
</Button>
</div>
</div>
) : null}
{selectedAssetId && !isCreatingAsset && canIntegrationManage ? (
<details className="card-std p-4 md:p-5">
<summary className="flex cursor-pointer list-none items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-zinc-900">ERP 同步状态</div>
<div className="mt-1 text-sm text-zinc-500">高级轨道,默认收起。</div>
</div>
<span className="text-sm font-medium text-primary">展开</span>
</summary>
<div className="mt-4">
{assetErpTrackQuery.isLoading ? (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 同步轨道加载中...</p>
) : assetErpTrackQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetErpTrackQuery.error)}</p>
) : assetErpStage ? (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1 text-xs text-zinc-500">
<div>
最近更新时间:{resolveAssetErpTimestamp(assetErpStage) ? formatDisplayDateTime(resolveAssetErpTimestamp(assetErpStage)) : "尚无同步记录"}
</div>
<div>变更来源:{assetErpStage.preview.sourceEventName ?? "-"}</div>
</div>
<StatusBadge status={assetErpStage.deliveryStatus} />
</div>
<div className="grid gap-2 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600 md:grid-cols-2">
<div>资产编号:{formatTextWithCompactLongNumbers(assetErpTrack?.asset.assetNo ?? currentAsset?.assetNo ?? "-")}</div>
<div>最近变更:{assetErpChangeTypeLabel(assetErpStage.preview.changeType)}</div>
<div>分类:{formatTextWithCompactLongNumbers(assetErpStage.preview.categoryCode ?? "-")}</div>
<div>资产状态:{assetStatusLabel(assetErpStage.preview.status ?? assetErpTrack?.asset.status)}</div>
<div>法体:{assetErpStage.preview.legalEntityCode ?? "-"}</div>
<div>成本中心:{assetErpStage.preview.costCenterCode ?? "-"}</div>
</div>
{formatAssetErpReason(assetErpStage.reason) ? (
<div className="rounded bg-zinc-50 px-3 py-2 text-xs text-zinc-500">{formatAssetErpReason(assetErpStage.reason)}</div>
) : null}
{assetErpStage.outbox?.lastError ? (
<div className="rounded bg-red-50 px-3 py-2 text-xs text-red-600">{assetErpStage.outbox.lastError}</div>
) : null}
{assetErpTrack?.permissions.canSeeExternalRefs && (assetErpStage.binding?.externalCode || assetErpStage.binding?.externalId) ? (
<div className="rounded bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
ERP 资产编号:{assetErpStage.binding?.externalCode ?? "-"}
{assetErpStage.binding?.externalId ? ` · ${assetErpStage.binding.externalId}` : ""}
</div>
) : null}
{assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="secondary"
disabled={requeueAssetErpMutation.isPending}
onClick={() => requeueAssetErpMutation.mutate()}
>
{requeueAssetErpMutation.isPending ? "处理中..." : assetErpActionLabel}
</Button>
</div>
) : null}
</div>
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
)}
</div>
</details>
) : null}
{selectedAssetId && !isCreatingAsset && currentAsset ? (
<AiSuggestionCard targetType="asset_card" targetId={currentAsset.id} />
) : null}
<div className="flex items-center gap-2">
{canManage ? (
<Button type="button" data-testid="asset-save" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
</Button>
) : null}
{currentAsset?.purchaseRequest ? (
<span className="text-sm text-zinc-500">
采购来源:{formatTextWithCompactLongNumbers(currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id)}
</span>
) : null}
</div>
</>
) : null}
</div>
{(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? (
<div className="space-y-5">
<SectionPanel title="操作" className="xl:sticky xl:top-24">
<div className="space-y-4">
{canManage ? (
<Button
type="button"
data-testid="asset-save"
disabled={saveAssetMutation.isPending || !canEditCurrent}
onClick={() => saveAssetMutation.mutate()}
>
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
</Button>
) : null}
{currentAsset && canTransfer && !isCreatingAsset && currentAsset.assetType === "fixed_asset" ? (
<div className="space-y-3 border-t border-zinc-200 pt-4">
<div className="text-sm font-semibold text-zinc-900">资产流转</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
归属人:{formatEmployeeSummary(currentAsset.assignee)} · 位置:{currentAsset.location ?? "未设置"}
</div>
{movementError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{movementError}</p>
) : null}
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标归属人</label>
<select
data-testid="asset-movement-assignee"
className="select-std"
value={movementEmployeeId}
onChange={(event) => setMovementEmployeeId(event.target.value)}
>
<option value="">保持当前归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.employeeNo})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标位置</label>
<Input
data-testid="asset-movement-location"
value={movementLocation}
onChange={(event) => setMovementLocation(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">备注</label>
<Input
data-testid="asset-movement-note"
value={movementNote}
onChange={(event) => setMovementNote(event.target.value)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
data-testid="asset-assign-submit"
disabled={assignAssetMutation.isPending || Boolean(currentAsset.assigneeEmployeeId) || !movementEmployeeId}
onClick={() => assignAssetMutation.mutate()}
>
{assignAssetMutation.isPending ? "领用中..." : "领用"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
data-testid="asset-return-submit"
disabled={returnAssetMutation.isPending || !currentAsset.assigneeEmployeeId}
onClick={() => returnAssetMutation.mutate()}
>
{returnAssetMutation.isPending ? "归还中..." : "归还"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
data-testid="asset-transfer-submit"
disabled={
transferAssetMutation.isPending ||
(!movementEmployeeId && movementLocation.trim() === (currentAsset.location ?? ""))
}
onClick={() => transferAssetMutation.mutate()}
>
{transferAssetMutation.isPending ? "调拨中..." : "调拨"}
</Button>
</div>
</div>
) : null}
</div>
</SectionPanel>
<DetailSideMeta title="资产摘要">
<div>状态:{isCreatingAsset ? "新建中" : assetStatusLabel(currentAsset?.status)}</div>
<div>类型:{assetTypeLabel(assetForm.assetType)}</div>
<div>责任人:{formatEmployeeSummary(currentAsset?.owner ?? currentAsset?.assignee)}</div>
<div>归属人:{formatEmployeeSummary(currentAsset?.assignee)}</div>
<div>保管人:{formatEmployeeSummary(currentAsset?.keeper)}</div>
<div>位置:{(currentAsset?.location ?? assetForm.location) || "-"}</div>
<div>
{isElectronicAssetType(assetForm.assetType) ? "最近验证" : "最近盘点"}:
{currentAsset?.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : assetForm.lastVerifiedAt || "-"}
</div>
<div>
采购来源:
{currentAsset?.purchaseRequest
? formatTextWithCompactLongNumbers(currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id)
: "-"}
</div>
</DetailSideMeta>
</div>
) : null}
</div>
)}
</ListWorkspace>
);