feat(P18B-3,P18B-4): 实现资产盘点页面与流转前端入口
这个提交包含在:
+41
-3
@@ -106,7 +106,7 @@ export type AssetListQuery = {
|
|||||||
mine?: boolean;
|
mine?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssetPayload = {
|
export type CreateAssetPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
legalEntityId: string;
|
legalEntityId: string;
|
||||||
@@ -120,6 +120,20 @@ export type AssetPayload = {
|
|||||||
retiredAt?: string | null;
|
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>) {
|
function toQueryString(query: Record<string, string | boolean | undefined>) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(query)) {
|
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}`);
|
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", {
|
return apiRequestData<{ asset: AssetItem }>("/api/assets", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
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}`, {
|
return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(payload),
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
icon: "inventory_2",
|
||||||
anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"],
|
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",
|
to: "/invoice-pool",
|
||||||
label: "票据中心",
|
label: "票据中心",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import {
|
import {
|
||||||
|
assignAsset,
|
||||||
createAsset,
|
createAsset,
|
||||||
createAssetCategory,
|
createAssetCategory,
|
||||||
fetchAssetAssignees,
|
fetchAssetAssignees,
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
fetchAssets,
|
fetchAssets,
|
||||||
patchAsset,
|
patchAsset,
|
||||||
patchAssetCategory,
|
patchAssetCategory,
|
||||||
|
returnAsset,
|
||||||
|
transferAsset,
|
||||||
type AssetCategoryItem,
|
type AssetCategoryItem,
|
||||||
type AssetStatus,
|
type AssetStatus,
|
||||||
} from "../api/assets";
|
} from "../api/assets";
|
||||||
@@ -77,6 +80,7 @@ export function AssetRegistryPage() {
|
|||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
|
|
||||||
const canManage = hasPermission(permissions, "asset.manage");
|
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 canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||||
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
|
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
|
||||||
|
|
||||||
@@ -94,6 +98,10 @@ export function AssetRegistryPage() {
|
|||||||
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||||
const [assetForm, setAssetForm] = useState(initialAssetForm);
|
const [assetForm, setAssetForm] = useState(initialAssetForm);
|
||||||
const [assetFormError, setAssetFormError] = useState("");
|
const [assetFormError, setAssetFormError] = useState("");
|
||||||
|
const [movementEmployeeId, setMovementEmployeeId] = useState("");
|
||||||
|
const [movementLocation, setMovementLocation] = useState("");
|
||||||
|
const [movementNote, setMovementNote] = useState("");
|
||||||
|
const [movementError, setMovementError] = useState("");
|
||||||
|
|
||||||
const categoriesQuery = useQuery({
|
const categoriesQuery = useQuery({
|
||||||
queryKey: ["assets", "categories", canManage],
|
queryKey: ["assets", "categories", canManage],
|
||||||
@@ -182,6 +190,10 @@ export function AssetRegistryPage() {
|
|||||||
activatedAt: asset.activatedAt ?? "",
|
activatedAt: asset.activatedAt ?? "",
|
||||||
retiredAt: asset.retiredAt ?? "",
|
retiredAt: asset.retiredAt ?? "",
|
||||||
});
|
});
|
||||||
|
setMovementEmployeeId("");
|
||||||
|
setMovementLocation(asset.location ?? "");
|
||||||
|
setMovementNote("");
|
||||||
|
setMovementError("");
|
||||||
setAssetFormError("");
|
setAssetFormError("");
|
||||||
}, [assetDetailQuery.data]);
|
}, [assetDetailQuery.data]);
|
||||||
|
|
||||||
@@ -224,7 +236,7 @@ export function AssetRegistryPage() {
|
|||||||
setAssetFormError("");
|
setAssetFormError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAssetPayload() {
|
function buildCreateAssetPayload() {
|
||||||
return {
|
return {
|
||||||
name: assetForm.name.trim(),
|
name: assetForm.name.trim(),
|
||||||
categoryId: assetForm.categoryId,
|
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() {
|
function validateAssetForm() {
|
||||||
if (!assetForm.name.trim()) {
|
if (!assetForm.name.trim()) {
|
||||||
throw new Error("请填写资产名称");
|
throw new Error("请填写资产名称");
|
||||||
@@ -284,14 +310,13 @@ export function AssetRegistryPage() {
|
|||||||
const saveAssetMutation = useMutation({
|
const saveAssetMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
validateAssetForm();
|
validateAssetForm();
|
||||||
const payload = buildAssetPayload();
|
|
||||||
if (isCreatingAsset) {
|
if (isCreatingAsset) {
|
||||||
return createAsset(payload);
|
return createAsset(buildCreateAssetPayload());
|
||||||
}
|
}
|
||||||
if (!selectedAssetId) {
|
if (!selectedAssetId) {
|
||||||
throw new Error("请先选择资产卡片");
|
throw new Error("请先选择资产卡片");
|
||||||
}
|
}
|
||||||
return patchAsset(selectedAssetId, payload);
|
return patchAsset(selectedAssetId, buildPatchAssetPayload());
|
||||||
},
|
},
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
await Promise.all([
|
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 purchaseOptions = useMemo(() => {
|
||||||
const items = approvedPurchasesQuery.data?.items ?? [];
|
const items = approvedPurchasesQuery.data?.items ?? [];
|
||||||
const currentPurchase = currentAsset?.purchaseRequest;
|
const currentPurchase = currentAsset?.purchaseRequest;
|
||||||
@@ -356,7 +469,7 @@ export function AssetRegistryPage() {
|
|||||||
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
|
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
|
||||||
actions={
|
actions={
|
||||||
canManage ? (
|
canManage ? (
|
||||||
<Button type="button" onClick={resetAssetEditor}>
|
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
|
||||||
新建资产卡片
|
新建资产卡片
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
@@ -368,7 +481,7 @@ export function AssetRegistryPage() {
|
|||||||
<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 ? (
|
||||||
<Button type="button" variant="secondary" onClick={() => resetCategoryEditor()}>
|
<Button type="button" data-testid="asset-category-reset" variant="secondary" onClick={() => resetCategoryEditor()}>
|
||||||
新建分类
|
新建分类
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -377,11 +490,11 @@ export function AssetRegistryPage() {
|
|||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">分类编码</label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">分类名称</label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">单据分类</label>
|
<label className="text-sm font-medium text-zinc-700">单据分类</label>
|
||||||
@@ -399,7 +512,7 @@ export function AssetRegistryPage() {
|
|||||||
启用分类
|
启用分类
|
||||||
</label>
|
</label>
|
||||||
{canManage ? (
|
{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 ? "保存分类" : "创建分类"}
|
{saveCategoryMutation.isPending ? "保存中..." : editingCategoryId ? "保存分类" : "创建分类"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -511,7 +624,7 @@ export function AssetRegistryPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{(assetsQuery.data?.items ?? []).length ? (
|
{(assetsQuery.data?.items ?? []).length ? (
|
||||||
assetsQuery.data?.items.map((item) => (
|
assetsQuery.data?.items.map((item) => (
|
||||||
<TableRow key={item.id} onClick={() => {
|
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
|
||||||
setIsCreatingAsset(false);
|
setIsCreatingAsset(false);
|
||||||
setSelectedAssetId(item.id);
|
setSelectedAssetId(item.id);
|
||||||
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
|
}} 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="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">资产名称</label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">资产状态</label>
|
<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) => (
|
{assetStatusOptions().filter((item) => item.value).map((item) => (
|
||||||
<option key={item.value} value={item.value}>
|
<option key={item.value} value={item.value}>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -580,7 +693,7 @@ export function AssetRegistryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">资产分类</label>
|
<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>
|
<option value="">请选择资产分类</option>
|
||||||
{(categoriesQuery.data?.items ?? []).filter((item) => item.enabled).map((item) => (
|
{(categoriesQuery.data?.items ?? []).filter((item) => item.enabled).map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
@@ -591,7 +704,7 @@ export function AssetRegistryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">法体</label>
|
<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;
|
const legalEntityId = event.target.value;
|
||||||
setAssetForm((current) => ({
|
setAssetForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -611,7 +724,7 @@ export function AssetRegistryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">成本中心</label>
|
<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>
|
<option value="">请选择成本中心</option>
|
||||||
{filteredAssetCostCenters.map((item) => (
|
{filteredAssetCostCenters.map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
@@ -622,7 +735,7 @@ export function AssetRegistryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">归属人</label>
|
<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>
|
<option value="">暂不分配</option>
|
||||||
{(assigneesQuery.data?.items ?? []).map((item) => (
|
{(assigneesQuery.data?.items ?? []).map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
@@ -644,11 +757,11 @@ export function AssetRegistryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">序列号</label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">存放位置</label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">启用日期</label>
|
<label className="text-sm font-medium text-zinc-700">启用日期</label>
|
||||||
@@ -660,9 +773,95 @@ export function AssetRegistryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-2">
|
||||||
{canManage ? (
|
{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 ? "创建资产卡片" : "保存资产卡片"}
|
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { LeaveRequestPage } from "../pages/LeaveRequestPage";
|
|||||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||||
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
|
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
|
||||||
import { AssetRegistryPage } from "../pages/AssetRegistryPage";
|
import { AssetRegistryPage } from "../pages/AssetRegistryPage";
|
||||||
|
import { InventoryChecksPage } from "../pages/InventoryChecksPage";
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
import { ProfilePage } from "../pages/ProfilePage";
|
import { ProfilePage } from "../pages/ProfilePage";
|
||||||
import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage";
|
import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage";
|
||||||
@@ -118,6 +119,15 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="inventory-checks"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["asset.inventory"]}>
|
||||||
|
<InventoryChecksPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="workflow/tasks"
|
path="workflow/tasks"
|
||||||
element={
|
element={
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户