feat(dashboard): 看板增强——流转完成率下钻明细 + 人员看板(按人聚合在制品设备)
This commit is contained in:
@ -20,6 +20,7 @@ const AdminLoginPage = lazy(() => import("./pages/admin/AdminLoginPage"));
|
||||
const AdminDashboard = lazy(() => import("./pages/admin/AdminDashboard"));
|
||||
const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage"));
|
||||
const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
|
||||
const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
|
||||
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
|
||||
|
||||
export default function App() {
|
||||
@ -48,6 +49,7 @@ export default function App() {
|
||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/admin/products" element={<AdminProductsPage />} />
|
||||
<Route path="/admin/tasks" element={<AdminTasksPage />} />
|
||||
<Route path="/admin/people" element={<AdminPeoplePage />} />
|
||||
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User } from "lucide-react";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users } from "lucide-react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
const MENU = [
|
||||
@ -21,6 +21,12 @@ const MENU = [
|
||||
icon: GitBranch,
|
||||
description: "身份证查任务树 · 裂变/返工可视化",
|
||||
},
|
||||
{
|
||||
title: "人员看板",
|
||||
path: "/admin/people",
|
||||
icon: Users,
|
||||
description: "按负责人查看在制品设备分布",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
|
||||
RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle,
|
||||
RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle, CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Radio, DatePicker, Drawer, Input } from "antd";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import {
|
||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages,
|
||||
type DashboardStats, type WipTask, type ProductMessageItem,
|
||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks,
|
||||
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask,
|
||||
} from "../../services/dashboardApi";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
@ -159,6 +159,35 @@ function MsgRow({ m }: { m: ProductMessageItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 已完成明细项(卡片式) ─────────────────────────────
|
||||
function CompletedRow({ t }: { t: CompletedTask }) {
|
||||
const nav = useNavigate();
|
||||
const completedTime = t.completed_at ? dayjs(t.completed_at).format("YYYY-MM-DD HH:mm") : "";
|
||||
return (
|
||||
<div
|
||||
onClick={() => t.product_sn && nav(`/admin/tasks?sn=${t.product_sn}`)}
|
||||
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-emerald-200 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" />
|
||||
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
|
||||
<span className="text-xs text-gray-500">|</span>
|
||||
<span className="text-xs text-gray-500 shrink-0">完成人: {t.assignee}</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-gray-400">{completedTime}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>{t.spec_model || "无规格"}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>序列号: {t.external_serial || "未录入"}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="font-mono text-gray-300">身份证: {t.product_sn}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 主组件 ───────────────────────────────────────────────
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
@ -177,6 +206,11 @@ export default function AdminDashboard() {
|
||||
const [msgTotal, setMsgTotal] = useState(0);
|
||||
const [msgLoading, setMsgLoading] = useState(false);
|
||||
|
||||
// 完成明细抽屉
|
||||
const [completedDrawerOpen, setCompletedDrawerOpen] = useState(false);
|
||||
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
|
||||
const [completedLoading, setCompletedLoading] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── 加载主数据 ──
|
||||
@ -211,6 +245,16 @@ export default function AdminDashboard() {
|
||||
loadMessages("");
|
||||
};
|
||||
|
||||
const openCompletedDrawer = () => {
|
||||
setCompletedDrawerOpen(true);
|
||||
setCompletedLoading(true);
|
||||
const { since, until } = rangeToParams(dateKey, customRange);
|
||||
fetchCompletedTasks(since, until)
|
||||
.then(setCompletedTasks)
|
||||
.catch(() => setCompletedTasks([]))
|
||||
.finally(() => setCompletedLoading(false));
|
||||
};
|
||||
|
||||
const onMsgSearch = (value: string) => {
|
||||
setMsgKeyword(value);
|
||||
loadMessages(value);
|
||||
@ -338,8 +382,11 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完成率 */}
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
{/* 完成率 — 可点击下钻 */}
|
||||
<button
|
||||
onClick={openCompletedDrawer}
|
||||
className="w-full cursor-pointer rounded-xl bg-white p-5 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-2 hover:ring-emerald-200"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">✅ 流转完成率</h3>
|
||||
@ -356,8 +403,8 @@ export default function AdminDashboard() {
|
||||
strokeDasharray={`${stats.tasks_total > 0 ? (stats.tasks_completed / stats.tasks_total) * 251 : 0} 251`}
|
||||
strokeLinecap="round" />
|
||||
</svg>
|
||||
<p className="mt-1 text-center text-[10px] text-gray-400">基于时间筛选后的已完成数</p>
|
||||
</div>
|
||||
<p className="mt-1 text-center text-[10px] text-emerald-500">基于时间筛选后的已完成数 · 查看明细 ></p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ═══ 在制品 + 快捷入口 ═══ */}
|
||||
@ -457,6 +504,29 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* ═══ 完成明细抽屉 ═══ */}
|
||||
<Drawer
|
||||
title={<span className="text-base font-bold">✅ 已完成明细 <span className="font-normal text-gray-400">{completedTasks.length} 条</span></span>}
|
||||
open={completedDrawerOpen}
|
||||
onClose={() => setCompletedDrawerOpen(false)}
|
||||
size="large"
|
||||
styles={{ body: { padding: 16, background: "#f8fafc" } }}
|
||||
>
|
||||
{completedLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
) : completedTasks.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-gray-400">
|
||||
该时段暂无已完成任务
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{completedTasks.map(t => <CompletedRow key={t.task_id} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
153
frontend/src/pages/admin/AdminPeoplePage.tsx
Normal file
153
frontend/src/pages/admin/AdminPeoplePage.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
/** 人员看板 — 按负责人聚合当前在制品设备 */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchPeopleWorkload, type PersonWorkload } from "../../services/dashboardApi";
|
||||
|
||||
const AVATAR_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444", "#06b6d4", "#6366f1"];
|
||||
|
||||
export default function AdminPeoplePage() {
|
||||
const [workloads, setWorkloads] = useState<PersonWorkload[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const navigate = useNavigate();
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
fetchPeopleWorkload()
|
||||
.then((data) => { setWorkloads(data); setError(null); })
|
||||
.catch(() => setError("加载失败,请确认后端已启动"))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const totalDevices = useMemo(
|
||||
() => workloads.reduce((sum, w) => sum + w.device_count, 0),
|
||||
[workloads],
|
||||
);
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页头 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">👥 人员看板</h2>
|
||||
<p className="mt-0.5 text-sm text-gray-400">按负责人聚合当前在制品设备 · 实时快照</p>
|
||||
</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">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 统计概览 */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<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>
|
||||
|
||||
{/* 加载 / 错误 / 空态 */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
{!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">
|
||||
<AlertCircle className="h-4 w-4" />{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && workloads.length === 0 && (
|
||||
<div className="py-20 text-center text-gray-400">🎉 当前无人在制品</div>
|
||||
)}
|
||||
|
||||
{/* 人员列表 */}
|
||||
{!loading && !error && workloads.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{workloads.map((w) => {
|
||||
const isOpen = expanded.has(w.assignee_id);
|
||||
const initial = (w.assignee_name || w.assignee_id || "?").charAt(0);
|
||||
const color = AVATAR_COLORS[(initial.charCodeAt(0) || 0) % AVATAR_COLORS.length];
|
||||
return (
|
||||
<div key={w.assignee_id} className="overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
{/* 人员头部 */}
|
||||
<button
|
||||
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"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-gray-800">{w.assignee_name || w.assignee_id}</span>
|
||||
<span className="ml-2 text-xs text-gray-400 font-mono">{w.assignee_id}</span>
|
||||
</div>
|
||||
<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"}`}>
|
||||
{w.device_count} 台
|
||||
</span>
|
||||
{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 && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-5 py-3">
|
||||
{w.devices.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-gray-400">无设备</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{w.devices.map((d) => (
|
||||
<div
|
||||
key={d.product_id}
|
||||
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"
|
||||
>
|
||||
<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" ? "进行中" : "待接收"}
|
||||
</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-400">序列号: {d.external_serial || "未录入"}</span>
|
||||
<span className="ml-auto font-mono text-xs text-gray-300">身份证: {d.serial_number}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -28,6 +28,33 @@ export interface WipTask {
|
||||
duration_hours: number;
|
||||
}
|
||||
|
||||
export interface CompletedTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
assignee: string;
|
||||
product_sn: string;
|
||||
external_serial: string | null;
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
export interface PersonDevice {
|
||||
product_id: string;
|
||||
serial_number: string;
|
||||
external_serial: string | null;
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
task_status: string;
|
||||
}
|
||||
|
||||
export interface PersonWorkload {
|
||||
assignee_id: string;
|
||||
assignee_name: string;
|
||||
device_count: number;
|
||||
devices: PersonDevice[];
|
||||
}
|
||||
|
||||
export interface ProductMessageItem {
|
||||
id: string;
|
||||
content: string;
|
||||
@ -56,6 +83,19 @@ export async function fetchWipTasks(limit = 20): Promise<WipTask[]> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchCompletedTasks(since?: string, until?: string): Promise<CompletedTask[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (since) params.since = since;
|
||||
if (until) params.until = until;
|
||||
const { data } = await api.get<CompletedTask[]>("/dashboard/completed-tasks", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
|
||||
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDashboardMessages(
|
||||
keyword = "", skip = 0, limit = 30,
|
||||
): Promise<ProductMessageList> {
|
||||
|
||||
Reference in New Issue
Block a user