feat(P18B-3,P18B-4): 实现资产盘点页面与流转前端入口

这个提交包含在:
2026-03-07 23:20:43 +08:00
父节点 946a357f8b
当前提交 94cb0fdf23
共修改 6 个文件,包含 774 行新增和 22 行删除
+218 -19
查看文件
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
assignAsset,
createAsset,
createAssetCategory,
fetchAssetAssignees,
@@ -11,6 +12,8 @@ import {
fetchAssets,
patchAsset,
patchAssetCategory,
returnAsset,
transferAsset,
type AssetCategoryItem,
type AssetStatus,
} from "../api/assets";
@@ -77,6 +80,7 @@ export function AssetRegistryPage() {
const { permissions } = useAuth();
const canManage = hasPermission(permissions, "asset.manage");
const canTransfer = hasPermission(permissions, "asset.transfer");
const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
@@ -94,6 +98,10 @@ export function AssetRegistryPage() {
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
const [assetForm, setAssetForm] = useState(initialAssetForm);
const [assetFormError, setAssetFormError] = useState("");
const [movementEmployeeId, setMovementEmployeeId] = useState("");
const [movementLocation, setMovementLocation] = useState("");
const [movementNote, setMovementNote] = useState("");
const [movementError, setMovementError] = useState("");
const categoriesQuery = useQuery({
queryKey: ["assets", "categories", canManage],
@@ -182,6 +190,10 @@ export function AssetRegistryPage() {
activatedAt: asset.activatedAt ?? "",
retiredAt: asset.retiredAt ?? "",
});
setMovementEmployeeId("");
setMovementLocation(asset.location ?? "");
setMovementNote("");
setMovementError("");
setAssetFormError("");
}, [assetDetailQuery.data]);
@@ -224,7 +236,7 @@ export function AssetRegistryPage() {
setAssetFormError("");
}
function buildAssetPayload() {
function buildCreateAssetPayload() {
return {
name: assetForm.name.trim(),
categoryId: assetForm.categoryId,
@@ -240,6 +252,20 @@ export function AssetRegistryPage() {
};
}
function buildPatchAssetPayload() {
return {
name: assetForm.name.trim(),
categoryId: assetForm.categoryId,
legalEntityId: assetForm.legalEntityId,
costCenterId: assetForm.costCenterId,
serialNo: assetForm.serialNo.trim() || null,
location: assetForm.location.trim() || null,
status: assetForm.status,
activatedAt: assetForm.activatedAt || null,
retiredAt: assetForm.retiredAt || null,
};
}
function validateAssetForm() {
if (!assetForm.name.trim()) {
throw new Error("请填写资产名称");
@@ -284,14 +310,13 @@ export function AssetRegistryPage() {
const saveAssetMutation = useMutation({
mutationFn: async () => {
validateAssetForm();
const payload = buildAssetPayload();
if (isCreatingAsset) {
return createAsset(payload);
return createAsset(buildCreateAssetPayload());
}
if (!selectedAssetId) {
throw new Error("请先选择资产卡片");
}
return patchAsset(selectedAssetId, payload);
return patchAsset(selectedAssetId, buildPatchAssetPayload());
},
onSuccess: async (data) => {
await Promise.all([
@@ -310,6 +335,94 @@ export function AssetRegistryPage() {
},
});
const assignAssetMutation = useMutation({
mutationFn: async () => {
if (!currentAsset) {
throw new Error("请先选择资产卡片");
}
if (!movementEmployeeId) {
throw new Error("请选择领用人");
}
return assignAsset(currentAsset.id, {
employeeId: movementEmployeeId,
note: movementNote.trim() || null,
});
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
]);
setMovementEmployeeId("");
setMovementNote("");
setMovementError("");
toast.success("资产已领用");
},
onError: (error) => {
const message = resolveError(error);
setMovementError(message);
toast.error(message);
},
});
const returnAssetMutation = useMutation({
mutationFn: async () => {
if (!currentAsset) {
throw new Error("请先选择资产卡片");
}
return returnAsset(currentAsset.id, { note: movementNote.trim() || null });
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
]);
setMovementEmployeeId("");
setMovementNote("");
setMovementError("");
toast.success("资产已归还");
},
onError: (error) => {
const message = resolveError(error);
setMovementError(message);
toast.error(message);
},
});
const transferAssetMutation = useMutation({
mutationFn: async () => {
if (!currentAsset) {
throw new Error("请先选择资产卡片");
}
const nextLocation = movementLocation.trim() || null;
const nextEmployeeId = movementEmployeeId || undefined;
if (!nextEmployeeId && nextLocation === (currentAsset.location ?? null)) {
throw new Error("请至少调整归属人或位置");
}
return transferAsset(currentAsset.id, {
toEmployeeId: nextEmployeeId,
toLocation: nextLocation,
note: movementNote.trim() || null,
});
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
]);
setMovementEmployeeId("");
setMovementNote("");
setMovementError("");
toast.success("资产调拨已完成");
},
onError: (error) => {
const message = resolveError(error);
setMovementError(message);
toast.error(message);
},
});
const purchaseOptions = useMemo(() => {
const items = approvedPurchasesQuery.data?.items ?? [];
const currentPurchase = currentAsset?.purchaseRequest;
@@ -356,7 +469,7 @@ export function AssetRegistryPage() {
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
actions={
canManage ? (
<Button type="button" onClick={resetAssetEditor}>
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
新建资产卡片
</Button>
) : null
@@ -368,7 +481,7 @@ export function AssetRegistryPage() {
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
{canManage ? (
<Button type="button" variant="secondary" onClick={() => resetCategoryEditor()}>
<Button type="button" data-testid="asset-category-reset" variant="secondary" onClick={() => resetCategoryEditor()}>
新建分类
</Button>
) : null}
@@ -377,11 +490,11 @@ export function AssetRegistryPage() {
<div className="grid gap-3">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">分类编码</label>
<Input value={categoryForm.code} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, code: event.target.value }))} />
<Input data-testid="asset-category-code" value={categoryForm.code} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, code: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">分类名称</label>
<Input value={categoryForm.name} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, name: event.target.value }))} />
<Input data-testid="asset-category-name" value={categoryForm.name} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, name: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">单据分类</label>
@@ -399,7 +512,7 @@ export function AssetRegistryPage() {
启用分类
</label>
{canManage ? (
<Button type="button" onClick={() => saveCategoryMutation.mutate()} disabled={saveCategoryMutation.isPending}>
<Button data-testid="asset-category-save" type="button" onClick={() => saveCategoryMutation.mutate()} disabled={saveCategoryMutation.isPending}>
{saveCategoryMutation.isPending ? "保存中..." : editingCategoryId ? "保存分类" : "创建分类"}
</Button>
) : null}
@@ -511,7 +624,7 @@ export function AssetRegistryPage() {
<TableBody>
{(assetsQuery.data?.items ?? []).length ? (
assetsQuery.data?.items.map((item) => (
<TableRow key={item.id} onClick={() => {
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
setIsCreatingAsset(false);
setSelectedAssetId(item.id);
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
@@ -566,11 +679,11 @@ export function AssetRegistryPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">资产名称</label>
<Input value={assetForm.name} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} />
<Input data-testid="asset-name" value={assetForm.name} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">资产状态</label>
<select className="select-std" value={assetForm.status} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, status: event.target.value as AssetStatus }))}>
<select data-testid="asset-status" className="select-std" value={assetForm.status} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, status: event.target.value as AssetStatus }))}>
{assetStatusOptions().filter((item) => item.value).map((item) => (
<option key={item.value} value={item.value}>
{item.label}
@@ -580,7 +693,7 @@ export function AssetRegistryPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">资产分类</label>
<select className="select-std" value={assetForm.categoryId} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, categoryId: event.target.value }))}>
<select data-testid="asset-category" className="select-std" value={assetForm.categoryId} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, categoryId: event.target.value }))}>
<option value="">请选择资产分类</option>
{(categoriesQuery.data?.items ?? []).filter((item) => item.enabled).map((item) => (
<option key={item.id} value={item.id}>
@@ -591,7 +704,7 @@ export function AssetRegistryPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">法体</label>
<select className="select-std" value={assetForm.legalEntityId} disabled={!canEditCurrent} onChange={(event) => {
<select data-testid="asset-legal-entity" className="select-std" value={assetForm.legalEntityId} disabled={!canEditCurrent} onChange={(event) => {
const legalEntityId = event.target.value;
setAssetForm((current) => ({
...current,
@@ -611,7 +724,7 @@ export function AssetRegistryPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">成本中心</label>
<select className="select-std" value={assetForm.costCenterId} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, costCenterId: event.target.value }))}>
<select data-testid="asset-cost-center" className="select-std" value={assetForm.costCenterId} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, costCenterId: event.target.value }))}>
<option value="">请选择成本中心</option>
{filteredAssetCostCenters.map((item) => (
<option key={item.id} value={item.id}>
@@ -622,7 +735,7 @@ export function AssetRegistryPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">归属人</label>
<select className="select-std" value={assetForm.assigneeEmployeeId} disabled={!canEditCurrent || !canManage} onChange={(event) => setAssetForm((current) => ({ ...current, assigneeEmployeeId: event.target.value }))}>
<select data-testid="asset-assignee" className="select-std" value={assetForm.assigneeEmployeeId} disabled={!canManage || !isCreatingAsset} onChange={(event) => setAssetForm((current) => ({ ...current, assigneeEmployeeId: event.target.value }))}>
<option value="">暂不分配</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
@@ -644,11 +757,11 @@ export function AssetRegistryPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">序列号</label>
<Input value={assetForm.serialNo} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, serialNo: event.target.value }))} />
<Input data-testid="asset-serial-no" value={assetForm.serialNo} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, serialNo: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">存放位置</label>
<Input value={assetForm.location} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, location: event.target.value }))} />
<Input data-testid="asset-location" value={assetForm.location} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, location: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">启用日期</label>
@@ -660,9 +773,95 @@ export function AssetRegistryPage() {
</div>
</div>
{currentAsset && canTransfer && !isCreatingAsset ? (
<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}
<div className="flex items-center gap-2">
{canManage ? (
<Button type="button" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
<Button type="button" data-testid="asset-save" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
</Button>
) : null}