657 行
25 KiB
TypeScript
657 行
25 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
import { fetchAssets, type AssetItem } from "../api/assets";
|
||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||
import {
|
||
fetchInventoryChecks,
|
||
recordInventoryItems,
|
||
startInventoryCheck,
|
||
type InventoryCheck,
|
||
type InventoryCheckResult,
|
||
} from "../api/inventoryChecks";
|
||
import { isApiError } from "../api/types";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { hasPermission } from "../auth/permissions";
|
||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||
import { PageHeader } from "../components/common/PageHeader";
|
||
import { BarcodeScannerPanel } from "../components/mobile/BarcodeScannerPanel";
|
||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||
import { Button } from "../components/ui/button";
|
||
import { Input } from "../components/ui/input";
|
||
import {
|
||
enqueueInventoryScan,
|
||
listQueuedInventoryScans,
|
||
removeQueuedInventoryScan,
|
||
updateQueuedInventoryScanError,
|
||
type QueuedInventoryScanItem,
|
||
} from "../mobile/inventoryScanQueue";
|
||
|
||
function resolveErrorMessage(error: unknown) {
|
||
if (isApiError(error)) {
|
||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
}
|
||
if (error instanceof Error) {
|
||
return error.message;
|
||
}
|
||
return "请求失败,请稍后重试。";
|
||
}
|
||
|
||
function statusLabel(status: string) {
|
||
switch (status) {
|
||
case "in_stock":
|
||
return "库存中";
|
||
case "in_use":
|
||
return "使用中";
|
||
case "under_repair":
|
||
return "维修中";
|
||
case "retired":
|
||
return "已退役";
|
||
case "archived":
|
||
return "已归档";
|
||
default:
|
||
return status;
|
||
}
|
||
}
|
||
|
||
function inventoryStatusLabel(status: string) {
|
||
switch (status) {
|
||
case "draft":
|
||
return "草稿";
|
||
case "counting":
|
||
return "盘点中";
|
||
case "completed":
|
||
return "已完成";
|
||
default:
|
||
return status;
|
||
}
|
||
}
|
||
|
||
function inventoryResultLabel(result: InventoryCheckResult) {
|
||
switch (result) {
|
||
case "matched":
|
||
return "一致";
|
||
case "missing":
|
||
return "缺失";
|
||
case "extra":
|
||
return "盘盈";
|
||
case "damaged":
|
||
return "损坏";
|
||
default:
|
||
return result;
|
||
}
|
||
}
|
||
|
||
function assetTypeLabel(assetType: AssetItem["assetType"]) {
|
||
switch (assetType) {
|
||
case "fixed_asset":
|
||
return "固定资产 FA";
|
||
case "electronic_document":
|
||
return "电子资料 EA-DOC";
|
||
case "electronic_system":
|
||
return "电子系统 EA-SYS";
|
||
case "code_repository":
|
||
return "代码仓库 EA-REPO";
|
||
case "backup_media":
|
||
return "备份介质 EA-BKP";
|
||
default:
|
||
return "资产";
|
||
}
|
||
}
|
||
|
||
function inventoryScopeLabel(inventoryCheck: InventoryCheck) {
|
||
if (inventoryCheck.scopeType === "all") return "全量盘点";
|
||
if (inventoryCheck.scopeType === "legal_entity") return "法体盘点";
|
||
if (inventoryCheck.scopeType === "cost_center") return "成本中心盘点";
|
||
return "位置盘点";
|
||
}
|
||
|
||
function formatDateTime(value: string) {
|
||
try {
|
||
return new Date(value).toLocaleString("zh-CN");
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function matchesInventoryScope(asset: AssetItem, inventoryCheck: InventoryCheck | null) {
|
||
if (!inventoryCheck) {
|
||
return true;
|
||
}
|
||
if (inventoryCheck.scopeType === "all") {
|
||
return true;
|
||
}
|
||
if (inventoryCheck.scopeType === "legal_entity") {
|
||
return asset.legalEntityId === inventoryCheck.scopeRefId;
|
||
}
|
||
if (inventoryCheck.scopeType === "cost_center") {
|
||
return asset.costCenterId === inventoryCheck.scopeRefId;
|
||
}
|
||
return (asset.location ?? "") === (inventoryCheck.scopeRefId ?? "");
|
||
}
|
||
|
||
export function MobileAssetScanPage() {
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const { permissions } = useAuth();
|
||
const [searchParams] = useSearchParams();
|
||
const inventoryCheckId = searchParams.get("inventoryCheckId") ?? "";
|
||
const isInventoryMode = Boolean(inventoryCheckId);
|
||
const canRecordInventory = hasPermission(permissions, "asset.inventory");
|
||
|
||
const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);
|
||
const [searchInput, setSearchInput] = useState("");
|
||
const [searchResults, setSearchResults] = useState<AssetItem[]>([]);
|
||
const [selectedAssetId, setSelectedAssetId] = useState("");
|
||
const [message, setMessage] = useState("");
|
||
const [error, setError] = useState("");
|
||
const [recordResult, setRecordResult] = useState<InventoryCheckResult>("matched");
|
||
const [recordNote, setRecordNote] = useState("");
|
||
const [confirmRecordScan, setConfirmRecordScan] = useState(false);
|
||
|
||
const inventoryChecksQuery = useQuery({
|
||
enabled: isInventoryMode && canRecordInventory,
|
||
queryKey: ["mobile", "asset-scan", "inventory-checks"],
|
||
queryFn: () => fetchInventoryChecks(),
|
||
});
|
||
|
||
const queueQuery = useQuery({
|
||
enabled: isInventoryMode && canRecordInventory,
|
||
queryKey: ["mobile", "inventory-scan-queue"],
|
||
queryFn: () => listQueuedInventoryScans(),
|
||
});
|
||
|
||
const selectedInventoryCheck = useMemo(() => {
|
||
if (!isInventoryMode) {
|
||
return null;
|
||
}
|
||
return (inventoryChecksQuery.data?.items ?? []).find((item) => item.id === inventoryCheckId) ?? null;
|
||
}, [inventoryCheckId, inventoryChecksQuery.data?.items, isInventoryMode]);
|
||
|
||
const filteredSearchResults = useMemo(() => {
|
||
return searchResults.filter((item) => matchesInventoryScope(item, selectedInventoryCheck));
|
||
}, [searchResults, selectedInventoryCheck]);
|
||
|
||
const selectedAsset = useMemo(
|
||
() => filteredSearchResults.find((item) => item.id === selectedAssetId) ?? null,
|
||
[filteredSearchResults, selectedAssetId],
|
||
);
|
||
|
||
const currentQueueItems = useMemo(() => {
|
||
const items = queueQuery.data ?? [];
|
||
if (!inventoryCheckId) {
|
||
return items;
|
||
}
|
||
return items.filter((item) => item.inventoryCheckId === inventoryCheckId);
|
||
}, [inventoryCheckId, queueQuery.data]);
|
||
|
||
const searchMutation = useMutation({
|
||
mutationFn: async (keyword: string) => fetchAssets({ q: keyword }),
|
||
onSuccess: (data, keyword) => {
|
||
const items = data.items;
|
||
setSearchResults(items);
|
||
setSelectedAssetId("");
|
||
setError("");
|
||
|
||
const inScopeItems = items.filter((item) => matchesInventoryScope(item, selectedInventoryCheck));
|
||
if (!items.length) {
|
||
setMessage(`未找到与“${keyword}”匹配的资产`);
|
||
return;
|
||
}
|
||
if (!inScopeItems.length) {
|
||
setMessage("已命中资产,但不在当前盘点范围内");
|
||
return;
|
||
}
|
||
if (inScopeItems.length === 1) {
|
||
setSelectedAssetId(inScopeItems[0].id);
|
||
setMessage(`已定位资产:${inScopeItems[0].assetNo}`);
|
||
return;
|
||
}
|
||
setMessage(`找到 ${inScopeItems.length} 条候选,请手动选择目标资产`);
|
||
},
|
||
onError: (searchError) => {
|
||
setSearchResults([]);
|
||
setSelectedAssetId("");
|
||
setError(resolveErrorMessage(searchError));
|
||
setMessage("");
|
||
},
|
||
});
|
||
|
||
const startCheckMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (!selectedInventoryCheck) {
|
||
throw new Error("未找到盘点任务");
|
||
}
|
||
return startInventoryCheck(selectedInventoryCheck.id);
|
||
},
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["mobile", "asset-scan", "inventory-checks"] });
|
||
setError("");
|
||
setMessage("盘点任务已启动,可继续扫码录入。");
|
||
toast.success("盘点任务已启动");
|
||
},
|
||
onError: (startError) => {
|
||
const nextError = resolveErrorMessage(startError);
|
||
setError(nextError);
|
||
toast.error(nextError);
|
||
},
|
||
});
|
||
|
||
const recordMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (!selectedInventoryCheck) {
|
||
throw new Error("未找到盘点任务");
|
||
}
|
||
if (!selectedAsset) {
|
||
throw new Error("请先选择资产");
|
||
}
|
||
return recordInventoryItems(selectedInventoryCheck.id, {
|
||
items: [
|
||
{
|
||
assetId: selectedAsset.id,
|
||
result: recordResult,
|
||
note: recordNote.trim() || null,
|
||
},
|
||
],
|
||
});
|
||
},
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["mobile", "asset-scan", "inventory-checks"] });
|
||
setError("");
|
||
setMessage("盘点结果已提交");
|
||
setRecordNote("");
|
||
toast.success("盘点结果已提交");
|
||
},
|
||
onError: (recordError) => {
|
||
const nextError = resolveErrorMessage(recordError);
|
||
setError(nextError);
|
||
toast.error(nextError);
|
||
},
|
||
});
|
||
|
||
const syncMutation = useMutation({
|
||
mutationFn: async (items: QueuedInventoryScanItem[]) => {
|
||
let succeeded = 0;
|
||
let failed = 0;
|
||
|
||
for (const item of items) {
|
||
try {
|
||
await recordInventoryItems(item.inventoryCheckId, {
|
||
items: [
|
||
{
|
||
assetId: item.assetId,
|
||
result: item.result,
|
||
note: item.note,
|
||
},
|
||
],
|
||
});
|
||
await removeQueuedInventoryScan(item.id);
|
||
succeeded += 1;
|
||
} catch (syncError) {
|
||
failed += 1;
|
||
await updateQueuedInventoryScanError(item.id, resolveErrorMessage(syncError));
|
||
}
|
||
}
|
||
|
||
return { succeeded, failed };
|
||
},
|
||
onSuccess: async (result) => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["mobile", "inventory-scan-queue"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["mobile", "asset-scan", "inventory-checks"] }),
|
||
]);
|
||
setError("");
|
||
if (result.succeeded || result.failed) {
|
||
setMessage(`队列同步完成:成功 ${result.succeeded},失败 ${result.failed}`);
|
||
}
|
||
if (result.succeeded) {
|
||
toast.success(`队列同步完成:成功 ${result.succeeded},失败 ${result.failed}`);
|
||
}
|
||
},
|
||
onError: (syncError) => {
|
||
const nextError = resolveErrorMessage(syncError);
|
||
setError(nextError);
|
||
toast.error(nextError);
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
const onOnline = () => {
|
||
setIsOnline(true);
|
||
if ((queueQuery.data ?? []).length && canRecordInventory) {
|
||
syncMutation.mutate(queueQuery.data ?? []);
|
||
}
|
||
};
|
||
const onOffline = () => setIsOnline(false);
|
||
|
||
window.addEventListener("online", onOnline);
|
||
window.addEventListener("offline", onOffline);
|
||
|
||
return () => {
|
||
window.removeEventListener("online", onOnline);
|
||
window.removeEventListener("offline", onOffline);
|
||
};
|
||
}, [canRecordInventory, queueQuery.data, syncMutation]);
|
||
|
||
const handleSearch = async (rawValue = searchInput) => {
|
||
const keyword = rawValue.trim();
|
||
setSearchInput(keyword);
|
||
setMessage("");
|
||
setError("");
|
||
|
||
if (!keyword) {
|
||
setError("请输入资产编号、序列号或名称");
|
||
return;
|
||
}
|
||
|
||
if (!isOnline) {
|
||
setError("当前离线,无法查找新资产。若已解析资产,可直接提交离线盘点结果。");
|
||
return;
|
||
}
|
||
|
||
await searchMutation.mutateAsync(keyword);
|
||
};
|
||
|
||
const handleOfflineQueue = async () => {
|
||
if (!selectedInventoryCheck) {
|
||
setError("未找到盘点任务,无法离线缓存");
|
||
return;
|
||
}
|
||
if (!selectedAsset) {
|
||
setError("请先在线解析出目标资产");
|
||
return;
|
||
}
|
||
if (selectedInventoryCheck.status !== "counting") {
|
||
setError("仅盘点中的任务支持离线缓存结果");
|
||
return;
|
||
}
|
||
|
||
await enqueueInventoryScan({
|
||
inventoryCheckId: selectedInventoryCheck.id,
|
||
assetId: selectedAsset.id,
|
||
assetNo: selectedAsset.assetNo,
|
||
result: recordResult,
|
||
note: recordNote,
|
||
});
|
||
await queryClient.invalidateQueries({ queryKey: ["mobile", "inventory-scan-queue"] });
|
||
setError("");
|
||
setMessage("当前离线,盘点结果已加入待同步队列");
|
||
toast.success("已加入离线队列");
|
||
};
|
||
|
||
const handleRecord = async () => {
|
||
if (!canRecordInventory) {
|
||
setError("当前账号没有盘点录入权限");
|
||
return;
|
||
}
|
||
if (!selectedInventoryCheck) {
|
||
setError("未找到盘点任务");
|
||
return;
|
||
}
|
||
if (selectedInventoryCheck.status === "draft") {
|
||
setError("请先启动盘点任务再记录结果");
|
||
return;
|
||
}
|
||
if (selectedInventoryCheck.status === "completed") {
|
||
setError("盘点任务已完成,不能继续录入");
|
||
return;
|
||
}
|
||
|
||
if (!isOnline) {
|
||
await handleOfflineQueue();
|
||
return;
|
||
}
|
||
|
||
await recordMutation.mutateAsync();
|
||
};
|
||
|
||
const actions = (
|
||
<div className="flex flex-wrap gap-2">
|
||
{isInventoryMode ? (
|
||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan")}>
|
||
返回盘点页
|
||
</Button>
|
||
) : (
|
||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile")}>
|
||
返回移动工作台
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<PageScaffold className="mobile-page-stack">
|
||
<PageHeader
|
||
title={isInventoryMode ? "移动扫码盘点" : "移动资产扫码"}
|
||
subtitle={isInventoryMode ? "在盘点任务上下文中扫码定位资产并快捷录入结果。" : "扫码或手工输入资产编号,快速查看资产卡片。"}
|
||
actions={actions}
|
||
/>
|
||
|
||
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-asset-scan-focus">
|
||
<div className="mobile-section-header mobile-section-header--top">
|
||
<div>
|
||
<h3 className="mobile-section-title">{isInventoryMode ? "先扫码,再确认盘点" : "扫码查资产"}</h3>
|
||
<p className="mt-1 text-sm text-zinc-600">二维码不可用时,可输入资产编号、序列号或名称兜底。</p>
|
||
</div>
|
||
<span className={`mobile-section-meta ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
||
</div>
|
||
<div className="mobile-info-grid text-sm">
|
||
<div className="mobile-subcard">模式:{isInventoryMode ? "盘点录入" : "资产查看"}</div>
|
||
<div className="mobile-subcard">确认:先看摘要,再提交</div>
|
||
<div className="mobile-subcard">范围:{selectedInventoryCheck ? inventoryScopeLabel(selectedInventoryCheck) : "按当前权限"}</div>
|
||
</div>
|
||
</section>
|
||
|
||
{error ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||
) : null}
|
||
|
||
{message ? (
|
||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||
) : null}
|
||
|
||
{isInventoryMode ? (
|
||
<div className="mobile-card space-y-3" data-testid="mobile-asset-scan-inventory-context">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-zinc-900">盘点任务上下文</h3>
|
||
<p className="text-sm text-zinc-500">任务 ID:{inventoryCheckId}</p>
|
||
</div>
|
||
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
||
</div>
|
||
|
||
{!canRecordInventory ? (
|
||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">当前账号没有盘点权限,只能查看扫码结果,不能录入盘点。</p>
|
||
) : null}
|
||
|
||
{canRecordInventory && inventoryChecksQuery.isLoading ? (
|
||
<p className="text-sm text-zinc-500">正在加载盘点任务...</p>
|
||
) : null}
|
||
|
||
{canRecordInventory && inventoryChecksQuery.isError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveErrorMessage(inventoryChecksQuery.error)}</p>
|
||
) : null}
|
||
|
||
{canRecordInventory && !inventoryChecksQuery.isLoading && !selectedInventoryCheck ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">未找到对应盘点任务,无法进入盘点录入模式。</p>
|
||
) : null}
|
||
|
||
{selectedInventoryCheck ? (
|
||
<div className="mobile-list-card flex flex-wrap items-center justify-between gap-3 text-sm">
|
||
<div>
|
||
<div className="font-medium text-zinc-900">{selectedInventoryCheck.checkNo}</div>
|
||
<div className="text-zinc-500">状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{inventoryScopeLabel(selectedInventoryCheck)} / {selectedInventoryCheck.scopeRefId ?? "全量"}</div>
|
||
</div>
|
||
{canRecordInventory && selectedInventoryCheck.status === "draft" ? (
|
||
<Button type="button" onClick={() => startCheckMutation.mutate()} disabled={startCheckMutation.isPending}>
|
||
{startCheckMutation.isPending ? "启动中..." : "开始并扫码"}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<BarcodeScannerPanel
|
||
disabled={searchMutation.isPending || (isInventoryMode && selectedInventoryCheck?.status === "draft")}
|
||
onDetected={(rawValue) => {
|
||
setSearchInput(rawValue);
|
||
void handleSearch(rawValue);
|
||
}}
|
||
/>
|
||
|
||
<div className="mobile-card space-y-3" data-testid="mobile-asset-scan-search">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h3 className="text-base font-semibold text-zinc-900">手工输入 / 搜索</h3>
|
||
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
||
</div>
|
||
|
||
<div className="grid gap-2 min-[390px]:grid-cols-[minmax(0,1fr)_auto]">
|
||
<Input
|
||
data-testid="mobile-asset-scan-input"
|
||
value={searchInput}
|
||
onChange={(event) => setSearchInput(event.target.value)}
|
||
placeholder="输入资产编号、序列号或名称"
|
||
/>
|
||
<Button type="button" onClick={() => void handleSearch()} disabled={searchMutation.isPending}>
|
||
{searchMutation.isPending ? "查询中..." : "查询资产"}
|
||
</Button>
|
||
</div>
|
||
|
||
{!isOnline ? (
|
||
<p className="text-sm text-zinc-500">离线状态下不可解析新资产;若当前页面已解析出目标资产,可继续离线缓存盘点结果。</p>
|
||
) : null}
|
||
</div>
|
||
|
||
{filteredSearchResults.length > 1 ? (
|
||
<div className="mobile-card space-y-3" data-testid="mobile-asset-scan-candidates">
|
||
<h3 className="text-base font-semibold text-zinc-900">候选资产</h3>
|
||
<div className="space-y-2">
|
||
{filteredSearchResults.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={`mobile-list-card w-full text-left text-sm ${selectedAssetId === item.id ? "border-primary bg-primary-50 text-primary" : "border-zinc-200 bg-white text-zinc-800"}`}
|
||
onClick={() => setSelectedAssetId(item.id)}
|
||
data-testid={`mobile-asset-scan-candidate-${item.id}`}
|
||
>
|
||
<div className="font-medium">{item.assetNo} · {item.name}</div>
|
||
<div className="text-xs text-zinc-500">状态:{statusLabel(item.status)} · 位置:{item.location ?? "未设置"}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{selectedAsset ? (
|
||
<div className="mobile-card mobile-focus-card space-y-4" data-testid="mobile-asset-scan-detail">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-zinc-900">{selectedAsset.assetNo}</h3>
|
||
<p className="text-sm text-zinc-500">{selectedAsset.name}</p>
|
||
</div>
|
||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan")}>
|
||
继续扫码
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="mobile-info-grid">
|
||
<div className="mobile-subcard text-sm">类型:{assetTypeLabel(selectedAsset.assetType)}</div>
|
||
<div className="mobile-subcard text-sm">状态:{statusLabel(selectedAsset.status)}</div>
|
||
<div className="mobile-subcard text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
|
||
<div className="mobile-subcard text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
|
||
<div className="mobile-subcard text-sm">位置:{selectedAsset.location ?? "-"}</div>
|
||
<div className="mobile-subcard text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
|
||
<div className="mobile-subcard text-sm">序列号:{selectedAsset.serialNo ?? "-"}</div>
|
||
<div className="mobile-subcard text-sm">责任人:{selectedAsset.owner?.name ?? "-"}</div>
|
||
</div>
|
||
|
||
{isInventoryMode && canRecordInventory && selectedInventoryCheck ? (
|
||
<div className="mobile-subcard space-y-3 bg-zinc-50">
|
||
<div>
|
||
<h4 className="text-sm font-semibold text-zinc-900">盘点录入</h4>
|
||
<p className="text-xs text-zinc-500">仅在盘点中的任务可提交或离线缓存结果。</p>
|
||
</div>
|
||
<div className="mobile-info-grid">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-zinc-700">盘点结果</label>
|
||
<select 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 value={recordNote} onChange={(event) => setRecordNote(event.target.value)} placeholder="可选备注" />
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
type="button"
|
||
onClick={() => setConfirmRecordScan(true)}
|
||
disabled={recordMutation.isPending || !selectedInventoryCheck || selectedInventoryCheck.status === "completed"}
|
||
>
|
||
{isOnline ? (recordMutation.isPending ? "提交中..." : "提交盘点结果") : "离线加入队列"}
|
||
</Button>
|
||
{currentQueueItems.length ? (
|
||
<Button type="button" variant="secondary" disabled={!isOnline || syncMutation.isPending} onClick={() => syncMutation.mutate(currentQueueItems)}>
|
||
{syncMutation.isPending ? "同步中..." : `同步队列 (${currentQueueItems.length})`}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{isInventoryMode && canRecordInventory ? (
|
||
<div className="mobile-card space-y-3" data-testid="mobile-asset-scan-queue">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h3 className="text-base font-semibold text-zinc-900">离线盘点队列</h3>
|
||
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length} 项` : "无待同步项"}</span>
|
||
</div>
|
||
|
||
{!currentQueueItems.length ? (
|
||
<p className="text-sm text-zinc-500">当前任务暂无离线队列。</p>
|
||
) : null}
|
||
|
||
{currentQueueItems.map((item) => (
|
||
<div key={item.id} className="mobile-list-card text-sm">
|
||
<div className="font-medium text-zinc-900">{item.assetNo}</div>
|
||
<div className="text-zinc-500">结果:{inventoryResultLabel(item.result)} · 创建时间:{formatDateTime(item.createdAt)}</div>
|
||
<div className="text-zinc-500">备注:{item.note ?? "-"}</div>
|
||
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
|
||
<div className="mt-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="secondary"
|
||
onClick={async () => {
|
||
await removeQueuedInventoryScan(item.id);
|
||
await queryClient.invalidateQueries({ queryKey: ["mobile", "inventory-scan-queue"] });
|
||
}}
|
||
>
|
||
移除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<ActionConfirmDialog
|
||
open={confirmRecordScan}
|
||
onOpenChange={setConfirmRecordScan}
|
||
kind="submit"
|
||
targetName={selectedAsset?.assetNo}
|
||
description={`将按“${inventoryResultLabel(recordResult)}”记录当前资产盘点结果${isOnline ? "" : ",当前离线时会先进入待同步队列"}。请确认资产名称、位置和责任人摘要无误。`}
|
||
confirmLabel={isOnline ? "确认提交结果" : "确认加入队列"}
|
||
onConfirm={() => handleRecord()}
|
||
/>
|
||
</PageScaffold>
|
||
);
|
||
}
|