feat(kanban): bocadillo agente + PDF descargable en reporte diario (issue 0094)

Anade tres capas sobre el reporte diario del issue 0093:

1) Bocadillo del agente: cuadro azul encima de "Tareas hechas" con un
   resumen en lenguaje natural (max 4 frases) generado por claude -p
   sobre el JSON del reporte. Botones Regenerar e icono Settings.

2) Settings del prompt: modal con textarea editable para el template
   del agente (key=daily_report_prompt). Compartido por todos los
   usuarios. Boton Restablecer por defecto.

3) PDF descargable: boton que abre ventana nueva con HTML imprimible
   (estilo A4, KPIs filtrados, tabla con enlaces absolutos por card).
   Permite compartir el listado de tareas hechas con los solicitantes.

Backend:
- Migration 013 anade tablas daily_summaries y settings; seed del
  prompt por defecto en castellano.
- daily_summary.go con GetSetting/SetSetting, GetDailySummary/Upsert,
  runClaudePrompt (envuelve claude -p) y GenerateDailySummary que
  orquesta DailyReportFor + plantilla + claude + persist.
- Nuevos endpoints:
  * GET  /api/reports/daily/summary
  * POST /api/reports/daily/summary
  * GET  /api/settings/{key}
  * PUT  /api/settings/{key}

Frontend:
- api.ts: getDailySummary, generateDailySummary, getSetting, setSetting.
- DailyReport.tsx: estado de summary, settingsOpen, promptDraft,
  filterRequester, filterAssignee, filteredDoneCards, exportPDF.
- Bocadillo con IconSparkles + IconRefresh + IconSettings.
- Modal de prompt con Guardar/Cancelar/Reset.
- Filtros Select por solicitante y asignado encima de la tabla.
- exportPDF abre window.open con HTML self-contained que incluye
  enlaces ${origin}/?card=${id} y window.print() automatico.

E2E nuevo (daily-summary-pdf.spec.ts): CRUD del setting, GET summary
shape, presencia del boton PDF/Settings/Regenerar en el modal. No
invoca claude real (binario externo, no disponible en CI).

