feat(P18B-3,P18B-4): 实现资产盘点页面与流转前端入口
这个提交包含在:
@@ -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}
|
||||
|
||||
在新工单中引用
屏蔽一个用户