Spaces:
Running
Running
Download frontend/src/components/admin/UsageDashboard.jsx from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 44.7 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/admin/UsageDashboard.jsx
- Command line
-
hf download hf://spaces/tntgroup/website_content/frontend/src/components/admin/UsageDashboard.jsx
-
curl -L -o UsageDashboard.jsx https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/admin/UsageDashboard.jsx
44.7 kB
| // 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 ( | |
| <div className="stack" style={{ gap: 18 }}> | |
| {/* 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 */} | |
| <div className="card rep-filters"> | |
| <label className="field"> | |
| <span>Người dùng</span> | |
| <select value={userId} onChange={(e) => setUserId(e.target.value)}> | |
| <option value="">Tất cả người dùng</option> | |
| {userOptions.map(([id, nm]) => ( | |
| <option key={id} value={id}>{nm}</option> | |
| ))} | |
| </select> | |
| </label> | |
| <label className="field"> | |
| <span>Công cụ</span> | |
| <select value={feature} onChange={(e) => setFeature(e.target.value)}> | |
| <option value="">Tất cả công cụ</option> | |
| {(data?.features || []).map((f) => ( | |
| <option key={f.feature} value={f.feature}>{f.label}</option> | |
| ))} | |
| </select> | |
| </label> | |
| <label className="field"> | |
| <span>Ngày</span> | |
| <select value={preset} onChange={(e) => pickPreset(e.target.value)}> | |
| {DATE_PRESETS.map((p) => ( | |
| <option key={p.id} value={p.id}>{p.label}</option> | |
| ))} | |
| </select> | |
| </label> | |
| {preset === "custom" && ( | |
| <> | |
| <label className="field"> | |
| <span>Từ ngày</span> | |
| <input type="date" value={from} max={to} onChange={(e) => setFrom(e.target.value)} /> | |
| </label> | |
| <label className="field"> | |
| <span>Đến ngày</span> | |
| <input type="date" value={to} min={from} onChange={(e) => setTo(e.target.value)} /> | |
| </label> | |
| </> | |
| )} | |
| <button className="btn primary" onClick={load} disabled={busy}> | |
| {busy ? "Đang tải…" : "Áp dụng"} | |
| </button> | |
| <button className="btn" onClick={reset}>Đặt lại</button> | |
| <div style={{ flex: 1 }} /> | |
| <a className="btn" href={`/api/analytics/admin/export.csv?${qs()}`} | |
| title="File CSV tiếng Việt: tên người, tên tool, tên bước, chi tiết đã dịch"> | |
| ⬇ Tải báo cáo (CSV) | |
| </a> | |
| <a className="btn ghost" href={`/api/analytics/admin/export.csv?${qs({ raw: "1" })}`} | |
| title="Bản 11 cột đúng schema gốc, cho bên kỹ thuật nạp vào công cụ khác"> | |
| ⬇ Log gốc | |
| </a> | |
| </div> | |
| <div className="rep-note"> | |
| Đang xem <b>{userId ? (names[userId] || userId) : "tất cả người dùng"}</b> | |
| {feature ? <> · công cụ <b>{(data?.features || []).find((f) => f.feature === feature)?.label || feature}</b></> : null} | |
| {" · "}từ <b>{from}</b> đến <b>{to}</b> | |
| {o ? <> · <b>{fmtNum(o.events)}</b> sự kiện trong <b>{o.days_in_range}</b> ngày có hoạt động</> : null} | |
| {data?.storage ? ( | |
| <span className="rep-badge" style={{ marginLeft: 10 }}> | |
| {data.storage.backend === "pg" ? "PostgreSQL" : "SQLite"} · {fmtNum(data.storage.rows)} dòng log | |
| </span> | |
| ) : null} | |
| </div> | |
| {err && ( | |
| <div style={{ padding: "10px 14px", background: "var(--red-soft)", borderRadius: "var(--radius-sm)", fontSize: 13, fontWeight: 600 }}> | |
| Lỗi: {err} | |
| </div> | |
| )} | |
| {!data ? ( | |
| <p className="small muted">Đang tải số liệu…</p> | |
| ) : o.events === 0 ? ( | |
| <div className="card"> | |
| <div className="sec-title"><span className="em">📭</span> Chưa có dữ liệu</div> | |
| <p className="small muted" style={{ margin: 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. | |
| </p> | |
| </div> | |
| ) : ( | |
| <> | |
| {/* ── NHÓM CHỈ SỐ ────────────────────────────────────── */} | |
| <Group title="👥 Tình trạng người dùng" | |
| hint="Ai được cấp quyền và mức độ quay lại sử dụng app"> | |
| <Metric label="Số tài khoản được cấp" value={fmtNum(data.granted_accounts)} | |
| sub="từ danh sách đăng nhập" tone="muted" /> | |
| <Metric label="User đã sử dụng" value={fmtNum(o.used_users)} | |
| sub="có thao tác thật, không chỉ ngó qua" /> | |
| <Metric label="Dùng hàng tuần" value={fmtNum(o.weekly_users)} | |
| sub="trong 7 ngày cuối của khoảng lọc" /> | |
| <Metric label="Dùng hàng ngày" value={fmtNum(o.daily_users)} | |
| sub="trong 24 giờ cuối của khoảng lọc" /> | |
| <Metric label="Có quay lại (A5)" value={fmtNum(o.returning_users)} | |
| sub={`trên ${o.users} người có hoạt động`} /> | |
| </Group> | |
| <Group title="⚡ Năng suất" | |
| hint="Làm được bao nhiêu việc, ra sản phẩm gì, có ưng không"> | |
| <Metric label="Phiên làm việc" value={fmtNum(o.sessions)} | |
| sub={`trung vị ${fmtShort(o.session_median_ms)} · dài nhất ${fmtShort(o.session_max_ms)}`} /> | |
| <Metric label="Số việc (task)" value={fmtNum(o.tasks)} | |
| sub={`${o.tasks_per_day.toFixed(1)} việc/ngày · ${o.tasks_done} có sản phẩm`} /> | |
| <Metric label="Số lần bấm chạy (A4)" | |
| value={o.attempts_avg == null ? "—" : o.attempts_avg.toFixed(1)} | |
| sub="mỗi việc · bấm lại nhiều = khó ưng" /> | |
| <Metric label="Tỷ lệ hoàn thành (A3)" value={fmtPct(o.completion_rate)} | |
| tone={o.completion_rate >= 0.7 ? "green" : "orange"} | |
| sub={`${o.abandoned} việc bỏ dở`} warn={o.completion_rate < 0.7} /> | |
| <Metric label="Sản phẩm làm ra (B1)" value={fmtNum(o.produced_total)} | |
| sub="file người dùng thật sự lấy về" /> | |
| <Satisfaction up={o.thumbs_up} down={o.thumbs_down} rate={o.thumbs_rate} /> | |
| <TopTool label="Tool đóng góp nhiều nhất" f={ranked.best} tone="green" /> | |
| <TopTool label="Tool đóng góp ít nhất" f={ranked.worst} tone="orange" | |
| empty="cần ít nhất 2 tool có việc để so" /> | |
| </Group> | |
| <Group title="⏱️ Thời gian" hint="Tổng thời lượng và tốc độ hoàn thành"> | |
| <Metric label="Tổng thời lượng dùng" value={fmtDur(o.active_ms)} | |
| sub="đã trừ lúc bỏ máy > 30′" /> | |
| <Metric label="Xong 1 việc mất (A1)" value={fmtDur(o.duration_median_ms)} | |
| sub={`chậm nhất 10%: ${fmtShort(o.duration_p90_ms)}`} /> | |
| <Metric label="Thời gian AI xử lý (B4)" value={fmtDur(o.machine_total_ms)} | |
| sub={`chờ tới lượt ${fmtShort(o.wait_total_ms)}`} /> | |
| <Metric label="Máy chạy mỗi việc" value={fmtShort(o.machine_median_ms)} | |
| sub="trung vị, đã trừ thời gian chờ" /> | |
| </Group> | |
| <Formulas /> | |
| {/* ── 1. HOẠT ĐỘNG HÀNG NGÀY ─────────────────────────── */} | |
| <div className="card"> | |
| <div className="sec-title"><span className="em">📈</span> Báo cáo hoạt động hàng ngày</div> | |
| <LineChart | |
| labels={ts.map((d) => 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) }, | |
| ]} | |
| /> | |
| <div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink-2)", margin: "22px 0 10px" }}> | |
| Thời lượng dùng mỗi ngày | |
| </div> | |
| <BarChart data={ts.slice(-14).map((d) => ({ | |
| label: d.date.slice(5), value: d.active_ms, text: fmtDur(d.active_ms), | |
| sub: `${d.users} người`, | |
| }))} /> | |
| </div> | |
| {/* ── 2. TOOL & SẢN PHẨM ─────────────────────────────── */} | |
| <div className="card"> | |
| <div className="sec-title"><span className="em">💼</span> Công cụ được sử dụng nhiều nhất</div> | |
| <BarChart data={(data.features || []).map((f) => ({ | |
| label: f.label, value: f.tasks, text: `${f.tasks} việc`, | |
| sub: `${f.users} người`, | |
| }))} /> | |
| </div> | |
| <div className="card"> | |
| <div className="sec-title"><span className="em">🧊</span> Sản phẩm làm ra</div> | |
| <BarChart color="green" data={(data.products || []).slice(0, 10).map((p) => ({ | |
| 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ề." /> | |
| </div> | |
| <ContribRanking features={data.features || []} weights={data.contrib_weights} /> | |
| {/* ── 3. CHI TIẾT THEO TOOL ──────────────────────────── */} | |
| <div className="card"> | |
| <div className="sec-title"><span className="em">🧪</span> Chi tiết theo tool</div> | |
| <div className="tablewrap"> | |
| <table className="list rep"> | |
| <thead> | |
| <tr> | |
| <th>Tên công cụ</th> | |
| <th className="mid">Số người</th> | |
| <th className="mid">Số task</th> | |
| <th className="mid">Việc<br />xong</th> | |
| <th className="mid">Sản phẩm<br />đầu ra</th> | |
| <th className="mid">Tỷ lệ<br />hoàn thành</th> | |
| <th className="mid">Thời gian<br />hoàn thành</th> | |
| <th className="mid">Số lần bấm<br />chạy / task</th> | |
| <th className="mid">Thời gian<br />AI xử lý</th> | |
| <th className="mid">Hài lòng</th> | |
| <th>Điểm dừng / bỏ dở</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {(data.features || []).map((f) => ( | |
| <tr key={f.feature}> | |
| <td className="name">{f.label}</td> | |
| <td className="mid">{f.users}</td> | |
| <td className="mid">{f.tasks}</td> | |
| <td className="mid big">{f.tasks_done}</td> | |
| <td className="mid big">{f.produced_total}</td> | |
| <td className="mid" style={{ color: f.completion_rate >= 0.7 ? "var(--green)" : "var(--orange)", fontWeight: 700 }}> | |
| {fmtPct(f.completion_rate)} | |
| </td> | |
| {/* 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. */} | |
| <td className="mid"> | |
| <Blank value={f.duration_median_ms} render={fmtShort} | |
| why="chưa việc nào chạy tới lúc có sản phẩm nên chưa đo được" /> | |
| </td> | |
| <td className="mid">{f.attempts_avg == null ? "—" : f.attempts_avg.toFixed(1)}</td> | |
| <td className="mid"> | |
| <Blank value={f.machine_median_ms} render={fmtShort} | |
| why="chưa ai bấm chạy nên máy chưa xử lý lần nào" /> | |
| </td> | |
| <td className="mid"> | |
| <Blank value={f.thumbs_up + f.thumbs_down ? f.thumbs_rate : null} | |
| render={fmtPct} why="chưa ai chấm 👍/👎 cho kết quả của tool này" | |
| title={`${f.thumbs_up} 👍 / ${f.thumbs_down} 👎`} /> | |
| </td> | |
| <td style={{ minWidth: 165 }}><StepBar counts={f.abandon_by_step} /></td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| <p className="small muted" style={{ margin: "14px 0 0", fontStyle: "italic" }}> | |
| “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 <b>—</b> 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ở”), <b>không phải</b> mất số liệu — | |
| rê chuột vào dấu — để xem lý do. | |
| </p> | |
| </div> | |
| {/* ── 4. THEO NHÂN SỰ ────────────────────────────────── */} | |
| <div className="card"> | |
| <div className="sec-title"> | |
| <span className="em">👥</span> Báo cáo chi tiết theo nhân sự ({(data.users || []).length}) | |
| </div> | |
| <div className="tablewrap"> | |
| <table className="list rep"> | |
| <thead> | |
| <tr> | |
| <th>Người dùng</th><th>Team</th><th>BU</th> | |
| <th>Hoạt động<br />lần cuối</th> | |
| <th className="mid">Số<br />ngày</th> | |
| <th className="mid">Số phiên<br />dùng</th> | |
| <th className="num">Tổng<br />thời lượng</th> | |
| <th className="mid">Số<br />task</th> | |
| <th className="mid">Xong</th> | |
| <th className="mid">Sản phẩm<br />đầu ra</th> | |
| <th className="num">Thời gian<br />AI xử lý</th> | |
| <th>Công cụ hay dùng</th> | |
| <th /> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {(data.users || []).map((u) => { | |
| const who = splitName(names[u.user_id] || u.user_id); | |
| return ( | |
| <tr key={u.user_id}> | |
| <td className="name"> | |
| {who.name} | |
| <span className="idchip">{u.user_id.toUpperCase()}</span> | |
| {u.returning && <span title="Có quay lại dùng (A5)" style={{ marginLeft: 6 }}>🔁</span>} | |
| </td> | |
| <td className="small">{who.team || <span className="muted">—</span>}</td> | |
| <td className="small">{who.bu || <span className="muted">—</span>}</td> | |
| <td className="small muted" style={{ whiteSpace: "nowrap" }}>{fmtTime(u.last_seen)}</td> | |
| <td className="mid">{u.days_active}</td> | |
| <td className="mid">{u.sessions}</td> | |
| <td className="num big" style={{ whiteSpace: "nowrap" }}>{fmtDur(u.active_ms)}</td> | |
| <td className="mid">{u.tasks}</td> | |
| {/* 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. */} | |
| <td className="mid big">{u.tasks_done}</td> | |
| <td className="mid">{fmtNum(u.produced_total)}</td> | |
| <td className="num">{fmtShort(u.machine_total_ms)}</td> | |
| <td style={{ maxWidth: 250 }}> | |
| {(u.top_features || []).length | |
| ? u.top_features.map((f) => ( | |
| <span key={f.feature || f.label} className="usechip">{f.label} · {f.tasks}</span> | |
| )) | |
| : <span className="muted">—</span>} | |
| </td> | |
| <td> | |
| <button className="btn sm" onClick={() => openDetail(u.user_id)}> | |
| {openUser === u.user_id ? "Đóng" : "Xem phiên"} | |
| </button> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| {openUser && ( | |
| <div style={{ marginTop: 16, borderTop: "1px solid var(--line)", paddingTop: 14 }}> | |
| <div style={{ fontWeight: 800, marginBottom: 10 }}> | |
| Nhật ký làm việc của{" "} | |
| <span style={{ color: "var(--orange)" }}>{names[openUser] || openUser}</span> | |
| <span className="small muted" style={{ marginLeft: 8 }}>{openUser.toUpperCase()}</span> | |
| </div> | |
| {!detail ? <p className="small muted">Đang tải…</p> : <UserSessions detail={detail} />} | |
| </div> | |
| )} | |
| </div> | |
| {/* ── 5. TỐC ĐỘ MÁY MỖI BƯỚC (B2) ────────────────────── */} | |
| <div className="card"> | |
| <div className="sec-title"><span className="em">⚙️</span> Tốc độ máy mỗi bước (B2)</div> | |
| {(data.jobs || []).length ? ( | |
| <div className="tablewrap"> | |
| <table className="list rep"> | |
| <thead> | |
| <tr> | |
| <th>Tool</th><th>Bước máy</th> | |
| <th className="mid">Số lần chạy</th> | |
| <th className="mid">TB lần bấm<br />chạy lại</th> | |
| <th className="num">Trung vị</th> | |
| <th className="num">Chậm nhất 10%</th> | |
| <th className="num">Tổng</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {(data.jobs || []).map((j, i) => ( | |
| <tr key={j.feature + j.phase + i}> | |
| <td className="name">{j.label || "—"}</td> | |
| <td>{j.phase_label || j.phase}</td> | |
| <td className="mid">{j.runs}</td> | |
| <td className="mid">{j.attempt_avg == null ? "—" : j.attempt_avg.toFixed(1)}</td> | |
| <td className="num big">{fmtShort(j.median_ms)}</td> | |
| <td className="num">{fmtShort(j.p90_ms)}</td> | |
| <td className="num small muted">{fmtDur(j.total_ms)}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| ) : ( | |
| <p className="small muted" style={{ margin: 0, fontStyle: "italic" }}> | |
| Chưa có lần chạy máy nào trong khoảng đã chọn. | |
| </p> | |
| )} | |
| </div> | |
| {/* ── 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ề. */} | |
| <div className="card"> | |
| <div className="sec-title"> | |
| <span className="em">🧾</span> Nhật ký chi tiết | |
| <button className="btn sm" onClick={loadRaw}> | |
| {rawOpen ? "Ẩn" : "Xem 300 dòng gần nhất"} | |
| </button> | |
| </div> | |
| {rawOpen && ( | |
| <div className="tablewrap" style={{ maxHeight: 460, overflowY: "auto" }}> | |
| <table className="list rep"> | |
| <thead> | |
| <tr> | |
| <th>Thời gian</th><th>Người dùng</th><th>Tool</th><th>Bước</th> | |
| <th>Hành động</th><th>Giá trị</th><th className="num">Máy chạy</th><th>Chi tiết</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {raw.map((e) => ( | |
| <tr key={e.event_id}> | |
| <td className="small muted" style={{ whiteSpace: "nowrap" }}>{fmtTime(e.event_ts)}</td> | |
| <td className="small">{e.vi_user || e.user_id}</td> | |
| <td className="small">{e.vi_feature || e.feature || "—"}</td> | |
| <td className="small muted" style={{ whiteSpace: "nowrap" }}>{e.vi_step || e.step || "—"}</td> | |
| <td className="small" style={{ fontWeight: 700, whiteSpace: "nowrap" }}>{e.vi_event || e.event_name}</td> | |
| <td className="small">{e.vi_value || e.event_value || "—"}</td> | |
| <td className="small num" style={{ whiteSpace: "nowrap" }}>{e.vi_latency || ""}</td> | |
| <td className="small muted" style={{ maxWidth: 340 }} title={e.vi_detail || ""}> | |
| {e.vi_detail || "—"} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| <p className="small muted" style={{ margin: "12px 0 0" }}> | |
| Lưu tại <b>{data.storage?.backend === "pg" ? "PostgreSQL" : "SQLite (máy chủ)"}</b> · | |
| {" "}{fmtNum(data.storage?.rows)} dòng · giữ {data.storage?.retention_days} ngày rồi tự dọn. | |
| </p> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ── Khối chỉ số ──────────────────────────────────────────────── | |
| function Group({ title, hint, children }) { | |
| return ( | |
| <div className="grp"> | |
| <div className="grp-title">{title}</div> | |
| <div className="grp-hint">{hint}</div> | |
| <div className="grp-rule" /> | |
| <div className="rep-kpis">{children}</div> | |
| </div> | |
| ); | |
| } | |
| // Ô 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 <span className="muted" title={why} style={{ cursor: "help" }}>—</span>; | |
| } | |
| return <span title={title || undefined}>{render(value)}</span>; | |
| } | |
| function Metric({ label, value, sub, tone = "", warn = false }) { | |
| return ( | |
| <div className="metric"> | |
| <div className="lbl">{label}</div> | |
| <div className={"val" + (tone ? " " + tone : "")}>{value}</div> | |
| {sub && <div className={"sub" + (warn ? " warn" : "")}>{sub}</div>} | |
| </div> | |
| ); | |
| } | |
| // Ô "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 ( | |
| <div className="metric"> | |
| <div className="lbl">Hài lòng (B3)</div> | |
| <div className="val muted">—</div> | |
| <div className="sub">chưa ai chấm 👍/👎 cho kết quả</div> | |
| </div> | |
| ); | |
| } | |
| const score = (rate || 0) * 5; | |
| return ( | |
| <div className="metric"> | |
| <div className="lbl">Hài lòng (B3)</div> | |
| <div className="sat"> | |
| <span className="sat-val">{score.toFixed(1)}<i>/5</i></span> | |
| <div className="sat-track"> | |
| <div className="sat-dot" style={{ left: `${Math.max(0, Math.min(100, (rate || 0) * 100))}%` }} /> | |
| </div> | |
| </div> | |
| <div className="sub">{up} 👍 / {down} 👎 trên {votes} phiếu</div> | |
| </div> | |
| ); | |
| } | |
| // Ô "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 ( | |
| <div className="metric"> | |
| <div className="lbl">{label}</div> | |
| <div className="val muted text">—</div> | |
| <div className="sub">{empty}</div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="metric"> | |
| <div className="lbl">{label}</div> | |
| <div className={"val text " + tone}>{f.label}</div> | |
| <div className="sub"> | |
| {(f.score_total ?? 0).toFixed(1)} điểm · {f.tasks} việc · {fmtNum(f.produced_total)} sản phẩm | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function Formulas() { | |
| return ( | |
| <details className="formula"> | |
| <summary>Cách tính các chỉ số (mở ra khi cần đối chiếu con số)</summary> | |
| <ul> | |
| <li><b>Số tài khoản được cấp</b>: đế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.</li> | |
| <li><b>User đã sử dụng</b>: có ít nhất một thao tác thật — <code>feature_open</code>, <code>run_click</code>, | |
| dán link hoặc tải file lên. Chỉ mở trang xem thì không tính.</li> | |
| <li><b>Dùng hàng tuần / hàng ngày</b>: 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).</li> | |
| <li><b>Phiên làm việc</b>: gom theo <code>session_id</code>; im lặng quá 30′ thì cắt sang phiên mới.</li> | |
| <li><b>Số việc (task)</b>: đếm <code>task_id</code> — mỗi lần làm ra một sản phẩm là một việc. | |
| <b> Tỷ lệ hoàn thành (A3)</b> = việc có đầu ra ÷ tổng việc.</li> | |
| <li><b>Số lần bấm chạy (A4)</b> = số lần máy chạy xong ÷ số việc. <b>Sản phẩm (B1)</b> = số file | |
| người dùng thật sự tải về hoặc chuyển tiếp sang tool khác.</li> | |
| <li><b>Hài lòng (B3)</b> = tỷ lệ 👍 trên tổng phiếu 👍/👎 người dùng bấm ở màn kết quả.</li> | |
| <li><b>Xong 1 việc mất (A1)</b> = trung vị từ lúc mở tool tới lúc có sản phẩm đầu tiên. | |
| <b> Thời gian AI xử lý (B4)</b> = tổng <code>latency_ms</code> của job, đã trừ thời gian chờ tới lượt.</li> | |
| </ul> | |
| </details> | |
| ); | |
| } | |
| // ── 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 ( | |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }} | |
| title={`${(score ?? 0).toFixed(1)} điểm quy đổi (thang 100)`}> | |
| <div style={{ | |
| flex: 1, minWidth: 40, height: 8, borderRadius: 99, overflow: "hidden", | |
| background: none | |
| ? "repeating-linear-gradient(90deg, var(--line) 0 4px, transparent 4px 8px)" | |
| : "var(--orange-soft)", | |
| }}> | |
| {!none && <div style={{ width: `${pct}%`, height: "100%", background: color, borderRadius: 99 }} />} | |
| </div> | |
| <span className="small" style={{ width: 86, textAlign: "right", whiteSpace: "nowrap" }}> | |
| <b>{text}</b>{unit ? <span className="muted"> {unit}</span> : null} | |
| </span> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="card"> | |
| <div className="sec-title"><span className="em">🏆</span> Tool đóng góp nhiều nhất / ít nhất</div> | |
| <p className="small muted" style={{ margin: 0 }}>Chưa có tool nào chạy việc trong khoảng đã chọn.</p> | |
| </div> | |
| ); | |
| } | |
| const best = rows[0]; | |
| const worst = rows[rows.length - 1]; | |
| const card = (title, f, tone) => ( | |
| <div style={{ | |
| flex: "1 1 220px", padding: "13px 15px", borderRadius: "var(--radius-sm)", | |
| background: tone === "good" ? "var(--green-soft)" : "var(--red-soft)", | |
| border: `1px solid ${tone === "good" ? "var(--green)" : "var(--red)"}`, | |
| }}> | |
| <div className="small" style={{ fontWeight: 700, color: tone === "good" ? "var(--green)" : "var(--red)" }}>{title}</div> | |
| <div style={{ fontSize: 20, fontWeight: 800, margin: "2px 0" }}>{f.label}</div> | |
| <div className="small muted"> | |
| {(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"} | |
| </div> | |
| </div> | |
| ); | |
| return ( | |
| <div className="card"> | |
| <div className="sec-title"><span className="em">🏆</span> Tool đóng góp nhiều nhất / ít nhất</div> | |
| <p className="small muted" style={{ marginTop: -10 }}> | |
| Điểm = <b>{pct(w.demand)}</b> nhu cầu người dùng (số việc) + <b>{pct(w.rerun)}</b> ít phải bấm chạy lại | |
| (A4 càng thấp điểm càng cao) + <b>{pct(w.output)}</b> 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 <b>số thật</b>, 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ì <b>ít càng tốt</b>, 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. | |
| </p> | |
| {rows.length > 1 && ( | |
| <div style={{ display: "flex", gap: 12, flexWrap: "wrap", margin: "12px 0" }}> | |
| {card("Đóng góp nhiều nhất", best, "good")} | |
| {card("Đóng góp ít nhất", worst, "warn")} | |
| </div> | |
| )} | |
| <div className="tablewrap"> | |
| <table className="list rep"> | |
| <thead> | |
| <tr> | |
| <th>#</th><th>Tool</th> | |
| <th>Nhu cầu · số việc ({pct(w.demand)})</th> | |
| <th>Lần bấm/việc ({pct(w.rerun)})</th> | |
| <th>Sản phẩm đầu ra ({pct(w.output)})</th> | |
| <th className="num">Tổng điểm</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {rows.map((f, i) => ( | |
| <tr key={f.feature}> | |
| <td className="small muted">{i + 1}</td> | |
| <td className="name">{f.label}</td> | |
| <td style={{ minWidth: 160 }}> | |
| <ScoreBar ratio={f.tasks / maxTasks} score={f.score_demand} | |
| text={fmtNum(f.tasks)} unit="việc" /> | |
| </td> | |
| <td style={{ minWidth: 170 }}> | |
| <ScoreBar ratio={(f.attempts_avg || 0) / maxAtt} score={f.score_rerun} | |
| color="#3b82f6" none={!f.attempts_avg} | |
| text={f.attempts_avg ? f.attempts_avg.toFixed(1) : "—"} | |
| unit={f.attempts_avg ? "lần/việc" : "chưa chạy"} /> | |
| </td> | |
| <td style={{ minWidth: 160 }}> | |
| <ScoreBar ratio={f.produced_total / maxProd} score={f.score_output} | |
| color="var(--green)" | |
| text={fmtNum(f.produced_total)} unit="sản phẩm" /> | |
| </td> | |
| <td className="num" style={{ fontWeight: 800 }}>{(f.score_total ?? 0).toFixed(1)}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ── Nhật ký từng phiên của một người ─────────────────────────── | |
| function UserSessions({ detail }) { | |
| const sessions = detail.sessions || []; | |
| if (!sessions.length) return <p className="small muted">Không có phiên nào trong khoảng đã chọn.</p>; | |
| return ( | |
| <div className="stack" style={{ gap: 12 }}> | |
| {sessions.map((s) => ( | |
| <div key={s.session_id} style={{ | |
| border: "1px solid var(--line)", borderRadius: "var(--radius-sm)", | |
| padding: "10px 12px", background: "var(--cream)", | |
| }}> | |
| <div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "baseline", marginBottom: 8 }}> | |
| <b style={{ fontSize: 13 }}>{fmtTime(s.start_ts)}</b> | |
| <span className="small muted"> | |
| dùng {fmtDur(s.active_ms)} · {s.n_tasks} việc · {s.n_events} thao tác | |
| </span> | |
| <span className="small muted" style={{ marginLeft: "auto" }}>{s.session_id}</span> | |
| </div> | |
| {(s.tasks || []).map((t) => ( | |
| <div key={t.task_id} style={{ | |
| borderTop: "1px dashed var(--line)", paddingTop: 8, marginTop: 8, | |
| }}> | |
| <div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}> | |
| <span style={{ fontWeight: 700, fontSize: 13 }}>{t.label}</span> | |
| {t.has_output ? ( | |
| <span style={{ fontSize: 11.5, fontWeight: 700, color: "var(--green)", background: "var(--green-soft)", padding: "2px 8px", borderRadius: 99 }}> | |
| ✓ {t.success_type === "forward" ? "chuyển tiếp" : "tải về"} · {t.produced} sản phẩm | |
| </span> | |
| ) : t.abandoned ? ( | |
| <span style={{ fontSize: 11.5, fontWeight: 700, color: "var(--red)", background: "var(--red-soft)", padding: "2px 8px", borderRadius: 99 }}> | |
| ✕ bỏ dở ở “{t.last_step || "?"}”{t.abandon_reason ? ` · ${t.abandon_reason}` : ""} | |
| </span> | |
| ) : ( | |
| <span className="small muted">đang làm dở</span> | |
| )} | |
| <span className="small muted"> | |
| {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 ? " · 👎" : ""} | |
| </span> | |
| </div> | |
| {!!(t.steps || []).length && ( | |
| <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 6 }}> | |
| {t.steps.map((st, i) => ( | |
| <span key={i} style={{ | |
| fontSize: 11, color: "var(--ink-2)", background: "var(--white)", | |
| border: "1px solid var(--line)", borderRadius: 99, padding: "2px 8px", | |
| }}> | |
| {st.step} {fmtShort(st.ms)} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| } | |