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:
@@ -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
|
||||
}
|
||||
+1250
File diff suppressed because one or more lines are too long
-1186
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
Reference in New Issue
Block a user