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 { Radio, DatePicker, Drawer, Input } from "antd";
|
||||||
import dayjs, { type Dayjs } from "dayjs";
|
import dayjs, { type Dayjs } from "dayjs";
|
||||||
import {
|
import {
|
||||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks,
|
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks,
|
||||||
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask,
|
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask,
|
||||||
} from "../../services/dashboardApi";
|
} from "../../services/dashboardApi";
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
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() {
|
export default function AdminDashboard() {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
@ -211,6 +248,11 @@ export default function AdminDashboard() {
|
|||||||
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
|
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
|
||||||
const [completedLoading, setCompletedLoading] = useState(false);
|
const [completedLoading, setCompletedLoading] = useState(false);
|
||||||
|
|
||||||
|
// 驳回/返工明细抽屉
|
||||||
|
const [rejectedDrawerOpen, setRejectedDrawerOpen] = useState(false);
|
||||||
|
const [rejectedTasks, setRejectedTasks] = useState<RejectedTask[]>([]);
|
||||||
|
const [rejectedLoading, setRejectedLoading] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// ── 加载主数据 ──
|
// ── 加载主数据 ──
|
||||||
@ -255,6 +297,16 @@ export default function AdminDashboard() {
|
|||||||
.finally(() => setCompletedLoading(false));
|
.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) => {
|
const onMsgSearch = (value: string) => {
|
||||||
setMsgKeyword(value);
|
setMsgKeyword(value);
|
||||||
loadMessages(value);
|
loadMessages(value);
|
||||||
@ -358,10 +410,16 @@ export default function AdminDashboard() {
|
|||||||
<h3 className="text-sm font-semibold text-gray-700">⚠️ 品质与协同</h3>
|
<h3 className="text-sm font-semibold text-gray-700">⚠️ 品质与协同</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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-xl font-bold text-red-600">{stats.tasks_rejected + stats.tasks_rework}</p>
|
||||||
<p className="text-[11px] text-red-500">驳回/返工</p>
|
<p className="flex items-center justify-center gap-1 text-[11px] text-red-500">
|
||||||
</div>
|
<AlertTriangle className="h-3 w-3" />驳回/返工 ↗
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
{/* 留言 — 可点击打开抽屉 */}
|
{/* 留言 — 可点击打开抽屉 */}
|
||||||
<button
|
<button
|
||||||
onClick={openMsgDrawer}
|
onClick={openMsgDrawer}
|
||||||
@ -527,6 +585,29 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,19 @@ export interface CompletedTask {
|
|||||||
completed_at: string;
|
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 {
|
export interface PersonDevice {
|
||||||
product_id: string;
|
product_id: string;
|
||||||
serial_number: string;
|
serial_number: string;
|
||||||
@ -119,6 +132,14 @@ export async function fetchCompletedTasks(since?: string, until?: string): Promi
|
|||||||
return data;
|
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[]> {
|
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
|
||||||
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
|
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
Reference in New Issue
Block a user