461 行
18 KiB
TypeScript
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>
|
|
);
|
|
}
|