feat(操作统计): 点击接收/转交/上传备注数字下钻查看明细

- 表格中接收/转交/上传备注数字改为可点击(蓝色下划线),点击打开明细抽屉
- 明细展示:任务名/备注/设备/身份证/时间,跟随顶部时间筛选
This commit is contained in:
2026-08-28 13:36:28 +08:00
parent 7205de369a
commit d0fc8cdecf
2 changed files with 79 additions and 5 deletions

View File

@ -8,8 +8,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, fetchRejectedTasks, fetchUserOperations,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation,
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations, fetchOperationDetail,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation, type OperationDetail,
} from "../../services/dashboardApi";
const { RangePicker } = DatePicker;
@ -276,6 +276,11 @@ export default function AdminDashboard() {
const [opLoading, setOpLoading] = useState(false);
const [opFilterUsers, setOpFilterUsers] = useState<Set<string>>(new Set()); // 多选人员,空 = 全部
const [opSort, setOpSort] = useState<{ key: string; order: "asc" | "desc" } | null>(null);
// 操作明细下钻
const [opDetailOpen, setOpDetailOpen] = useState(false);
const [opDetailList, setOpDetailList] = useState<OperationDetail[]>([]);
const [opDetailLoading, setOpDetailLoading] = useState(false);
const [opDetailTitle, setOpDetailTitle] = useState("");
const navigate = useNavigate();
@ -369,6 +374,18 @@ export default function AdminDashboard() {
});
};
// 点击数字下钻查看明细
const openOpDetail = (user: UserOperation, actionType: string, label: string) => {
setOpDetailOpen(true);
setOpDetailTitle(`${user.user_name} · ${label}`);
setOpDetailLoading(true);
const { since, until } = rangeToParams(dateKey, customRange);
fetchOperationDetail(user.user_id, actionType, since, until)
.then(setOpDetailList)
.catch(() => setOpDetailList([]))
.finally(() => setOpDetailLoading(false));
};
const onMsgSearch = (value: string) => {
setMsgKeyword(value);
loadMessages(value);
@ -776,9 +793,15 @@ export default function AdminDashboard() {
<tr key={u.user_id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/50">
<td className="py-2 pr-3 text-xs text-gray-400">{idx + 1}</td>
<td className="py-2 pr-3 font-medium text-gray-800">{u.user_name}</td>
<td className="py-2 pr-3 text-right text-gray-600">{u.receive_count}</td>
<td className="py-2 pr-3 text-right text-gray-600">{u.transfer_count}</td>
<td className="py-2 pr-3 text-right text-gray-600">{u.record_count}</td>
<td className="py-2 pr-3 text-right">
<button onClick={() => openOpDetail(u, "receive", "接收")} className="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline">{u.receive_count}</button>
</td>
<td className="py-2 pr-3 text-right">
<button onClick={() => openOpDetail(u, "transfer", "转交")} className="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline">{u.transfer_count}</button>
</td>
<td className="py-2 pr-3 text-right">
<button onClick={() => openOpDetail(u, "record", "上传备注")} className="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline">{u.record_count}</button>
</td>
<td className="py-2 text-right font-bold text-blue-600">{u.total}</td>
</tr>
))}
@ -789,6 +812,39 @@ export default function AdminDashboard() {
})()
)}
</Drawer>
{/* ═══ 操作明细抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">📋 {opDetailTitle} <span className="font-normal text-gray-400">{opDetailList.length} </span></span>}
open={opDetailOpen}
onClose={() => setOpDetailOpen(false)}
size="large"
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
{opDetailLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : opDetailList.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400"></div>
) : (
<div>
{opDetailList.map((d, i) => (
<div key={i} className="mb-2 rounded-lg border border-gray-100 bg-white px-4 py-3 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800">{d.task_name}</span>
<span className="shrink-0 text-xs text-gray-400">{d.time ? dayjs(d.time).format("YYYY-MM-DD HH:mm") : ""}</span>
</div>
{d.remark && <p className="mt-1 text-xs leading-relaxed text-gray-600">{d.remark}</p>}
<div className="mt-1.5 text-[11px] text-gray-400">
<span>{d.material_name || "未知设备"}</span>
{d.product_sn && <span className="ml-2 font-mono">: {d.product_sn}</span>}
</div>
</div>
))}
</div>
)}
</Drawer>
</div>
);
}

View File

@ -63,6 +63,14 @@ export interface UserOperation {
total: number;
}
export interface OperationDetail {
task_name: string;
product_sn: string;
material_name: string;
remark: string | null;
time: string;
}
export interface PersonDevice {
product_id: string;
serial_number: string;
@ -159,6 +167,16 @@ export async function fetchUserOperations(since?: string, until?: string): Promi
return data;
}
export async function fetchOperationDetail(
userId: string, actionType: string, since?: string, until?: string,
): Promise<OperationDetail[]> {
const params: Record<string, string> = { user_id: userId, action_type: actionType };
if (since) params.since = since;
if (until) params.until = until;
const { data } = await api.get<OperationDetail[]>("/dashboard/user-operations/detail", { params });
return data;
}
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
return data;