import { BarChart, LineChart } from "@mantine/charts"; import "@mantine/charts/styles.css"; import { Area, AreaChart as RAreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { Badge, Box, Center, Grid, Group, Loader, MultiSelect, Paper, Select, SimpleGrid, Stack, Table, Text, Title, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import "@mantine/dates/styles.css"; import { IconCheckbox, IconClipboardList, IconClockHour4, IconLock, IconTrendingUp, } from "@tabler/icons-react"; import dayjs from "dayjs"; import { useEffect, useMemo, useState } from "react"; import * as api from "../api"; import type { Metrics, User } from "../types"; import { formatDuration } from "./format"; interface Props { users: User[]; } function fmtDate(d: Date | null): string | undefined { if (!d) return undefined; return dayjs(d).format("YYYY-MM-DD"); } function KPI({ icon, label, value, hint, color, }: { icon: React.ReactNode; label: string; value: string | number; hint?: string; color?: string; }) { return ( {icon} {label} {value} {hint && ( {hint} )} ); } export function Dashboard({ users }: Props) { const [from, setFrom] = useState(() => dayjs().subtract(30, "day").toDate()); const [to, setTo] = useState(() => new Date()); const [assigneeId, setAssigneeId] = useState(null); const [requester, setRequester] = useState(null); const [tags, setTags] = useState([]); const [tagOptions, setTagOptions] = useState([]); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [requesterOptions, setRequesterOptions] = useState([]); useEffect(() => { api.listTags().then(setTagOptions).catch(() => {}); }, []); useEffect(() => { let cancelled = false; setLoading(true); api .getMetrics({ from: fmtDate(from), to: fmtDate(to), assignee_id: assigneeId || undefined, requester: requester || undefined, tags: tags.length > 0 ? tags : undefined, }) .then((m) => { if (cancelled) return; setData(m); setRequesterOptions((prev) => { const set = new Set(prev); for (const r of m.top_requesters ?? []) set.add(r.requester); return Array.from(set).sort(); }); }) .catch(() => {}) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [from, to, assigneeId, requester, tags]); const userOptions = useMemo( () => users.map((u) => ({ value: u.id, label: u.display_name || u.username })), [users] ); const cumulativeFlow = useMemo(() => { if (!data) return []; const arr = data.cumulative_flow ?? []; const firstIdx = arr.findIndex((p) => p.total > 0 || p.done > 0); const sliced = firstIdx <= 0 ? arr : arr.slice(Math.max(0, firstIdx - 1)); return sliced.map((p) => ({ date: p.date, done: p.done, wip: Math.max(0, p.total - p.done), total: p.total, })); }, [data]); const throughputSeries = useMemo(() => { if (!data) return []; const map = new Map(); for (const d of data.throughput_daily ?? []) { map.set(d.date, { date: d.date, completed: d.count, created: 0 }); } for (const d of data.created_daily ?? []) { const cur = map.get(d.date) ?? { date: d.date, completed: 0, created: 0 }; cur.created = d.count; map.set(d.date, cur); } return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date)); }, [data]); const byColumnSeries = useMemo(() => { if (!data) return []; return (data.by_column ?? []).map((c) => ({ column: c.name + (c.is_done ? " ✓" : ""), tarjetas: c.count, })); }, [data]); const topAssigneeSeries = useMemo(() => { if (!data) return []; return (data.top_assignees ?? []) .slice() .sort((a, b) => b.completed_in_range + b.active - (a.completed_in_range + a.active)) .slice(0, 8) .map((a) => ({ usuario: a.display_name || a.username, completadas: a.completed_in_range, activas: a.active, })); }, [data]); const topRequesterSeries = useMemo(() => { if (!data) return []; return (data.top_requesters ?? []).map((r) => ({ solicitante: r.requester, activas: r.active, completadas: r.completed_in_range, })); }, [data]); const movementsSeries = useMemo(() => { if (!data) return []; return (data.movements_by_user ?? []) .filter((m) => m.moves > 0) .slice(0, 8) .map((m) => ({ usuario: m.display_name || m.username, movimientos: m.moves, })); }, [data]); return ( Dashboard setFrom(v as Date | null)} size="xs" clearable={false} valueFormat="YYYY-MM-DD" style={{ minWidth: 140 }} /> setTo(v as Date | null)} size="xs" clearable={false} valueFormat="YYYY-MM-DD" style={{ minWidth: 140 }} /> ({ value: r, label: r }))} clearable searchable style={{ minWidth: 160 }} /> {loading && !data && (
)} {data && (() => { const totals = data.totals ?? ({} as Metrics["totals"]); const lead = data.lead_time ?? ({ n: 0, avg_ms: 0, p50_ms: 0, p90_ms: 0, p99_ms: 0 } as Metrics["lead_time"]); const t = (k: keyof typeof totals) => totals[k] ?? 0; return ( <> } label="Totales" value={t("cards")} hint={`${t("columns")} columnas, ${t("users")} usuarios`} /> } label="Activas" value={t("cards_active")} hint={`Sin completar`} color="blue" /> } label="Completadas (rango)" value={t("cards_completed_in_range")} hint={`${t("cards_done")} completadas total · ${t("cards_created_in_range")} creadas rango`} color="green" /> } label="Lead time p50" value={lead.n > 0 ? formatDuration(lead.p50_ms) : 0} hint={`p90 ${lead.n > 0 ? formatDuration(lead.p90_ms) : 0} · n=${lead.n}`} /> } label="Bloqueos activos" value={t("active_locks")} hint={`Total bloqueado: ${formatDuration(data.lock_total_ms ?? 0)}`} color={t("active_locks") > 0 ? "yellow" : undefined} /> Cumulative Flow Diagram total vs hechas (acumulado) {cumulativeFlow.length === 0 ? ( Sin datos. ) : (
)}
Throughput diario {throughputSeries.length === 0 ? ( Sin datos en el rango. ) : ( )} Tarjetas por columna {byColumnSeries.length === 0 ? ( Sin columnas. ) : ( )} Top asignados {topAssigneeSeries.length === 0 ? ( Sin asignaciones. ) : ( )} Top solicitantes {topRequesterSeries.length === 0 ? ( Sin solicitantes en el rango. ) : ( )} Movimientos por usuario (rango) {movementsSeries.length === 0 ? ( Sin movimientos registrados. ) : ( )} Tiempo en columna (cycle time) Columna n p50 p90 avg {(data.cycle_time_per_column ?? []).map((c) => ( {c.name} {c.is_done && ( done )} {c.stats.n} {c.stats.n > 0 ? formatDuration(c.stats.p50_ms) : "—"} {c.stats.n > 0 ? formatDuration(c.stats.p90_ms) : "—"} {c.stats.n > 0 ? formatDuration(c.stats.avg_ms) : "—"} ))}
); })()}
); }