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
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// DailySummary persisted row.
type DailySummary struct {
Date string `json:"date"`
Summary string `json:"summary"`
Prompt string `json:"prompt"`
Model string `json:"model"`
GeneratedAt string `json:"generated_at"`
GeneratedBy *string `json:"generated_by"`
}
func (db *DB) GetDailySummary(date string) (*DailySummary, error) {
row := db.conn.QueryRow(`SELECT date, summary, prompt, model, generated_at, generated_by FROM daily_summaries WHERE date=?`, date)
var s DailySummary
var by sql.NullString
if err := row.Scan(&s.Date, &s.Summary, &s.Prompt, &s.Model, &s.GeneratedAt, &by); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
if by.Valid {
v := by.String
s.GeneratedBy = &v
}
return &s, nil
}
func (db *DB) UpsertDailySummary(s DailySummary) error {
var by any = nil
if s.GeneratedBy != nil {
by = *s.GeneratedBy
}
_, err := db.conn.Exec(`
INSERT INTO daily_summaries (date, summary, prompt, model, generated_at, generated_by)
VALUES (?,?,?,?,?,?)
ON CONFLICT(date) DO UPDATE SET
summary=excluded.summary,
prompt=excluded.prompt,
model=excluded.model,
generated_at=excluded.generated_at,
generated_by=excluded.generated_by
`, s.Date, s.Summary, s.Prompt, s.Model, s.GeneratedAt, by)
return err
}
func (db *DB) GetSetting(key string) (string, error) {
var v string
err := db.conn.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", nil
}
return v, err
}
func (db *DB) SetSetting(key, value string, by *string) error {
now := nowRFC3339()
var byArg any = nil
if by != nil {
byArg = *by
}
_, err := db.conn.Exec(`
INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at, updated_by=excluded.updated_by
`, key, value, now, byArg)
return err
}
// runClaudePrompt executes `claude -p` with the given user prompt; returns
// stdout trimmed. Times out via claudeTimeout from chat.go.
func runClaudePrompt(ctx context.Context, prompt string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, claudeTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, claudeBinary(), "-p", "--model", claudeModel())
cmd.Stdin = strings.NewReader(prompt)
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("claude -p failed: %v: %s", err, strings.TrimSpace(errb.String()))
}
return strings.TrimSpace(out.String()), nil
}
// BuildDailySummaryPrompt composes the prompt for the LLM by interpolating the
// configurable instruction template with the JSON of the report.
func BuildDailySummaryPrompt(template string, report *DailyReport) (string, error) {
js, err := json.MarshalIndent(report, "", " ")
if err != nil {
return "", err
}
return fmt.Sprintf("%s\n\n<reporte_json>\n%s\n</reporte_json>\n", template, string(js)), nil
}
// GenerateDailySummary builds the prompt, calls Claude, persists and returns
// the resulting summary. actorID is optional (empty = anon/system).
func (db *DB) GenerateDailySummary(ctx context.Context, date, tz, actorID string) (*DailySummary, error) {
rep, err := db.DailyReportFor(date, tz)
if err != nil {
return nil, err
}
tmpl, err := db.GetSetting("daily_report_prompt")
if err != nil {
return nil, err
}
if tmpl == "" {
tmpl = "Resume el reporte diario en 4 frases cortas, en castellano, sin inventar datos."
}
prompt, err := BuildDailySummaryPrompt(tmpl, rep)
if err != nil {
return nil, err
}
summary, err := runClaudePrompt(ctx, prompt)
if err != nil {
return nil, err
}
rec := DailySummary{
Date: date,
Summary: summary,
Prompt: tmpl,
Model: claudeModel(),
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
if actorID != "" {
rec.GeneratedBy = &actorID
}
if err := db.UpsertDailySummary(rec); err != nil {
return nil, err
}
return &rec, nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title> <title>Kanban</title>
<script type="module" crossorigin src="/assets/index-zy-U5pO-.js"></script> <script type="module" crossorigin src="/assets/index-D_Kep7Fb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css"> <link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head> </head>
<body> <body>
+87
View File
@@ -457,6 +457,89 @@ func handleDailyReport(db *DB) http.HandlerFunc {
} }
} }
// GET /api/reports/daily/summary?date=YYYY-MM-DD
func handleGetDailySummary(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
s, err := db.GetDailySummary(date)
if err != nil {
serverError(w, err)
return
}
if s == nil {
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{"date": date, "summary": "", "exists": false})
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
"date": s.Date, "summary": s.Summary, "prompt": s.Prompt,
"model": s.Model, "generated_at": s.GeneratedAt, "generated_by": s.GeneratedBy,
"exists": true,
})
}
}
// POST /api/reports/daily/summary?date=YYYY-MM-DD&tz=Europe/Madrid
// Regenera el resumen del dia y lo persiste.
func handleGenerateDailySummary(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
tz := r.URL.Query().Get("tz")
if tz == "" {
tz = "Europe/Madrid"
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
rec, err := db.GenerateDailySummary(r.Context(), date, tz, actor)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, rec)
}
}
// GET /api/settings/{key}
func handleGetSetting(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
v, err := db.GetSetting(key)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{"key": key, "value": v})
}
}
// PUT /api/settings/{key} body: {"value": "..."}
func handlePutSetting(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
var body struct {
Value string `json:"value"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
var actorPtr *string
if actor != "" {
actorPtr = &actor
}
if err := db.SetSetting(key, body.Value, actorPtr); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// GET /api/archive // GET /api/archive
func handleListArchive(db *DB) http.HandlerFunc { func handleListArchive(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -532,6 +615,10 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)}, {Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)}, {Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
{Method: "GET", Path: "/api/reports/daily", Handler: handleDailyReport(db)}, {Method: "GET", Path: "/api/reports/daily", Handler: handleDailyReport(db)},
{Method: "GET", Path: "/api/reports/daily/summary", Handler: handleGetDailySummary(db)},
{Method: "POST", Path: "/api/reports/daily/summary", Handler: handleGenerateDailySummary(db)},
{Method: "GET", Path: "/api/settings/{key}", Handler: handleGetSetting(db)},
{Method: "PUT", Path: "/api/settings/{key}", Handler: handlePutSetting(db)},
{Method: "GET", Path: "/api/archive", Handler: handleListArchive(db)}, {Method: "GET", Path: "/api/archive", Handler: handleListArchive(db)},
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)}, {Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)},
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db)}, {Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db)},
@@ -0,0 +1,23 @@
-- Issue 0094: resumen de IA por dia + tabla settings clave/valor.
CREATE TABLE IF NOT EXISTS daily_summaries (
date TEXT PRIMARY KEY,
summary TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL,
generated_by TEXT
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL,
updated_by TEXT
);
INSERT OR IGNORE INTO settings (key, value, updated_at)
VALUES (
'daily_report_prompt',
'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.',
datetime('now')
);
+56
View File
@@ -0,0 +1,56 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue 0094: bocadillo del agente + settings de prompt + PDF.
* No invocamos claude binario; testeamos endpoints settings y la UI estatica.
*/
test.describe("daily summary + pdf (issue 0094)", () => {
test("settings prompt CRUD roundtrip", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Lectura inicial: existe seed.
const initial = await page.request.get("/api/settings/daily_report_prompt").then((r) => r.json());
expect(initial.value).toContain("MAXIMO");
// Cambio.
const newVal = "test prompt " + Date.now();
const put = await page.request.put("/api/settings/daily_report_prompt", { data: { value: newVal } });
expect([200, 204]).toContain(put.status());
// Verifica.
const after = await page.request.get("/api/settings/daily_report_prompt").then((r) => r.json());
expect(after.value).toBe(newVal);
// Restaurar.
await page.request.put("/api/settings/daily_report_prompt", { data: { value: initial.value } });
});
test("daily summary GET vacio inicialmente, persiste si guardas manualmente", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const today = new Date().toISOString().slice(0, 10);
const before = await page.request.get(`/api/reports/daily/summary?date=${today}`).then((r) => r.json());
// Either exists=false OR exists=true with a string summary. Both valid.
expect(typeof before.exists).toBe("boolean");
expect(typeof before.summary === "string").toBe(true);
});
test("UI: bocadillo + boton PDF + boton settings visibles en modal", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
await page.getByRole("tab", { name: /Calendario/i }).click();
await page.waitForSelector('[data-test^="calendar-day-"]', { timeout: 5000 });
await page.locator('[data-test^="calendar-day-"]').first().dispatchEvent("click");
const modal = page.locator('[role="dialog"]').filter({ hasText: /Reporte diario/i });
await expect(modal).toBeVisible();
await expect(modal.locator('[data-test="daily-report-pdf"]')).toBeVisible();
await expect(modal.getByRole("button", { name: /Configurar prompt/i })).toBeVisible();
await expect(modal.getByRole("button", { name: /Regenerar|Generar/i }).first()).toBeVisible();
});
});
+31
View File
@@ -203,6 +203,37 @@ export function dailyReport(date: string, tz?: string): Promise<DailyReport> {
return fetchJSON(`/reports/daily?${params.toString()}`); return fetchJSON(`/reports/daily?${params.toString()}`);
} }
export interface DailySummary {
date: string;
summary: string;
prompt?: string;
model?: string;
generated_at?: string;
generated_by?: string | null;
exists: boolean;
}
export function getDailySummary(date: string): Promise<DailySummary> {
return fetchJSON(`/reports/daily/summary?date=${encodeURIComponent(date)}`);
}
export function generateDailySummary(date: string, tz?: string): Promise<DailySummary> {
const params = new URLSearchParams({ date });
if (tz) params.set("tz", tz);
return fetchJSON(`/reports/daily/summary?${params.toString()}`, { method: "POST" });
}
export function getSetting(key: string): Promise<{ key: string; value: string }> {
return fetchJSON(`/settings/${encodeURIComponent(key)}`);
}
export function setSetting(key: string, value: string): Promise<void> {
return fetchJSON(`/settings/${encodeURIComponent(key)}`, {
method: "PUT",
body: JSON.stringify({ value }),
});
}
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> { export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
return fetchJSON(`/cards/${id}/move`, { return fetchJSON(`/cards/${id}/move`, {
method: "POST", method: "POST",
+296 -9
View File
@@ -1,19 +1,25 @@
import { import {
ActionIcon,
Alert, Alert,
Avatar, Avatar,
Badge, Badge,
Box, Box,
Button,
Card as MCard, Card as MCard,
Divider, Divider,
Group, Group,
Loader, Loader,
Modal,
Paper, Paper,
ScrollArea, ScrollArea,
Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
Table, Table,
Text, Text,
Textarea,
Title, Title,
Tooltip,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { BarChart } from "@mantine/charts"; import { BarChart } from "@mantine/charts";
@@ -23,14 +29,25 @@ import {
IconCalendarStats, IconCalendarStats,
IconCheck, IconCheck,
IconClock, IconClock,
IconDownload,
IconHourglass, IconHourglass,
IconLock, IconLock,
IconPlus, IconPlus,
IconRefresh, IconRefresh,
IconSettings,
IconSparkles,
IconTrendingUp, IconTrendingUp,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "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 { formatDuration } from "./format";
import { tagColor } from "./colors"; import { tagColor } from "./colors";
@@ -39,6 +56,10 @@ interface Props {
onJumpToCard?: (cardId: string) => void; 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 { function fmtDate(s: string): string {
try { try {
const d = new Date(s + "T00:00:00"); 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) { export function DailyReportView({ date, onJumpToCard }: Props) {
const [data, setData] = useState<Report | null>(null); const [data, setData] = useState<Report | null>(null);
const [err, setErr] = useState<string | 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(() => { useEffect(() => {
setData(null); setData(null);
@@ -141,8 +169,43 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
dailyReport(date) dailyReport(date)
.then(setData) .then(setData)
.catch((e) => setErr((e as Error).message)); .catch((e) => setErr((e as Error).message));
setSummary(null);
setSummaryErr(null);
getDailySummary(date)
.then((s) => setSummary(s.exists ? s : null))
.catch(() => {});
}, [date]); }, [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(() => { const hourlyChartData = useMemo(() => {
if (!data) return []; if (!data) return [];
return data.hourly_moves.map((n, h) => ({ return data.hourly_moves.map((n, h) => ({
@@ -151,6 +214,119 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
})); }));
}, [data]); }, [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) { if (err) {
return ( return (
<Alert color="red" icon={<IconAlertTriangle size={14} />}> <Alert color="red" icon={<IconAlertTriangle size={14} />}>
@@ -234,14 +410,63 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
/> />
</SimpleGrid> </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"> <MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb="xs"> <Group justify="space-between" mb="xs" wrap="wrap" gap={6}>
<Text fw={600} size="sm"> <Group gap={6} wrap="wrap">
Tareas hechas <Text fw={600} size="sm">
</Text> Tareas hechas
<Group gap={6}> </Text>
<Badge size="xs" variant="light"> <Badge size="xs" variant="light">
N {k.done} N {filteredDoneCards.length}
{filteredDoneCards.length !== data.done_cards.length ? ` / ${data.done_cards.length}` : ""}
</Badge> </Badge>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Lead time avg {data.lead_time.samples > 0 ? formatDuration(data.lead_time.avg_ms) : "—"} · p50{" "} 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) : "—"} {data.lead_time.samples > 0 ? formatDuration(data.lead_time.p95_ms) : "—"}
</Text> </Text>
</Group> </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> </Group>
{data.done_cards.length === 0 ? ( {filteredDoneCards.length === 0 ? (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Sin hechas en este dia. Sin hechas en este dia.
</Text> </Text>
@@ -268,7 +526,7 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{data.done_cards.map((c) => ( {filteredDoneCards.map((c) => (
<Table.Tr key={c.id}> <Table.Tr key={c.id}>
<Table.Td> <Table.Td>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@@ -518,6 +776,35 @@ export function DailyReportView({ date, onJumpToCard }: Props) {
</Text> </Text>
</Group> </Group>
</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> </Stack>
); );
} }