Files
kanban/frontend/src/components/Dashboard.tsx
T
egutierrez 7ce227ddea feat(kanban): deadlines en cards (context menu, badges, calendario, history)
- migration 009 + columna deadline TEXT en cards
- backend: CardPatch.HasDeadline, eventos deadline_set/deadline_cleared
- KanbanCard: menu derecho con DatePicker, badge countdown con colores por ratio (azul>=50%, amarillo<50%, rojo<10%, red.9 overdue)
- App.tsx: filtro "Con deadline", handleSetCardDeadline optimista, jump-to-card + highlight
- CalendarView: popover por dia con seq_num + titulo, click navega a card en tablero
- HistoryModal: render eventos deadline_set/deadline_cleared
- .gitignore: *.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:45:36 +02:00

547 lines
18 KiB
TypeScript

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 (
<Paper withBorder p="md" radius="md">
<Stack gap={4}>
<Group gap={6} c="dimmed">
{icon}
<Text size="xs" tt="uppercase" fw={600}>
{label}
</Text>
</Group>
<Text size="xl" fw={700} c={color}>
{value}
</Text>
{hint && (
<Text size="xs" c="dimmed">
{hint}
</Text>
)}
</Stack>
</Paper>
);
}
export function Dashboard({ users }: Props) {
const [from, setFrom] = useState<Date | null>(() => dayjs().subtract(30, "day").toDate());
const [to, setTo] = useState<Date | null>(() => new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [requester, setRequester] = useState<string | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [data, setData] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(false);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
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<string, { date: string; completed: number; created: number }>();
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 (
<Box p="md">
<Stack gap="md">
<Group justify="space-between">
<Title order={3}>Dashboard</Title>
<Group gap="xs" wrap="nowrap">
<DatePickerInput
label="Desde"
value={from}
onChange={(v) => setFrom(v as Date | null)}
size="xs"
clearable={false}
valueFormat="YYYY-MM-DD"
style={{ minWidth: 140 }}
/>
<DatePickerInput
label="Hasta"
value={to}
onChange={(v) => setTo(v as Date | null)}
size="xs"
clearable={false}
valueFormat="YYYY-MM-DD"
style={{ minWidth: 140 }}
/>
<Select
label="Asignado"
size="xs"
placeholder="Todos"
value={assigneeId}
onChange={setAssigneeId}
data={userOptions}
clearable
searchable
style={{ minWidth: 160 }}
/>
<Select
label="Solicitante"
size="xs"
placeholder="Todos"
value={requester}
onChange={setRequester}
data={requesterOptions.map((r) => ({ value: r, label: r }))}
clearable
searchable
style={{ minWidth: 160 }}
/>
<MultiSelect
label="Tags"
size="xs"
placeholder="Todas"
value={tags}
onChange={setTags}
data={tagOptions}
clearable
searchable
style={{ minWidth: 200 }}
/>
</Group>
</Group>
{loading && !data && (
<Center p="xl">
<Loader />
</Center>
)}
{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 (
<>
<SimpleGrid cols={{ base: 2, md: 5 }} spacing="md">
<KPI
icon={<IconClipboardList size={14} />}
label="Totales"
value={t("cards")}
hint={`${t("columns")} columnas, ${t("users")} usuarios`}
/>
<KPI
icon={<IconClipboardList size={14} />}
label="Activas"
value={t("cards_active")}
hint={`Sin completar`}
color="blue"
/>
<KPI
icon={<IconCheckbox size={14} />}
label="Completadas (rango)"
value={t("cards_completed_in_range")}
hint={`${t("cards_done")} completadas total · ${t("cards_created_in_range")} creadas rango`}
color="green"
/>
<KPI
icon={<IconClockHour4 size={14} />}
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}`}
/>
<KPI
icon={<IconLock size={14} />}
label="Bloqueos activos"
value={t("active_locks")}
hint={`Total bloqueado: ${formatDuration(data.lock_total_ms ?? 0)}`}
color={t("active_locks") > 0 ? "yellow" : undefined}
/>
</SimpleGrid>
<Paper withBorder p="md" radius="md">
<Group gap={6} mb="sm">
<IconTrendingUp size={16} />
<Text fw={600}>Cumulative Flow Diagram</Text>
<Text size="xs" c="dimmed">
total vs hechas (acumulado)
</Text>
</Group>
{cumulativeFlow.length === 0 ? (
<Text c="dimmed" size="sm">
Sin datos.
</Text>
) : (
<div style={{ height: 260, width: "100%" }}>
<ResponsiveContainer width="100%" height="100%">
<RAreaChart data={cumulativeFlow} margin={{ top: 10, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="5 5" stroke="var(--mantine-color-gray-4)" />
<XAxis dataKey="date" tick={{ fontSize: 12, fill: "currentColor" }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12, fill: "currentColor" }} />
<Tooltip
contentStyle={{
background: "var(--mantine-color-body)",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 6,
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="linear"
dataKey="done"
name="Hechas"
stackId="cfd"
stroke="var(--mantine-color-green-6)"
fill="var(--mantine-color-green-6)"
fillOpacity={0.55}
strokeWidth={2}
isAnimationActive={false}
dot={{ r: 3, fill: "var(--mantine-color-green-6)", strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
<Area
type="linear"
dataKey="wip"
name="En curso"
stackId="cfd"
stroke="var(--mantine-color-blue-6)"
fill="var(--mantine-color-blue-6)"
fillOpacity={0.55}
strokeWidth={2}
isAnimationActive={false}
dot={{ r: 3, fill: "var(--mantine-color-blue-6)", strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
</RAreaChart>
</ResponsiveContainer>
</div>
)}
</Paper>
<Grid>
<Grid.Col span={{ base: 12, md: 8 }}>
<Paper withBorder p="md" radius="md">
<Group gap={6} mb="sm">
<IconTrendingUp size={16} />
<Text fw={600}>Throughput diario</Text>
</Group>
{throughputSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin datos en el rango.
</Text>
) : (
<LineChart
h={240}
data={throughputSeries}
dataKey="date"
curveType="monotone"
withLegend
series={[
{ name: "completed", label: "Completadas", color: "green.6" },
{ name: "created", label: "Creadas", color: "blue.6" },
]}
/>
)}
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Tarjetas por columna
</Text>
{byColumnSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin columnas.
</Text>
) : (
<BarChart
h={240}
data={byColumnSeries}
dataKey="column"
orientation="vertical"
yAxisProps={{ width: 100 }}
series={[{ name: "tarjetas", label: "Tarjetas", color: "blue.6" }]}
/>
)}
</Paper>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Top asignados
</Text>
{topAssigneeSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin asignaciones.
</Text>
) : (
<BarChart
h={240}
data={topAssigneeSeries}
dataKey="usuario"
orientation="vertical"
yAxisProps={{ width: 120 }}
withLegend
series={[
{ name: "completadas", label: "Completadas", color: "green.6" },
{ name: "activas", label: "Activas", color: "blue.6" },
]}
type="stacked"
/>
)}
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Top solicitantes
</Text>
{topRequesterSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin solicitantes en el rango.
</Text>
) : (
<BarChart
h={Math.max(240, topRequesterSeries.length * 32)}
data={topRequesterSeries}
dataKey="solicitante"
orientation="vertical"
yAxisProps={{ width: 160, interval: 0 }}
withLegend
series={[
{ name: "completadas", label: "Completadas", color: "green.6" },
{ name: "activas", label: "Activas", color: "violet.6" },
]}
type="stacked"
/>
)}
</Paper>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Movimientos por usuario (rango)
</Text>
{movementsSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin movimientos registrados.
</Text>
) : (
<BarChart
h={240}
data={movementsSeries}
dataKey="usuario"
orientation="vertical"
yAxisProps={{ width: 120 }}
series={[{ name: "movimientos", label: "Movimientos", color: "orange.6" }]}
/>
)}
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Tiempo en columna (cycle time)
</Text>
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Columna</Table.Th>
<Table.Th>n</Table.Th>
<Table.Th>p50</Table.Th>
<Table.Th>p90</Table.Th>
<Table.Th>avg</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(data.cycle_time_per_column ?? []).map((c) => (
<Table.Tr key={c.column_id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={500}>
{c.name}
</Text>
{c.is_done && (
<Badge size="xs" color="green" variant="light">
done
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>{c.stats.n}</Table.Td>
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.p50_ms) : "—"}</Table.Td>
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.p90_ms) : "—"}</Table.Td>
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.avg_ms) : "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Grid.Col>
</Grid>
</>
);
})()}
</Stack>
</Box>
);
}