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
+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
func handleListArchive(db *DB) http.HandlerFunc {
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: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(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: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)},
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db)},