7ce227ddea
- 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>
547 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|