// 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 (
{/* KHÔNG in lại tiêu đề "Quản trị thời lượng" ở đây: trang AdminUsers đã có sẵn tiêu đề + câu mô tả đổi theo tab, in thêm là lặp hai lần. */} {/* Thanh lọc */}
{preset === "custom" && ( <> )}
⬇ Tải báo cáo (CSV) ⬇ Log gốc
Đang xem {userId ? (names[userId] || userId) : "tất cả người dùng"} {feature ? <> · công cụ {(data?.features || []).find((f) => f.feature === feature)?.label || feature} : null} {" · "}từ {from} đến {to} {o ? <> · {fmtNum(o.events)} sự kiện trong {o.days_in_range} ngày có hoạt động : null} {data?.storage ? ( {data.storage.backend === "pg" ? "PostgreSQL" : "SQLite"} · {fmtNum(data.storage.rows)} dòng log ) : null}
{err && (
Lỗi: {err}
)} {!data ? (

Đang tải số liệu…

) : o.events === 0 ? (
📭 Chưa có dữ liệu

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.

) : ( <> {/* ── NHÓM CHỈ SỐ ────────────────────────────────────── */} = 0.7 ? "green" : "orange"} sub={`${o.abandoned} việc bỏ dở`} warn={o.completion_rate < 0.7} /> {/* ── 1. HOẠT ĐỘNG HÀNG NGÀY ─────────────────────────── */}
📈 Báo cáo hoạt động hàng ngày
d.date)} series={[ { key: "users", label: "Người dùng", color: "var(--orange)", values: ts.map((d) => d.users) }, { key: "tasks", label: "Số việc", color: "#3b82f6", values: ts.map((d) => d.tasks) }, { key: "done", label: "Việc có sản phẩm", color: "var(--green)", values: ts.map((d) => d.tasks_done) }, ]} />
Thời lượng dùng mỗi ngày
({ label: d.date.slice(5), value: d.active_ms, text: fmtDur(d.active_ms), sub: `${d.users} người`, }))} />
{/* ── 2. TOOL & SẢN PHẨM ─────────────────────────────── */}
💼 Công cụ được sử dụng nhiều nhất
({ label: f.label, value: f.tasks, text: `${f.tasks} việc`, sub: `${f.users} người`, }))} />
🧊 Sản phẩm làm ra
({ label: `${p.label} · ${p.kind}`, value: p.count, text: fmtNum(p.count), }))} empty="Chưa có sản phẩm nào được tải về." />
{/* ── 3. CHI TIẾT THEO TOOL ──────────────────────────── */}
🧪 Chi tiết theo tool
{(data.features || []).map((f) => ( {/* 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. */} ))}
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)} {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.

{/* ── 4. THEO NHÂN SỰ ────────────────────────────────── */}
👥 Báo cáo chi tiết theo nhân sự ({(data.users || []).length})
{(data.users || []).map((u) => { const who = splitName(names[u.user_id] || u.user_id); return ( {/* 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. */} ); })}
Người dùngTeamBU 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}{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} )) : —}
{openUser && (
Nhật ký làm việc của{" "} {names[openUser] || openUser} {openUser.toUpperCase()}
{!detail ?

Đang tải…

: }
)}
{/* ── 5. TỐC ĐỘ MÁY MỖI BƯỚC (B2) ────────────────────── */}
⚙️ Tốc độ máy mỗi bước (B2)
{(data.jobs || []).length ? (
{(data.jobs || []).map((j, i) => ( ))}
ToolBướ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.

)}
{/* ── 6. NHẬT KÝ CHI TIẾT ────────────────────────────── */} {/* Bản tiếng Việt do backend dịch sẵn (`vi_*`) — frontend không giữ bảng dịch riêng để khỏi lệch với file CSV tải về. */}
🧾 Nhật ký chi tiết
{rawOpen && (
{raw.map((e) => ( ))}
Thời gianNgười dùngToolBước Hành độngGiá trịMáy chạyChi 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.

)}
); } // ── Khối chỉ số ──────────────────────────────────────────────── function Group({ title, hint, children }) { return (
{title}
{hint}
{children}
); } // Ô số có thể RỖNG THẬT. "Không có số" khác hẳn "số bằng 0": tool mở ra rồi bỏ // dở thì không có thời gian hoàn thành nào để in — dấu — kèm lý do, đừng để // người đọc tưởng báo cáo mất dữ liệu. function Blank({ value, render, why, title = "" }) { if (value == null || Number.isNaN(value)) { return —; } return {render(value)}; } function Metric({ label, value, sub, tone = "", warn = false }) { return (
{label}
{value}
{sub &&
{sub}
}
); } // Ô "Hài lòng (B3)": quy tỷ lệ 👍 về thang 5 sao rồi đặt chấm trên dải màu. // Chưa ai chấm thì nói thẳng "chưa có phiếu", KHÔNG vẽ chấm ở mốc 0 — 0 điểm // và "chưa có dữ liệu" là hai chuyện hoàn toàn khác nhau. function Satisfaction({ up, down, rate }) { const votes = (up || 0) + (down || 0); if (!votes) { return (
Hài lòng (B3)
—
chưa ai chấm 👍/👎 cho kết quả
); } const score = (rate || 0) * 5; return (
Hài lòng (B3)
{score.toFixed(1)}/5
{up} 👍 / {down} 👎 trên {votes} phiếu
); } // Ô "tool đóng góp nhiều / ít nhất". Giá trị là TÊN TOOL nên phải dùng cỡ chữ // nhỏ hơn ô số (.val.text), kẻo "Chèn chữ vào video" tràn khỏi thẻ. function TopTool({ label, f, tone, empty = "chưa có tool nào chạy việc" }) { if (!f) { return (
{label}
—
{empty}
); } return (
{label}
{f.label}
{(f.score_total ?? 0).toFixed(1)} điểm · {f.tasks} việc · {fmtNum(f.produced_total)} sản phẩm
); } function Formulas() { return (
Cách tính các chỉ số (mở ra khi cần đối chiếu con số)
  • Số tài khoản được cấp: đếm tài khoản đã được duyệt trong tab “Quản lý người dùng”, kể cả người chưa từng đăng nhập; không suy từ log.
  • User đã sử dụng: có ít nhất một thao tác thật — feature_open, run_click, dán link hoặc tải file lên. Chỉ mở trang xem thì không tính.
  • Dùng hàng tuần / hàng ngày: có thao tác trong 7 ngày / 24 giờ cuối của khoảng đang lọc (không phải so với hôm nay, để xem lại báo cáo cũ vẫn đúng).
  • Phiên làm việc: gom theo session_id; im lặng quá 30′ thì cắt sang phiên mới.
  • Số việc (task): đếm 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.
  • Số lần bấm chạy (A4) = số lần máy chạy xong ÷ số việc. Sản phẩm (B1) = số file người dùng thật sự tải về hoặc chuyển tiếp sang tool khác.
  • Hài lòng (B3) = tỷ lệ 👍 trên tổng phiếu 👍/👎 người dùng bấm ở màn kết quả.
  • Xong 1 việc mất (A1) = trung vị từ lúc mở tool tới lúc có sản phẩm đầu tiên. Thời gian AI xử lý (B4) = tổng latency_ms của job, đã trừ thời gian chờ tới lượt.
); } // ── Tool đóng góp nhiều / ít nhất ────────────────────────────── // Điểm tổng hợp do backend chấm (etl.score_contribution): // 40% nhu cầu người dùng (số việc) + 20% ít phải bấm chạy lại (A4 nghịch đảo) // + 40% sản phẩm đầu ra (B1). Mỗi phần quy về thang 100 theo tool cao nhất // TRONG KỲ ĐANG LỌC -> chỉ so giữa các tool cùng khoảng, không so giữa 2 khoảng. // Thanh trong bảng vẽ theo ĐÚNG CON SỐ hiện bên cạnh (so với tool cao nhất // cùng cột), KHÔNG vẽ theo điểm quy đổi. Vẽ theo điểm thì cột "ít chạy lại" // nhìn ngược đời: 0.5 lần/việc thanh DÀI hơn 0.8 lần/việc (vì ít bấm = điểm // cao). Số to = thanh dài, không ngoại lệ — cột nào "ít càng tốt" thì ghi rõ // ở tiêu đề. Điểm quy đổi vẫn xem được ở tooltip khi rê chuột. // `none` = chưa có số để vẽ (tool mở ra chưa bấm chạy) -> thanh gạch mờ, để // không bị đọc nhầm thành "0 lần bấm = giỏi nhất". function ScoreBar({ ratio, score, text, unit, color = "var(--orange)", none = false }) { const pct = Math.max(0, Math.min(100, (ratio || 0) * 100)); return (
{!none &&
}
{text}{unit ? {unit} : null}
); } function ContribRanking({ features, weights }) { const rows = (features || []).filter((f) => f.tasks) .slice().sort((a, b) => (b.score_total ?? 0) - (a.score_total ?? 0)); const w = weights || { demand: 0.4, rerun: 0.2, output: 0.4 }; // Mốc vẽ thanh: tool cao nhất của TỪNG CỘT (min 1 để khỏi chia 0). const maxTasks = Math.max(1, ...rows.map((f) => f.tasks || 0)); const maxProd = Math.max(1, ...rows.map((f) => f.produced_total || 0)); const maxAtt = Math.max(0.1, ...rows.map((f) => f.attempts_avg || 0)); const pct = (x) => `${Math.round((x || 0) * 100)}%`; if (!rows.length) { return (
🏆 Tool đóng góp nhiều nhất / ít nhất

Chưa có tool nào chạy việc trong khoảng đã chọn.

); } const best = rows[0]; const worst = rows[rows.length - 1]; const card = (title, f, tone) => (
{title}
{f.label}
{(f.score_total ?? 0).toFixed(1)} điểm · {f.tasks} việc · {fmtNum(f.produced_total)} sản phẩm · {" "}{f.attempts_avg ? `${f.attempts_avg.toFixed(1)} lần bấm/việc` : "chưa bấm chạy lần nào"}
); return (
🏆 Tool đóng góp nhiều nhất / ít nhất

Đ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 && (
{card("Đóng góp nhiều nhất", best, "good")} {card("Đóng góp ít nhất", worst, "warn")}
)}
{rows.map((f, i) => ( ))}
#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)}
); } // ── Nhật ký từng phiên của một người ─────────────────────────── function UserSessions({ detail }) { const sessions = detail.sessions || []; if (!sessions.length) return

