feat: WIP矩阵单表化/固定表头/首列复合/下钻日志弹窗/时间联动
This commit is contained in:
@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* WIP 分布矩阵 — 在制品透视表(规格型号 × 人员/工序,单元格=设备数量)
|
* WIP 分布矩阵 — 在制品透视表(规格型号 × 工序,单元格=设备数量)
|
||||||
* Y 轴 = 规格型号,X 轴 = 人员 或 工序,单元格 = 该交叉点的在制品设备数
|
* Y 轴 = 规格型号,X 轴 = 工序(固定标准主轴 + 动态工序),单表横向滚动
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 { Loader2 } from "lucide-react";
|
||||||
import dayjs, { type Dayjs } from "dayjs";
|
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 { RangePicker } = DatePicker;
|
||||||
|
|
||||||
// 每块最多显示的工序列数,超过则分块垂直排列,避免横向超长
|
// 🔧 固定标准主轴:核心工序 + 状态列始终显示(即使计数为 0),表头不随操作内容增减
|
||||||
const CHUNK_MATRIX = 6;
|
const FIXED_STEP_COLUMNS = ["备货", "生产", "测试", "维修", "待确认", "已完成", "已入库", "已出库"];
|
||||||
|
|
||||||
// ⏰ 时间筛选(与全局概览一致)
|
// ⏰ 时间筛选(与全局概览一致)
|
||||||
type DateRangeKey = "today" | "7d" | "30d" | "custom";
|
type DateRangeKey = "today" | "7d" | "30d" | "custom";
|
||||||
@ -28,12 +29,32 @@ function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
|
|||||||
return { since: since.toISOString() };
|
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() {
|
export default function MatrixBoard() {
|
||||||
// 固定按工序分布(已取消按人员分布)
|
|
||||||
const [dateKey, setDateKey] = useState<DateRangeKey>("today");
|
const [dateKey, setDateKey] = useState<DateRangeKey>("today");
|
||||||
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
|
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||||
const [data, setData] = useState<WipMatrixRow[]>([]);
|
const [data, setData] = useState<WipMatrixRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
// 🔧 单元格下钻:点击数值>0 的交叉点,打开设备明细抽屉
|
||||||
|
const [drill, setDrill] = useState<{ spec: string; process: string; productName: string } | null>(null);
|
||||||
|
const [drillData, setDrillData] = useState<WipMatrixDetailRow[]>([]);
|
||||||
|
const [drillLoading, setDrillLoading] = useState(false);
|
||||||
|
// 🔧 设备日志弹窗:点击明细行 → 先看日志,底部按钮再跳流转树
|
||||||
|
const [recordModal, setRecordModal] = useState<string | null>(null);
|
||||||
|
const [records, setRecords] = useState<DeviceRecord[]>([]);
|
||||||
|
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -44,64 +65,103 @@ export default function MatrixBoard() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [dateKey, customRange]);
|
}, [dateKey, customRange]);
|
||||||
|
|
||||||
// 🔧 核心:扁平数组 → 动态交叉表(规格型号为行、dimension_key 为列)
|
async function openDrill(spec: string, process: string, productName: string) {
|
||||||
// 列数超过 CHUNK_MATRIX 时分块垂直排列,避免一行十几个人员横向超长
|
setDrill({ spec, process, productName });
|
||||||
const matrixChunks = useMemo(() => {
|
setDrillLoading(true);
|
||||||
const keys = Array.from(new Set(data.map((d) => d.dimension_key)));
|
try {
|
||||||
if (keys.length === 0) return [];
|
// 🔧 携带当前时间筛选,保证下钻明细与矩阵计数一致(近7天/近30天等)
|
||||||
const chunks: { columns: any[]; dataSource: any[]; keys: string[] }[] = [];
|
const { since, until } = rangeToParams(dateKey, customRange);
|
||||||
|
const res = await fetchWipMatrixDetail(spec, process, since, until);
|
||||||
for (let i = 0; i < keys.length; i += CHUNK_MATRIX) {
|
setDrillData(res);
|
||||||
const chunkKeys = keys.slice(i, i + CHUNK_MATRIX);
|
} catch {
|
||||||
const keySet = new Set(chunkKeys);
|
setDrillData([]);
|
||||||
const rowsMap = new Map<string, any>();
|
} finally {
|
||||||
for (const item of data) {
|
setDrillLoading(false);
|
||||||
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) => <span className="font-semibold text-gray-700">{v}</span>,
|
|
||||||
},
|
|
||||||
...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> },
|
|
||||||
];
|
|
||||||
|
|
||||||
chunks.push({ columns, dataSource: Array.from(rowsMap.values()), keys: chunkKeys });
|
|
||||||
}
|
}
|
||||||
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<string, any>();
|
||||||
|
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) => (
|
||||||
|
// 🔧 点击产品名称 → 显示该型号全部工序的设备
|
||||||
|
<div className="cursor-pointer px-1 hover:bg-blue-50" onClick={() => openDrill(record.spec_model, "", record.product_name)}>
|
||||||
|
<div className="font-bold text-blue-600 hover:underline">{record.product_name || "—"}</div>
|
||||||
|
<div className="text-xs text-gray-500">{v}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...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 (
|
||||||
|
<div
|
||||||
|
className={`py-1 ${v > 0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
|
||||||
|
onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
|
||||||
|
>
|
||||||
|
<div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div>
|
||||||
|
{assignees.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="mx-auto max-w-[90px] truncate text-[10px] leading-tight text-gray-400"
|
||||||
|
title={assignees.join("、")}
|
||||||
|
>
|
||||||
|
{assignees.join("、")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
title: "合计",
|
||||||
|
dataIndex: "row_total",
|
||||||
|
fixed: "right" as const,
|
||||||
|
align: "center" as const,
|
||||||
|
width: 76,
|
||||||
|
render: (v: number) => <span className="font-bold text-blue-600">{v}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { columns, dataSource: Array.from(rowsMap.values()), keys };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -109,9 +169,13 @@ export default function MatrixBoard() {
|
|||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-800">📊 WIP 分布矩阵</h2>
|
<h2 className="text-xl font-bold text-gray-800">📊 WIP 分布矩阵</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500">生产分布透视表:规格型号 × 工序 · 含在库/完成</p>
|
<p className="mt-1 text-sm text-gray-500">生产分布透视表:规格型号 × 工序 · 含已完成/已入库</p>
|
||||||
|
{/* 🔴 术语解释:区分"已完成"(车间完工待实收)与"已入库"(仓库已扫码实收) */}
|
||||||
|
<p className="mt-1 text-xs text-red-500">
|
||||||
|
ⓘ "已完成" = 车间完工已转交仓库,等待仓库扫码实收;"已入库" = 仓库已扫码确认实收。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* ⏰ 时间筛选(与全局概览一致) */}
|
{/* ⏰ 时间筛选 */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={dateKey}
|
value={dateKey}
|
||||||
@ -141,53 +205,152 @@ export default function MatrixBoard() {
|
|||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
</div>
|
</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">
|
<div className="w-full overflow-x-auto rounded-xl bg-white p-2 shadow-sm">
|
||||||
{matrixChunks.map((chunk, idx) => (
|
<Table
|
||||||
<div key={idx} className="relative">
|
columns={columns}
|
||||||
{idx > 0 && <div className="absolute -top-4 left-0 w-full border-t border-dashed border-gray-200" />}
|
dataSource={dataSource}
|
||||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
loading={loading}
|
||||||
<Table
|
rowKey="spec_model"
|
||||||
columns={chunk.columns}
|
bordered
|
||||||
dataSource={chunk.dataSource}
|
size="small"
|
||||||
loading={loading}
|
pagination={false}
|
||||||
rowKey="spec_model"
|
scroll={{ x: "max-content" }}
|
||||||
bordered
|
summary={(pageData: readonly any[]) => {
|
||||||
size="small"
|
const totals: Record<string, number> = {};
|
||||||
pagination={false}
|
let rowTotal = 0;
|
||||||
scroll={{ x: "max-content" }}
|
for (const row of pageData) {
|
||||||
summary={(pageData: readonly any[]) => {
|
rowTotal += row.row_total || 0;
|
||||||
const totals: Record<string, number> = {};
|
for (const k of keys) totals[k] = (totals[k] || 0) + (row[k] || 0);
|
||||||
let rowTotal = 0;
|
}
|
||||||
for (const row of pageData) {
|
return (
|
||||||
rowTotal += row.row_total || 0;
|
<Table.Summary fixed>
|
||||||
for (const k of chunk.keys) totals[k] = (totals[k] || 0) + (row[k] || 0);
|
<Table.Summary.Row>
|
||||||
}
|
<Table.Summary.Cell index={0}><span className="px-1 font-bold text-gray-800">合计</span></Table.Summary.Cell>
|
||||||
return (
|
{keys.map(k => (
|
||||||
<Table.Summary fixed>
|
<Table.Summary.Cell key={k} index={0} align="center">
|
||||||
<Table.Summary.Row>
|
<span className="font-semibold text-gray-700">{totals[k] || 0}</span>
|
||||||
<Table.Summary.Cell index={0}><span className="font-bold text-gray-800">合计</span></Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
{chunk.keys.map(k => (
|
))}
|
||||||
<Table.Summary.Cell key={k} index={0} align="center">
|
<Table.Summary.Cell index={0} align="center">
|
||||||
<span className="font-semibold text-gray-700">{totals[k] || 0}</span>
|
<span className="font-bold text-blue-600">{rowTotal}</span>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
))}
|
</Table.Summary.Row>
|
||||||
<Table.Summary.Cell index={0} align="center">
|
</Table.Summary>
|
||||||
<span className="font-bold text-blue-600">{rowTotal}</span>
|
);
|
||||||
</Table.Summary.Cell>
|
}}
|
||||||
</Table.Summary.Row>
|
/>
|
||||||
</Table.Summary>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 🔧 单元格下钻抽屉:展示该 规格型号×工序 交叉点下的设备明细 */}
|
||||||
|
<Drawer
|
||||||
|
title={`设备明细 — ${drill?.productName || drill?.spec || "—"}${drill?.process ? `(${drill?.spec} × ${drill?.process})` : `(${drill?.spec} · 全部工序)`}`}
|
||||||
|
open={!!drill}
|
||||||
|
onClose={() => setDrill(null)}
|
||||||
|
width={900}
|
||||||
|
>
|
||||||
|
{drillLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
) : drillData.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-sm text-gray-400">该交叉点暂无设备</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey="product_id"
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
pagination={false}
|
||||||
|
dataSource={drillData}
|
||||||
|
// 🔧 整行可点击:先弹日志信息,底部按钮再跳流转树
|
||||||
|
onRow={(record) => ({
|
||||||
|
className: "cursor-pointer",
|
||||||
|
onClick: () => openRecords(record.serial_number),
|
||||||
|
})}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: "产品名称",
|
||||||
|
dataIndex: "material_name",
|
||||||
|
width: 160,
|
||||||
|
render: (v) => <span className="font-semibold text-blue-600">{v || "—"}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "身份证号",
|
||||||
|
dataIndex: "serial_number",
|
||||||
|
width: 150,
|
||||||
|
render: (v) => <span className="font-mono text-blue-600">{v}</span>,
|
||||||
|
},
|
||||||
|
{ 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 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* 设备日志弹窗:点击明细行先看日志,底部按钮再跳流转树 */}
|
||||||
|
<Modal
|
||||||
|
title={`日志信息 — ${recordModal || ""}`}
|
||||||
|
open={!!recordModal}
|
||||||
|
onCancel={() => setRecordModal(null)}
|
||||||
|
width={640}
|
||||||
|
footer={[
|
||||||
|
<Button key="tree" type="primary" onClick={() => recordModal && window.open(`/admin/tasks?sn=${recordModal}`, "_blank")}>
|
||||||
|
查看流程树
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{recordsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-gray-400">暂无记录</div>
|
||||||
|
) : (
|
||||||
|
/* 🔧 时间线展示:与人员看板统一,只显示"谁写了什么"(操作人+备注+时间+图片),不含状态 */
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
|
<Timeline
|
||||||
|
items={[...records].reverse().map((r, i) => ({
|
||||||
|
key: i,
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-gray-800">{r.task_name || "—"}</span>
|
||||||
|
<span className="text-xs text-gray-400">· {r.assignee_name || "—"}</span>
|
||||||
|
<span className="ml-auto text-xs text-gray-400">
|
||||||
|
{r.created_at ? dayjs(r.created_at).format("MM-DD HH:mm") : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{r.remark && <div className="mt-1 text-sm leading-relaxed text-gray-700">{r.remark}</div>}
|
||||||
|
{r.images && r.images.length > 0 && (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{r.images.map((img, j) => (
|
||||||
|
<Image
|
||||||
|
key={j}
|
||||||
|
src={imageUrl(img)}
|
||||||
|
alt={`备注图片 ${j + 1}`}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="rounded-md object-cover"
|
||||||
|
style={{ objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,7 +107,7 @@ export default function AdminTasksPage() {
|
|||||||
{
|
{
|
||||||
key: "overall_status", label: "当前工序", colSpan: 1,
|
key: "overall_status", label: "当前工序", colSpan: 1,
|
||||||
filterType: "enum", getFilterValue: (p) => p.overall_status || "—",
|
filterType: "enum", getFilterValue: (p) => p.overall_status || "—",
|
||||||
enumOptions: ["备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库"].map((v) => ({ value: v, label: v })),
|
enumOptions: ["备货", "生产", "测试", "维修", "待仓库收货", "已入库", "已出库"].map((v) => ({ value: v, label: v })),
|
||||||
render: (p) => <span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span>,
|
render: (p) => <span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -73,11 +73,27 @@ export interface OperationDetail {
|
|||||||
|
|
||||||
export interface WipMatrixRow {
|
export interface WipMatrixRow {
|
||||||
spec_model: string;
|
spec_model: string;
|
||||||
|
product_name: string;
|
||||||
dimension_key: string;
|
dimension_key: string;
|
||||||
count: number;
|
count: number;
|
||||||
assignees: string[];
|
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 {
|
export interface PersonDevice {
|
||||||
product_id: string;
|
product_id: string;
|
||||||
serial_number: string;
|
serial_number: string;
|
||||||
@ -194,6 +210,16 @@ export async function fetchWipMatrix(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchWipMatrixDetail(
|
||||||
|
spec: string, process: string, since?: string, until?: string,
|
||||||
|
): Promise<WipMatrixDetailRow[]> {
|
||||||
|
const params: Record<string, string> = { spec, process };
|
||||||
|
if (since) params.since = since;
|
||||||
|
if (until) params.until = until;
|
||||||
|
const { data } = await api.get<WipMatrixDetailRow[]>("/dashboard/wip-matrix/detail", { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
|
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
|
||||||
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
|
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
Reference in New Issue
Block a user