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 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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user