2026-08-07 11:44:12 +08:00
|
|
|
|
/** 任务全景 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";
|
2026-08-07 17:26:14 +08:00
|
|
|
|
import { useAuth } from "../../contexts/AuthContext";
|
2026-08-07 11:44:12 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-04 17:30:26 +08:00
|
|
|
|
|
|
|
|
|
|
export default function AdminTasksPage() {
|
2026-08-07 11:44:12 +08:00
|
|
|
|
const { toast } = useToast();
|
2026-08-07 17:26:14 +08:00
|
|
|
|
const { user: currentUser } = useAuth();
|
2026-08-07 11:44:12 +08:00
|
|
|
|
|
|
|
|
|
|
// 搜索 & 筛选
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-08-09 18:10:13 +08:00
|
|
|
|
// ---- 加载产品列表(始终拉全量,不做服务端状态过滤) ----
|
|
|
|
|
|
async function loadProducts(kw: string) {
|
2026-08-07 11:44:12 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 18:10:13 +08:00
|
|
|
|
useEffect(() => { loadProducts(keyword); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
2026-08-07 11:44:12 +08:00
|
|
|
|
|
|
|
|
|
|
function handleSearch(e?: React.FormEvent) {
|
|
|
|
|
|
e?.preventDefault();
|
|
|
|
|
|
setExpandedOrders(new Set());
|
|
|
|
|
|
setExpandedProducts(new Set());
|
|
|
|
|
|
setTaskTrees({});
|
2026-08-09 18:10:13 +08:00
|
|
|
|
loadProducts(keyword);
|
2026-08-07 11:44:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 18:37:02 +08:00
|
|
|
|
// ---- 按订单分组 + 本地状态过滤(单一数据源: macro_status || status) ----
|
2026-08-07 11:44:12 +08:00
|
|
|
|
const orderGroups = useMemo<OrderGroup[]>(() => {
|
|
|
|
|
|
const map = new Map<string, ProductResponse[]>();
|
|
|
|
|
|
for (const p of products) {
|
2026-08-09 18:10:13 +08:00
|
|
|
|
if (statusFilter) {
|
2026-08-09 18:37:02 +08:00
|
|
|
|
const currentStatus = (p.macro_status || p.status).toUpperCase();
|
|
|
|
|
|
if (currentStatus !== statusFilter) continue;
|
2026-08-09 18:10:13 +08:00
|
|
|
|
}
|
2026-08-07 11:44:12 +08:00
|
|
|
|
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"
|
|
|
|
|
|
),
|
|
|
|
|
|
}));
|
2026-08-09 18:37:02 +08:00
|
|
|
|
}, [products, statusFilter]);
|
2026-08-07 11:44:12 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- 手风琴切换 ----
|
|
|
|
|
|
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);
|
2026-08-09 18:10:13 +08:00
|
|
|
|
await loadProducts(keyword);
|
2026-08-07 11:44:12 +08:00
|
|
|
|
} 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);
|
2026-08-09 18:10:13 +08:00
|
|
|
|
await loadProducts(keyword);
|
2026-08-07 11:44:12 +08:00
|
|
|
|
} 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);
|
2026-08-09 18:10:13 +08:00
|
|
|
|
await loadProducts(keyword);
|
2026-08-07 11:44:12 +08:00
|
|
|
|
} 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];
|
2026-08-07 17:26:14 +08:00
|
|
|
|
// 综合状态:优先流转树状态,兜底产品状态
|
2026-08-09 18:37:02 +08:00
|
|
|
|
// 单一数据源:后端预计算 macro_status,兜底产品 status
|
|
|
|
|
|
const currentStatus = p.macro_status || p.status;
|
|
|
|
|
|
const statusCfg = getStatusConfig(currentStatus);
|
2026-08-07 11:44:12 +08:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
) : (
|
2026-08-07 17:26:14 +08:00
|
|
|
|
p.current_location_name || p.current_location_id || "—"
|
2026-08-07 11:44:12 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</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}
|
2026-08-07 17:26:14 +08:00
|
|
|
|
currentUser={currentUser}
|
2026-08-07 11:44:12 +08:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
);
|
2026-08-04 17:30:26 +08:00
|
|
|
|
}
|