Không có phiên nào trong khoảng đã chọn.

; return (
{sessions.map((s) => (
{fmtTime(s.start_ts)} dùng {fmtDur(s.active_ms)} · {s.n_tasks} việc · {s.n_events} thao tác {s.session_id}
{(s.tasks || []).map((t) => (
{t.label} {t.has_output ? ( ✓ {t.success_type === "forward" ? "chuyển tiếp" : "tải về"} · {t.produced} sản phẩm ) : t.abandoned ? ( ✕ bỏ dở ở “{t.last_step || "?"}”{t.abandon_reason ? ` · ${t.abandon_reason}` : ""} ) : ( đang làm dở )} {t.has_output ? `xong sau ${fmtDur(t.duration_ms)}` : `kéo dài ${fmtDur(t.span_ms)}`} {" · "}máy {fmtShort(t.machine_ms)} {t.wait_ms ? ` (chờ ${fmtShort(t.wait_ms)})` : ""} {t.attempts ? ` · bấm chạy ${t.attempts} lần` : ""} {t.thumbs_up ? " · 👍" : ""}{t.thumbs_down ? " · 👎" : ""}
{!!(t.steps || []).length && (
{t.steps.map((st, i) => ( {st.step} {fmtShort(st.ms)} ))}
)}
))}
))}
); }