857 lines
36 KiB
TypeScript
857 lines
36 KiB
TypeScript
/** 任务全景 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>
|
||
);
|
||
}
|