修正 ERP 边界与资产页面一致性(05-30-fix-erp-boundary-asset-consistency)
这个提交包含在:
+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>
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户