chore: auto-commit (28 archivos)

- app.md
- auth.go
- chat.go
- chat.log
- db.go
- frontend/package.json
- frontend/pnpm-lock.yaml
- frontend/src/App.tsx
- frontend/src/Root.tsx
- frontend/src/api.ts
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 00:27:18 +02:00
parent c915e721af
commit bee688e574
28 changed files with 3601 additions and 300 deletions
+467
View File
@@ -0,0 +1,467 @@
import { AreaChart, BarChart, LineChart } from "@mantine/charts";
import "@mantine/charts/styles.css";
import {
Badge,
Box,
Center,
Grid,
Group,
Loader,
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 [data, setData] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(false);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
setLoading(true);
api
.getMetrics({
from: fmtDate(from),
to: fmtDate(to),
assignee_id: assigneeId || undefined,
requester: requester || 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]);
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);
if (firstIdx <= 0) return arr;
return arr.slice(Math.max(0, firstIdx - 1));
}, [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,
tarjetas: r.total,
}));
}, [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 }}
/>
</Group>
</Group>
{loading && !data && (
<Center p="xl">
<Loader />
</Center>
)}
{data && (
<>
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
<KPI
icon={<IconClipboardList size={14} />}
label="Tarjetas totales"
value={data.totals.cards}
hint={`${data.totals.columns} columnas, ${data.totals.users} usuarios`}
/>
<KPI
icon={<IconCheckbox size={14} />}
label="Completadas (rango)"
value={data.totals.cards_completed_in_range}
hint={`${data.totals.cards_created_in_range} creadas en rango`}
color="green"
/>
<KPI
icon={<IconClockHour4 size={14} />}
label="Lead time p50"
value={formatDuration(data.lead_time.p50_ms)}
hint={`p90 ${formatDuration(data.lead_time.p90_ms)} · n=${data.lead_time.n}`}
/>
<KPI
icon={<IconLock size={14} />}
label="Bloqueos activos"
value={data.totals.active_locks}
hint={`Total bloqueado: ${formatDuration(data.lock_total_ms)}`}
color={data.totals.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>
) : (
<AreaChart
h={260}
data={cumulativeFlow}
dataKey="date"
withLegend
withDots
withGradient
fillOpacity={0.5}
strokeWidth={2.5}
curveType="monotone"
gridAxis="xy"
series={[
{ name: "total", label: "Total", color: "blue.6" },
{ name: "done", label: "Hechas", color: "green.6" },
]}
yAxisProps={{ allowDecimals: false }}
/>
)}
</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={240}
data={topRequesterSeries}
dataKey="solicitante"
orientation="vertical"
yAxisProps={{ width: 120 }}
series={[{ name: "tarjetas", label: "Tarjetas", color: "violet.6" }]}
/>
)}
</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>
);
}