feat(dashboard): 看板增强——流转完成率下钻明细 + 人员看板(按人聚合在制品设备)
This commit is contained in:
@ -6,6 +6,8 @@ from app.core.database import get_db
|
||||
from app.services.dashboard_service import (
|
||||
get_dashboard_stats, DashboardStats,
|
||||
get_wip_tasks, WipTask,
|
||||
get_completed_tasks, CompletedTask,
|
||||
get_people_workload, PersonWorkload,
|
||||
search_product_messages, ProductMessageList,
|
||||
)
|
||||
|
||||
@ -38,6 +40,27 @@ async def wip_tasks(
|
||||
return await get_wip_tasks(db, limit)
|
||||
|
||||
|
||||
@router.get("/completed-tasks", response_model=list[CompletedTask])
|
||||
async def completed_tasks(
|
||||
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""流转完成率下钻 — 按时段查询已完成任务明细"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_completed_tasks(db, since=since_dt, until=until_dt, limit=limit)
|
||||
|
||||
|
||||
@router.get("/people-workload", response_model=list[PersonWorkload])
|
||||
async def people_workload(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""人员负载 — 按负责人聚合当前在制品设备数(独立人员看板)"""
|
||||
return await get_people_workload(db)
|
||||
|
||||
|
||||
@router.get("/messages", response_model=ProductMessageList)
|
||||
async def dashboard_messages(
|
||||
keyword: str = Query("", description="搜索: SN码/物料名/留言人/内容"),
|
||||
|
||||
@ -37,6 +37,33 @@ class WipTask(BaseModel):
|
||||
duration_hours: float
|
||||
|
||||
|
||||
class CompletedTask(BaseModel):
|
||||
task_id: str
|
||||
task_name: str # 完成的工序节点
|
||||
assignee: str # 完成人中文姓名
|
||||
product_sn: str # 16位HEX身份证
|
||||
external_serial: str | None # 业务序列号
|
||||
material_name: str # 产品名称(物料名称)
|
||||
spec_model: str # 规格型号
|
||||
completed_at: str # 完成时间 ISO
|
||||
|
||||
|
||||
class PersonDevice(BaseModel):
|
||||
product_id: str
|
||||
serial_number: str # 16位HEX身份证
|
||||
external_serial: str | None # 业务序列号
|
||||
material_name: str # 产品名称
|
||||
spec_model: str # 规格型号
|
||||
task_status: str # 该设备名下的状态 WIP/PENDING
|
||||
|
||||
|
||||
class PersonWorkload(BaseModel):
|
||||
assignee_id: str
|
||||
assignee_name: str # 中文姓名
|
||||
device_count: int
|
||||
devices: list[PersonDevice]
|
||||
|
||||
|
||||
class ProductMessageItem(BaseModel):
|
||||
id: str
|
||||
content: str
|
||||
@ -180,6 +207,129 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
return wip_list[:limit]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 已完成任务明细(流转完成率下钻 — 按时段过滤)
|
||||
# ============================================================
|
||||
|
||||
async def get_completed_tasks(
|
||||
db: AsyncSession,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[CompletedTask]:
|
||||
"""按时段查询已完成任务明细(上帝视角),用于「流转完成率」卡片下钻。"""
|
||||
from app.models.task import Task, TASK_STATUS_COMPLETED
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import BEIJING_TZ
|
||||
|
||||
stmt = (
|
||||
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(Task.status == TASK_STATUS_COMPLETED)
|
||||
)
|
||||
if since:
|
||||
stmt = stmt.where(Task.completed_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(Task.completed_at <= until)
|
||||
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
raw_ids = list({t.assignee_id for t, *_ in rows if t.assignee_id})
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(raw_ids)
|
||||
|
||||
items: list[CompletedTask] = []
|
||||
for task, sn, ext, mat, spec in rows:
|
||||
t = task.completed_at
|
||||
if t:
|
||||
if t.tzinfo is None:
|
||||
from datetime import timezone as dt_timezone
|
||||
t = t.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
|
||||
else:
|
||||
t = t.astimezone(BEIJING_TZ)
|
||||
time_str = t.isoformat() if t else ""
|
||||
items.append(CompletedTask(
|
||||
task_id=str(task.id),
|
||||
task_name=task.task_name,
|
||||
assignee=name_map.get(task.assignee_id or "", task.assignee_id or "未分配"),
|
||||
product_sn=sn or "",
|
||||
external_serial=ext or None,
|
||||
material_name=mat or "",
|
||||
spec_model=spec or "",
|
||||
completed_at=time_str,
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 人员负载(按人聚合在制品设备 — 独立「人员看板」)
|
||||
# ============================================================
|
||||
|
||||
async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
|
||||
"""上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重)。"""
|
||||
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
|
||||
from app.models.product import Product
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Task.assignee_id,
|
||||
Product.id, Product.serial_number, Product.external_serial,
|
||||
Product.material_name, Product.spec_model,
|
||||
Task.status,
|
||||
)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
|
||||
Task.assignee_id.isnot(None),
|
||||
)
|
||||
.order_by(Task.assignee_id, Product.created_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 按 assignee 聚合,product 去重;状态优先级 WIP > PENDING
|
||||
by_assignee: dict[str, dict[str, PersonDevice]] = {}
|
||||
for row in rows:
|
||||
assignee = row[0]
|
||||
product_id = str(row[1])
|
||||
status = row[6] or ""
|
||||
devices = by_assignee.setdefault(assignee, {})
|
||||
if product_id in devices:
|
||||
# 已有该设备:若新状态为 WIP 则提升(更活跃)
|
||||
if status == "WIP":
|
||||
devices[product_id].task_status = "WIP"
|
||||
continue
|
||||
devices[product_id] = PersonDevice(
|
||||
product_id=product_id,
|
||||
serial_number=row[2] or "",
|
||||
external_serial=row[3] or None,
|
||||
material_name=row[4] or "",
|
||||
spec_model=row[5] or "",
|
||||
task_status=status,
|
||||
)
|
||||
|
||||
raw_ids = list(by_assignee.keys())
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(raw_ids)
|
||||
|
||||
workloads = [
|
||||
PersonWorkload(
|
||||
assignee_id=assignee,
|
||||
assignee_name=name_map.get(assignee, assignee),
|
||||
device_count=len(devices),
|
||||
devices=list(devices.values()),
|
||||
)
|
||||
for assignee, devices in by_assignee.items()
|
||||
]
|
||||
workloads.sort(key=lambda w: w.device_count, reverse=True)
|
||||
return workloads
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 协同留言搜索(上帝视角 — 全厂)
|
||||
# ============================================================
|
||||
|
||||
@ -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