重构资产盘点与移动扫码页面(05-30-asset-inventory-mobile-redesign)
这个提交包含在:
@@ -20,6 +20,7 @@ import {
|
||||
type AssetCategoryItem,
|
||||
type AssetErpStage,
|
||||
type AssetErpTrack,
|
||||
type AssetItem,
|
||||
type AssetSensitivityLevel,
|
||||
type AssetStatus,
|
||||
type AssetType,
|
||||
@@ -136,10 +137,6 @@ function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label:
|
||||
];
|
||||
}
|
||||
|
||||
function sensitivityLabel(value: AssetSensitivityLevel | string | null | undefined) {
|
||||
return sensitivityOptions().find((item) => item.value === value)?.label ?? "未分级";
|
||||
}
|
||||
|
||||
function typeHint(assetType: AssetType) {
|
||||
switch (assetType) {
|
||||
case "electronic_document":
|
||||
@@ -155,6 +152,25 @@ function typeHint(assetType: AssetType) {
|
||||
}
|
||||
}
|
||||
|
||||
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 CompactAssetNo({ value, copyable = true }: { value: string; copyable?: boolean }) {
|
||||
const displayValue = formatCompactIdentifier(value, { head: 14, tail: 6, minLength: 24 });
|
||||
const compacted = isCompactedIdentifier(value, displayValue);
|
||||
@@ -270,6 +286,7 @@ export function AssetRegistryPage() {
|
||||
const [assigneeFilter, setAssigneeFilter] = useState("");
|
||||
const [assetSearch, setAssetSearch] = useState("");
|
||||
const [mine, setMine] = useState(!canViewAll);
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
|
||||
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
|
||||
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||
@@ -780,6 +797,35 @@ export function AssetRegistryPage() {
|
||||
|
||||
const categoryItems = categoriesQuery.data?.items ?? [];
|
||||
const assetItems = assetsQuery.data?.items ?? [];
|
||||
const orderedAssetItems = useMemo(() => {
|
||||
return [...assetItems].sort((a, b) => {
|
||||
const score = (item: AssetItem) => {
|
||||
let value = 0;
|
||||
if (item.status === "under_repair") value += 50;
|
||||
if (item.assetType === "fixed_asset" && !item.assignee) value += 35;
|
||||
if (!item.lastVerifiedAt) value += 25;
|
||||
if (item.status === "in_use") value += 10;
|
||||
return value;
|
||||
};
|
||||
const scoreDelta = score(b) - score(a);
|
||||
if (scoreDelta !== 0) return scoreDelta;
|
||||
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
|
||||
});
|
||||
}, [assetItems]);
|
||||
const assetStats = useMemo(() => {
|
||||
return assetItems.reduce(
|
||||
(acc, item) => {
|
||||
acc.total += 1;
|
||||
if (item.assetType === "fixed_asset") acc.fixedAsset += 1;
|
||||
else acc.electronicAsset += 1;
|
||||
if (item.assetType === "fixed_asset" && !item.assignee) acc.unassigned += 1;
|
||||
if (!item.lastVerifiedAt) acc.needsVerification += 1;
|
||||
if ((item.assetType === "fixed_asset" && !item.assignee) || !item.lastVerifiedAt) acc.attention += 1;
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, fixedAsset: 0, electronicAsset: 0, unassigned: 0, needsVerification: 0, attention: 0 },
|
||||
);
|
||||
}, [assetItems]);
|
||||
const selectAsset = (assetId: string) => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId(assetId);
|
||||
@@ -789,23 +835,55 @@ export function AssetRegistryPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="资产台账"
|
||||
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
|
||||
subtitle="管理固定资产责任链、位置、盘点和电子资产索引;分类维护和高级筛选收在台账工具。"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
|
||||
移动扫码查找
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
|
||||
新建资产卡片
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
|
||||
台账工具
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4" data-testid="asset-registry-summary">
|
||||
<div className="finance-metric-card">
|
||||
<div className="text-sm text-zinc-500">当前可见</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.total}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{mine ? "默认我相关资产" : "全范围查看"}</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="text-sm text-zinc-500">固定资产 FA</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.fixedAsset}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">责任、位置、盘点和交接</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="text-sm text-zinc-500">电子资产索引</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.electronicAsset}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">只登记责任和验证摘要</div>
|
||||
</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="text-sm text-zinc-500">待补责任/验证</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.attention}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">优先补齐保管和最近盘点</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={showLedgerTools ? "grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]" : "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>
|
||||
<p className="mt-1">新建资产卡片、分类维护、全范围与高级筛选放在这里,普通首屏只保留查找和当前资产处理。</p>
|
||||
{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 ? (
|
||||
@@ -846,6 +924,57 @@ export function AssetRegistryPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">高级筛选</h4>
|
||||
<p className="text-xs text-zinc-500">低频分类、组织、责任人和敏感级别筛选放在工具区。</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
|
||||
{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)}>
|
||||
<option value="">全部分类</option>
|
||||
{(categoriesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{formatTextWithCompactLongNumbers(item.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
|
||||
setLegalEntityFilter(event.target.value);
|
||||
setCostCenterFilter("");
|
||||
}}>
|
||||
<option value="">全部法体</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{formatTextWithCompactLongNumbers(item.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={costCenterFilter} onChange={(event) => setCostCenterFilter(event.target.value)}>
|
||||
<option value="">全部成本中心</option>
|
||||
{filteredFilterCostCenters.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
|
||||
<option value="">全部归属人</option>
|
||||
{(assigneesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categoriesQuery.isLoading ? <TableSkeleton columns={3} rows={5} /> : null}
|
||||
{categoriesQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(categoriesQuery.error)}</p>
|
||||
@@ -909,10 +1038,11 @@ export function AssetRegistryPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className={showLedgerTools ? "grid gap-6 2xl:grid-cols-[minmax(0,0.95fr)_minmax(420px,1.05fr)]" : "grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(420px,1.05fr)]"}>
|
||||
<FilterBar className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_190px_auto]">
|
||||
<Input
|
||||
placeholder="搜索编号/名称/hash/VER"
|
||||
value={assetSearch}
|
||||
@@ -932,52 +1062,10 @@ export function AssetRegistryPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
|
||||
{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)}>
|
||||
<option value="">全部分类</option>
|
||||
{(categoriesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{formatTextWithCompactLongNumbers(item.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
|
||||
setLegalEntityFilter(event.target.value);
|
||||
setCostCenterFilter("");
|
||||
}}>
|
||||
<option value="">全部法体</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{formatTextWithCompactLongNumbers(item.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={costCenterFilter} onChange={(event) => setCostCenterFilter(event.target.value)}>
|
||||
<option value="">全部成本中心</option>
|
||||
{filteredFilterCostCenters.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
|
||||
<option value="">全部归属人</option>
|
||||
{(assigneesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{canViewAll ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
|
||||
仅自己
|
||||
我相关
|
||||
</Button>
|
||||
<Button type="button" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
|
||||
全范围
|
||||
@@ -993,8 +1081,8 @@ export function AssetRegistryPage() {
|
||||
{!assetsQuery.isLoading && !assetsQuery.isError ? (
|
||||
<>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{assetItems.length ? (
|
||||
assetItems.map((item) => (
|
||||
{orderedAssetItems.length ? (
|
||||
orderedAssetItems.map((item) => (
|
||||
<button
|
||||
data-testid="asset-mobile-row"
|
||||
type="button"
|
||||
@@ -1032,6 +1120,14 @@ export function AssetRegistryPage() {
|
||||
<div className="text-zinc-400">归属人</div>
|
||||
<div className="mt-0.5 truncate text-zinc-700">{item.assignee?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-zinc-400">位置</div>
|
||||
<div className="mt-0.5 truncate text-zinc-700">{item.location ?? "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="text-zinc-400">下一步</div>
|
||||
<div className="mt-0.5 truncate text-zinc-700">{assetNextStep(item)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
@@ -1045,15 +1141,15 @@ export function AssetRegistryPage() {
|
||||
<tr>
|
||||
<TableHead>资产</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>敏感级别</TableHead>
|
||||
<TableHead>责任链</TableHead>
|
||||
<TableHead>位置</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>负责人</TableHead>
|
||||
<TableHead>下一步</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assetItems.length ? (
|
||||
assetItems.map((item) => (
|
||||
{orderedAssetItems.length ? (
|
||||
orderedAssetItems.map((item) => (
|
||||
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
|
||||
selectAsset(item.id);
|
||||
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
|
||||
@@ -1066,12 +1162,16 @@ export function AssetRegistryPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{assetTypeLabel(item.assetType)}</TableCell>
|
||||
<TableCell title={item.category?.name ?? undefined}>
|
||||
{formatTextWithCompactLongNumbers(item.category?.name ?? "-")}
|
||||
<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>{sensitivityLabel(item.sensitivityLevel)}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>{item.owner?.name ?? item.assignee?.name ?? "-"}</TableCell>
|
||||
<TableCell>{assetNextStep(item)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
@@ -1124,6 +1224,27 @@ export function AssetRegistryPage() {
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{assetFormError}</p>
|
||||
) : null}
|
||||
|
||||
{!isCreatingAsset && currentAsset ? (
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
@@ -1292,6 +1413,7 @@ export function AssetRegistryPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isElectronicAssetType(assetForm.assetType) ? (
|
||||
<section className="space-y-4 border-t border-zinc-200 pt-5">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">电子资产安全字段</h4>
|
||||
@@ -1325,10 +1447,11 @@ export function AssetRegistryPage() {
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
||||
{currentAsset && canTransfer && !isCreatingAsset ? (
|
||||
{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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户