feat(全局概览): 驳回/返工卡片可点击下钻明细抽屉

- 驳回/返工数字改为可点击按钮,打开明细抽屉(仿完成明细)
- RejectedRow 展示设备/被驳回工序/驳回人/返工给谁/原因/时间,行点击跳转任务全景
- 跟随顶部时间筛选(since/until)
This commit is contained in:
2026-08-28 11:19:03 +08:00
parent 7e00683a21
commit d92fcc4d4a
2 changed files with 107 additions and 5 deletions

View File

@ -7,8 +7,8 @@ import { useNavigate } from "react-router-dom";
import { Radio, DatePicker, Drawer, Input } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import {
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask,
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask,
} from "../../services/dashboardApi";
const { RangePicker } = DatePicker;
@ -188,6 +188,43 @@ function CompletedRow({ t }: { t: CompletedTask }) {
);
}
// ─── 被驳回明细项(卡片式) ──────────────────────────────
function RejectedRow({ t }: { t: RejectedTask }) {
const nav = useNavigate();
const rejectedTime = t.rejected_at ? dayjs(t.rejected_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-red-200 hover:shadow-md"
>
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 text-red-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="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600"></span>
<span className="text-xs text-gray-500 shrink-0">: {t.rejected_by}</span>
<span className="ml-auto shrink-0 text-xs text-gray-400">{rejectedTime}</span>
</div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
🔁 : {t.rework_assignee}
</span>
<span className="text-gray-300">|</span>
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>: {t.reject_reason || "—"}</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);
@ -211,6 +248,11 @@ export default function AdminDashboard() {
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
const [completedLoading, setCompletedLoading] = useState(false);
// 驳回/返工明细抽屉
const [rejectedDrawerOpen, setRejectedDrawerOpen] = useState(false);
const [rejectedTasks, setRejectedTasks] = useState<RejectedTask[]>([]);
const [rejectedLoading, setRejectedLoading] = useState(false);
const navigate = useNavigate();
// ── 加载主数据 ──
@ -255,6 +297,16 @@ export default function AdminDashboard() {
.finally(() => setCompletedLoading(false));
};
const openRejectedDrawer = () => {
setRejectedDrawerOpen(true);
setRejectedLoading(true);
const { since, until } = rangeToParams(dateKey, customRange);
fetchRejectedTasks(since, until)
.then(setRejectedTasks)
.catch(() => setRejectedTasks([]))
.finally(() => setRejectedLoading(false));
};
const onMsgSearch = (value: string) => {
setMsgKeyword(value);
loadMessages(value);
@ -358,10 +410,16 @@ export default function AdminDashboard() {
<h3 className="text-sm font-semibold text-gray-700"> </h3>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-red-50 p-3 text-center">
{/* 驳回/返工 — 可点击打开明细抽屉 */}
<button
onClick={openRejectedDrawer}
className="rounded-lg bg-red-50 p-3 text-center border-0 cursor-pointer transition-colors hover:bg-red-100"
>
<p className="text-xl font-bold text-red-600">{stats.tasks_rejected + stats.tasks_rework}</p>
<p className="text-[11px] text-red-500">/</p>
</div>
<p className="flex items-center justify-center gap-1 text-[11px] text-red-500">
<AlertTriangle className="h-3 w-3" />/
</p>
</button>
{/* 留言 — 可点击打开抽屉 */}
<button
onClick={openMsgDrawer}
@ -527,6 +585,29 @@ export default function AdminDashboard() {
</div>
)}
</Drawer>
{/* ═══ 驳回/返工明细抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">🔴 / <span className="font-normal text-gray-400"> {rejectedTasks.length} </span></span>}
open={rejectedDrawerOpen}
onClose={() => setRejectedDrawerOpen(false)}
size="large"
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
{rejectedLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-red-500" />
</div>
) : rejectedTasks.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400">
</div>
) : (
<div>
{rejectedTasks.map(t => <RejectedRow key={t.task_id} t={t} />)}
</div>
)}
</Drawer>
</div>
);
}

View File

@ -39,6 +39,19 @@ export interface CompletedTask {
completed_at: string;
}
export interface RejectedTask {
task_id: string;
task_name: string;
product_sn: string;
external_serial: string | null;
material_name: string;
spec_model: string;
rejected_by: string;
rework_assignee: string;
reject_reason: string | null;
rejected_at: string;
}
export interface PersonDevice {
product_id: string;
serial_number: string;
@ -119,6 +132,14 @@ export async function fetchCompletedTasks(since?: string, until?: string): Promi
return data;
}
export async function fetchRejectedTasks(since?: string, until?: string): Promise<RejectedTask[]> {
const params: Record<string, string> = {};
if (since) params.since = since;
if (until) params.until = until;
const { data } = await api.get<RejectedTask[]>("/dashboard/rejected-tasks", { params });
return data;
}
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
return data;