重构资产盘点与移动扫码页面(05-30-asset-inventory-mobile-redesign)
这个提交包含在:
@@ -32,10 +32,10 @@
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] 资产台账不把分类维护、盘点任务、资产流转和 ERP 同步混成一组首屏按钮。
|
- [x] 资产台账不把分类维护、盘点任务、资产流转和 ERP 同步混成一组首屏按钮。
|
||||||
- [ ] 资产详情能说明责任链、位置、状态、来源和下一步。
|
- [x] 资产详情能说明责任链、位置、状态、来源和下一步。
|
||||||
- [ ] 盘点任务和移动扫码动作清楚、单一、可确认。
|
- [x] 盘点任务和移动扫码动作清楚、单一、可确认。
|
||||||
- [ ] 高敏电子资产只展示脱敏索引和责任摘要。
|
- [x] 高敏电子资产只展示脱敏索引和责任摘要。
|
||||||
|
|
||||||
## Test Plan
|
## Test Plan
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "asset-inventory-mobile-redesign",
|
"name": "asset-inventory-mobile-redesign",
|
||||||
"title": "资产盘点与移动扫码页面重构",
|
"title": "资产盘点与移动扫码页面重构",
|
||||||
"description": "统一资产台账、盘点任务和移动扫码页面:固定资产责任链、二维码/序列号兜底、流转和处置动作归位。",
|
"description": "统一资产台账、盘点任务和移动扫码页面:固定资产责任链、二维码/序列号兜底、流转和处置动作归位。",
|
||||||
"status": "planning",
|
"status": "in_progress",
|
||||||
"dev_type": null,
|
"dev_type": null,
|
||||||
"scope": "frontend-page-redesign",
|
"scope": "frontend-page-redesign",
|
||||||
"package": null,
|
"package": null,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ActionConfirmKind =
|
|||||||
| "disable"
|
| "disable"
|
||||||
| "batchApprove"
|
| "batchApprove"
|
||||||
| "batchReject"
|
| "batchReject"
|
||||||
|
| "complete"
|
||||||
| "financeReview"
|
| "financeReview"
|
||||||
| "financePayment"
|
| "financePayment"
|
||||||
| "duplicateOverride"
|
| "duplicateOverride"
|
||||||
@@ -94,6 +95,12 @@ export function getActionConfirmCopy(kind: ActionConfirmKind, context: ActionCon
|
|||||||
confirmLabel: "确认批量驳回",
|
confirmLabel: "确认批量驳回",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
},
|
},
|
||||||
|
complete: {
|
||||||
|
title: withTarget("确认完成", targetName),
|
||||||
|
description: detail ?? "确认后该任务将进入完成状态,后续不能继续作为当前任务处理。",
|
||||||
|
confirmLabel: "确认完成",
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
financeReview: {
|
financeReview: {
|
||||||
title: withTarget("确认财务复核", targetName),
|
title: withTarget("确认财务复核", targetName),
|
||||||
description: detail ?? "确认后将更新财务复核结果,并写入财务处理记录。",
|
description: detail ?? "确认后将更新财务复核结果,并写入财务处理记录。",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type AssetCategoryItem,
|
type AssetCategoryItem,
|
||||||
type AssetErpStage,
|
type AssetErpStage,
|
||||||
type AssetErpTrack,
|
type AssetErpTrack,
|
||||||
|
type AssetItem,
|
||||||
type AssetSensitivityLevel,
|
type AssetSensitivityLevel,
|
||||||
type AssetStatus,
|
type AssetStatus,
|
||||||
type AssetType,
|
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) {
|
function typeHint(assetType: AssetType) {
|
||||||
switch (assetType) {
|
switch (assetType) {
|
||||||
case "electronic_document":
|
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 }) {
|
function CompactAssetNo({ value, copyable = true }: { value: string; copyable?: boolean }) {
|
||||||
const displayValue = formatCompactIdentifier(value, { head: 14, tail: 6, minLength: 24 });
|
const displayValue = formatCompactIdentifier(value, { head: 14, tail: 6, minLength: 24 });
|
||||||
const compacted = isCompactedIdentifier(value, displayValue);
|
const compacted = isCompactedIdentifier(value, displayValue);
|
||||||
@@ -270,6 +286,7 @@ export function AssetRegistryPage() {
|
|||||||
const [assigneeFilter, setAssigneeFilter] = useState("");
|
const [assigneeFilter, setAssigneeFilter] = useState("");
|
||||||
const [assetSearch, setAssetSearch] = useState("");
|
const [assetSearch, setAssetSearch] = useState("");
|
||||||
const [mine, setMine] = useState(!canViewAll);
|
const [mine, setMine] = useState(!canViewAll);
|
||||||
|
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||||
|
|
||||||
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
|
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
|
||||||
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||||
@@ -780,6 +797,35 @@ export function AssetRegistryPage() {
|
|||||||
|
|
||||||
const categoryItems = categoriesQuery.data?.items ?? [];
|
const categoryItems = categoriesQuery.data?.items ?? [];
|
||||||
const assetItems = assetsQuery.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) => {
|
const selectAsset = (assetId: string) => {
|
||||||
setIsCreatingAsset(false);
|
setIsCreatingAsset(false);
|
||||||
setSelectedAssetId(assetId);
|
setSelectedAssetId(assetId);
|
||||||
@@ -789,23 +835,55 @@ export function AssetRegistryPage() {
|
|||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
title="资产台账"
|
title="资产台账"
|
||||||
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
|
subtitle="管理固定资产责任链、位置、盘点和电子资产索引;分类维护和高级筛选收在台账工具。"
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
|
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
|
||||||
移动扫码查找
|
移动扫码查找
|
||||||
</Button>
|
</Button>
|
||||||
{canManage ? (
|
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
|
||||||
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
|
台账工具
|
||||||
新建资产卡片
|
</Button>
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
|
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4" data-testid="asset-registry-summary">
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
|
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
@@ -846,6 +924,57 @@ export function AssetRegistryPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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.isLoading ? <TableSkeleton columns={3} rows={5} /> : null}
|
||||||
{categoriesQuery.isError ? (
|
{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>
|
<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}
|
) : null}
|
||||||
</div>
|
</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">
|
<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
|
<Input
|
||||||
placeholder="搜索编号/名称/hash/VER"
|
placeholder="搜索编号/名称/hash/VER"
|
||||||
value={assetSearch}
|
value={assetSearch}
|
||||||
@@ -932,52 +1062,10 @@ export function AssetRegistryPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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 ? (
|
{canViewAll ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
|
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
|
||||||
仅自己
|
我相关
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
|
<Button type="button" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
|
||||||
全范围
|
全范围
|
||||||
@@ -993,8 +1081,8 @@ export function AssetRegistryPage() {
|
|||||||
{!assetsQuery.isLoading && !assetsQuery.isError ? (
|
{!assetsQuery.isLoading && !assetsQuery.isError ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3 md:hidden">
|
<div className="space-y-3 md:hidden">
|
||||||
{assetItems.length ? (
|
{orderedAssetItems.length ? (
|
||||||
assetItems.map((item) => (
|
orderedAssetItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
data-testid="asset-mobile-row"
|
data-testid="asset-mobile-row"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1032,6 +1120,14 @@ export function AssetRegistryPage() {
|
|||||||
<div className="text-zinc-400">归属人</div>
|
<div className="text-zinc-400">归属人</div>
|
||||||
<div className="mt-0.5 truncate text-zinc-700">{item.assignee?.name ?? "-"}</div>
|
<div className="mt-0.5 truncate text-zinc-700">{item.assignee?.name ?? "-"}</div>
|
||||||
</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
@@ -1045,15 +1141,15 @@ export function AssetRegistryPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<TableHead>资产</TableHead>
|
<TableHead>资产</TableHead>
|
||||||
<TableHead>类型</TableHead>
|
<TableHead>类型</TableHead>
|
||||||
<TableHead>分类</TableHead>
|
<TableHead>责任链</TableHead>
|
||||||
<TableHead>敏感级别</TableHead>
|
<TableHead>位置</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead>负责人</TableHead>
|
<TableHead>下一步</TableHead>
|
||||||
</tr>
|
</tr>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{assetItems.length ? (
|
{orderedAssetItems.length ? (
|
||||||
assetItems.map((item) => (
|
orderedAssetItems.map((item) => (
|
||||||
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
|
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
|
||||||
selectAsset(item.id);
|
selectAsset(item.id);
|
||||||
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
|
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
|
||||||
@@ -1066,12 +1162,16 @@ export function AssetRegistryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{assetTypeLabel(item.assetType)}</TableCell>
|
<TableCell>{assetTypeLabel(item.assetType)}</TableCell>
|
||||||
<TableCell title={item.category?.name ?? undefined}>
|
<TableCell>
|
||||||
{formatTextWithCompactLongNumbers(item.category?.name ?? "-")}
|
<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>
|
||||||
<TableCell>{sensitivityLabel(item.sensitivityLevel)}</TableCell>
|
|
||||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||||
<TableCell>{item.owner?.name ?? item.assignee?.name ?? "-"}</TableCell>
|
<TableCell>{assetNextStep(item)}</TableCell>
|
||||||
</TableRow>
|
</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>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{assetFormError}</p>
|
||||||
) : null}
|
) : 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">
|
<div className="space-y-6">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -1292,6 +1413,7 @@ export function AssetRegistryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{isElectronicAssetType(assetForm.assetType) ? (
|
||||||
<section className="space-y-4 border-t border-zinc-200 pt-5">
|
<section className="space-y-4 border-t border-zinc-200 pt-5">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-zinc-900">电子资产安全字段</h4>
|
<h4 className="text-sm font-semibold text-zinc-900">电子资产安全字段</h4>
|
||||||
@@ -1325,10 +1447,11 @@ export function AssetRegistryPage() {
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</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="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import {
|
|||||||
} from "../api/inventoryChecks";
|
} from "../api/inventoryChecks";
|
||||||
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
|
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
|
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
@@ -84,6 +86,7 @@ export function InventoryChecksPage() {
|
|||||||
const [recordAssetId, setRecordAssetId] = useState("");
|
const [recordAssetId, setRecordAssetId] = useState("");
|
||||||
const [recordResult, setRecordResult] = useState<InventoryCheckResult>("matched");
|
const [recordResult, setRecordResult] = useState<InventoryCheckResult>("matched");
|
||||||
const [recordNote, setRecordNote] = useState("");
|
const [recordNote, setRecordNote] = useState("");
|
||||||
|
const [confirmCompleteCheck, setConfirmCompleteCheck] = useState(false);
|
||||||
|
|
||||||
const inventoryChecksQuery = useQuery({
|
const inventoryChecksQuery = useQuery({
|
||||||
queryKey: ["inventory-checks"],
|
queryKey: ["inventory-checks"],
|
||||||
@@ -122,6 +125,19 @@ export function InventoryChecksPage() {
|
|||||||
() => (inventoryChecksQuery.data?.items ?? []).find((item) => item.id === selectedCheckId) ?? null,
|
() => (inventoryChecksQuery.data?.items ?? []).find((item) => item.id === selectedCheckId) ?? null,
|
||||||
[inventoryChecksQuery.data?.items, selectedCheckId],
|
[inventoryChecksQuery.data?.items, selectedCheckId],
|
||||||
);
|
);
|
||||||
|
const inventoryStats = useMemo(() => {
|
||||||
|
const items = inventoryChecksQuery.data?.items ?? [];
|
||||||
|
return items.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.total += 1;
|
||||||
|
if (item.status === "draft") acc.draft += 1;
|
||||||
|
if (item.status === "counting") acc.counting += 1;
|
||||||
|
if (item.status === "completed") acc.completed += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ total: 0, draft: 0, counting: 0, completed: 0 },
|
||||||
|
);
|
||||||
|
}, [inventoryChecksQuery.data?.items]);
|
||||||
|
|
||||||
const scopedAssets = useMemo(() => {
|
const scopedAssets = useMemo(() => {
|
||||||
const items = assetsQuery.data?.items ?? [];
|
const items = assetsQuery.data?.items ?? [];
|
||||||
@@ -228,10 +244,10 @@ export function InventoryChecksPage() {
|
|||||||
}, [costCentersQuery.data?.items, legalEntitiesQuery.data?.items, scopeType]);
|
}, [costCentersQuery.data?.items, legalEntitiesQuery.data?.items, scopeType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<PageScaffold className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="资产盘点"
|
title="资产盘点"
|
||||||
subtitle="创建、启动、记录并完成资产盘点任务。"
|
subtitle="按任务执行盘点:先确定范围,再启动、扫码/录入、核对结果并确认完成。"
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -244,6 +260,29 @@ export function InventoryChecksPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section className="ops-status-strip grid gap-4 md:grid-cols-4" data-testid="inventory-check-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">{inventoryStats.total}</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">{inventoryStats.draft}</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">{inventoryStats.counting}</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">{inventoryStats.completed}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">保留差异和备注</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="card-std space-y-4 p-6">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">新建盘点任务</h3>
|
<h3 className="text-base font-semibold text-zinc-900">新建盘点任务</h3>
|
||||||
@@ -347,7 +386,7 @@ export function InventoryChecksPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedCheck.status === "counting" ? (
|
{selectedCheck.status === "counting" ? (
|
||||||
<Button data-testid="inventory-complete" type="button" variant="secondary" onClick={() => completeCheckMutation.mutate()} disabled={completeCheckMutation.isPending}>
|
<Button data-testid="inventory-complete" type="button" variant="secondary" onClick={() => setConfirmCompleteCheck(true)} disabled={completeCheckMutation.isPending}>
|
||||||
{completeCheckMutation.isPending ? "完成中..." : "完成盘点"}
|
{completeCheckMutation.isPending ? "完成中..." : "完成盘点"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -426,6 +465,15 @@ export function InventoryChecksPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ActionConfirmDialog
|
||||||
|
open={confirmCompleteCheck}
|
||||||
|
onOpenChange={setConfirmCompleteCheck}
|
||||||
|
kind="complete"
|
||||||
|
targetName={selectedCheck?.checkNo}
|
||||||
|
description={`完成后该盘点任务将停止继续录入。当前已记录 ${selectedCheck?.items.length ?? 0} 条结果,请确认差异和备注已核对。`}
|
||||||
|
confirmLabel="确认完成盘点"
|
||||||
|
onConfirm={() => completeCheckMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</PageScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { BarcodeScannerPanel } from "../components/mobile/BarcodeScannerPanel";
|
import { BarcodeScannerPanel } from "../components/mobile/BarcodeScannerPanel";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
@@ -82,6 +83,30 @@ function inventoryResultLabel(result: InventoryCheckResult) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assetTypeLabel(assetType: AssetItem["assetType"]) {
|
||||||
|
switch (assetType) {
|
||||||
|
case "fixed_asset":
|
||||||
|
return "固定资产 FA";
|
||||||
|
case "electronic_document":
|
||||||
|
return "电子资料 EA-DOC";
|
||||||
|
case "electronic_system":
|
||||||
|
return "电子系统 EA-SYS";
|
||||||
|
case "code_repository":
|
||||||
|
return "代码仓库 EA-REPO";
|
||||||
|
case "backup_media":
|
||||||
|
return "备份介质 EA-BKP";
|
||||||
|
default:
|
||||||
|
return "资产";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryScopeLabel(inventoryCheck: InventoryCheck) {
|
||||||
|
if (inventoryCheck.scopeType === "all") return "全量盘点";
|
||||||
|
if (inventoryCheck.scopeType === "legal_entity") return "法体盘点";
|
||||||
|
if (inventoryCheck.scopeType === "cost_center") return "成本中心盘点";
|
||||||
|
return "位置盘点";
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
function formatDateTime(value: string) {
|
||||||
try {
|
try {
|
||||||
return new Date(value).toLocaleString("zh-CN");
|
return new Date(value).toLocaleString("zh-CN");
|
||||||
@@ -123,6 +148,7 @@ export function MobileAssetScanPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [recordResult, setRecordResult] = useState<InventoryCheckResult>("matched");
|
const [recordResult, setRecordResult] = useState<InventoryCheckResult>("matched");
|
||||||
const [recordNote, setRecordNote] = useState("");
|
const [recordNote, setRecordNote] = useState("");
|
||||||
|
const [confirmRecordScan, setConfirmRecordScan] = useState(false);
|
||||||
|
|
||||||
const inventoryChecksQuery = useQuery({
|
const inventoryChecksQuery = useQuery({
|
||||||
enabled: isInventoryMode && canRecordInventory,
|
enabled: isInventoryMode && canRecordInventory,
|
||||||
@@ -388,20 +414,35 @@ export function MobileAssetScanPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile")}>
|
<Button type="button" variant="secondary" onClick={() => navigate("/mobile")}>
|
||||||
返回资产台账
|
返回移动工作台
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold className="space-y-4">
|
<PageScaffold className="mobile-page-stack">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={isInventoryMode ? "移动扫码盘点" : "移动资产扫码"}
|
title={isInventoryMode ? "移动扫码盘点" : "移动资产扫码"}
|
||||||
subtitle={isInventoryMode ? "在盘点任务上下文中扫码定位资产并快捷录入结果。" : "扫码或手工输入资产编号,快速查看资产卡片。"}
|
subtitle={isInventoryMode ? "在盘点任务上下文中扫码定位资产并快捷录入结果。" : "扫码或手工输入资产编号,快速查看资产卡片。"}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-asset-scan-focus">
|
||||||
|
<div className="mobile-section-header mobile-section-header--top">
|
||||||
|
<div>
|
||||||
|
<h3 className="mobile-section-title">{isInventoryMode ? "先扫码,再确认盘点" : "扫码查资产"}</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600">二维码不可用时,可输入资产编号、序列号或名称兜底。</p>
|
||||||
|
</div>
|
||||||
|
<span className={`mobile-section-meta ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-info-grid text-sm">
|
||||||
|
<div className="mobile-subcard">模式:{isInventoryMode ? "盘点录入" : "资产查看"}</div>
|
||||||
|
<div className="mobile-subcard">确认:先看摘要,再提交</div>
|
||||||
|
<div className="mobile-subcard">范围:{selectedInventoryCheck ? inventoryScopeLabel(selectedInventoryCheck) : "按当前权限"}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -440,7 +481,7 @@ export function MobileAssetScanPage() {
|
|||||||
<div className="mobile-list-card flex flex-wrap items-center justify-between gap-3 text-sm">
|
<div className="mobile-list-card flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-zinc-900">{selectedInventoryCheck.checkNo}</div>
|
<div className="font-medium text-zinc-900">{selectedInventoryCheck.checkNo}</div>
|
||||||
<div className="text-zinc-500">状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{selectedInventoryCheck.scopeType} / {selectedInventoryCheck.scopeRefId ?? "全量"}</div>
|
<div className="text-zinc-500">状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{inventoryScopeLabel(selectedInventoryCheck)} / {selectedInventoryCheck.scopeRefId ?? "全量"}</div>
|
||||||
</div>
|
</div>
|
||||||
{canRecordInventory && selectedInventoryCheck.status === "draft" ? (
|
{canRecordInventory && selectedInventoryCheck.status === "draft" ? (
|
||||||
<Button type="button" onClick={() => startCheckMutation.mutate()} disabled={startCheckMutation.isPending}>
|
<Button type="button" onClick={() => startCheckMutation.mutate()} disabled={startCheckMutation.isPending}>
|
||||||
@@ -504,7 +545,7 @@ export function MobileAssetScanPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedAsset ? (
|
{selectedAsset ? (
|
||||||
<div className="mobile-card space-y-4" data-testid="mobile-asset-scan-detail">
|
<div className="mobile-card mobile-focus-card space-y-4" data-testid="mobile-asset-scan-detail">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-zinc-900">{selectedAsset.assetNo}</h3>
|
<h3 className="text-lg font-semibold text-zinc-900">{selectedAsset.assetNo}</h3>
|
||||||
@@ -516,12 +557,14 @@ export function MobileAssetScanPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mobile-info-grid">
|
<div className="mobile-info-grid">
|
||||||
|
<div className="mobile-subcard text-sm">类型:{assetTypeLabel(selectedAsset.assetType)}</div>
|
||||||
<div className="mobile-subcard text-sm">状态:{statusLabel(selectedAsset.status)}</div>
|
<div className="mobile-subcard text-sm">状态:{statusLabel(selectedAsset.status)}</div>
|
||||||
<div className="mobile-subcard text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
|
<div className="mobile-subcard text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
|
||||||
<div className="mobile-subcard text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
|
<div className="mobile-subcard text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
|
||||||
<div className="mobile-subcard text-sm">位置:{selectedAsset.location ?? "-"}</div>
|
<div className="mobile-subcard text-sm">位置:{selectedAsset.location ?? "-"}</div>
|
||||||
<div className="mobile-subcard text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
|
<div className="mobile-subcard text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
|
||||||
<div className="mobile-subcard text-sm">序列号:{selectedAsset.serialNo ?? "-"}</div>
|
<div className="mobile-subcard text-sm">序列号:{selectedAsset.serialNo ?? "-"}</div>
|
||||||
|
<div className="mobile-subcard text-sm">责任人:{selectedAsset.owner?.name ?? "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isInventoryMode && canRecordInventory && selectedInventoryCheck ? (
|
{isInventoryMode && canRecordInventory && selectedInventoryCheck ? (
|
||||||
@@ -548,7 +591,7 @@ export function MobileAssetScanPage() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleRecord()}
|
onClick={() => setConfirmRecordScan(true)}
|
||||||
disabled={recordMutation.isPending || !selectedInventoryCheck || selectedInventoryCheck.status === "completed"}
|
disabled={recordMutation.isPending || !selectedInventoryCheck || selectedInventoryCheck.status === "completed"}
|
||||||
>
|
>
|
||||||
{isOnline ? (recordMutation.isPending ? "提交中..." : "提交盘点结果") : "离线加入队列"}
|
{isOnline ? (recordMutation.isPending ? "提交中..." : "提交盘点结果") : "离线加入队列"}
|
||||||
@@ -598,6 +641,16 @@ export function MobileAssetScanPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ActionConfirmDialog
|
||||||
|
open={confirmRecordScan}
|
||||||
|
onOpenChange={setConfirmRecordScan}
|
||||||
|
kind="submit"
|
||||||
|
targetName={selectedAsset?.assetNo}
|
||||||
|
description={`将按“${inventoryResultLabel(recordResult)}”记录当前资产盘点结果${isOnline ? "" : ",当前离线时会先进入待同步队列"}。请确认资产名称、位置和责任人摘要无误。`}
|
||||||
|
confirmLabel={isOnline ? "确认提交结果" : "确认加入队列"}
|
||||||
|
onConfirm={() => handleRecord()}
|
||||||
|
/>
|
||||||
</PageScaffold>
|
</PageScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
|||||||
const assetName = `Playwright资产卡片${suffix}`;
|
const assetName = `Playwright资产卡片${suffix}`;
|
||||||
|
|
||||||
await login(page, FINANCE_MGR);
|
await login(page, FINANCE_MGR);
|
||||||
await page.getByRole("link", { name: "资产台账" }).click();
|
await (await openNavLink(page, "资产")).click();
|
||||||
|
await expect(page.getByTestId("asset-registry-summary")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("asset-category-code")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "台账工具" }).click();
|
||||||
|
await expect(page.getByTestId("asset-ledger-tools")).toBeVisible();
|
||||||
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await page.getByTestId("asset-category-reset").click();
|
await page.getByTestId("asset-category-reset").click();
|
||||||
@@ -163,6 +167,8 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
|||||||
await page.getByTestId("inventory-item-save").click();
|
await page.getByTestId("inventory-item-save").click();
|
||||||
await expect(page.getByRole("row").filter({ hasText: assetName }).last()).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByRole("row").filter({ hasText: assetName }).last()).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByTestId("inventory-complete").click();
|
await page.getByTestId("inventory-complete").click();
|
||||||
|
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认完成");
|
||||||
|
await page.getByTestId("action-confirm-submit").click();
|
||||||
await expect(page.locator(".card-std").nth(1).getByText("已完成", { exact: true })).toBeVisible({ timeout: 30_000 });
|
await expect(page.locator(".card-std").nth(1).getByText("已完成", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await logout(page, FINANCE_MGR.name);
|
await logout(page, FINANCE_MGR.name);
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户