// Tab "Quản lý thời lượng" — người dùng nào, dùng tool gì, mất bao lâu, // làm ra sản phẩm gì. Mọi con số tính từ bảng log thô `raw_events` ở backend. // // Bố cục dựng theo hai file mẫu đã chốt form: // dashboard_mau -> phần đầu: tiêu đề, thanh lọc, ba nhóm ô chỉ số. // dashboard_full -> phần sau: các card nội dung (hoạt động ngày, tool, bảng). // // Mã chỉ số ghi kèm nhãn (A1..B4) để đối chiếu với User_Flow_and_Tracking_Spec. import { useCallback, useEffect, useMemo, useState } from "react"; import { jget } from "../../api"; import { BarChart, LineChart, StepBar, fmtDur, fmtNum, fmtPct, fmtShort, fmtTime, } from "./UsageCharts"; const isoDay = (d) => { const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; }; const shift = (n) => { const d = new Date(); d.setDate(d.getDate() + n); return d; }; const daysAgo = (n) => isoDay(shift(-n)); // Tuần bắt đầu từ THỨ HAI (lịch VN), không phải Chủ nhật kiểu Mỹ. const weekRange = (offset) => { const d = new Date(); const dow = (d.getDay() + 6) % 7; // T2=0 … CN=6 const mon = new Date(d); mon.setDate(d.getDate() - dow + offset * 7); const sun = new Date(mon); sun.setDate(mon.getDate() + 6); return [isoDay(mon), isoDay(sun > d && offset === 0 ? d : sun)]; }; const monthRange = (offset) => { const now = new Date(); const first = new Date(now.getFullYear(), now.getMonth() + offset, 1); const last = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0); return [isoDay(first), isoDay(last > now && offset === 0 ? now : last)]; }; // Một ô chọn ngày duy nhất thay cho cặp "Từ ngày / Đến ngày" — quản trị hầu hết // chỉ muốn "7 ngày qua" chứ không muốn gõ hai cái ngày. Chọn "Tùy chọn khoảng // ngày" mới mở lịch ra cho ai cần mốc chính xác. // Không có lựa chọn TƯƠNG LAI (ngày mai, 7 ngày tới…): đây là báo cáo đọc log // đã ghi, khoảng tương lai lúc nào cũng rỗng. const DATE_PRESETS = [ { id: "today", label: "Hôm nay", range: () => [isoDay(new Date()), isoDay(new Date())] }, { id: "yesterday", label: "Hôm qua", range: () => [daysAgo(1), daysAgo(1)] }, { id: "7d", label: "7 ngày qua", range: () => [daysAgo(6), isoDay(new Date())] }, { id: "30d", label: "30 ngày qua", range: () => [daysAgo(29), isoDay(new Date())] }, { id: "90d", label: "90 ngày qua", range: () => [daysAgo(89), isoDay(new Date())] }, { id: "week", label: "Tuần này", range: () => weekRange(0) }, { id: "lastweek", label: "Tuần trước", range: () => weekRange(-1) }, { id: "month", label: "Tháng này", range: () => monthRange(0) }, { id: "lastmonth", label: "Tháng trước", range: () => monthRange(-1) }, { id: "custom", label: "Tùy chọn khoảng ngày…" }, ]; const DEFAULT_PRESET = "30d"; // Tên đăng nhập nội bộ viết kiểu "họ tên - team - bu" -> tách ra 3 cột cho // bảng nhân sự. Không đúng khuôn thì để nguyên tên, hai cột kia bỏ trống. function splitName(full) { const parts = String(full || "").split(" - ").map((x) => x.trim()).filter(Boolean); return { name: parts[0] || full || "", team: parts[1] || "", bu: parts[2] || "" }; } export default function UsageDashboard({ token }) { const [preset, setPreset] = useState(DEFAULT_PRESET); const [from, setFrom] = useState(() => daysAgo(29)); const [to, setTo] = useState(() => isoDay(new Date())); const [feature, setFeature] = useState(""); const [userId, setUserId] = useState(""); const [data, setData] = useState(null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const [openUser, setOpenUser] = useState(""); const [detail, setDetail] = useState(null); const [rawOpen, setRawOpen] = useState(false); const [raw, setRaw] = useState([]); const qs = useCallback((extra = {}) => { const p = new URLSearchParams({ token, date_from: from, date_to: to, ...extra }); if (feature) p.set("feature", feature); if (userId && extra.user_id === undefined) p.set("user_id", userId); return p.toString(); }, [token, from, to, feature, userId]); const load = useCallback(async () => { setBusy(true); setErr(""); try { const d = await jget(`/api/analytics/admin/summary?${qs()}`); if (!d.ok) throw new Error(d.detail || "Không đọc được số liệu"); setData(d); setRaw([]); // đổi bộ lọc -> nhật ký cũ không còn đúng } catch (e) { setErr(e.message); } finally { setBusy(false); } }, [qs]); useEffect(() => { load(); }, [load]); // Chọn mốc dựng sẵn -> tự tính từ/đến. Riêng "Tùy chọn khoảng ngày" thì giữ // nguyên hai mốc đang có làm điểm khởi đầu cho người dùng chỉnh tay. const pickPreset = (id) => { setPreset(id); const p = DATE_PRESETS.find((x) => x.id === id); if (p?.range) { const [f, t] = p.range(); setFrom(f); setTo(t); } }; const reset = () => { pickPreset(DEFAULT_PRESET); setFeature(""); setUserId(""); }; const openDetail = async (uid) => { if (openUser === uid) { setOpenUser(""); setDetail(null); return; } setOpenUser(uid); setDetail(null); try { const d = await jget(`/api/analytics/admin/user?${qs({ user_id: uid })}`); if (d.ok) setDetail(d); } catch { /* im lặng, panel sẽ hiện "đang tải" */ } }; const loadRaw = async () => { setRawOpen((v) => !v); if (raw.length) return; try { const d = await jget(`/api/analytics/admin/events?${qs({ limit: "300" })}`); if (d.ok) setRaw(d.events); } catch { /* bỏ qua */ } }; const o = data?.overall; const ts = data?.timeseries || []; const names = data?.user_names || {}; // Tool đóng góp nhiều / ít nhất — cùng bảng xếp hạng với card 🏆 bên dưới, // kéo lên đây thành hai ô để nhìn phát thấy ngay, khỏi cuộn xuống tìm. const ranked = useMemo(() => { const rows = (data?.features || []).filter((f) => f.tasks) .slice().sort((a, b) => (b.score_total ?? 0) - (a.score_total ?? 0)); return { best: rows[0] || null, worst: rows.length > 1 ? rows[rows.length - 1] : null }; }, [data?.features]); // Danh sách người cho ô lọc: mọi mã được cấp + ai từng xuất hiện trong log. // Giữ nguyên khi đang lọc một người, kẻo chọn xong thì danh sách rụng còn 1. const userOptions = useMemo(() => { const m = new Map(); Object.entries(names).forEach(([id, nm]) => m.set(id, nm || id)); (data?.users || []).forEach((u) => { if (!m.has(u.user_id)) m.set(u.user_id, u.user_id); }); if (userId && !m.has(userId)) m.set(userId, userId); return [...m.entries()].sort((a, b) => a[1].localeCompare(b[1], "vi")); }, [names, data?.users, userId]); return (
Đang tải số liệu…
) : o.events === 0 ? (Không có thao tác nào trong khoảng đã chọn. Thử nới rộng ngày hoặc bỏ bớt bộ lọc.
| Tên công cụ | Số người | Số task | Việc xong |
Sản phẩm đầu ra |
Tỷ lệ hoàn thành |
Thời gian hoàn thành |
Số lần bấm chạy / task |
Thời gian AI xử lý |
Hài lòng | Điểm dừng / bỏ dở |
|---|---|---|---|---|---|---|---|---|---|---|
| {f.label} | {f.users} | {f.tasks} | {f.tasks_done} | {f.produced_total} | = 0.7 ? "var(--green)" : "var(--orange)", fontWeight: 700 }}> {fmtPct(f.completion_rate)} | {/* Tool mở ra rồi bỏ dở thì mấy cột dưới KHÔNG có số thật để in. Trước đây in trơ dấu "—", nhìn cứ như báo cáo hỏng; nay rê chuột vào là biết vì sao trống. */}
|
{f.attempts_avg == null ? "—" : f.attempts_avg.toFixed(1)} |
|
|
“Thời gian hoàn thành” là trung vị từ lúc mở tool tới lúc có sản phẩm đầu tiên — gồm cả thời gian người ngồi chỉnh lẫn thời gian AI xử lý. Cột “Thời gian AI xử lý” chỉ tính lúc máy chạy thật, đã trừ thời gian chờ tới lượt. Dấu — nghĩa là tool có người mở nhưng chưa việc nào chạy tới bước đó (xem cột “Việc xong” và phễu “Điểm dừng / bỏ dở”), không phải mất số liệu — rê chuột vào dấu — để xem lý do.
| Người dùng | Team | BU | Hoạt động lần cuối |
Số ngày |
Số phiên dùng |
Tổng thời lượng |
Số task |
Xong | Sản phẩm đầu ra |
Thời gian AI xử lý |
Công cụ hay dùng | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {who.name} {u.user_id.toUpperCase()} {u.returning && 🔁} | {who.team || —} | {who.bu || —} | {fmtTime(u.last_seen)} | {u.days_active} | {u.sessions} | {fmtDur(u.active_ms)} | {u.tasks} | {/* Chỉ SỐ việc xong — bỏ phần trăm đi kèm: cột này đứng cạnh cột "Số task" nên tỷ lệ tự đọc ra được, in thêm % chỉ làm ô chật và rối. */}{u.tasks_done} | {fmtNum(u.produced_total)} | {fmtShort(u.machine_total_ms)} | {(u.top_features || []).length ? u.top_features.map((f) => ( {f.label} · {f.tasks} )) : —} |
Đang tải…
:| Tool | Bước máy | Số lần chạy | TB lần bấm chạy lại |
Trung vị | Chậm nhất 10% | Tổng |
|---|---|---|---|---|---|---|
| {j.label || "—"} | {j.phase_label || j.phase} | {j.runs} | {j.attempt_avg == null ? "—" : j.attempt_avg.toFixed(1)} | {fmtShort(j.median_ms)} | {fmtShort(j.p90_ms)} | {fmtDur(j.total_ms)} |
Chưa có lần chạy máy nào trong khoảng đã chọn.
)}| Thời gian | Người dùng | Tool | Bước | Hành động | Giá trị | Máy chạy | Chi tiết |
|---|---|---|---|---|---|---|---|
| {fmtTime(e.event_ts)} | {e.vi_user || e.user_id} | {e.vi_feature || e.feature || "—"} | {e.vi_step || e.step || "—"} | {e.vi_event || e.event_name} | {e.vi_value || e.event_value || "—"} | {e.vi_latency || ""} | {e.vi_detail || "—"} |
Lưu tại {data.storage?.backend === "pg" ? "PostgreSQL" : "SQLite (máy chủ)"} · {" "}{fmtNum(data.storage?.rows)} dòng · giữ {data.storage?.retention_days} ngày rồi tự dọn.
feature_open, run_click,
dán link hoặc tải file lên. Chỉ mở trang xem thì không tính.session_id; im lặng quá 30′ thì cắt sang phiên mới.task_id — mỗi lần làm ra một sản phẩm là một việc.
Tỷ lệ hoàn thành (A3) = việc có đầu ra ÷ tổng việc.latency_ms của job, đã trừ thời gian chờ tới lượt.Chưa có tool nào chạy việc trong khoảng đã chọn.
Điểm = {pct(w.demand)} nhu cầu người dùng (số việc) + {pct(w.rerun)} ít phải bấm chạy lại (A4 càng thấp điểm càng cao) + {pct(w.output)} số sản phẩm đầu ra (B1). Mỗi phần quy về thang 100 theo tool cao nhất trong khoảng đang lọc — chỉ dùng để so các tool với nhau. Bảng dưới hiện số thật, thanh vẽ theo đúng con số đó (so với tool cao nhất cùng cột) — riêng cột lần bấm/việc thì ít càng tốt, thanh ngắn là hay. Rê chuột vào thanh để xem điểm quy đổi. Tool mở ra mà chưa bấm chạy lần nào thì cột đó tính 0 điểm (chưa chạy thì chưa đóng góp), không phải 100.
{rows.length > 1 && (| # | Tool | Nhu cầu · số việc ({pct(w.demand)}) | Lần bấm/việc ({pct(w.rerun)}) | Sản phẩm đầu ra ({pct(w.output)}) | Tổng điểm |
|---|---|---|---|---|---|
| {i + 1} | {f.label} |
|
|
|
{(f.score_total ?? 0).toFixed(1)} |
Không có phiên nào trong khoảng đã chọn.
; return (