feat(web): 人员工时台账表格+多维搜索+CSV导出+新流转红点标注+分页中文
This commit is contained in:
@ -1,6 +1,7 @@
|
|||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { App as AntApp } from "antd";
|
import { App as AntApp, ConfigProvider } from "antd";
|
||||||
|
import zhCN from "antd/locale/zh_CN";
|
||||||
|
|
||||||
import { ToastProvider } from "./components/ui/Toast";
|
import { ToastProvider } from "./components/ui/Toast";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
@ -25,7 +26,8 @@ const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPa
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AntApp>
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<AntApp>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@ -57,6 +59,7 @@ export default function App() {
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</AntApp>
|
</AntApp>
|
||||||
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,88 @@
|
|||||||
/** 人员看板 — 按负责人聚合当前在制品设备 */
|
/** 人员看板 — 实时在制品 + 历史工时台账 */
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, RefreshCw,
|
Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, RefreshCw, Clock, Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { fetchPeopleWorkload, type PersonWorkload } from "../../services/dashboardApi";
|
import { Radio, DatePicker, Table, Button, AutoComplete, Modal, Timeline, Image, Select } from "antd";
|
||||||
|
import type { ColumnsType } from "antd/es/table";
|
||||||
|
import dayjs, { type Dayjs } from "dayjs";
|
||||||
|
import "dayjs/locale/zh-cn";
|
||||||
|
dayjs.locale("zh-cn");
|
||||||
|
import {
|
||||||
|
fetchPeopleWorkload, fetchPeopleHistory, downloadPeopleHistory,
|
||||||
|
type PersonWorkload, type PersonHistoryRecord, type PeopleHistoryQuery,
|
||||||
|
} from "../../services/dashboardApi";
|
||||||
|
import { getTask } from "../../services/taskApi";
|
||||||
|
import type { TaskRecordResponse } from "../../types/api";
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const AVATAR_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444", "#06b6d4", "#6366f1"];
|
const AVATAR_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444", "#06b6d4", "#6366f1"];
|
||||||
|
|
||||||
|
// ─── 滞留时长 SLA 预警颜色 / 标签 ───────────────────────
|
||||||
|
function durationColor(h: number) {
|
||||||
|
if (h >= 48) return "text-red-600 bg-red-100 font-bold";
|
||||||
|
if (h >= 24) return "text-orange-600 bg-orange-50";
|
||||||
|
return "text-emerald-600 bg-emerald-50";
|
||||||
|
}
|
||||||
|
function durationLabel(h: number) {
|
||||||
|
if (h >= 24) return `${Math.round(h / 24)}天`;
|
||||||
|
if (h >= 1) return `${h}小时`;
|
||||||
|
return `${Math.round(h * 60)}分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 图片 URL 拼接(对齐 TaskFlowView) ──────────────────
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 任务状态徽章 ───────────────────────────────────────
|
||||||
|
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||||
|
WIP: { label: "进行中", cls: "bg-blue-100 text-blue-700" },
|
||||||
|
PENDING: { label: "待接收", cls: "bg-amber-100 text-amber-700" },
|
||||||
|
COMPLETED: { label: "已完成", cls: "bg-green-100 text-green-700" },
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminPeoplePage() {
|
export default function AdminPeoplePage() {
|
||||||
|
const [mode, setMode] = useState<"realtime" | "history">("realtime");
|
||||||
|
|
||||||
|
// 实时
|
||||||
const [workloads, setWorkloads] = useState<PersonWorkload[]>([]);
|
const [workloads, setWorkloads] = useState<PersonWorkload[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 历史
|
||||||
|
const [records, setRecords] = useState<PersonHistoryRecord[]>([]);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||||
|
const [historyRange, setHistoryRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [fAssignee, setFAssignee] = useState("");
|
||||||
|
const [fSpec, setFSpec] = useState("");
|
||||||
|
const [fSn, setFSn] = useState("");
|
||||||
|
const [fTask, setFTask] = useState("");
|
||||||
|
const [recordDetail, setRecordDetail] = useState<{ open: boolean; taskName: string; records: TaskRecordResponse[] }>({
|
||||||
|
open: false, taskName: "", records: [],
|
||||||
|
});
|
||||||
|
// 已读记录数(task_id -> 已读的 record_count),用于「新流转」红点标注
|
||||||
|
const [readCounts, setReadCounts] = useState<Record<string, number>>(() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem("people_history_read_counts") || "{}");
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
@ -23,12 +93,100 @@ export default function AdminPeoplePage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildQuery = (range: [Dayjs, Dayjs] | null): PeopleHistoryQuery => {
|
||||||
|
const q: PeopleHistoryQuery = {};
|
||||||
|
if (range) {
|
||||||
|
q.since = range[0].startOf("day").toISOString();
|
||||||
|
q.until = range[1].endOf("day").toISOString();
|
||||||
|
}
|
||||||
|
if (fAssignee.trim()) q.assignee_id = fAssignee.trim();
|
||||||
|
if (fSpec.trim()) q.spec_model = fSpec.trim();
|
||||||
|
if (fSn.trim()) q.product_sn = fSn.trim();
|
||||||
|
if (fTask.trim()) q.task_name = fTask.trim();
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadHistory = (range: [Dayjs, Dayjs] | null) => {
|
||||||
|
setHistoryLoading(true);
|
||||||
|
fetchPeopleHistory(buildQuery(range))
|
||||||
|
.then((data) => { setRecords(data); setHistoryError(null); })
|
||||||
|
.catch(() => setHistoryError("加载工时台账失败"))
|
||||||
|
.finally(() => setHistoryLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
await downloadPeopleHistory(buildQuery(historyRange));
|
||||||
|
} catch {
|
||||||
|
setHistoryError("导出失败,请稍后重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRecordDetail = async (record: PersonHistoryRecord) => {
|
||||||
|
try {
|
||||||
|
const task = await getTask(record.task_id);
|
||||||
|
setRecordDetail({
|
||||||
|
open: true,
|
||||||
|
taskName: record.task_name || record.task_id,
|
||||||
|
records: task.records || [],
|
||||||
|
});
|
||||||
|
// 标记该任务为已读(记录数同步)
|
||||||
|
const next = { ...readCounts, [record.task_id]: record.record_count };
|
||||||
|
setReadCounts(next);
|
||||||
|
localStorage.setItem("people_history_read_counts", JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
setHistoryError("加载记录详情失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setFAssignee("");
|
||||||
|
setFSpec("");
|
||||||
|
setFSn("");
|
||||||
|
setFTask("");
|
||||||
|
setHistoryRange(null);
|
||||||
|
loadHistory(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAllRead = () => {
|
||||||
|
const next: Record<string, number> = {};
|
||||||
|
for (const r of records) next[r.task_id] = r.record_count;
|
||||||
|
setReadCounts(next);
|
||||||
|
localStorage.setItem("people_history_read_counts", JSON.stringify(next));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const totalDevices = useMemo(
|
// 自动搜索:模式切换 / 筛选条件 / 时间范围变化时触发(防抖 300ms)
|
||||||
() => workloads.reduce((sum, w) => sum + w.device_count, 0),
|
useEffect(() => {
|
||||||
[workloads],
|
if (mode !== "history") return;
|
||||||
);
|
const timer = setTimeout(() => loadHistory(historyRange), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [mode, fAssignee, fSpec, fSn, fTask, historyRange]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const totalDevices = useMemo(() => {
|
||||||
|
const uniqueIds = new Set<string>();
|
||||||
|
for (const w of workloads) {
|
||||||
|
for (const d of w.devices) uniqueIds.add(d.product_id);
|
||||||
|
}
|
||||||
|
return uniqueIds.size;
|
||||||
|
}, [workloads]);
|
||||||
|
|
||||||
|
const totalHistoryDevices = useMemo(() => {
|
||||||
|
const uniqueIds = new Set<string>();
|
||||||
|
for (const r of records) uniqueIds.add(r.product_sn);
|
||||||
|
return uniqueIds.size;
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
|
// 搜索下拉选项(从当前结果去重提取)
|
||||||
|
const assigneeOptions = useMemo(() => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const r of records) if (r.assignee_id && !seen.has(r.assignee_id)) seen.set(r.assignee_id, r.assignee_name || r.assignee_id);
|
||||||
|
return Array.from(seen.entries()).map(([id, name]) => ({ value: id, label: `${name} (${id})` }));
|
||||||
|
}, [records]);
|
||||||
|
const specOptions = useMemo(() => Array.from(new Set(records.map(r => r.spec_model).filter(Boolean))).map(v => ({ value: v })), [records]);
|
||||||
|
const snOptions = useMemo(() => Array.from(new Set(records.map(r => r.product_sn).filter(Boolean))).map(v => ({ value: v })), [records]);
|
||||||
|
const taskOptions = useMemo(() => Array.from(new Set(records.map(r => r.task_name).filter(Boolean))).map(v => ({ value: v })), [records]);
|
||||||
|
|
||||||
function toggle(id: string) {
|
function toggle(id: string) {
|
||||||
setExpanded((prev) => {
|
setExpanded((prev) => {
|
||||||
@ -39,115 +197,331 @@ export default function AdminPeoplePage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 历史表格列 ───────────────────────────────────────
|
||||||
|
const columns: ColumnsType<PersonHistoryRecord> = [
|
||||||
|
{
|
||||||
|
title: "状态", dataIndex: "status", key: "status", width: 100,
|
||||||
|
render: (s: string, record: PersonHistoryRecord) => {
|
||||||
|
const cfg = STATUS_MAP[s] || { label: s, cls: "bg-gray-100 text-gray-600" };
|
||||||
|
const unread = record.record_count > (readCounts[record.task_id] || 0);
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-bold ${cfg.cls}`}>{cfg.label}</span>
|
||||||
|
{unread && <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" title="有新的流转记录" />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: "负责人", dataIndex: "assignee_name", key: "assignee_name", width: 110 },
|
||||||
|
{ title: "设备身份证", dataIndex: "product_sn", key: "product_sn", width: 190, render: (v: string) => <span className="font-mono text-xs">{v}</span> },
|
||||||
|
{ title: "规格型号", dataIndex: "spec_model", key: "spec_model", width: 130, render: (v: string) => v || "—" },
|
||||||
|
{ title: "任务名", dataIndex: "task_name", key: "task_name", ellipsis: true },
|
||||||
|
{ title: "开始时间", dataIndex: "received_at", key: "received_at", width: 120, render: (v: string) => v || "—" },
|
||||||
|
{ title: "结束时间", dataIndex: "completed_at", key: "completed_at", width: 120, render: (v: string) => v || <span className="text-blue-500">进行中</span> },
|
||||||
|
{
|
||||||
|
title: "总耗时", dataIndex: "duration_hours", key: "duration_hours", width: 120,
|
||||||
|
sorter: (a, b) => a.duration_hours - b.duration_hours,
|
||||||
|
render: (h: number) => (
|
||||||
|
<span className={`rounded-md px-2 py-0.5 text-xs font-bold ${durationColor(h)}`}>
|
||||||
|
{durationLabel(h)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "最新动态/备注", dataIndex: "latest_valid_remark", key: "latest_valid_remark",
|
||||||
|
render: (_: unknown, record: PersonHistoryRecord) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="flex-1 truncate text-xs text-gray-600">{record.latest_valid_remark || "—"}</span>
|
||||||
|
{record.record_count > 0 && (
|
||||||
|
<a
|
||||||
|
className="shrink-0 text-xs text-blue-600 hover:underline"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openRecordDetail(record); }}
|
||||||
|
>
|
||||||
|
({record.record_count}条)
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 页头 */}
|
{/* 页头 */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-800">👥 人员看板</h2>
|
<h2 className="text-xl font-bold text-gray-800">👥 人员看板</h2>
|
||||||
<p className="mt-0.5 text-sm text-gray-400">按负责人聚合当前在制品设备 · 实时快照</p>
|
<p className="mt-0.5 text-sm text-gray-400">实时在制品 / 历史工时台账</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={load} className="flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-gray-500 hover:bg-gray-100">
|
<Radio.Group
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
value={mode}
|
||||||
</button>
|
onChange={(e) => setMode(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
>
|
||||||
|
<Radio.Button value="realtime">实时</Radio.Button>
|
||||||
|
<Radio.Button value="history">历史</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计概览 */}
|
{/* ═══ 实时模式 ═══ */}
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
{mode === "realtime" && (
|
||||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<Users className="h-5 w-5 text-blue-600" />
|
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||||
<h3 className="text-sm font-semibold text-gray-700">在岗人员</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-blue-600" />
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">当前负责人</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-gray-800">{workloads.length}<span className="text-sm font-normal text-gray-400"> 人</span></p>
|
||||||
|
<p className="text-[11px] text-gray-400">当前有在制品的负责人</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white p-5 shadow-sm sm:col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5 text-emerald-600" />
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">在制品设备总量</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-emerald-600">{totalDevices}<span className="text-sm font-normal text-gray-400"> 台</span></p>
|
||||||
|
<p className="text-[11px] text-gray-400">按唯一产品去重统计;并发设备会在多个负责人名下各显示一次</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-3xl font-bold text-gray-800">{workloads.length}<span className="text-sm font-normal text-gray-400"> 人</span></p>
|
|
||||||
<p className="text-[11px] text-gray-400">持有在制品的负责人数</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white p-5 shadow-sm sm:col-span-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Package className="h-5 w-5 text-emerald-600" />
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700">在制品设备总量</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-3xl font-bold text-emerald-600">{totalDevices}<span className="text-sm font-normal text-gray-400"> 台</span></p>
|
|
||||||
<p className="text-[11px] text-gray-400">一台设备若被多人并发处理,会在多人名下各计一次</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 加载 / 错误 / 空态 */}
|
{loading && (
|
||||||
{loading && (
|
<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>
|
)}
|
||||||
)}
|
{!loading && error && (
|
||||||
{!loading && error && (
|
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
<AlertCircle className="h-4 w-4" />{error}
|
||||||
<AlertCircle className="h-4 w-4" />{error}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{!loading && !error && workloads.length === 0 && (
|
||||||
{!loading && !error && workloads.length === 0 && (
|
<div className="py-20 text-center text-gray-400">🎉 当前无人在制品</div>
|
||||||
<div className="py-20 text-center text-gray-400">🎉 当前无人在制品</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 人员列表 */}
|
{!loading && !error && workloads.length > 0 && (
|
||||||
{!loading && !error && workloads.length > 0 && (
|
<div className="space-y-3">
|
||||||
<div className="space-y-3">
|
{workloads.map((w) => {
|
||||||
{workloads.map((w) => {
|
const isOpen = expanded.has(w.assignee_id);
|
||||||
const isOpen = expanded.has(w.assignee_id);
|
const initial = (w.assignee_name || w.assignee_id || "?").charAt(0);
|
||||||
const initial = (w.assignee_name || w.assignee_id || "?").charAt(0);
|
const color = AVATAR_COLORS[(initial.charCodeAt(0) || 0) % AVATAR_COLORS.length];
|
||||||
const color = AVATAR_COLORS[(initial.charCodeAt(0) || 0) % AVATAR_COLORS.length];
|
return (
|
||||||
return (
|
<div key={w.assignee_id} className="overflow-hidden rounded-xl bg-white shadow-sm">
|
||||||
<div key={w.assignee_id} className="overflow-hidden rounded-xl bg-white shadow-sm">
|
<button
|
||||||
{/* 人员头部 */}
|
onClick={() => toggle(w.assignee_id)}
|
||||||
<button
|
className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors"
|
||||||
onClick={() => toggle(w.assignee_id)}
|
>
|
||||||
className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors"
|
<div
|
||||||
>
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||||
<div
|
style={{ backgroundColor: color }}
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
|
>
|
||||||
style={{ backgroundColor: color }}
|
{initial}
|
||||||
>
|
</div>
|
||||||
{initial}
|
<div className="flex-1 min-w-0">
|
||||||
</div>
|
<span className="text-sm font-semibold text-gray-800">{w.assignee_name || w.assignee_id}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<span className="ml-2 text-xs text-gray-400 font-mono">{w.assignee_id}</span>
|
||||||
<span className="text-sm font-semibold text-gray-800">{w.assignee_name || w.assignee_id}</span>
|
</div>
|
||||||
<span className="ml-2 text-xs text-gray-400 font-mono">{w.assignee_id}</span>
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${w.device_count > 0 ? "bg-blue-50 text-blue-700" : "bg-gray-100 text-gray-500"}`}>
|
||||||
</div>
|
{w.device_count} 台
|
||||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${w.device_count > 0 ? "bg-blue-50 text-blue-700" : "bg-gray-100 text-gray-500"}`}>
|
</span>
|
||||||
{w.device_count} 台
|
{isOpen ? <ChevronDown className="h-4 w-4 shrink-0 text-gray-400" /> : <ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />}
|
||||||
</span>
|
</button>
|
||||||
{isOpen ? <ChevronDown className="h-4 w-4 shrink-0 text-gray-400" /> : <ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 展开明细 */}
|
{isOpen && (
|
||||||
{isOpen && (
|
<div className="border-t border-gray-100 bg-gray-50/50 px-5 py-3">
|
||||||
<div className="border-t border-gray-100 bg-gray-50/50 px-5 py-3">
|
{w.devices.length === 0 ? (
|
||||||
{w.devices.length === 0 ? (
|
<p className="py-6 text-center text-xs text-gray-400">无设备</p>
|
||||||
<p className="py-6 text-center text-xs text-gray-400">无设备</p>
|
) : (
|
||||||
) : (
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
{w.devices.map((d) => (
|
||||||
{w.devices.map((d) => (
|
<div
|
||||||
<div
|
key={d.product_id}
|
||||||
key={d.product_id}
|
onClick={() => navigate(`/admin/tasks?sn=${d.serial_number}`)}
|
||||||
onClick={() => navigate(`/admin/tasks?sn=${d.serial_number}`)}
|
className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-2.5 transition-shadow hover:border-blue-200 hover:shadow-md"
|
||||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-2.5 transition-shadow hover:border-blue-200 hover:shadow-md"
|
>
|
||||||
>
|
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${d.task_status === "WIP" ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"}`}>
|
||||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${d.task_status === "WIP" ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"}`}>
|
{d.task_status === "WIP" ? "进行中" : "待接收"}
|
||||||
{d.task_status === "WIP" ? "进行中" : "待接收"}
|
</span>
|
||||||
</span>
|
<span className="text-sm font-semibold text-gray-800 truncate">{d.material_name || "未知设备"}</span>
|
||||||
<span className="text-sm font-semibold text-gray-800 truncate">{d.material_name || "未知设备"}</span>
|
<span className="text-xs text-gray-500">{d.spec_model || "无规格"}</span>
|
||||||
<span className="text-xs text-gray-500">{d.spec_model || "无规格"}</span>
|
<span className="text-xs text-gray-400">序列号: {d.external_serial || "未录入"}</span>
|
||||||
<span className="text-xs text-gray-400">序列号: {d.external_serial || "未录入"}</span>
|
<span className="font-mono text-xs text-gray-300">身份证: {d.serial_number}</span>
|
||||||
<span className="ml-auto font-mono text-xs text-gray-300">身份证: {d.serial_number}</span>
|
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
|
<span className={`rounded-md px-2 py-0.5 text-xs font-bold ${durationColor(d.duration_hours)}`}>
|
||||||
|
<Clock className="mr-0.5 inline h-3 w-3" />
|
||||||
|
{durationLabel(d.duration_hours)}
|
||||||
|
</span>
|
||||||
|
{d.received_at && <span className="text-xs text-gray-400">{d.received_at}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ═══ 历史模式(工时台账) ═══ */}
|
||||||
|
{mode === "history" && (
|
||||||
|
<>
|
||||||
|
{/* 概览 */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5 text-emerald-600" />
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">生产设备总量</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-emerald-600">{totalHistoryDevices}<span className="text-sm font-normal text-gray-400"> 台</span></p>
|
||||||
|
<p className="text-[11px] text-gray-400">按唯一身份证去重统计</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5 text-blue-600" />
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">工序记录</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-gray-800">{records.length}<span className="text-sm font-normal text-gray-400"> 条</span></p>
|
||||||
|
<p className="text-[11px] text-gray-400">含进行中与已完成</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<RangePicker
|
||||||
|
size="small"
|
||||||
|
value={historyRange as any}
|
||||||
|
onChange={(dates) => setHistoryRange(dates as [Dayjs, Dayjs] | null)}
|
||||||
|
disabledDate={(d) => d.isAfter(dayjs(), "day")}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
placeholder={["开始", "结束"]}
|
||||||
|
/>
|
||||||
|
<AutoComplete
|
||||||
|
size="small"
|
||||||
|
placeholder="负责人"
|
||||||
|
value={fAssignee}
|
||||||
|
onChange={setFAssignee}
|
||||||
|
options={assigneeOptions}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 150 }}
|
||||||
|
/>
|
||||||
|
<AutoComplete
|
||||||
|
size="small"
|
||||||
|
placeholder="规格型号"
|
||||||
|
value={fSpec}
|
||||||
|
onChange={setFSpec}
|
||||||
|
options={specOptions}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 130 }}
|
||||||
|
/>
|
||||||
|
<AutoComplete
|
||||||
|
size="small"
|
||||||
|
placeholder="身份证"
|
||||||
|
value={fSn}
|
||||||
|
onChange={setFSn}
|
||||||
|
options={snOptions}
|
||||||
|
allowClear
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
<AutoComplete
|
||||||
|
size="small"
|
||||||
|
placeholder="任务名"
|
||||||
|
value={fTask}
|
||||||
|
onChange={setFTask}
|
||||||
|
options={taskOptions}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 130 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={setPageSize}
|
||||||
|
options={[
|
||||||
|
{ value: 50, label: "50 条/页" },
|
||||||
|
{ value: 100, label: "100 条/页" },
|
||||||
|
{ value: 500, label: "500 条/页" },
|
||||||
|
{ value: 1000, label: "1000 条/页" },
|
||||||
|
]}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
/>
|
||||||
|
<Button size="small" icon={<Download className="h-3 w-3" />} onClick={handleExport}>
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={handleReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={markAllRead}>
|
||||||
|
全部已读
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
{historyError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<AlertCircle className="h-4 w-4" />{historyError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<Table<PersonHistoryRecord>
|
||||||
|
rowKey="task_id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={records}
|
||||||
|
loading={historyLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize, showTotal: (t) => `共 ${t} 条` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 记录详情弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={`记录详情 — ${recordDetail.taskName}`}
|
||||||
|
open={recordDetail.open}
|
||||||
|
onCancel={() => setRecordDetail((s) => ({ ...s, open: false }))}
|
||||||
|
footer={null}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
{recordDetail.records.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-gray-400">暂无记录</p>
|
||||||
|
) : (
|
||||||
|
<Timeline
|
||||||
|
items={[...recordDetail.records].reverse().map((r) => ({
|
||||||
|
color: "gray",
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400">{r.created_at ? dayjs(r.created_at).format("MM-DD HH:mm") : ""}</div>
|
||||||
|
<div className="mt-0.5 text-sm text-gray-700">{r.remark || "—"}</div>
|
||||||
|
{r.images && r.images.length > 0 && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{r.images.map((img, j) => (
|
||||||
|
<Image
|
||||||
|
key={j}
|
||||||
|
src={imageUrl(img)}
|
||||||
|
alt={`记录图片 ${j + 1}`}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="rounded border object-cover"
|
||||||
|
style={{ objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,8 @@ export interface PersonDevice {
|
|||||||
material_name: string;
|
material_name: string;
|
||||||
spec_model: string;
|
spec_model: string;
|
||||||
task_status: string;
|
task_status: string;
|
||||||
|
duration_hours: number;
|
||||||
|
received_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonWorkload {
|
export interface PersonWorkload {
|
||||||
@ -55,6 +57,32 @@ export interface PersonWorkload {
|
|||||||
devices: PersonDevice[];
|
devices: PersonDevice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PersonHistoryRecord {
|
||||||
|
task_id: string;
|
||||||
|
task_name: string;
|
||||||
|
assignee_id: string;
|
||||||
|
assignee_name: string;
|
||||||
|
product_sn: string;
|
||||||
|
external_serial: string | null;
|
||||||
|
material_name: string;
|
||||||
|
spec_model: string;
|
||||||
|
status: string;
|
||||||
|
received_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
duration_hours: number;
|
||||||
|
latest_valid_remark: string | null;
|
||||||
|
record_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeopleHistoryQuery {
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
assignee_id?: string;
|
||||||
|
spec_model?: string;
|
||||||
|
product_sn?: string;
|
||||||
|
task_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProductMessageItem {
|
export interface ProductMessageItem {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@ -96,6 +124,34 @@ export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPeopleHistory(query: PeopleHistoryQuery = {}): Promise<PersonHistoryRecord[]> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v) params[k] = v;
|
||||||
|
}
|
||||||
|
const { data } = await api.get<PersonHistoryRecord[]>("/dashboard/people-history", { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadPeopleHistory(query: PeopleHistoryQuery = {}): Promise<void> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v) params[k] = v;
|
||||||
|
}
|
||||||
|
const res = await api.get("/dashboard/people-history/export", {
|
||||||
|
params,
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(res.data as Blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "people_history.csv";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchDashboardMessages(
|
export async function fetchDashboardMessages(
|
||||||
keyword = "", skip = 0, limit = 30,
|
keyword = "", skip = 0, limit = 30,
|
||||||
): Promise<ProductMessageList> {
|
): Promise<ProductMessageList> {
|
||||||
|
|||||||
Reference in New Issue
Block a user