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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user