// 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

{empty}

; const max = Math.max(...rows.map((r) => Math.abs(r.value)), 1); return (
{rows.map((r, i) => (
{r.label} {r.text != null ? r.text : fmtNum(r.value)} {r.sub && · {r.sub}}
))}
); } // ── Đườ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

Cần ít nhất 2 ngày dữ liệu để vẽ.

; } 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 (
{[0, 0.5, 1].map((f) => ( {max * f >= 1000 ? `${Math.round((max * f) / 1000)}k` : Math.round(max * f)} ))} {series.map((s) => ( `${x(i)},${y(v)}`).join(" ")} fill="none" stroke={s.color} strokeWidth="3" strokeLinejoin="round" strokeLinecap="round" /> {s.values.map((v, i) => ( {`${pts[i]} · ${s.label}: ${fmtNum(v)}${unit}`} ))} ))} {pts.map((p, i) => (i % stepX === 0 ? ( {p.slice(5)} ) : null))}
{series.map((s) => ( {s.label} ))}
); } // ── 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 —; return (
{entries.map(([k, v]) => (
))}
{entries.map(([k, v]) => `${STEP_VI[k] || k} ${v}`).join(" · ")}
); }