feat(kanban): reporte diario al click en dia del calendario (issue 0093)

Adds a daily report dashboard accessible by clicking a day number in the
calendar view. Renders inside a full-width modal (90% width).

Backend (new file backend/reports.go):
- Type DailyReport with KPIs, rankings, done_cards list, reopened cards,
  3-bucket stale list (7/14/30d), lead time avg+p50+p95, 24-hour
  movement histogram, deadlines met/missed list, tag distribution and
  archived count.
- DB.DailyReportFor(date, tz) uses Europe/Madrid by default; computes
  [start,end) in local time, converts to UTC and queries:
  * cards.completed_at in range  -> done list
  * card_events kind=created in range -> created counts
  * card_column_history.entered_at in range -> moves + hourly
  * previousColumnWasDone() -> reopened detection
  * card_lock_history overlapping the day -> blocked_ms
  * stale buckets: open history entries on non-done columns aged >=7d
- New route GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid.

Frontend:
- api.ts: DailyReport type + dailyReport(date, tz?) call.
- New component DailyReportView (components/DailyReport.tsx):
  * 6 KPI cards (Hechas, Creadas, Movimientos, Bloqueado, Reabiertas,
    Deadlines on-time %).
  * 4 ranking cards (Top assignees done, Top assignees created,
    Top requesters atendidas, Top requesters aportadas).
  * Done cards table with click-to-jump (links open the card in board).
  * Mantine BarChart with movements per hour.
  * Tag chips, reopened list, deadlines list with late_ms, stale buckets.
- CalendarView wraps the day number in UnstyledButton with data-test
  attribute and forwards onOpenDailyReport.
- App.handleOpenDailyReport opens modals.open size 90% with the view;
  click on a card title closes the modal and jumps to the board with
  highlight (reuses existing handleJumpToCard).

Tests (e2e/daily-report.spec.ts):
- Endpoint shape: kpis, done_cards, hourly_moves[24], stale buckets.
- Calendar day click opens the modal with "Reporte diario" title and
  KPI labels visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 17:43:29 +02:00
