diff --git a/frontend/src/pages/MatrixBoard.tsx b/frontend/src/pages/MatrixBoard.tsx index 974ab5c..5c7e53c 100644 --- a/frontend/src/pages/MatrixBoard.tsx +++ b/frontend/src/pages/MatrixBoard.tsx @@ -1,17 +1,18 @@ /** - * WIP 分布矩阵 — 在制品透视表(规格型号 × 人员/工序,单元格=设备数量) - * Y 轴 = 规格型号,X 轴 = 人员 或 工序,单元格 = 该交叉点的在制品设备数 + * WIP 分布矩阵 — 在制品透视表(规格型号 × 工序,单元格=设备数量) + * Y 轴 = 规格型号,X 轴 = 工序(固定标准主轴 + 动态工序),单表横向滚动 */ import { useEffect, useMemo, useState } from "react"; -import { Radio, Table, DatePicker } from "antd"; +import { Radio, Table, DatePicker, Drawer, Modal, Button, Image, Timeline } from "antd"; import { Loader2 } from "lucide-react"; import dayjs, { type Dayjs } from "dayjs"; -import { fetchWipMatrix, type WipMatrixRow } from "../services/dashboardApi"; +import { fetchWipMatrix, fetchWipMatrixDetail, type WipMatrixRow, type WipMatrixDetailRow } from "../services/dashboardApi"; +import { fetchDeviceRecords, type DeviceRecord } from "../services/analyticsApi"; const { RangePicker } = DatePicker; -// 每块最多显示的工序列数,超过则分块垂直排列,避免横向超长 -const CHUNK_MATRIX = 6; +// 🔧 固定标准主轴:核心工序 + 状态列始终显示(即使计数为 0),表头不随操作内容增减 +const FIXED_STEP_COLUMNS = ["备货", "生产", "测试", "维修", "待确认", "已完成", "已入库", "已出库"]; // ⏰ 时间筛选(与全局概览一致) type DateRangeKey = "today" | "7d" | "30d" | "custom"; @@ -28,12 +29,32 @@ function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) { return { since: since.toISOString() }; } +// ─── 图片 URL 拼接(对齐 AnalyticsDashboard / AdminPeoplePage) ──────────────── +function imageUrl(u: string) { + if (!u) return ""; + if (u.startsWith("http")) return u; + const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, ""); + const path = u.startsWith("/") ? u : "/" + u; + if (path.startsWith("/api/")) { + const origin = base.replace(/\/api(\/v\d+)?$/, ""); + return origin + path; + } + return base + path; +} + export default function MatrixBoard() { - // 固定按工序分布(已取消按人员分布) const [dateKey, setDateKey] = useState("today"); const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); + // 🔧 单元格下钻:点击数值>0 的交叉点,打开设备明细抽屉 + const [drill, setDrill] = useState<{ spec: string; process: string; productName: string } | null>(null); + const [drillData, setDrillData] = useState([]); + const [drillLoading, setDrillLoading] = useState(false); + // 🔧 设备日志弹窗:点击明细行 → 先看日志,底部按钮再跳流转树 + const [recordModal, setRecordModal] = useState(null); + const [records, setRecords] = useState([]); + const [recordsLoading, setRecordsLoading] = useState(false); useEffect(() => { setLoading(true); @@ -44,64 +65,103 @@ export default function MatrixBoard() { .finally(() => setLoading(false)); }, [dateKey, customRange]); - // 🔧 核心:扁平数组 → 动态交叉表(规格型号为行、dimension_key 为列) - // 列数超过 CHUNK_MATRIX 时分块垂直排列,避免一行十几个人员横向超长 - const matrixChunks = useMemo(() => { - const keys = Array.from(new Set(data.map((d) => d.dimension_key))); - if (keys.length === 0) return []; - const chunks: { columns: any[]; dataSource: any[]; keys: string[] }[] = []; - - for (let i = 0; i < keys.length; i += CHUNK_MATRIX) { - const chunkKeys = keys.slice(i, i + CHUNK_MATRIX); - const keySet = new Set(chunkKeys); - const rowsMap = new Map(); - for (const item of data) { - if (!keySet.has(item.dimension_key)) continue; - if (!rowsMap.has(item.spec_model)) { - rowsMap.set(item.spec_model, { spec_model: item.spec_model, _assignees: {}, row_total: 0 }); - } - const row = rowsMap.get(item.spec_model); - row[item.dimension_key] = (row[item.dimension_key] || 0) + item.count; - row.row_total += item.count; - // 记录每个交叉点的主负责人(中文名,去重) - row._assignees[item.dimension_key] = item.assignees || []; - } - - const columns: any[] = [ - { - title: "规格型号", - dataIndex: "spec_model", - fixed: "left", - width: 170, - sorter: (a: any, b: any) => String(a.spec_model).localeCompare(String(b.spec_model), "zh"), - sortDirections: ["ascend", "descend"], - render: (v: string) => {v}, - }, - ...chunkKeys.map((key) => ({ - title: key, - dataIndex: key, - align: "center" as const, - width: 120, - render: (val: number, record: any) => { - const assignees = record._assignees?.[key] || []; - return ( -
-
{val || 0}
- {assignees.length > 0 && ( -
- {assignees.join("、")} -
- )} -
- ); - }, - })), - { title: "合计", dataIndex: "row_total", fixed: "right" as const, align: "center" as const, width: 90, render: (v: number) => {v} }, - ]; - - chunks.push({ columns, dataSource: Array.from(rowsMap.values()), keys: chunkKeys }); + async function openDrill(spec: string, process: string, productName: string) { + setDrill({ spec, process, productName }); + setDrillLoading(true); + try { + // 🔧 携带当前时间筛选,保证下钻明细与矩阵计数一致(近7天/近30天等) + const { since, until } = rangeToParams(dateKey, customRange); + const res = await fetchWipMatrixDetail(spec, process, since, until); + setDrillData(res); + } catch { + setDrillData([]); + } finally { + setDrillLoading(false); } - return chunks; + } + + async function openRecords(sn: string) { + setRecordModal(sn); + setRecordsLoading(true); + try { + const res = await fetchDeviceRecords(sn); + setRecords(res); + } catch { + setRecords([]); + } finally { + setRecordsLoading(false); + } + } + + // 🔧 全局唯一表头 + 单表数据:固定标准主轴在前,动态工序(数据中出现的新工序)追加在后 + const { columns, dataSource, keys } = useMemo(() => { + const dynamicKeys = Array.from(new Set(data.map((d) => d.dimension_key))); + const keys = Array.from(new Set([...FIXED_STEP_COLUMNS, ...dynamicKeys])); + + const rowsMap = new Map(); + for (const item of data) { + if (!rowsMap.has(item.spec_model)) { + rowsMap.set(item.spec_model, { spec_model: item.spec_model, product_name: item.product_name, _assignees: {}, row_total: 0 }); + } + const row = rowsMap.get(item.spec_model); + row[item.dimension_key] = (row[item.dimension_key] || 0) + item.count; + row.row_total += item.count; + row._assignees[item.dimension_key] = item.assignees || []; + } + + const columns: any[] = [ + { + title: "产品 / 规格", + dataIndex: "spec_model", + fixed: "left", + width: 180, + sorter: (a: any, b: any) => String(a.spec_model).localeCompare(String(b.spec_model), "zh"), + sortDirections: ["ascend", "descend"], + render: (v: string, record: any) => ( + // 🔧 点击产品名称 → 显示该型号全部工序的设备 +
openDrill(record.spec_model, "", record.product_name)}> +
{record.product_name || "—"}
+
{v}
+
+ ), + }, + ...keys.map((key) => ({ + title: key, + dataIndex: key, + align: "center" as const, + width: 96, + render: (val: number, record: any) => { + const v = val || 0; + const assignees = record._assignees?.[key] || []; + return ( +
0 ? "cursor-pointer hover:bg-blue-50" : ""}`} + onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)} + > +
0 ? "text-blue-600" : "text-gray-800"}`}>{v}
+ {assignees.length > 0 && ( +
+ {assignees.join("、")} +
+ )} +
+ ); + }, + })), + { + title: "合计", + dataIndex: "row_total", + fixed: "right" as const, + align: "center" as const, + width: 76, + render: (v: number) => {v}, + }, + ]; + + return { columns, dataSource: Array.from(rowsMap.values()), keys }; }, [data]); return ( @@ -109,9 +169,13 @@ export default function MatrixBoard() {

📊 WIP 分布矩阵

-

生产分布透视表:规格型号 × 工序 · 含在库/完成

+

生产分布透视表:规格型号 × 工序 · 含已完成/已入库

+ {/* 🔴 术语解释:区分"已完成"(车间完工待实收)与"已入库"(仓库已扫码实收) */} +

+ ⓘ "已完成" = 车间完工已转交仓库,等待仓库扫码实收;"已入库" = 仓库已扫码确认实收。 +

- {/* ⏰ 时间筛选(与全局概览一致) */} + {/* ⏰ 时间筛选 */}
- ) : matrixChunks.length === 0 ? ( -
暂无数据
) : ( - /* 🔧 瀑布流:人员/工序列超 CHUNK_MATRIX 时,按列分块垂直向下排列 */ -
- {matrixChunks.map((chunk, idx) => ( -
- {idx > 0 &&
} -
- { - const totals: Record = {}; - let rowTotal = 0; - for (const row of pageData) { - rowTotal += row.row_total || 0; - for (const k of chunk.keys) totals[k] = (totals[k] || 0) + (row[k] || 0); - } - return ( - - - 合计 - {chunk.keys.map(k => ( - - {totals[k] || 0} - - ))} - - {rowTotal} - - - - ); - }} - /> - - - ))} + /* 🔧 单一完整表格 + 横向滚动:动态工序变多时整表同一行顺滑滚动 */ +
+
{ + const totals: Record = {}; + let rowTotal = 0; + for (const row of pageData) { + rowTotal += row.row_total || 0; + for (const k of keys) totals[k] = (totals[k] || 0) + (row[k] || 0); + } + return ( + + + 合计 + {keys.map(k => ( + + {totals[k] || 0} + + ))} + + {rowTotal} + + + + ); + }} + /> )} + + {/* 🔧 单元格下钻抽屉:展示该 规格型号×工序 交叉点下的设备明细 */} + setDrill(null)} + width={900} + > + {drillLoading ? ( +
+ +
+ ) : drillData.length === 0 ? ( +
该交叉点暂无设备
+ ) : ( +
({ + className: "cursor-pointer", + onClick: () => openRecords(record.serial_number), + })} + columns={[ + { + title: "产品名称", + dataIndex: "material_name", + width: 160, + render: (v) => {v || "—"}, + }, + { + title: "身份证号", + dataIndex: "serial_number", + width: 150, + render: (v) => {v}, + }, + { title: "负责人", dataIndex: "assignee", width: 80, render: (v) => v || "—" }, + { title: "工序滞留", dataIndex: "duration_hours", width: 90, render: (v) => (v != null ? `${Math.round(v * 10) / 10}h` : "—") }, + { title: "项目总时长", dataIndex: "total_hours", width: 100, render: (v) => (v != null ? `${Math.round(v * 10) / 10}h` : "—") }, + { title: "工作日时长", dataIndex: "work_hours", width: 100, render: (v) => (v != null ? `${Math.round(v * 10) / 10}h` : "—") }, + { title: "接手时间", dataIndex: "received_at", width: 105, render: (v) => v || "—" }, + ]} + scroll={{ x: 800 }} + /> + )} + + + {/* 设备日志弹窗:点击明细行先看日志,底部按钮再跳流转树 */} + setRecordModal(null)} + width={640} + footer={[ + , + ]} + > + {recordsLoading ? ( +
+ +
+ ) : records.length === 0 ? ( +
暂无记录
+ ) : ( + /* 🔧 时间线展示:与人员看板统一,只显示"谁写了什么"(操作人+备注+时间+图片),不含状态 */ +
+ ({ + key: i, + children: ( +
+
+ {r.task_name || "—"} + · {r.assignee_name || "—"} + + {r.created_at ? dayjs(r.created_at).format("MM-DD HH:mm") : ""} + +
+ {r.remark &&
{r.remark}
} + {r.images && r.images.length > 0 && ( + +
+ {r.images.map((img, j) => ( + + ))} +
+
+ )} +
+ ), + }))} + /> +
+ )} +
); } diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx index 67f5e4d..59432cb 100644 --- a/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/frontend/src/pages/admin/AdminTasksPage.tsx @@ -107,7 +107,7 @@ export default function AdminTasksPage() { { key: "overall_status", label: "当前工序", colSpan: 1, filterType: "enum", getFilterValue: (p) => p.overall_status || "—", - enumOptions: ["备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库"].map((v) => ({ value: v, label: v })), + enumOptions: ["备货", "生产", "测试", "维修", "待仓库收货", "已入库", "已出库"].map((v) => ({ value: v, label: v })), render: (p) => {p.overall_status || "—"}, }, { diff --git a/frontend/src/services/dashboardApi.ts b/frontend/src/services/dashboardApi.ts index 0da6977..b05ec76 100644 --- a/frontend/src/services/dashboardApi.ts +++ b/frontend/src/services/dashboardApi.ts @@ -73,11 +73,27 @@ export interface OperationDetail { export interface WipMatrixRow { spec_model: string; + product_name: string; dimension_key: string; count: number; assignees: string[]; } +export interface WipMatrixDetailRow { + product_id: string; + serial_number: string; + external_serial: string | null; + material_name: string; + spec_model: string; + task_status: string; + assignee_id: string; + assignee: string; + duration_hours: number; + total_hours: number; // 整个项目总时长(自然小时) + work_hours: number; // 仅工作日有效时长(小时) + received_at: string | null; +} + export interface PersonDevice { product_id: string; serial_number: string; @@ -194,6 +210,16 @@ export async function fetchWipMatrix( return data; } +export async function fetchWipMatrixDetail( + spec: string, process: string, since?: string, until?: string, +): Promise { + const params: Record = { spec, process }; + if (since) params.since = since; + if (until) params.until = until; + const { data } = await api.get("/dashboard/wip-matrix/detail", { params }); + return data; +} + export async function fetchPeopleWorkload(): Promise { const { data } = await api.get("/dashboard/people-workload"); return data;