website_content / frontend /src /components /admin /UsageDashboard.jsx
Administrator
Claude Opus 5
fix(api): 7 màn còn lại dùng chung lớp đọc JSON an toàn
d94104e
Raw History Blame Contribute Delete
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>
);
}