Spaces:
Running
Running
Download frontend/src/components/admin/UsageCharts.jsx from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 6.82 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/admin/UsageCharts.jsx
- Command line
-
hf download hf://spaces/tntgroup/website_content/frontend/src/components/admin/UsageCharts.jsx
-
curl -L -o UsageCharts.jsx https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/admin/UsageCharts.jsx
6.82 kB
| // Biểu đồ SVG thuần cho tab "Quản lý thời lượng". | |
| // Không thêm thư viện biểu đồ: cả frontend hiện chỉ có react + react-router, | |
| // mà mấy dạng ở đây (cột ngang, đường theo ngày) vẽ tay còn nhẹ và gọn hơn. | |
| // Mọi con số đều IN THẲNG lên biểu đồ, không bắt người xem ước lượng theo trục. | |
| // ── Định dạng ─────────────────────────────────────────────────── | |
| export function fmtDur(ms) { | |
| if (ms == null || Number.isNaN(ms)) return "—"; | |
| const s = Math.round(ms / 1000); | |
| if (s < 60) return `${s} giây`; | |
| const m = Math.floor(s / 60); | |
| if (m < 60) return s % 60 ? `${m} phút ${s % 60} giây` : `${m} phút`; | |
| const h = Math.floor(m / 60); | |
| return m % 60 ? `${h} giờ ${m % 60} phút` : `${h} giờ`; | |
| } | |
| export function fmtShort(ms) { | |
| if (ms == null || Number.isNaN(ms)) return "—"; | |
| const s = ms / 1000; | |
| if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s`; | |
| const m = s / 60; | |
| if (m < 60) return `${m < 10 ? m.toFixed(1) : Math.round(m)}p`; | |
| return `${(m / 60).toFixed(1)}h`; | |
| } | |
| export const fmtPct = (v) => (v == null ? "—" : `${Math.round(v * 100)}%`); | |
| export const fmtNum = (v) => (v == null ? "—" : Number(v).toLocaleString("vi-VN")); | |
| export const fmtTime = (ts) => | |
| ts ? new Date(ts * 1000).toLocaleString("vi-VN", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }) : "—"; | |
| // ── Cột ngang ─────────────────────────────────────────────────── | |
| // data: [{ label, value, text?, sub? }] | |
| export function BarChart({ data, color = "", empty = "Chưa có dữ liệu" }) { | |
| const rows = (data || []).filter((d) => d && d.value != null); | |
| if (!rows.length) return <p className="small muted" style={{ fontStyle: "italic", margin: 0 }}>{empty}</p>; | |
| const max = Math.max(...rows.map((r) => Math.abs(r.value)), 1); | |
| return ( | |
| <div> | |
| {rows.map((r, i) => ( | |
| <div className="barrow" key={r.label + i}> | |
| <div className="bar-head"> | |
| <span className="n">{r.label}</span> | |
| <span className="v"> | |
| {r.text != null ? r.text : fmtNum(r.value)} | |
| {r.sub && <span className="dim"> · {r.sub}</span>} | |
| </span> | |
| </div> | |
| <div className="bar-track"> | |
| <div className={"bar-fill" + (color ? " " + color : "")} | |
| style={{ width: `${Math.max(2, (Math.abs(r.value) / max) * 100)}%` }} /> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| // ── Đường theo ngày ───────────────────────────────────────────── | |
| // series: [{ key, label, color, values: number[] }] · labels: string[] | |
| export function LineChart({ labels, series, height = 170, unit = "" }) { | |
| const pts = labels || []; | |
| if (pts.length < 2) { | |
| return <p className="small muted" style={{ fontStyle: "italic", margin: 0 }}>Cần ít nhất 2 ngày dữ liệu để vẽ.</p>; | |
| } | |
| const W = 720, H = height, PADL = 38, PADR = 10, PADT = 12, PADB = 24; | |
| const max = Math.max(1, ...series.flatMap((s) => s.values.map((v) => v || 0))); | |
| const x = (i) => PADL + (i * (W - PADL - PADR)) / (pts.length - 1); | |
| const y = (v) => H - PADB - ((v || 0) / max) * (H - PADT - PADB); | |
| // Nhãn trục X: thưa ra để không chồng chữ. | |
| const stepX = Math.max(1, Math.ceil(pts.length / 8)); | |
| return ( | |
| <div style={{ width: "100%", overflowX: "auto" }}> | |
| <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", minWidth: 420, display: "block" }} | |
| role="img" aria-label="Hoạt động theo ngày"> | |
| {[0, 0.5, 1].map((f) => ( | |
| <g key={f}> | |
| <line x1={PADL} x2={W - PADR} y1={y(max * f)} y2={y(max * f)} | |
| stroke="var(--line)" strokeWidth="1" /> | |
| <text x={PADL - 6} y={y(max * f) + 4} textAnchor="end" | |
| fontSize="10" fill="var(--muted)"> | |
| {max * f >= 1000 ? `${Math.round((max * f) / 1000)}k` : Math.round(max * f)} | |
| </text> | |
| </g> | |
| ))} | |
| {series.map((s) => ( | |
| <g key={s.key}> | |
| <polyline | |
| points={s.values.map((v, i) => `${x(i)},${y(v)}`).join(" ")} | |
| fill="none" stroke={s.color} strokeWidth="3" | |
| strokeLinejoin="round" strokeLinecap="round" /> | |
| {s.values.map((v, i) => ( | |
| <circle key={i} cx={x(i)} cy={y(v)} r="4" fill={s.color}> | |
| <title>{`${pts[i]} · ${s.label}: ${fmtNum(v)}${unit}`}</title> | |
| </circle> | |
| ))} | |
| </g> | |
| ))} | |
| {pts.map((p, i) => (i % stepX === 0 ? ( | |
| <text key={p} x={x(i)} y={H - 7} textAnchor="middle" fontSize="10" fill="var(--muted)"> | |
| {p.slice(5)} | |
| </text> | |
| ) : null))} | |
| </svg> | |
| <div style={{ display: "flex", gap: 14, flexWrap: "wrap", marginTop: 4 }}> | |
| {series.map((s) => ( | |
| <span key={s.key} style={{ fontSize: 12, color: "var(--ink-2)", display: "flex", alignItems: "center", gap: 6 }}> | |
| <i style={{ width: 10, height: 3, background: s.color, borderRadius: 2, display: "inline-block" }} /> | |
| {s.label} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ── Thanh chia phần (dùng cho "bỏ dở ở bước nào") ────────────── | |
| const STEP_COLORS = { | |
| open: "#c9c2ba", prepare: "var(--orange)", run: "#f2b21c", | |
| review: "#3b82f6", output: "var(--green)", done: "#16a34a", | |
| }; | |
| // Tên bước bằng tiếng Việt — bảng báo cáo không để lọt khoá máy ra màn hình. | |
| const STEP_ORDER = ["open", "prepare", "run", "review", "output", "done"]; | |
| export const STEP_VI = { | |
| open: "mở tool", prepare: "chuẩn bị", run: "máy chạy", | |
| review: "xem lại", output: "lấy sản phẩm", done: "xong", | |
| }; | |
| export function StepBar({ counts }) { | |
| const entries = Object.entries(counts || {}).filter(([, v]) => v > 0) | |
| .sort((a, b) => STEP_ORDER.indexOf(a[0]) - STEP_ORDER.indexOf(b[0])); | |
| const total = entries.reduce((a, [, v]) => a + v, 0); | |
| if (!total) return <span className="small muted">—</span>; | |
| return ( | |
| <div> | |
| <div className="funnel"> | |
| {entries.map(([k, v]) => ( | |
| <div key={k} title={`${STEP_VI[k] || k}: ${v} việc`} | |
| style={{ width: `${(v / total) * 100}%`, background: STEP_COLORS[k] || "var(--muted)" }} /> | |
| ))} | |
| </div> | |
| <div className="funnel-lbl"> | |
| {entries.map(([k, v]) => `${STEP_VI[k] || k} ${v}`).join(" · ")} | |
| </div> | |
| </div> | |
| ); | |
| } | |