文件
hyb-oa/client/src/pages/InventoryChecksPage.tsx
T

461 行
18 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
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 { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { PageScaffold } from "../components/patterns/PageScaffold";
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 navigate = useNavigate();
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 [confirmCompleteCheck, setConfirmCompleteCheck] = useState(false);
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(() => {
if (!selectedCheckId) {
return;
}
if (!inventoryChecksQuery.data?.items?.some((item) => item.id === selectedCheckId)) {
setSelectedCheckId("");
}
}, [inventoryChecksQuery.data?.items, selectedCheckId]);
const selectedCheck = useMemo(
() => (inventoryChecksQuery.data?.items ?? []).find((item) => item.id === selectedCheckId) ?? null,
[inventoryChecksQuery.data?.items, selectedCheckId],
);
const inventoryStats = useMemo(() => {
const items = inventoryChecksQuery.data?.items ?? [];
return items.reduce(
(acc, item) => {
acc.total += 1;
if (item.status === "draft") acc.draft += 1;
if (item.status === "counting") acc.counting += 1;
if (item.status === "completed") acc.completed += 1;
return acc;
},
{ total: 0, draft: 0, counting: 0, completed: 0 },
);
}, [inventoryChecksQuery.data?.items]);
const scopedAssets = useMemo(() => {
const 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 (
<PageScaffold className="space-y-6">
<PageHeader
title="资产盘点"
actions={
<Button
type="button"
variant="secondary"
disabled={!selectedCheckId}
onClick={() => navigate(selectedCheckId ? `/mobile/assets/scan?inventoryCheckId=${selectedCheckId}` : "/mobile/assets/scan")}
>
移动扫码盘点
</Button>
}
/>
<section className="flex flex-wrap gap-2" data-testid="inventory-check-summary">
{[
["全部", inventoryStats.total],
["草稿", inventoryStats.draft],
["盘点中", inventoryStats.counting],
["已完成", inventoryStats.completed],
].map(([label, value]) => (
<span key={label} className="rounded-full border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-700">
{label} <span className="font-semibold text-zinc-900">{value}</span>
</span>
))}
</section>
<div className={selectedCheck ? "grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]" : "grid gap-6"}>
<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="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>
{selectedCheck ? (
<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}
{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={() => setConfirmCompleteCheck(true)} 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>
) : null}
</div>
<ActionConfirmDialog
open={confirmCompleteCheck}
onOpenChange={setConfirmCompleteCheck}
kind="complete"
targetName={selectedCheck?.checkNo}
description={`完成后该盘点任务将停止继续录入。当前已记录 ${selectedCheck?.items.length ?? 0} 条结果,请确认差异和备注已核对。`}
confirmLabel="确认完成盘点"
onConfirm={() => completeCheckMutation.mutate()}
/>
</PageScaffold>
);
}