titusgiap's picture
Claude Opus 5
fix(báo cáo): commit trước lỡ đẩy bản JSX nháp, lệch hẳn với CSS
16d58d0
Raw History Blame Contribute Delete
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>
);
}