feat(全局概览): 驳回/返工卡片可点击下钻明细抽屉
- 驳回/返工数字改为可点击按钮,打开明细抽屉(仿完成明细) - RejectedRow 展示设备/被驳回工序/驳回人/返工给谁/原因/时间,行点击跳转任务全景 - 跟随顶部时间筛选(since/until)
This commit is contained in:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user