Suite completa 11/11 pasa.
This commit is contained in:
2026-05-14 18:08:09 +02:00
parent fc7e6a34a7
commit 9c5e76e03f
9 changed files with 1887 additions and 1196 deletions
+296 -9
View File
@@ -1,19 +1,25 @@
import {
ActionIcon,
Alert,
Avatar,
Badge,
Box,
Button,
Card as MCard,
Divider,
Group,
Loader,
Modal,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
Title,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { BarChart } from "@mantine/charts";
@@ -23,14 +29,25 @@ import {
IconCalendarStats,
IconCheck,
IconClock,
IconDownload,
IconHourglass,
IconLock,
IconPlus,
IconRefresh,
IconSettings,
IconSparkles,
IconTrendingUp,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { dailyReport, type DailyReport as Report } from "../api";
import {
dailyReport,
generateDailySummary,
getDailySummary,
getSetting,
setSetting,
type DailyReport as Report,
type DailySummary,
} from "../api";
import { formatDuration } from "./format";
import { tagColor } from "./colors";
@@ -39,6 +56,10 @@ interface Props {
onJumpToCard?: (cardId: string) => void;
}
const PROMPT_KEY = "daily_report_prompt";
const PROMPT_DEFAULT =
"Eres un coach de equipo. Resume el reporte diario en un MAXIMO de 4 frases cortas, mencionando: (1) total de tareas hechas y quien destaco, (2) cualquier card reabierta o deadline vencido que merezca atencion, (3) cards estancadas criticas (30+ dias) si las hay, (4) una frase corta de animo o aviso si toca. Tono natural, primera persona del plural, sin emojis. No inventes datos; usa solo los del JSON del reporte.";
function fmtDate(s: string): string {
try {
const d = new Date(s + "T00:00:00");
@@ -134,6 +155,13 @@ function RankingList<T extends { name: string; count: number; user_id?: string }
export function DailyReportView({ date, onJumpToCard }: Props) {
const [data, setData] = useState<Report | null>(null);
const [err, setErr] = useState<string | null>(null);
const [summary, setSummary] = useState<DailySummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryErr, setSummaryErr] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [promptDraft, setPromptDraft] = useState("");
const [filterRequester, setFilterRequester] = useState<string | null>(null);
const [filterAssignee, setFilterAssignee] = useState<string | null>(null);
useEffect(() => {
setData(null);
@@ -141,8 +169,43 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
dailyReport(date)
.then(setData)
.catch((e) => setErr((e as Error).message));
setSummary(null);
setSummaryErr(null);
getDailySummary(date)
.then((s) => setSummary(s.exists ? s : null))
.catch(() => {});
}, [date]);
const regenerateSummary = async () => {
setSummaryLoading(true);
setSummaryErr(null);
try {
const s = await generateDailySummary(date);
setSummary({ ...s, exists: true });
} catch (e) {
setSummaryErr((e as Error).message);
} finally {
setSummaryLoading(false);
}
};
const openSettings = async () => {
try {
const s = await getSetting(PROMPT_KEY);
setPromptDraft(s.value || PROMPT_DEFAULT);
} catch {
setPromptDraft(PROMPT_DEFAULT);
}
setSettingsOpen(true);
};
const saveSettings = async () => {
await setSetting(PROMPT_KEY, promptDraft);
setSettingsOpen(false);
};
const resetSettings = () => setPromptDraft(PROMPT_DEFAULT);
const hourlyChartData = useMemo(() => {
if (!data) return [];
return data.hourly_moves.map((n, h) => ({
@@ -151,6 +214,119 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
}));
}, [data]);
const requesterOptions = useMemo(() => {
if (!data) return [];
const set = new Set<string>();
for (const c of data.done_cards) if (c.requester) set.add(c.requester);
return Array.from(set).sort();
}, [data]);
const assigneeOptions = useMemo(() => {
if (!data) return [];
const m = new Map<string, string>();
for (const c of data.done_cards) {
if (c.assignee_id) m.set(c.assignee_id, c.assignee_name || c.assignee_id);
}
return Array.from(m.entries()).map(([value, label]) => ({ value, label }));
}, [data]);
const filteredDoneCards = useMemo(() => {
if (!data) return [];
return data.done_cards.filter((c) => {
if (filterRequester && c.requester !== filterRequester) return false;
if (filterAssignee && c.assignee_id !== filterAssignee) return false;
return true;
});
}, [data, filterRequester, filterAssignee]);
const exportPDF = () => {
if (!data) return;
const win = window.open("", "_blank");
if (!win) return;
const origin = window.location.origin;
const dateLabel = (() => {
try {
return new Date(data.date + "T00:00:00").toLocaleDateString("es-ES", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return data.date;
}
})();
const filterSub: string[] = [];
if (filterRequester) filterSub.push(`solicitante=${filterRequester}`);
if (filterAssignee) {
const a = assigneeOptions.find((o) => o.value === filterAssignee);
filterSub.push(`asignado=${a?.label || filterAssignee}`);
}
const escape = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const rows = filteredDoneCards
.map((c) => {
const tags = (c.tags || []).map(escape).join(", ");
const link = `${origin}/?card=${c.id}`;
return `<tr>
<td class="num">${String(c.seq_num).padStart(5, "0")}</td>
<td><a href="${link}">${escape(c.title)}</a></td>
<td>${escape(c.requester || "")}</td>
<td>${escape(c.assignee_name || "")}</td>
<td>${escape(tags)}</td>
<td class="num">${formatDuration(c.lead_time_ms)}</td>
</tr>`;
})
.join("");
const html = `<!doctype html>
<html lang="es"><head><meta charset="utf-8" />
<title>Reporte ${data.date}</title>
<style>
@page { margin: 18mm 15mm; }
body { font-family: system-ui, sans-serif; color: #222; }
h1 { font-size: 18pt; margin-bottom: 4px; }
.sub { color: #666; font-size: 10pt; margin-bottom: 16px; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 18px; }
.kpi { border: 1px solid #ddd; border-radius: 6px; padding: 8px; }
.kpi .l { font-size: 8pt; color: #888; text-transform: uppercase; }
.kpi .v { font-size: 16pt; font-weight: 700; }
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
th, td { border-bottom: 1px solid #e5e5e5; padding: 6px 4px; text-align: left; vertical-align: top; }
th { background: #f5f5f5; font-weight: 600; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
a { color: #1c7ed6; text-decoration: none; }
a:hover { text-decoration: underline; }
footer { margin-top: 20px; font-size: 8pt; color: #888; }
</style></head><body>
<h1>Reporte diario · ${escape(dateLabel)}</h1>
<div class="sub">${escape(data.date)} · ${escape(data.tz)}${
filterSub.length ? " · filtros: " + filterSub.map(escape).join(", ") : ""
}</div>
<div class="kpis">
<div class="kpi"><div class="l">Hechas</div><div class="v">${filteredDoneCards.length}</div></div>
<div class="kpi"><div class="l">Lead time avg</div><div class="v">${formatDuration(data.lead_time.avg_ms)}</div></div>
<div class="kpi"><div class="l">Deadlines on-time</div><div class="v">${data.deadlines.met}/${data.deadlines.met + data.deadlines.missed}</div></div>
<div class="kpi"><div class="l">Reabiertas</div><div class="v">${data.kpis.reopened}</div></div>
</div>
${summary?.summary ? `<p style="border-left:4px solid #1c7ed6; padding:8px 12px; background:#eef6fd; border-radius:4px;">${escape(summary.summary)}</p>` : ""}
<table>
<thead><tr>
<th class="num">#</th>
<th>Titulo</th>
<th>Solicitante</th>
<th>Asignado</th>
<th>Tags</th>
<th class="num">Lead time</th>
</tr></thead>
<tbody>${rows || '<tr><td colspan="6" style="text-align:center;color:#888;">Sin tareas que cumplan el filtro.</td></tr>'}</tbody>
</table>
<footer>Generado por kanban · ${escape(origin)}</footer>
<script>window.addEventListener("load", () => setTimeout(() => window.print(), 250));</script>
</body></html>`;
win.document.write(html);
win.document.close();
};
if (err) {
return (
<Alert color="red" icon={<IconAlertTriangle size={14} />}>
@@ -234,14 +410,63 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
/>
</SimpleGrid>
{/* Bocadillo de agente — encima de tareas hechas */}
<Paper
withBorder
radius="md"
p="sm"
bg="var(--mantine-color-blue-light)"
style={{ borderLeftWidth: 4, borderLeftColor: "var(--mantine-color-blue-6)" }}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={6} align="flex-start" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<IconSparkles size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0, marginTop: 2 }} />
<Box style={{ flex: 1 }}>
{summaryErr && (
<Alert color="red" mb={4} icon={<IconAlertTriangle size={14} />}>
{summaryErr}
</Alert>
)}
{summaryLoading ? (
<Group gap={6}><Loader size="xs" /><Text size="sm" c="dimmed">Generando resumen</Text></Group>
) : summary?.summary ? (
<>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>{summary.summary}</Text>
{summary.generated_at && (
<Text size="xs" c="dimmed" mt={4}>
Generado {new Date(summary.generated_at).toLocaleString()} · {summary.model}
</Text>
)}
</>
) : (
<Text size="sm" c="dimmed" fs="italic">Aun no hay resumen del dia. Pulsa "Generar".</Text>
)}
</Box>
</Group>
<Group gap={4} wrap="nowrap">
<Tooltip label={summary?.exists ? "Regenerar" : "Generar"} withArrow>
<ActionIcon variant="subtle" color="blue" onClick={regenerateSummary} loading={summaryLoading} aria-label="Regenerar resumen">
<IconRefresh size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Configurar prompt" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={openSettings} aria-label="Configurar prompt">
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
<MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
Tareas hechas
</Text>
<Group gap={6}>
<Group justify="space-between" mb="xs" wrap="wrap" gap={6}>
<Group gap={6} wrap="wrap">
<Text fw={600} size="sm">
Tareas hechas
</Text>
<Badge size="xs" variant="light">
N {k.done}
N {filteredDoneCards.length}
{filteredDoneCards.length !== data.done_cards.length ? ` / ${data.done_cards.length}` : ""}
</Badge>
<Text size="xs" c="dimmed">
Lead time avg {data.lead_time.samples > 0 ? formatDuration(data.lead_time.avg_ms) : "—"} · p50{" "}
@@ -249,8 +474,41 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p95_ms) : "—"}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Select
size="xs"
placeholder="Solicitante"
data={requesterOptions}
value={filterRequester}
onChange={setFilterRequester}
clearable
searchable
style={{ width: 160 }}
aria-label="Filtrar por solicitante"
/>
<Select
size="xs"
placeholder="Asignado"
data={assigneeOptions}
value={filterAssignee}
onChange={setFilterAssignee}
clearable
searchable
style={{ width: 160 }}
aria-label="Filtrar por asignado"
/>
<Button
size="xs"
leftSection={<IconDownload size={14} />}
variant="light"
onClick={exportPDF}
data-test="daily-report-pdf"
>
PDF
</Button>
</Group>
</Group>
{data.done_cards.length === 0 ? (
{filteredDoneCards.length === 0 ? (
<Text size="xs" c="dimmed">
Sin hechas en este dia.
</Text>
@@ -268,7 +526,7 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.done_cards.map((c) => (
{filteredDoneCards.map((c) => (
<Table.Tr key={c.id}>
<Table.Td>
<Text size="xs" c="dimmed">
@@ -518,6 +776,35 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
</Text>
</Group>
</Group>
<Modal opened={settingsOpen} onClose={() => setSettingsOpen(false)} title="Prompt del agente diario" size="lg" zIndex={500}>
<Stack gap="sm">
<Text size="xs" c="dimmed">
Plantilla que el agente recibe junto al JSON del reporte. Compartida por todos los usuarios.
</Text>
<Textarea
autosize
minRows={6}
maxRows={20}
value={promptDraft}
onChange={(e) => setPromptDraft(e.currentTarget.value)}
data-test="daily-report-prompt"
/>
<Group justify="space-between">
<Button size="xs" variant="subtle" onClick={resetSettings}>
Restablecer por defecto
</Button>
<Group gap={6}>
<Button size="xs" variant="subtle" color="gray" onClick={() => setSettingsOpen(false)}>
Cancelar
</Button>
<Button size="xs" onClick={saveSettings} data-test="daily-report-prompt-save">
Guardar
</Button>
</Group>
</Group>
</Stack>
</Modal>
</Stack>
);
}