Files
track/frontend/src/pages/admin/AdminTasksPage.tsx

857 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** 任务全景 Dashboard — 按订单聚合 + 关键词搜索 + 状态筛选 */
import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
} from "lucide-react";
import { Tooltip, Popover, Checkbox, Input } from "antd";
import api from "../../services/api";
import { scanProduct } from "../../services/productApi";
import {
receiveTask, rejectTask, transferTask,
} from "../../services/taskApi";
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig, lifecycleBadge, ALL_OVERALL_OPTIONS } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
const STATUS_TABS = [
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" },
{ key: "OUTBOUND", label: "已出库" },
];
type SortOrder = "asc" | "desc";
interface ColumnDef {
key: string;
label: string;
colSpan: number;
sortable?: boolean;
sortValue?: (p: ProductResponse) => string | number;
filterType?: "text" | "enum";
getFilterValue?: (p: ProductResponse) => string;
enumOptions?: { value: string; label: string }[];
render: (p: ProductResponse) => React.ReactNode;
}
interface OrderGroup {
orderNo: string;
products: ProductResponse[];
allInWarehouse: boolean;
allCompleted: boolean;
}
/** 滞留时长格式化:小时 → 天/小时/分钟 */
function formatDuration(hours: number | null): string {
if (hours == null) return "—";
if (hours >= 24) return `${Math.round(hours / 24)}`;
if (hours >= 1) return `${Math.round(hours)}小时`;
return `${Math.max(1, Math.round(hours * 60))}分钟`;
}
export default function AdminTasksPage() {
const { toast } = useToast();
const { user: currentUser } = useAuth();
const [searchParams] = useSearchParams();
// 搜索 & 筛选
const [keyword, setKeyword] = useState("");
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 手风琴展开状态
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 🔧 排他展开:同时只展开一个产品的流转树
const [activeTreeProductId, setActiveTreeProductId] = useState<string | null>(null);
const [taskTrees, setTaskTrees] = useState<Record<string, ProductScanResponse | null>>({});
const [treeLoading, setTreeLoading] = useState<Record<string, boolean>>({});
// 弹窗
const [modalTarget, setModalTarget] = useState<ModalTarget | null>(null);
const [submitting, setSubmitting] = useState(false);
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
// ---- 列配置10列----
const columns: ColumnDef[] = [
{
key: "serial_number", label: "产品身份证", colSpan: 2,
sortable: true, sortValue: (p) => p.serial_number,
filterType: "text", getFilterValue: (p) => p.serial_number,
render: (p) => (
<div className="font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
),
},
{
key: "external_serial", label: "序列号", colSpan: 1,
sortable: true, sortValue: (p) => p.external_serial || "",
filterType: "text", getFilterValue: (p) => p.external_serial || "",
render: (p) => <div className="font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>,
},
{
key: "spec", label: "规格型号", colSpan: 2,
filterType: "text", getFilterValue: (p) => p.spec_model || p.material_name || p.material_id || "",
render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>,
},
{
key: "overall_status", label: "当前工序", colSpan: 1,
filterType: "enum", getFilterValue: (p) => p.overall_status || "—",
// 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来
enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })),
render: (p) => {
const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase);
return (
<span className="flex items-center gap-1.5">
<span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span>
{lb && (
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
{lb.label}
</span>
)}
</span>
);
},
},
{
key: "status", label: "产品状态", colSpan: 1,
filterType: "enum", getFilterValue: (p) => (p.macro_status || p.status).toUpperCase(),
enumOptions: [
{ value: "PENDING", label: "待接收" },
{ value: "WIP", label: "进行中" },
{ value: "COMPLETED", label: "已完成" },
{ value: "ARCHIVED", label: "已入库" },
{ value: "OUTBOUND", label: "已出库" },
],
render: (p) => {
const statusCfg = getStatusConfig(p.macro_status || p.status);
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span>;
},
},
{
key: "location", label: "当前位置", colSpan: 1,
filterType: "enum",
getFilterValue: (p) => p.current_location_id === "virtual_warehouse" ? "仓库" : (p.current_location_name || p.current_location_id || "—"),
render: (p) => (
<div className="text-xs text-gray-500 truncate">
{p.current_location_id === "virtual_warehouse" ? <span className="inline-flex items-center gap-1 text-purple-600">🏭 </span> : (p.current_location_name || p.current_location_id || "—")}
</div>
),
},
{
key: "active_duration", label: "当前人滞留", colSpan: 1,
sortable: true, sortValue: (p) => p.active_duration_hours ?? -1,
render: (p) => {
const h = p.active_duration_hours;
if (h == null) return <span className="text-xs text-gray-300"></span>;
const cls = h >= 24 ? "bg-red-50 text-red-600" : h >= 1 ? "bg-orange-50 text-orange-600" : "bg-emerald-50 text-emerald-600";
return <span className={`rounded-md px-1.5 py-0.5 text-xs font-bold ${cls}`}>{formatDuration(h)}</span>;
},
},
{
key: "latest", label: "最新动态", colSpan: 2,
sortable: true, sortValue: (p) => p.latest_record_time ? new Date(p.latest_record_time).getTime() : 0,
filterType: "text",
getFilterValue: (p) => {
const t = p.latest_record_time ? new Date(p.latest_record_time).toLocaleString("zh-CN") : "";
return `${p.latest_record_assignee_name || ""} ${p.latest_record_content || ""} ${t}`;
},
render: (p) => (
p.latest_record_time ? (
<Tooltip title={(p.latest_record_assignee_name ? `${p.latest_record_assignee_name}: ` : "") + (p.latest_record_content || "") + (p.latest_record_has_images ? " [含图片]" : "")}>
<div className="cursor-default">
<div className="text-[10px] text-gray-400">{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</div>
<div className="mt-0.5 flex items-center gap-1 truncate text-[11px] text-gray-600">
{p.latest_record_has_images && <span className="shrink-0">📷</span>}
<span className="truncate">
{p.latest_record_assignee_name && <span className="font-medium text-gray-700">{p.latest_record_assignee_name}: </span>}
{p.latest_record_content || (p.latest_record_has_images ? "图片记录" : "—")}
</span>
</div>
</div>
</Tooltip>
) : <span className="text-gray-300"></span>
),
},
{
key: "created_at", label: "创建时间", colSpan: 2,
sortable: true, sortValue: (p) => new Date(p.created_at).getTime(),
filterType: "text", getFilterValue: (p) => new Date(p.created_at).toLocaleDateString("zh-CN"),
render: (p) => <div className="text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>,
},
{
key: "actions", label: "操作", colSpan: 2,
render: (p) => {
const isTreeLoading = treeLoading[p.serial_number];
return (
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
</button>
);
},
},
];
// ---- 列顺序 / 行排序 / 列筛选状态 ----
const [columnOrder, setColumnOrder] = useState<string[]>(() => columns.map((c) => c.key));
const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null);
const [textFilters, setTextFilters] = useState<Record<string, string>>({});
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>({});
const [dragCol, setDragCol] = useState<string | null>(null);
const [dragOverCol, setDragOverCol] = useState<string | null>(null);
// 🔧 列显隐:默认全部显示,勾选取消的列从表格中去掉(操作列始终显示)
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
// 从看板跳转: ?sn=xxx → 自动搜索 + 自动展开流转树
const autoSn = searchParams.get("sn") || "";
// ---- 加载产品列表(始终拉全量,不做服务端状态过滤) ----
async function loadProducts(kw: string) {
setLoading(true);
setError(null);
try {
const params: Record<string, string | number> = { limit: 1000 };
if (kw.trim()) params.keyword = kw.trim();
const { data } = await api.get<ProductResponse[]>("/products/", { params });
setProducts(data);
return data;
} catch {
setError("加载产品列表失败,请检查后端服务");
return [];
} finally {
setLoading(false);
}
}
useEffect(() => {
if (autoSn) {
setKeyword(autoSn);
loadProducts(autoSn).then((data) => {
// 产品加载完成后自动打开流转树弹窗
const found = data.find((p: ProductResponse) => p.serial_number === autoSn);
if (found) openTreeModal(found.serial_number);
});
} else {
loadProducts(keyword);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
setExpandedOrders(new Set());
setTaskTrees({});
setActiveTreeProductId(null);
loadProducts(keyword);
}
// 🚀 二维码弹窗打开时锁定背景滚动(防止滚动穿透)
useEffect(() => {
if (!qrSerial) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, [qrSerial]);
// ---- 按订单/设备分组 + 状态过滤 + 列筛选 + 组内排序 ----
const orderGroups = useMemo<OrderGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
if (statusFilters.size > 0) {
const currentStatus = (p.macro_status || p.status).toUpperCase();
if (!statusFilters.has(currentStatus)) continue;
}
// 列筛选(所有列)
let skip = false;
for (const col of columns) {
if (col.filterType === "text") {
const kw = (textFilters[col.key] || "").trim().toLowerCase();
if (kw && !(col.getFilterValue!(p) || "").toLowerCase().includes(kw)) { skip = true; break; }
} else if (col.filterType === "enum") {
const set = enumFilters[col.key];
if (set && set.size > 0 && !set.has(col.getFilterValue!(p))) { skip = true; break; }
}
}
if (skip) continue;
const key = groupBy === "device"
? (p.material_name || p.material_id || "未命名设备")
: (p.order_no || "未绑定订单");
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([orderNo, prods]) => {
// 组内排序(升/降)
let sorted = prods;
if (sort) {
const col = columns.find((c) => c.key === sort!.key);
if (col?.sortValue) {
sorted = [...prods].sort((a, b) => {
const va = col.sortValue!(a);
const vb = col.sortValue!(b);
const cmp = (typeof va === "number" && typeof vb === "number")
? va - vb
: String(va ?? "").localeCompare(String(vb ?? ""));
return sort!.order === "asc" ? cmp : -cmp;
});
}
}
return {
orderNo,
products: sorted,
// 已入库 = MOM 已扫码实收 (macro_status==ARCHIVED),而非仅"在仓库位置"
allInWarehouse: prods.every(
(p) => (p.macro_status || p.status).toUpperCase() === "ARCHIVED"
),
allCompleted: prods.every(
(p) => (p.macro_status || p.status).toUpperCase() === "COMPLETED"
),
};
});
}, [products, statusFilters, groupBy, textFilters, enumFilters, sort]); // eslint-disable-line react-hooks/exhaustive-deps
function toggleStatusFilter(key: string) {
setStatusFilters((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
// ---- 列头排序:无 → 升序 → 降序 → 无 ----
function toggleSort(key: string) {
setSort((prev) => {
if (!prev || prev.key !== key) return { key, order: "asc" };
if (prev.order === "asc") return { key, order: "desc" };
return null;
});
}
// ---- 列显隐:勾选=显示,取消=隐藏(操作列始终显示) ----
function toggleColumnVisible(key: string) {
setHiddenColumns((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
// ---- 枚举列多选筛选 ----
function toggleEnumFilter(key: string, value: string) {
setEnumFilters((prev) => {
const cur = prev[key] || new Set<string>();
const next = new Set(cur);
if (next.has(value)) next.delete(value); else next.add(value);
const copy = { ...prev };
if (next.size === 0) delete copy[key];
else copy[key] = next;
return copy;
});
}
// ---- 列头拖拽换序 ----
function onDragStart(e: React.DragEvent, key: string) {
setDragCol(key);
e.dataTransfer.effectAllowed = "move";
}
function onDragOver(e: React.DragEvent, key: string) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverCol(key);
}
function onDrop(e: React.DragEvent, targetKey: string) {
e.preventDefault();
if (!dragCol || dragCol === targetKey) { setDragCol(null); setDragOverCol(null); return; }
setColumnOrder((prev) => {
const next = [...prev];
const from = next.indexOf(dragCol);
const to = next.indexOf(targetKey);
if (from < 0 || to < 0) return prev;
next.splice(from, 1);
next.splice(to, 0, dragCol);
return next;
});
setDragCol(null);
setDragOverCol(null);
}
// 🔧 默认全部展开
useEffect(() => {
setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)));
}, [orderGroups]);
// ---- 手风琴切换 ----
function toggleOrder(orderNo: string) {
setExpandedOrders((prev) => {
const next = new Set(prev);
if (next.has(orderNo)) next.delete(orderNo);
else next.add(orderNo);
return next;
});
}
// ---- 流转树宽屏弹窗(懒加载,缓存到 taskTrees ----
function openTreeModal(serialNumber: string) {
setActiveTreeProductId(serialNumber);
if (!taskTrees[serialNumber]) {
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
scanProduct(serialNumber)
.then((result) => setTaskTrees((s) => ({ ...s, [serialNumber]: result })))
.catch((err: any) => toast(err?.response?.data?.detail ?? err?.message ?? "加载任务树失败", "error"))
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
}
}
function closeTreeModal() {
setActiveTreeProductId(null);
}
// ---- 任务操作 ----
const refreshProductTree = useCallback(async (serialNumber: string) => {
try {
const result = await scanProduct(serialNumber);
setTaskTrees((s) => ({ ...s, [serialNumber]: result }));
} catch { /* 静默 */ }
}, []);
async function handleReceive() {
if (!modalTarget) return;
setSubmitting(true);
try {
await receiveTask(modalTarget.task.id);
toast("任务已接收", "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error");
} finally {
setSubmitting(false);
}
}
async function handleReject(reason: string) {
if (!modalTarget) return;
setSubmitting(true);
try {
await rejectTask(modalTarget.task.id, reason);
toast("任务已驳回", "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error");
} finally {
setSubmitting(false);
}
}
async function handleTransfer(
nextTaskName: string,
assignees: string[],
note: string,
) {
if (!modalTarget) return;
setSubmitting(true);
try {
const result = await transferTask(
modalTarget.task.id, assignees, nextTaskName, note || undefined,
);
toast(result.message, "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error");
} finally {
setSubmitting(false);
}
}
// ---- 渲染 ----
const modalTask = modalTarget?.task ?? null;
// 按当前列顺序渲染的列
const orderedColumns = columnOrder
.map((key) => columns.find((c) => c.key === key))
.filter((c): c is ColumnDef => !!c);
// 🔧 实际显示的列(排除被隐藏的)+ 动态总跨度
const visibleColumns = orderedColumns.filter((c) => !hiddenColumns.has(c.key));
const visibleSpan = visibleColumns.reduce((s, c) => s + c.colSpan, 0);
// 列设置面板内容(列显隐)
function renderColumnPanel() {
const toggleable = columns.filter((c) => c.key !== "actions");
return (
<div className="max-h-80 w-48 overflow-auto p-2">
{toggleable.map((col) => (
<label key={col.key} className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-xs text-gray-700 hover:bg-gray-50">
<Checkbox checked={!hiddenColumns.has(col.key)} onChange={() => toggleColumnVisible(col.key)} />
<span className="truncate">{col.label}</span>
</label>
))}
<div className="mt-1 flex items-center justify-between border-t border-gray-100 pt-1.5">
<button onClick={() => setHiddenColumns(new Set())} className="text-xs text-blue-600 hover:underline"></button>
<span className="text-[10px] text-gray-300"></span>
</div>
</div>
);
}
// 列筛选面板内容
function renderFilterPanel(col: ColumnDef) {
if (col.filterType === "text") {
return (
<div className="w-52 p-2">
<Input
size="small"
placeholder={`筛选${col.label}`}
value={textFilters[col.key] || ""}
onChange={(e) => setTextFilters((prev) => ({ ...prev, [col.key]: e.target.value }))}
allowClear
/>
</div>
);
}
// enum优先固定选项否则从当前数据动态去重
const options = col.enumOptions
|| Array.from(new Set(products.map((p) => col.getFilterValue!(p)))).filter(Boolean).map((v) => ({ value: v, label: v }));
const selected = enumFilters[col.key] || new Set<string>();
return (
<div className="max-h-64 w-44 overflow-auto p-2">
{options.length === 0 ? (
<div className="py-2 text-center text-xs text-gray-400"></div>
) : options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-xs text-gray-700 hover:bg-gray-50">
<Checkbox checked={selected.has(opt.value)} onChange={() => toggleEnumFilter(col.key, opt.value)} />
<span className="truncate">{opt.label}</span>
</label>
))}
</div>
);
}
return (
<div>
{/* ---- 标题 ---- */}
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-800"> Dashboard</h2>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
{/* ---- 搜索 + 筛选 ---- */}
<div className="mb-6 rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">:</span>
{(["order", "device"] as const).map(m => (
<button key={m} onClick={() => setGroupBy(m)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${groupBy === m ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-500 hover:bg-gray-200"}`}>
{m === "order" ? "按订单" : "按设备"}
</button>
))}
</div>
<div className="flex items-center gap-1">
<Popover trigger="click" placement="bottomRight" content={renderColumnPanel()}>
<button className="flex items-center gap-1 rounded px-2 py-1 text-[11px] text-gray-500 hover:bg-gray-100 hover:text-blue-600">
<Columns className="h-3 w-3" />
</button>
</Popover>
<button onClick={() => setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)))}
className="rounded px-2 py-1 text-[11px] text-blue-600 hover:bg-blue-50"></button>
<button onClick={() => setExpandedOrders(new Set())}
className="rounded px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-100"></button>
</div>
</div>
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索产品身份证、业务序列号、订单号、名称、规格型号..."
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-8 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
{keyword && (
<button
type="button"
onClick={() => { setKeyword(""); loadProducts(""); }}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<button
type="submit"
disabled={loading}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
</button>
</form>
{/* 状态筛选 Tabs多选 */}
<div className="mt-3 flex gap-1.5 flex-wrap">
<button
onClick={() => setStatusFilters(new Set())}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilters.size === 0
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
</button>
{STATUS_TABS.map((tab) => {
const active = statusFilters.has(tab.key);
return (
<button
key={tab.key}
onClick={() => toggleStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
active
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{tab.label}
</button>
);
})}
</div>
</div>
{/* ---- 错误 ---- */}
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{/* ---- 加载中 ---- */}
{loading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
)}
{/* ---- 空态 ---- */}
{!loading && products.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<Package className="mb-3 h-12 w-12" />
<p></p>
<p className="mt-1 text-sm"></p>
</div>
)}
{/* ---- 订单手风琴列表 ---- */}
{!loading && orderGroups.length > 0 && (
<div className="space-y-3">
{orderGroups.map((group) => {
const isOpen = expandedOrders.has(group.orderNo);
return (
<div
key={group.orderNo}
className="overflow-hidden rounded-xl bg-white shadow-sm"
>
{/* 订单头部 */}
<button
onClick={() => toggleOrder(group.orderNo)}
className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />
)}
<Package className="h-4 w-4 shrink-0 text-blue-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800">
{group.orderNo === "未绑定订单" || group.orderNo === "未命名设备"
? "📋 未分类"
: groupBy === "device"
? `⚙️ 设备: ${group.orderNo}`
: `📦 订单: ${group.orderNo}`}
</span>
<span className="text-xs text-gray-400">
{group.products.length}
</span>
</div>
</div>
{/* 入库状态标签 */}
{group.allInWarehouse && group.products.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700">
<Warehouse className="h-3 w-3" />
</span>
)}
{group.allCompleted && !group.allInWarehouse && group.products.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-50 px-2.5 py-0.5 text-xs font-medium text-orange-600">
<Warehouse className="h-3 w-3" />
</span>
)}
{!group.allInWarehouse && !group.allCompleted && (
<span className="text-xs text-gray-400"></span>
)}
</button>
{/* 订单展开内容 */}
{isOpen && (
<div className="border-t border-gray-100">
{/* 表头(可排序 / 可筛选 / 可拖拽换列 / 可显隐) */}
<div className="grid gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500" style={{ gridTemplateColumns: `repeat(${visibleSpan}, minmax(0, 1fr))` }}>
{visibleColumns.map((col) => {
const sorted = sort?.key === col.key;
const hasFilter = col.filterType === "text"
? !!(textFilters[col.key] || "").trim()
: !!enumFilters[col.key]?.size;
return (
<div
key={col.key}
draggable
onDragStart={(e) => onDragStart(e, col.key)}
onDragOver={(e) => onDragOver(e, col.key)}
onDrop={(e) => onDrop(e, col.key)}
onDragEnd={() => { setDragCol(null); setDragOverCol(null); }}
className={`flex select-none items-center gap-1 rounded ${dragOverCol === col.key ? "bg-blue-100 ring-2 ring-blue-300" : ""}`}
style={{ gridColumn: `span ${col.colSpan} / span ${col.colSpan}` }}
>
<span
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
className={`flex items-center gap-0.5 ${col.sortable ? "cursor-pointer hover:text-blue-600" : ""}`}
>
{col.label}
{col.sortable && (
sorted
? (sort!.order === "asc" ? <ArrowUp className="h-3 w-3 text-blue-600" /> : <ArrowDown className="h-3 w-3 text-blue-600" />)
: <ArrowUpDown className="h-3 w-3 text-gray-300" />
)}
</span>
{col.filterType && (
<Popover trigger="click" placement="bottomLeft" content={renderFilterPanel(col)}>
<button
onClick={(e) => e.stopPropagation()}
className={`rounded p-0.5 hover:bg-gray-200 ${hasFilter ? "text-blue-600" : "text-gray-400"}`}
>
<Filter className="h-3 w-3" />
</button>
</Popover>
)}
</div>
);
})}
</div>
{/* 产品行 */}
{group.products.map((p) => {
return (
<div key={p.id}>
<div className="grid gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50" style={{ gridTemplateColumns: `repeat(${visibleSpan}, minmax(0, 1fr))` }}>
{visibleColumns.map((col) => (
<div key={col.key} style={{ gridColumn: `span ${col.colSpan} / span ${col.colSpan}` }}>
{col.render(p)}
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* 🔧 二维码弹窗 */}
{qrSerial && (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setQrSerial(null)}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div className="relative z-10 rounded-xl bg-white p-6 shadow-2xl text-center" onClick={e => e.stopPropagation()}>
<h3 className="mb-3 text-sm font-bold text-gray-800"></h3>
<img src={`/api/v1/products/qrcode/${qrSerial}`} alt={`QR-${qrSerial}`} className="mx-auto h-64 w-64 rounded-lg border" />
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-700">{qrSerial}</p>
<button onClick={() => setQrSerial(null)} className="mt-4 rounded-lg bg-blue-600 px-6 py-2 text-sm text-white hover:bg-blue-700"></button>
</div>
</div>
)}
{/* 🔧 流转树宽屏弹窗 */}
{activeTreeProductId && (
<Modal
open
onClose={closeTreeModal}
title={`🔀 流转树 — ${activeTreeProductId}`}
widthClass="max-w-6xl"
bodyClassName="max-h-[90vh] overflow-y-auto"
>
{treeLoading[activeTreeProductId] ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
</div>
) : taskTrees[activeTreeProductId]?.task_tree?.length ? (
<TaskFlowView
tasks={taskTrees[activeTreeProductId].task_tree}
onAction={setModalTarget}
currentUser={currentUser}
assigneeNames={taskTrees[activeTreeProductId].assignee_names}
size="lg"
/>
) : (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<GitBranch className="mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium text-gray-500"></p>
<p className="mt-1 text-xs text-gray-400"></p>
</div>
)}
</Modal>
)}
{/* ---- 弹窗 ---- */}
<ReceiveConfirmModal
open={modalTarget?.action === "receive"}
task={modalTask}
submitting={submitting}
onClose={() => setModalTarget(null)}
onConfirm={handleReceive}
/>
<RejectModal
open={modalTarget?.action === "reject"}
task={modalTask}
submitting={submitting}
onClose={() => setModalTarget(null)}
onSubmit={handleReject}
/>
<TransferModal
open={modalTarget?.action === "transfer"}
task={modalTask}
submitting={submitting}
onClose={() => setModalTarget(null)}
onSubmit={handleTransfer}
/>
</div>
);
}