feat(任务全景): 表头排序/筛选/列拖拽 + 新增当前人滞留列
- 状态筛选 tabs 去掉「已入库」并支持多选 - 表头点击排序:身份证/序列号/当前人滞留/最新动态/创建时间,三态循环(升/降/取消) - 表头漏斗筛选:文本列模糊过滤,枚举列(宏观状态/任务状态/当前位置)多选过滤 - 列头拖拽调整列显示顺序 - 新增「当前人滞留」列,读 active_duration_hours 按天/小时/分钟着色展示 - 修复 handleSearch 引用不存在的 setExpandedProducts(应为此前的展开状态) 导致的查询报错
This commit is contained in:
@ -3,9 +3,9 @@ import { useState, useEffect, useMemo, useCallback } from "react";
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Search, Loader2, Package, ChevronDown, ChevronRight,
|
Search, Loader2, Package, ChevronDown, ChevronRight,
|
||||||
Warehouse, GitBranch, X,
|
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Tooltip } from "antd";
|
import { Tooltip, Popover, Checkbox, Input } from "antd";
|
||||||
import api from "../../services/api";
|
import api from "../../services/api";
|
||||||
import { scanProduct } from "../../services/productApi";
|
import { scanProduct } from "../../services/productApi";
|
||||||
import {
|
import {
|
||||||
@ -15,25 +15,45 @@ import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
|
|||||||
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
|
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
|
||||||
import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
||||||
import type { ProductResponse } from "../../types/admin";
|
import type { ProductResponse } from "../../types/admin";
|
||||||
import type { ProductScanResponse, TaskResponse } from "../../types/api";
|
import type { ProductScanResponse } from "../../types/api";
|
||||||
import { useToast } from "../../components/ui/Toast";
|
import { useToast } from "../../components/ui/Toast";
|
||||||
import { getStatusConfig } from "../../constants/task";
|
import { getStatusConfig } from "../../constants/task";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
||||||
const STATUS_TABS = [
|
const STATUS_TABS = [
|
||||||
{ key: "", label: "全部" },
|
|
||||||
{ key: "PENDING", label: "待接收" },
|
{ key: "PENDING", label: "待接收" },
|
||||||
{ key: "WIP", label: "进行中" },
|
{ key: "WIP", label: "进行中" },
|
||||||
{ key: "COMPLETED", label: "已完成" },
|
{ key: "COMPLETED", label: "已完成" },
|
||||||
{ key: "ARCHIVED", 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 {
|
interface OrderGroup {
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
products: ProductResponse[];
|
products: ProductResponse[];
|
||||||
allInWarehouse: boolean;
|
allInWarehouse: 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() {
|
export default function AdminTasksPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
@ -41,7 +61,7 @@ export default function AdminTasksPage() {
|
|||||||
|
|
||||||
// 搜索 & 筛选
|
// 搜索 & 筛选
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
|
||||||
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
|
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
|
||||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -60,6 +80,122 @@ export default function AdminTasksPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
|
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: ["备货", "生产", "测试", "维修", "在库"].map((v) => ({ value: v, label: v })),
|
||||||
|
render: (p) => <span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</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: "已完成" },
|
||||||
|
],
|
||||||
|
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 productExpanded = activeTreeProductId === p.serial_number;
|
||||||
|
const isTreeLoading = treeLoading[p.serial_number];
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOTAL_SPAN = columns.reduce((s, c) => s + c.colSpan, 0);
|
||||||
|
|
||||||
|
// ---- 列顺序 / 行排序 / 列筛选状态 ----
|
||||||
|
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);
|
||||||
|
|
||||||
// 从看板跳转: ?sn=xxx → 自动搜索 + 自动展开流转树
|
// 从看板跳转: ?sn=xxx → 自动搜索 + 自动展开流转树
|
||||||
const autoSn = searchParams.get("sn") || "";
|
const autoSn = searchParams.get("sn") || "";
|
||||||
|
|
||||||
@ -96,19 +232,31 @@ export default function AdminTasksPage() {
|
|||||||
|
|
||||||
function handleSearch(e?: React.FormEvent) {
|
function handleSearch(e?: React.FormEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
setExpandedProducts(new Set());
|
setExpandedOrders(new Set());
|
||||||
setTaskTrees({});
|
setTaskTrees({});
|
||||||
loadProducts(keyword);
|
loadProducts(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 按订单/设备分组 + 本地状态过滤 ----
|
// ---- 按订单/设备分组 + 状态过滤 + 列筛选 + 组内排序 ----
|
||||||
const orderGroups = useMemo<OrderGroup[]>(() => {
|
const orderGroups = useMemo<OrderGroup[]>(() => {
|
||||||
const map = new Map<string, ProductResponse[]>();
|
const map = new Map<string, ProductResponse[]>();
|
||||||
for (const p of products) {
|
for (const p of products) {
|
||||||
if (statusFilter) {
|
if (statusFilters.size > 0) {
|
||||||
const currentStatus = (p.macro_status || p.status).toUpperCase();
|
const currentStatus = (p.macro_status || p.status).toUpperCase();
|
||||||
if (currentStatus !== statusFilter) continue;
|
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"
|
const key = groupBy === "device"
|
||||||
? (p.material_name || p.material_id || "未命名设备")
|
? (p.material_name || p.material_id || "未命名设备")
|
||||||
: (p.order_no || "未绑定订单");
|
: (p.order_no || "未绑定订单");
|
||||||
@ -117,14 +265,87 @@ export default function AdminTasksPage() {
|
|||||||
}
|
}
|
||||||
return Array.from(map.entries())
|
return Array.from(map.entries())
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([orderNo, prods]) => ({
|
.map(([orderNo, prods]) => {
|
||||||
orderNo,
|
// 组内排序(升/降)
|
||||||
products: prods,
|
let sorted = prods;
|
||||||
allInWarehouse: prods.every(
|
if (sort) {
|
||||||
(p) => p.current_location_id === "virtual_warehouse"
|
const col = columns.find((c) => c.key === sort!.key);
|
||||||
),
|
if (col?.sortValue) {
|
||||||
}));
|
sorted = [...prods].sort((a, b) => {
|
||||||
}, [products, statusFilter, groupBy]);
|
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,
|
||||||
|
allInWarehouse: prods.every(
|
||||||
|
(p) => p.current_location_id === "virtual_warehouse"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [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 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(() => {
|
useEffect(() => {
|
||||||
@ -227,6 +448,44 @@ export default function AdminTasksPage() {
|
|||||||
// ---- 渲染 ----
|
// ---- 渲染 ----
|
||||||
const modalTask = modalTarget?.task ?? null;
|
const modalTask = modalTarget?.task ?? null;
|
||||||
|
|
||||||
|
// 按当前列顺序渲染的列
|
||||||
|
const orderedColumns = columnOrder
|
||||||
|
.map((key) => columns.find((c) => c.key === key))
|
||||||
|
.filter((c): c is ColumnDef => !!c);
|
||||||
|
|
||||||
|
// 列筛选面板内容
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* ---- 标题 ---- */}
|
{/* ---- 标题 ---- */}
|
||||||
@ -286,21 +545,34 @@ export default function AdminTasksPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* 状态筛选 Tabs */}
|
{/* 状态筛选 Tabs(多选) */}
|
||||||
<div className="mt-3 flex gap-1.5 flex-wrap">
|
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||||
{STATUS_TABS.map((tab) => (
|
<button
|
||||||
<button
|
onClick={() => setStatusFilters(new Set())}
|
||||||
key={tab.key}
|
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
|
||||||
onClick={() => setStatusFilter(tab.key)}
|
statusFilters.size === 0
|
||||||
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
|
? "bg-blue-600 text-white"
|
||||||
statusFilter === tab.key
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||||
? "bg-blue-600 text-white"
|
}`}
|
||||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
>
|
||||||
}`}
|
全部
|
||||||
>
|
</button>
|
||||||
{tab.label}
|
{STATUS_TABS.map((tab) => {
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -377,17 +649,48 @@ export default function AdminTasksPage() {
|
|||||||
{/* 订单展开内容 */}
|
{/* 订单展开内容 */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="border-t border-gray-100">
|
<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(16, minmax(0, 1fr))" }}>
|
<div className="grid gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500" style={{ gridTemplateColumns: `repeat(${TOTAL_SPAN}, minmax(0, 1fr))` }}>
|
||||||
<div className="col-span-2">产品身份证</div>
|
{orderedColumns.map((col) => {
|
||||||
<div className="col-span-1">序列号</div>
|
const sorted = sort?.key === col.key;
|
||||||
<div className="col-span-2">规格型号</div>
|
const hasFilter = col.filterType === "text"
|
||||||
<div className="col-span-1">宏观状态</div>
|
? !!(textFilters[col.key] || "").trim()
|
||||||
<div className="col-span-1">任务状态</div>
|
: !!enumFilters[col.key]?.size;
|
||||||
<div className="col-span-1">当前位置</div>
|
return (
|
||||||
<div className="col-span-2">最新动态</div>
|
<div
|
||||||
<div className="col-span-2">创建时间</div>
|
key={col.key}
|
||||||
<div className="col-span-2">操作</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 产品行 */}
|
{/* 产品行 */}
|
||||||
@ -395,53 +698,15 @@ export default function AdminTasksPage() {
|
|||||||
const productExpanded = activeTreeProductId === p.serial_number;
|
const productExpanded = activeTreeProductId === p.serial_number;
|
||||||
const isTreeLoading = treeLoading[p.serial_number];
|
const isTreeLoading = treeLoading[p.serial_number];
|
||||||
const tree = taskTrees[p.serial_number];
|
const tree = taskTrees[p.serial_number];
|
||||||
// 综合状态:优先流转树状态,兜底产品状态
|
|
||||||
// 单一数据源:后端预计算 macro_status,兜底产品 status
|
|
||||||
const currentStatus = p.macro_status || p.status;
|
|
||||||
const statusCfg = getStatusConfig(currentStatus);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={p.id}>
|
<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(16, minmax(0, 1fr))" }}>
|
<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(${TOTAL_SPAN}, minmax(0, 1fr))` }}>
|
||||||
<div className="col-span-2 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>
|
{orderedColumns.map((col) => (
|
||||||
<div className="col-span-1 font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>
|
<div key={col.key} style={{ gridColumn: `span ${col.colSpan} / span ${col.colSpan}` }}>
|
||||||
<div className="col-span-2 text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>
|
{col.render(p)}
|
||||||
<div className="col-span-1"><span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span></div>
|
</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-1 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">
|
|
||||||
{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>}
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* 展开的流转树 — 卡片堆叠视图 */}
|
{/* 展开的流转树 — 卡片堆叠视图 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user