2026-08-28 16:25:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* WIP 分布矩阵 — 在制品透视表(规格型号 × 人员/工序,单元格=设备数量)
|
|
|
|
|
|
* Y 轴 = 规格型号,X 轴 = 人员 或 工序,单元格 = 该交叉点的在制品设备数
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
2026-08-28 16:36:45 +08:00
|
|
|
|
import { Radio, Table, DatePicker } from "antd";
|
2026-08-28 16:35:26 +08:00
|
|
|
|
import { Loader2 } from "lucide-react";
|
2026-08-28 16:36:45 +08:00
|
|
|
|
import dayjs, { type Dayjs } from "dayjs";
|
2026-08-28 16:25:26 +08:00
|
|
|
|
import { fetchWipMatrix, type WipMatrixRow } from "../services/dashboardApi";
|
|
|
|
|
|
|
2026-08-28 16:32:01 +08:00
|
|
|
|
const { RangePicker } = DatePicker;
|
|
|
|
|
|
|
2026-08-28 16:36:45 +08:00
|
|
|
|
// 每块最多显示的工序列数,超过则分块垂直排列,避免横向超长
|
2026-08-28 16:35:26 +08:00
|
|
|
|
const CHUNK_MATRIX = 6;
|
|
|
|
|
|
|
2026-08-28 16:36:45 +08:00
|
|
|
|
// ⏰ 时间筛选(与全局概览一致)
|
|
|
|
|
|
type DateRangeKey = "today" | "7d" | "30d" | "custom";
|
|
|
|
|
|
function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
|
|
|
|
|
|
if (key === "custom" && customRange) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
since: customRange[0].startOf("day").toISOString(),
|
|
|
|
|
|
until: customRange[1].endOf("day").toISOString(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
const since = dayjs().startOf("day");
|
|
|
|
|
|
if (key === "7d") return { since: since.subtract(7, "day").toISOString() };
|
|
|
|
|
|
if (key === "30d") return { since: since.subtract(30, "day").toISOString() };
|
|
|
|
|
|
return { since: since.toISOString() };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 16:25:26 +08:00
|
|
|
|
export default function MatrixBoard() {
|
2026-08-28 16:36:45 +08:00
|
|
|
|
// 固定按工序分布(已取消按人员分布)
|
|
|
|
|
|
const [dateKey, setDateKey] = useState<DateRangeKey>("today");
|
|
|
|
|
|
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
|
2026-08-28 16:25:26 +08:00
|
|
|
|
const [data, setData] = useState<WipMatrixRow[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setLoading(true);
|
2026-08-28 16:36:45 +08:00
|
|
|
|
const { since, until } = rangeToParams(dateKey, customRange);
|
|
|
|
|
|
fetchWipMatrix("task_name", since, until)
|
2026-08-28 16:25:26 +08:00
|
|
|
|
.then(setData)
|
|
|
|
|
|
.catch(() => setData([]))
|
|
|
|
|
|
.finally(() => setLoading(false));
|
2026-08-28 16:36:45 +08:00
|
|
|
|
}, [dateKey, customRange]);
|
2026-08-28 16:25:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 🔧 核心:扁平数组 → 动态交叉表(规格型号为行、dimension_key 为列)
|
2026-08-28 16:35:26 +08:00
|
|
|
|
// 列数超过 CHUNK_MATRIX 时分块垂直排列,避免一行十几个人员横向超长
|
|
|
|
|
|
const matrixChunks = useMemo(() => {
|
2026-08-28 16:25:26 +08:00
|
|
|
|
const keys = Array.from(new Set(data.map((d) => d.dimension_key)));
|
2026-08-28 16:35:26 +08:00
|
|
|
|
if (keys.length === 0) return [];
|
2026-08-28 17:00:02 +08:00
|
|
|
|
const chunks: { columns: any[]; dataSource: any[]; keys: string[] }[] = [];
|
2026-08-28 16:35:26 +08:00
|
|
|
|
|
|
|
|
|
|
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<string, any>();
|
|
|
|
|
|
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 || [];
|
2026-08-28 16:25:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 16:35:26 +08:00
|
|
|
|
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) => <span className="font-semibold text-gray-700">{v}</span>,
|
2026-08-28 16:25:26 +08:00
|
|
|
|
},
|
2026-08-28 16:35:26 +08:00
|
|
|
|
...chunkKeys.map((key) => ({
|
|
|
|
|
|
title: key,
|
|
|
|
|
|
dataIndex: key,
|
|
|
|
|
|
align: "center" as const,
|
|
|
|
|
|
width: 120,
|
|
|
|
|
|
render: (val: number, record: any) => {
|
|
|
|
|
|
const assignees = record._assignees?.[key] || [];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="text-sm font-bold text-gray-800">{val || 0}</div>
|
|
|
|
|
|
{assignees.length > 0 && (
|
|
|
|
|
|
<div className="max-w-[100px] truncate text-[10px] text-gray-400" title={assignees.join("、")}>
|
|
|
|
|
|
{assignees.join("、")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
})),
|
|
|
|
|
|
{ title: "合计", dataIndex: "row_total", fixed: "right" as const, align: "center" as const, width: 90, render: (v: number) => <span className="font-bold text-blue-600">{v}</span> },
|
|
|
|
|
|
];
|
2026-08-28 16:25:26 +08:00
|
|
|
|
|
2026-08-28 17:00:02 +08:00
|
|
|
|
chunks.push({ columns, dataSource: Array.from(rowsMap.values()), keys: chunkKeys });
|
2026-08-28 16:35:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
return chunks;
|
2026-08-28 16:25:26 +08:00
|
|
|
|
}, [data]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-xl font-bold text-gray-800">📊 WIP 分布矩阵</h2>
|
2026-08-28 16:36:45 +08:00
|
|
|
|
<p className="mt-1 text-sm text-gray-500">生产分布透视表:规格型号 × 工序 · 含在库/完成</p>
|
2026-08-28 16:32:01 +08:00
|
|
|
|
</div>
|
2026-08-28 16:36:45 +08:00
|
|
|
|
{/* ⏰ 时间筛选(与全局概览一致) */}
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<Radio.Group
|
|
|
|
|
|
value={dateKey}
|
|
|
|
|
|
onChange={e => { setDateKey(e.target.value); setCustomRange(null); }}
|
2026-08-28 16:32:01 +08:00
|
|
|
|
size="small"
|
2026-08-28 16:36:45 +08:00
|
|
|
|
optionType="button"
|
|
|
|
|
|
buttonStyle="solid"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Radio.Button value="today">今天</Radio.Button>
|
|
|
|
|
|
<Radio.Button value="7d">近7天</Radio.Button>
|
|
|
|
|
|
<Radio.Button value="30d">近30天</Radio.Button>
|
|
|
|
|
|
<Radio.Button value="custom">自定义</Radio.Button>
|
2026-08-28 16:32:01 +08:00
|
|
|
|
</Radio.Group>
|
2026-08-28 16:36:45 +08:00
|
|
|
|
{dateKey === "custom" && (
|
|
|
|
|
|
<RangePicker
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
value={customRange as any}
|
|
|
|
|
|
onChange={dates => setCustomRange(dates as [Dayjs, Dayjs] | null)}
|
|
|
|
|
|
style={{ width: 240 }}
|
|
|
|
|
|
placeholder={["开始", "结束"]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-08-28 16:25:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-28 16:35:26 +08:00
|
|
|
|
{loading && data.length === 0 ? (
|
|
|
|
|
|
<div className="flex items-center justify-center py-20">
|
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : matrixChunks.length === 0 ? (
|
|
|
|
|
|
<div className="rounded-xl bg-white py-16 text-center text-sm text-gray-400 shadow-sm">暂无数据</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
/* 🔧 瀑布流:人员/工序列超 CHUNK_MATRIX 时,按列分块垂直向下排列 */
|
|
|
|
|
|
<div className="flex flex-col gap-8">
|
|
|
|
|
|
{matrixChunks.map((chunk, idx) => (
|
|
|
|
|
|
<div key={idx} className="relative">
|
|
|
|
|
|
{idx > 0 && <div className="absolute -top-4 left-0 w-full border-t border-dashed border-gray-200" />}
|
|
|
|
|
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
|
|
|
|
|
<Table
|
|
|
|
|
|
columns={chunk.columns}
|
|
|
|
|
|
dataSource={chunk.dataSource}
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
rowKey="spec_model"
|
|
|
|
|
|
bordered
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
pagination={false}
|
|
|
|
|
|
scroll={{ x: "max-content" }}
|
2026-08-28 17:00:02 +08:00
|
|
|
|
summary={(pageData: readonly any[]) => {
|
|
|
|
|
|
const totals: Record<string, number> = {};
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<Table.Summary fixed>
|
|
|
|
|
|
<Table.Summary.Row>
|
|
|
|
|
|
<Table.Summary.Cell index={0}><span className="font-bold text-gray-800">合计</span></Table.Summary.Cell>
|
|
|
|
|
|
{chunk.keys.map(k => (
|
|
|
|
|
|
<Table.Summary.Cell key={k} index={0} align="center">
|
|
|
|
|
|
<span className="font-semibold text-gray-700">{totals[k] || 0}</span>
|
|
|
|
|
|
</Table.Summary.Cell>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<Table.Summary.Cell index={0} align="center">
|
|
|
|
|
|
<span className="font-bold text-blue-600">{rowTotal}</span>
|
|
|
|
|
|
</Table.Summary.Cell>
|
|
|
|
|
|
</Table.Summary.Row>
|
|
|
|
|
|
</Table.Summary>
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
2026-08-28 16:35:26 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-28 16:25:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|