parent 9d3ab5f0f3
commit fc7e6a34a7
10 changed files with 2497 additions and 1187 deletions
+19 -4
View File
@@ -19,15 +19,17 @@ import { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { Card, Metrics, User } from "../types";
// Hace clickable el numero del dia para abrir el reporte diario (issue 0093).
interface Props {
users: User[];
cards: Card[];
onJumpToCard?: (cardId: string) => void;
onOpenDailyReport?: (date: string) => void;
}
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
export function CalendarView({ users, cards, onJumpToCard }: Props) {
export function CalendarView({ users, cards, onJumpToCard, onOpenDailyReport }: Props) {
const [openDate, setOpenDate] = useState<string | null>(null);
const [month, setMonth] = useState<Date>(new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
@@ -199,9 +201,22 @@ export function CalendarView({ users, cards, onJumpToCard }: Props) {
}}
>
<Stack gap={2}>
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
{dayNum}
</Text>
<UnstyledButton
onClick={() => cell.date && onOpenDailyReport?.(cell.date as string)}
title="Ver reporte diario"
style={{ alignSelf: "flex-start" }}
data-test={`calendar-day-${cell.date}`}
>
<Text
size="xs"
fw={isToday ? 700 : 500}
c={isToday ? "blue" : undefined}
td={onOpenDailyReport ? "underline" : undefined}
style={{ cursor: onOpenDailyReport ? "pointer" : "default" }}
>
{dayNum}
</Text>
</UnstyledButton>
{stats.created > 0 && (
<Group gap={3} wrap="nowrap">
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
+523
View File
@@ -0,0 +1,523 @@
import {
Alert,
Avatar,
Badge,
Box,
Card as MCard,
Divider,
Group,
Loader,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Table,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { BarChart } from "@mantine/charts";
import {
IconAlertTriangle,
IconArrowBackUp,
IconCalendarStats,
IconCheck,
IconClock,
IconHourglass,
IconLock,
IconPlus,
IconRefresh,
IconTrendingUp,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { dailyReport, type DailyReport as Report } from "../api";
import { formatDuration } from "./format";
import { tagColor } from "./colors";
interface Props {
date: string; // YYYY-MM-DD
onJumpToCard?: (cardId: string) => void;
}
function fmtDate(s: string): string {
try {
const d = new Date(s + "T00:00:00");
return d.toLocaleDateString("es-ES", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return s;
}
}
function KPI({
label,
value,
color,
icon,
sub,
}: {
label: string;
value: string | number;
color?: string;
icon?: React.ReactNode;
sub?: string;
}) {
return (
<Paper p="sm" withBorder radius="md">
<Group gap={6} mb={2} align="center">
{icon}
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
</Group>
<Text fz={28} fw={700} c={color}>
{value}
</Text>
{sub && (
<Text size="xs" c="dimmed">
{sub}
</Text>
)}
</Paper>
);
}
function RankingList<T extends { name: string; count: number; user_id?: string }>({
title,
rows,
emptyText,
withAvatar = false,
}: {
title: string;
rows: T[];
emptyText: string;
withAvatar?: boolean;
}) {
return (
<MCard withBorder radius="md" p="sm">
<Text fw={600} size="sm" mb={6}>
{title}
</Text>
{rows.length === 0 ? (
<Text size="xs" c="dimmed">
{emptyText}
</Text>
) : (
<Stack gap={4}>
{rows.map((r, i) => (
<Group key={(r.user_id || r.name) + i} gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
{withAvatar && (
<Avatar size={22} radius="xl" color={tagColor(r.name || String(i))}>
{(r.name || "?").slice(0, 2).toUpperCase()}
</Avatar>
)}
<Text size="sm" truncate>
{r.name || "(sin nombre)"}
</Text>
</Group>
<Badge size="sm" variant="light" color={i === 0 ? "teal" : "gray"}>
{r.count}
</Badge>
</Group>
))}
</Stack>
)}
</MCard>
);
}
export function DailyReportView({ date, onJumpToCard }: Props) {
const [data, setData] = useState<Report | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
setData(null);
setErr(null);
dailyReport(date)
.then(setData)
.catch((e) => setErr((e as Error).message));
}, [date]);
const hourlyChartData = useMemo(() => {
if (!data) return [];
return data.hourly_moves.map((n, h) => ({
hora: String(h).padStart(2, "0") + ":00",
movimientos: n,
}));
}, [data]);
if (err) {
return (
<Alert color="red" icon={<IconAlertTriangle size={14} />}>
{err}
</Alert>
);
}
if (!data) {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
const k = data.kpis;
const onTimePct =
k.deadlines_met + k.deadlines_missed > 0
? Math.round((k.deadlines_met / (k.deadlines_met + k.deadlines_missed)) * 100)
: null;
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap={6}>
<IconCalendarStats size={20} />
<Title order={4}>Reporte diario</Title>
</Group>
<Text size="sm" c="dimmed" tt="capitalize">
{fmtDate(data.date)}
</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4, md: 6 }} spacing="xs">
<KPI label="Hechas" value={k.done} color="teal" icon={<IconCheck size={14} color="var(--mantine-color-teal-6)" />} />
<KPI label="Creadas" value={k.created} icon={<IconPlus size={14} />} />
<KPI label="Movimientos" value={k.moves} icon={<IconRefresh size={14} />} />
<KPI
label="Bloqueado"
value={formatDuration(k.blocked_ms)}
color="yellow"
icon={<IconLock size={14} color="var(--mantine-color-yellow-6)" />}
/>
<KPI
label="Reabiertas"
value={k.reopened}
color={k.reopened > 0 ? "orange" : undefined}
icon={<IconArrowBackUp size={14} />}
/>
<KPI
label="Deadlines"
value={onTimePct != null ? `${onTimePct}%` : "—"}
color={onTimePct == null ? "dimmed" : onTimePct >= 80 ? "teal" : "red"}
sub={`${k.deadlines_met} on-time / ${k.deadlines_missed} vencidos`}
icon={<IconHourglass size={14} />}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="xs">
<RankingList
title="Asignado: mas hechas"
rows={data.top_assignees_done}
emptyText="Sin hechas con asignado."
withAvatar
/>
<RankingList
title="Asignado: mas creadas"
rows={data.top_assignees_created}
emptyText="Sin actor en creadas."
withAvatar
/>
<RankingList
title="Solicitante: mas atendidas"
rows={data.top_requesters_done}
emptyText="Sin solicitantes con hechas."
/>
<RankingList
title="Solicitante: mas aportadas"
rows={data.top_requesters_added}
emptyText="Sin nuevas con solicitante."
/>
</SimpleGrid>
<MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
Tareas hechas
</Text>
<Group gap={6}>
<Badge size="xs" variant="light">
N {k.done}
</Badge>
<Text size="xs" c="dimmed">
Lead time avg {data.lead_time.samples > 0 ? formatDuration(data.lead_time.avg_ms) : "—"} · p50{" "}
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p50_ms) : "—"} · p95{" "}
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p95_ms) : "—"}
</Text>
</Group>
</Group>
{data.done_cards.length === 0 ? (
<Text size="xs" c="dimmed">
Sin hechas en este dia.
</Text>
) : (
<ScrollArea style={{ maxHeight: 280 }} type="auto">
<Table verticalSpacing={4} fz="xs" highlightOnHover striped="even">
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 70 }}>#</Table.Th>
<Table.Th>Titulo</Table.Th>
<Table.Th>Solicitante</Table.Th>
<Table.Th>Asignado</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th style={{ width: 110 }}>Lead time</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.done_cards.map((c) => (
<Table.Tr key={c.id}>
<Table.Td>
<Text size="xs" c="dimmed">
{String(c.seq_num).padStart(5, "0")}
</Text>
</Table.Td>
<Table.Td>
<UnstyledButton onClick={() => onJumpToCard?.(c.id)} style={{ textAlign: "left" }}>
<Text size="xs" fw={500} td="underline">
{c.title}
</Text>
</UnstyledButton>
</Table.Td>
<Table.Td>
<Text size="xs">{c.requester || "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{c.assignee_name || "—"}</Text>
</Table.Td>
<Table.Td>
<Group gap={2} wrap="wrap">
{(c.tags || []).slice(0, 3).map((t) => (
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
{t}
</Badge>
))}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{formatDuration(c.lead_time_ms)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</MCard>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb={6}>
<Text fw={600} size="sm">
Movimientos por hora
</Text>
<Badge size="xs" variant="light">
{k.moves}
</Badge>
</Group>
{k.moves === 0 ? (
<Text size="xs" c="dimmed">
Sin movimientos.
</Text>
) : (
<BarChart
h={160}
data={hourlyChartData}
dataKey="hora"
series={[{ name: "movimientos", color: "blue.6" }]}
tickLine="y"
withTooltip
valueFormatter={(v: number) => String(v)}
/>
)}
</MCard>
<MCard withBorder radius="md" p="sm">
<Text fw={600} size="sm" mb={6}>
Tags trabajadas
</Text>
{data.tags_done.length === 0 ? (
<Text size="xs" c="dimmed">
Sin tags.
</Text>
) : (
<Group gap={4} wrap="wrap">
{data.tags_done.map((t) => (
<Badge key={t.name} variant="light" color={tagColor(t.name)} size="sm">
{t.name} · {t.count}
</Badge>
))}
</Group>
)}
</MCard>
</SimpleGrid>
{data.reopened_cards.length > 0 && (
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconArrowBackUp size={14} color="var(--mantine-color-orange-6)" />
<Text fw={600} size="sm">
Reabiertas (Done otra)
</Text>
<Badge size="xs" variant="light" color="orange">
{data.reopened_cards.length}
</Badge>
</Group>
<Stack gap={4}>
{data.reopened_cards.map((r) => (
<Group key={r.card_id + r.ts} gap={6} wrap="nowrap" justify="space-between">
<UnstyledButton onClick={() => onJumpToCard?.(r.card_id)} style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" truncate td="underline">
{r.title}
</Text>
</UnstyledButton>
<Text size="xs" c="dimmed">
{r.from_column} {r.to_column}
</Text>
{r.actor_name && (
<Badge size="xs" variant="light" color="cyan">
{r.actor_name}
</Badge>
)}
</Group>
))}
</Stack>
</MCard>
)}
{(data.deadlines.missed > 0 || data.deadlines.met > 0) && (
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconHourglass size={14} />
<Text fw={600} size="sm">
Deadlines
</Text>
<Badge size="xs" variant="light" color="teal">
{data.deadlines.met} on-time
</Badge>
<Badge size="xs" variant="light" color="red">
{data.deadlines.missed} vencidos
</Badge>
</Group>
{data.deadlines.list.length > 0 && (
<Stack gap={4}>
{data.deadlines.list.map((d) => (
<Group key={d.card_id} gap={6} justify="space-between" wrap="nowrap">
<UnstyledButton onClick={() => onJumpToCard?.(d.card_id)} style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" truncate td="underline">
{d.title}
</Text>
</UnstyledButton>
<Text size="xs" c="red">
+{formatDuration(d.late_ms)} tarde
</Text>
</Group>
))}
</Stack>
)}
</MCard>
)}
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconTrendingUp size={14} />
<Text fw={600} size="sm">
Cards estancadas (al final del dia)
</Text>
<Badge size="xs" variant="light" color="orange">
{data.stale_cards.d7.length}d7
</Badge>
<Badge size="xs" variant="light" color="red">
{data.stale_cards.d14.length}d14
</Badge>
<Badge size="xs" variant="filled" color="red">
{data.stale_cards.d30.length}d30
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
<Box>
<Text size="xs" fw={500} c="orange" mb={4}>
7-13 dias
</Text>
<Stack gap={2}>
{data.stale_cards.d7.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate>
{s.title}{" "}
<Text span c="dimmed" size="xs">
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d7.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
<Box>
<Text size="xs" fw={500} c="red" mb={4}>
14-29 dias
</Text>
<Stack gap={2}>
{data.stale_cards.d14.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate>
{s.title}{" "}
<Text span c="dimmed" size="xs">
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d14.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
<Box>
<Text size="xs" fw={500} c="red.8" mb={4}>
30+ dias
</Text>
<Stack gap={2}>
{data.stale_cards.d30.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate fw={600}>
{s.title}{" "}
<Text span c="dimmed" size="xs" fw={400}>
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d30.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
</SimpleGrid>
</MCard>
<Divider />
<Group gap={6} justify="space-between">
<Group gap={4}>
<IconClock size={14} />
<Text size="xs" c="dimmed">
TZ: {data.tz} · cards archivadas hoy: {data.archived_today}
</Text>
</Group>
</Group>
</Stack>
);
}