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

这个提交包含在:
2026-03-07 23:20:43 +08:00
父节点 946a357f8b
当前提交 94cb0fdf23
共修改 6 个文件,包含 774 行新增和 22 行删除
+41 -3
查看文件
@@ -106,7 +106,7 @@ export type AssetListQuery = {
mine?: boolean;
};
export type AssetPayload = {
export type CreateAssetPayload = {
name: string;
categoryId: string;
legalEntityId: string;
@@ -120,6 +120,20 @@ export type AssetPayload = {
retiredAt?: string | null;
};
export type PatchAssetPayload = Partial<
Omit<CreateAssetPayload, "purchaseRequestId" | "assigneeEmployeeId">
>;
export type AssetMovementResponse = {
assetId: string;
assetNo: string;
status: AssetStatus;
assigneeEmployeeId: string | null;
location: string | null;
assignmentId?: string | null;
transferId?: string | null;
};
function toQueryString(query: Record<string, string | boolean | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
@@ -181,16 +195,40 @@ export async function fetchAssetDetail(assetId: string) {
return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`);
}
export async function createAsset(payload: AssetPayload) {
export async function createAsset(payload: CreateAssetPayload) {
return apiRequestData<{ asset: AssetItem }>("/api/assets", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchAsset(assetId: string, payload: Partial<Omit<AssetPayload, "purchaseRequestId">>) {
export async function patchAsset(assetId: string, payload: PatchAssetPayload) {
return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function assignAsset(assetId: string, payload: { employeeId: string; note?: string | null }) {
return apiRequestData<AssetMovementResponse>(`/api/assets/${assetId}/assign`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function returnAsset(assetId: string, payload: { note?: string | null } = {}) {
return apiRequestData<AssetMovementResponse>(`/api/assets/${assetId}/return`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function transferAsset(
assetId: string,
payload: { toEmployeeId?: string | null; toLocation?: string | null; note?: string | null },
) {
return apiRequestData<AssetMovementResponse>(`/api/assets/${assetId}/transfer`, {
method: "POST",
body: JSON.stringify(payload),
});
}
+83
查看文件
@@ -0,0 +1,83 @@
import { apiRequestData } from "./http";
import type { AssetStatus } from "./assets";
export type InventoryCheckScopeType = "all" | "legal_entity" | "cost_center" | "location";
export type InventoryCheckStatus = "draft" | "counting" | "completed";
export type InventoryCheckResult = "matched" | "missing" | "extra" | "damaged";
export type InventoryCheckItem = {
id: string;
assetId: string;
result: InventoryCheckResult;
note: string | null;
asset: {
id: string;
assetNo: string;
name: string;
status: AssetStatus;
legalEntityId: string;
costCenterId: string;
location: string | null;
assigneeEmployeeId: string | null;
} | null;
};
export type InventoryCheck = {
id: string;
checkNo: string;
scopeType: InventoryCheckScopeType;
scopeRefId: string | null;
status: InventoryCheckStatus;
startedAt: string | null;
completedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
items: InventoryCheckItem[];
};
function toQueryString(query: Record<string, string | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (!value) {
continue;
}
params.set(key, value);
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}
export async function fetchInventoryChecks(params: { status?: InventoryCheckStatus; scopeType?: InventoryCheckScopeType } = {}) {
return apiRequestData<{ items: InventoryCheck[] }>(
`/api/inventory-checks${toQueryString(params as Record<string, string | undefined>)}`,
);
}
export async function createInventoryCheck(payload: { scopeType: InventoryCheckScopeType; scopeRefId?: string | null }) {
return apiRequestData<{ inventoryCheck: InventoryCheck }>("/api/inventory-checks", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function startInventoryCheck(inventoryCheckId: string) {
return apiRequestData<{ inventoryCheck: InventoryCheck }>(`/api/inventory-checks/${inventoryCheckId}/start`, {
method: "POST",
});
}
export async function recordInventoryItems(
inventoryCheckId: string,
payload: { items: Array<{ assetId: string; result: InventoryCheckResult; note?: string | null }> },
) {
return apiRequestData<{ inventoryCheck: InventoryCheck }>(`/api/inventory-checks/${inventoryCheckId}/items`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function completeInventoryCheck(inventoryCheckId: string) {
return apiRequestData<{ inventoryCheck: InventoryCheck }>(`/api/inventory-checks/${inventoryCheckId}/complete`, {
method: "POST",
});
}
@@ -42,6 +42,12 @@ export const NAV_ITEMS: NavItem[] = [
icon: "inventory_2",
anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"],
},
{
to: "/inventory-checks",
label: "资产盘点",
icon: "fact_check",
anyOf: ["asset.inventory"],
},
{
to: "/invoice-pool",
label: "票据中心",
+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}
+416
查看文件
@@ -0,0 +1,416 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchAssets } from "../api/assets";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
completeInventoryCheck,
createInventoryCheck,
fetchInventoryChecks,
recordInventoryItems,
startInventoryCheck,
type InventoryCheckResult,
type InventoryCheckScopeType,
} from "../api/inventoryChecks";
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
}
function scopeLabel(scopeType: InventoryCheckScopeType) {
switch (scopeType) {
case "all":
return "全量盘点";
case "legal_entity":
return "法体盘点";
case "cost_center":
return "成本中心盘点";
case "location":
return "位置盘点";
default:
return scopeType;
}
}
function resultLabel(result: InventoryCheckResult) {
switch (result) {
case "matched":
return "一致";
case "missing":
return "缺失";
case "extra":
return "盘盈";
case "damaged":
return "损坏";
default:
return result;
}
}
function statusLabel(status: string) {
switch (status) {
case "draft":
return "草稿";
case "counting":
return "盘点中";
case "completed":
return "已完成";
default:
return status;
}
}
export function InventoryChecksPage() {
const queryClient = useQueryClient();
const [scopeType, setScopeType] = useState<InventoryCheckScopeType>("all");
const [scopeRefId, setScopeRefId] = useState("");
const [selectedCheckId, setSelectedCheckId] = useState("");
const [recordAssetId, setRecordAssetId] = useState("");
const [recordResult, setRecordResult] = useState<InventoryCheckResult>("matched");
const [recordNote, setRecordNote] = useState("");
const inventoryChecksQuery = useQuery({
queryKey: ["inventory-checks"],
queryFn: () => fetchInventoryChecks(),
});
const legalEntitiesQuery = useQuery({
queryKey: ["inventory-checks", "legal-entities"],
queryFn: () => fetchLegalEntities({ enabled: true }),
});
const costCentersQuery = useQuery({
queryKey: ["inventory-checks", "cost-centers"],
queryFn: () => fetchCostCenters({ enabled: true }),
});
const assetsQuery = useQuery({
queryKey: ["inventory-checks", "assets"],
queryFn: () => fetchAssets({ mine: false }),
});
useEffect(() => {
const nextId = inventoryChecksQuery.data?.items?.[0]?.id ?? "";
if (!selectedCheckId && nextId) {
setSelectedCheckId(nextId);
}
if (selectedCheckId && inventoryChecksQuery.data?.items?.some((item) => item.id === selectedCheckId)) {
return;
}
if (nextId !== selectedCheckId) {
setSelectedCheckId(nextId);
}
}, [inventoryChecksQuery.data?.items, selectedCheckId]);
const selectedCheck = useMemo(
() => (inventoryChecksQuery.data?.items ?? []).find((item) => item.id === selectedCheckId) ?? null,
[inventoryChecksQuery.data?.items, selectedCheckId],
);
const scopedAssets = useMemo(() => {
const items = assetsQuery.data?.items ?? [];
const scope = selectedCheck ?? { scopeType, scopeRefId: scopeRefId || null };
if (scope.scopeType === "all") {
return items;
}
if (scope.scopeType === "legal_entity") {
return items.filter((item) => item.legalEntityId === scope.scopeRefId);
}
if (scope.scopeType === "cost_center") {
return items.filter((item) => item.costCenterId === scope.scopeRefId);
}
return items.filter((item) => (item.location ?? "") === (scope.scopeRefId ?? ""));
}, [assetsQuery.data?.items, scopeRefId, scopeType, selectedCheck]);
const createCheckMutation = useMutation({
mutationFn: async () => {
const payload = {
scopeType,
scopeRefId: scopeType === "all" ? null : scopeRefId || null,
};
return createInventoryCheck(payload);
},
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ["inventory-checks"] });
setSelectedCheckId(data.inventoryCheck.id);
toast.success("盘点任务已创建");
},
onError: (error) => {
toast.error(resolveError(error));
},
});
const startCheckMutation = useMutation({
mutationFn: async () => {
if (!selectedCheck) {
throw new Error("请先选择盘点任务");
}
return startInventoryCheck(selectedCheck.id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["inventory-checks"] });
toast.success("盘点任务已启动");
},
onError: (error) => {
toast.error(resolveError(error));
},
});
const recordItemMutation = useMutation({
mutationFn: async () => {
if (!selectedCheck) {
throw new Error("请先选择盘点任务");
}
if (!recordAssetId) {
throw new Error("请选择资产卡片");
}
return recordInventoryItems(selectedCheck.id, {
items: [
{
assetId: recordAssetId,
result: recordResult,
note: recordNote.trim() || null,
},
],
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["inventory-checks"] });
setRecordAssetId("");
setRecordNote("");
toast.success("盘点结果已记录");
},
onError: (error) => {
toast.error(resolveError(error));
},
});
const completeCheckMutation = useMutation({
mutationFn: async () => {
if (!selectedCheck) {
throw new Error("请先选择盘点任务");
}
return completeInventoryCheck(selectedCheck.id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["inventory-checks"] });
toast.success("盘点任务已完成");
},
onError: (error) => {
toast.error(resolveError(error));
},
});
const scopeOptions = useMemo(() => {
if (scopeType === "legal_entity") {
return (legalEntitiesQuery.data?.items ?? []).map((item) => ({ id: item.id, label: item.name }));
}
if (scopeType === "cost_center") {
return (costCentersQuery.data?.items ?? []).map((item) => ({ id: item.id, label: item.name }));
}
return [];
}, [costCentersQuery.data?.items, legalEntitiesQuery.data?.items, scopeType]);
return (
<div className="space-y-6">
<PageHeader title="资产盘点" subtitle="创建、启动、记录并完成资产盘点任务。" />
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新建盘点任务</h3>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">盘点范围</label>
<select data-testid="inventory-scope-type" className="select-std" value={scopeType} onChange={(event) => {
setScopeType(event.target.value as InventoryCheckScopeType);
setScopeRefId("");
}}>
<option value="all">全量盘点</option>
<option value="legal_entity">法体盘点</option>
<option value="cost_center">成本中心盘点</option>
<option value="location">位置盘点</option>
</select>
</div>
{scopeType === "location" ? (
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">位置</label>
<Input data-testid="inventory-scope-ref" value={scopeRefId} onChange={(event) => setScopeRefId(event.target.value)} />
</div>
) : null}
{scopeType !== "all" && scopeType !== "location" ? (
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">范围对象</label>
<select data-testid="inventory-scope-ref" className="select-std" value={scopeRefId} onChange={(event) => setScopeRefId(event.target.value)}>
<option value="">请选择</option>
{scopeOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</div>
) : null}
<Button data-testid="inventory-create" type="button" disabled={createCheckMutation.isPending} onClick={() => createCheckMutation.mutate()}>
{createCheckMutation.isPending ? "创建中..." : "创建盘点任务"}
</Button>
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<tr>
<TableHead>单号</TableHead>
<TableHead>范围</TableHead>
<TableHead>状态</TableHead>
</tr>
</TableHeader>
<TableBody>
{(inventoryChecksQuery.data?.items ?? []).map((item) => (
<TableRow
data-testid="inventory-row"
key={item.id}
className={item.id === selectedCheckId ? "bg-zinc-50 cursor-pointer" : "cursor-pointer"}
onClick={() => setSelectedCheckId(item.id)}
>
<TableCell className="font-medium text-zinc-900">{item.checkNo}</TableCell>
<TableCell>{scopeLabel(item.scopeType)}</TableCell>
<TableCell>{statusLabel(item.status)}</TableCell>
</TableRow>
))}
{!inventoryChecksQuery.isLoading && !(inventoryChecksQuery.data?.items ?? []).length ? (
<TableRow>
<TableCell colSpan={3} className="py-4 text-zinc-500">
暂无盘点任务
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</div>
<div className="card-std space-y-4 p-6">
{inventoryChecksQuery.isLoading ? <TableSkeleton columns={3} rows={6} /> : null}
{inventoryChecksQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(inventoryChecksQuery.error)}</p>
) : null}
{!inventoryChecksQuery.isLoading && !selectedCheck ? (
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
请先创建或选择盘点任务。
</div>
) : null}
{selectedCheck ? (
<>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900">{selectedCheck.checkNo}</h3>
<p className="text-sm text-zinc-500">
{scopeLabel(selectedCheck.scopeType)} · {selectedCheck.scopeRefId ?? "全量"}
</p>
</div>
<div className="flex items-center gap-2">
<span data-testid="inventory-selected-status" className="rounded-full bg-zinc-100 px-3 py-1 text-xs text-zinc-700">{statusLabel(selectedCheck.status)}</span>
{selectedCheck.status === "draft" ? (
<Button data-testid="inventory-start" type="button" onClick={() => startCheckMutation.mutate()} disabled={startCheckMutation.isPending}>
{startCheckMutation.isPending ? "启动中..." : "启动盘点"}
</Button>
) : null}
{selectedCheck.status === "counting" ? (
<Button data-testid="inventory-complete" type="button" variant="secondary" onClick={() => completeCheckMutation.mutate()} disabled={completeCheckMutation.isPending}>
{completeCheckMutation.isPending ? "完成中..." : "完成盘点"}
</Button>
) : null}
</div>
</div>
{selectedCheck.status === "counting" ? (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
<h4 className="mb-3 text-sm font-semibold text-zinc-900">记录盘点结果</h4>
<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="inventory-item-asset" className="select-std" value={recordAssetId} onChange={(event) => setRecordAssetId(event.target.value)}>
<option value="">请选择资产</option>
{scopedAssets.map((item) => (
<option key={item.id} value={item.id}>
{item.assetNo} · {item.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">盘点结果</label>
<select data-testid="inventory-item-result" className="select-std" value={recordResult} onChange={(event) => setRecordResult(event.target.value as InventoryCheckResult)}>
<option value="matched">一致</option>
<option value="missing">缺失</option>
<option value="extra">盘盈</option>
<option value="damaged">损坏</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">备注</label>
<Input data-testid="inventory-item-note" value={recordNote} onChange={(event) => setRecordNote(event.target.value)} />
</div>
</div>
<div className="mt-4">
<Button data-testid="inventory-item-save" type="button" onClick={() => recordItemMutation.mutate()} disabled={recordItemMutation.isPending}>
{recordItemMutation.isPending ? "记录中..." : "记录盘点结果"}
</Button>
</div>
</div>
) : null}
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<tr>
<TableHead>资产</TableHead>
<TableHead>结果</TableHead>
<TableHead>备注</TableHead>
</tr>
</TableHeader>
<TableBody>
{selectedCheck.items.length ? (
selectedCheck.items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.asset?.assetNo ?? item.assetId}</div>
<div className="text-xs text-zinc-500">{item.asset?.name ?? "未知资产"}</div>
</TableCell>
<TableCell>{resultLabel(item.result)}</TableCell>
<TableCell>{item.note ?? "-"}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} className="py-4 text-zinc-500">
暂无盘点结果
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
) : null}
</div>
</div>
</div>
);
}
+10
查看文件
@@ -19,6 +19,7 @@ import { LeaveRequestPage } from "../pages/LeaveRequestPage";
import { NotificationsPage } from "../pages/NotificationsPage";
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
import { AssetRegistryPage } from "../pages/AssetRegistryPage";
import { InventoryChecksPage } from "../pages/InventoryChecksPage";
import { NotFoundPage } from "../pages/NotFoundPage";
import { ProfilePage } from "../pages/ProfilePage";
import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage";
@@ -118,6 +119,15 @@ export function AppRouter() {
}
/>
<Route
path="inventory-checks"
element={
<RequirePermission anyOf={["asset.inventory"]}>
<InventoryChecksPage />
</RequirePermission>
}
/>
<Route
path="workflow/tasks"
element={