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

493 lines
20 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 {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X,
} from "lucide-react";
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 { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
const STATUS_TABS = [
{ key: "", label: "全部" },
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" },
];
interface OrderGroup {
orderNo: string;
products: ProductResponse[];
allInWarehouse: boolean;
}
export default function AdminTasksPage() {
const { toast } = useToast();
const { user: currentUser } = useAuth();
// 搜索 & 筛选
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("");
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 [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
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);
// ---- 加载产品列表(始终拉全量,不做服务端状态过滤) ----
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);
} catch {
setError("加载产品列表失败,请检查后端服务");
} finally {
setLoading(false);
}
}
useEffect(() => { loadProducts(keyword); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
setExpandedOrders(new Set());
setExpandedProducts(new Set());
setTaskTrees({});
loadProducts(keyword);
}
/** 🔧 本地计算产品综合状态与表格列渲染逻辑100%统一) */
function calcProductStatus(p: ProductResponse): string {
const tree = taskTrees[p.serial_number];
if (tree?.task_tree?.length) {
if (tree.task_tree.some(t => t.status === "WIP")) return "WIP";
if (tree.task_tree.every(t => t.status === "COMPLETED" || t.status === "ARCHIVED")) return "COMPLETED";
return tree.task_tree[0].status;
}
return p.status; // 未展开流转树时兜底产品状态
}
// ---- 按订单分组 + 本地状态过滤 ----
const orderGroups = useMemo<OrderGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
// 🔧 本地过滤Tab切换时不再请求后端
if (statusFilter) {
const s = calcProductStatus(p).toUpperCase();
if (s !== statusFilter) continue;
}
const key = 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]) => ({
orderNo,
products: prods,
allInWarehouse: prods.every(
(p) => p.current_location_id === "virtual_warehouse"
),
}));
}, [products, statusFilter, taskTrees]);
}, [products]);
// ---- 手风琴切换 ----
function toggleOrder(orderNo: string) {
setExpandedOrders((prev) => {
const next = new Set(prev);
if (next.has(orderNo)) next.delete(orderNo);
else next.add(orderNo);
return next;
});
}
// ---- 任务树展开/收起 ----
async function toggleProductTree(serialNumber: string) {
setExpandedProducts((prev) => {
const next = new Set(prev);
if (next.has(serialNumber)) {
next.delete(serialNumber);
return next;
}
// 加载任务树
next.add(serialNumber);
if (!taskTrees[serialNumber]) {
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
scanProduct(serialNumber)
.then((result) => {
setTaskTrees((s) => ({ ...s, [serialNumber]: result }));
})
.catch((err: any) => {
const msg =
err?.response?.data?.detail ?? err?.message ?? "加载任务树失败";
toast(msg, "error");
setExpandedProducts((prev2) => {
const n2 = new Set(prev2);
n2.delete(serialNumber);
return n2;
});
})
.finally(() => {
setTreeLoading((s) => ({ ...s, [serialNumber]: false }));
});
}
return next;
});
}
// ---- 任务操作 ----
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;
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">
<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-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
</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">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilter === tab.key
? "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}`}
</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.allInWarehouse && (
<span className="text-xs text-gray-400"></span>
)}
</button>
{/* 订单展开内容 */}
{isOpen && (
<div className="border-t border-gray-100">
{/* 表头 */}
<div className="grid grid-cols-12 gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500">
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
<div className="col-span-1"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
</div>
{/* 产品行 */}
{group.products.map((p) => {
const productExpanded = expandedProducts.has(p.serial_number);
const isTreeLoading = treeLoading[p.serial_number];
const tree = taskTrees[p.serial_number];
// 综合状态:优先流转树状态,兜底产品状态
const treeStatus = tree
? (tree.task_tree?.some(t => t.status === "WIP") ? "WIP"
: tree.task_tree?.every(t => t.status === "COMPLETED" || t.status === "ARCHIVED") ? "COMPLETED"
: tree.task_tree?.[0]?.status)
: null;
const displayStatus = treeStatus || p.status;
const statusCfg = getStatusConfig(displayStatus);
return (
<div key={p.id}>
<div className="grid grid-cols-12 gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50">
<div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider">
{p.serial_number}
</div>
<div className="col-span-2 text-xs text-gray-500 truncate">
{p.spec_model || p.material_name || p.material_id || "—"}
</div>
<div className="col-span-1">
<span className="text-xs font-medium text-gray-700">
{p.overall_status || "—"}
</span>
</div>
<div className="col-span-1">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}
>
{statusCfg.label}
</span>
</div>
<div className="col-span-2 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>
<div className="col-span-2 text-xs text-gray-400">
{new Date(p.created_at).toLocaleDateString("zh-CN")}
</div>
<div className="col-span-2">
<button
onClick={() => toggleProductTree(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" />
) : productExpanded ? (
<X className="h-3 w-3" />
) : (
<GitBranch className="h-3 w-3" />
)}
{productExpanded ? "收起" : "流转树"}
</button>
</div>
</div>
{/* 展开的流转树 — 卡片堆叠视图 */}
{productExpanded && tree && (
<div className="border-t border-dashed border-blue-100 bg-gradient-to-b from-blue-50/40 to-white px-5 py-4">
<h4 className="mb-3 flex items-center gap-2 text-xs font-semibold text-gray-500">
<GitBranch className="h-3.5 w-3.5" />
{p.serial_number}
</h4>
{tree.task_tree && tree.task_tree.length > 0 ? (
<TaskFlowView
tasks={tree.task_tree}
onAction={setModalTarget}
currentUser={currentUser}
/>
) : (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<GitBranch className="mb-3 h-10 w-10 text-gray-300" />
<p className="text-sm font-medium text-gray-500"></p>
<p className="mt-1 text-xs text-gray-400"></p>
</div>
)}
</div>
)}
{productExpanded && isTreeLoading && (
<div className="border-t border-dashed border-gray-100 bg-gray-50/50 px-5 py-12 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-blue-400" />
<p className="mt-2 text-xs text-gray-400">...</p>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* ---- 弹窗 ---- */}
<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>
);
}