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) : "—"}
))}
>
);
})()}
);
}