Files
track/frontend/src/pages/MatrixBoard.tsx
duxingchen fdd7465afb feat(前端): 新增 WIP 分布矩阵透视表页面
- MatrixBoard.tsx: Radio切换 按人员/按工序,动态交叉表转换(规格型号为行)
- 单元格显示设备数量+主负责人,首列规格固定左、末列合计固定右,bordered Excel风格
- 路由 /admin/matrix 注册,侧边栏新增「WIP 分布矩阵」入口
2026-08-28 16:25:26 +08:00

93 lines
3.7 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.

/**
* WIP 分布矩阵 — 在制品透视表(规格型号 × 人员/工序,单元格=设备数量)
* Y 轴 = 规格型号X 轴 = 人员 或 工序,单元格 = 该交叉点的在制品设备数
*/
import { useEffect, useMemo, useState } from "react";
import { Radio, Table } from "antd";
import { fetchWipMatrix, type WipMatrixRow } from "../services/dashboardApi";
export default function MatrixBoard() {
const [dimension, setDimension] = useState<"assignee" | "task_name">("assignee");
const [data, setData] = useState<WipMatrixRow[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetchWipMatrix(dimension)
.then(setData)
.catch(() => setData([]))
.finally(() => setLoading(false));
}, [dimension]);
// 🔧 核心:扁平数组 → 动态交叉表规格型号为行、dimension_key 为列)
const { columns, dataSource } = useMemo(() => {
const keys = Array.from(new Set(data.map((d) => d.dimension_key)));
const rowsMap = new Map<string, any>();
for (const item of data) {
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 ds = Array.from(rowsMap.values());
const cols: any[] = [
{ title: "规格型号", dataIndex: "spec_model", fixed: "left", width: 190, render: (v: string) => <span className="font-semibold text-gray-700">{v}</span> },
...keys.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> },
];
return { columns: cols, dataSource: ds };
}, [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>
<p className="mt-1 text-sm text-gray-500"> × {dimension === "assignee" ? "人员" : "工序"} · </p>
</div>
<Radio.Group value={dimension} onChange={(e) => setDimension(e.target.value)} optionType="button" buttonStyle="solid">
<Radio.Button value="assignee"></Radio.Button>
<Radio.Button value="task_name"></Radio.Button>
</Radio.Group>
</div>
<div className="rounded-xl bg-white p-4 shadow-sm">
<Table
columns={columns}
dataSource={dataSource}
loading={loading}
rowKey="spec_model"
bordered
size="small"
pagination={false}
scroll={{ x: "max-content" }}
/>
</div>
</div>
);
}