Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 472fa25bae | |||
| aab4f12fc4 | |||
| e86c93cb73 | |||
| 489d2bbef6 | |||
| ac5f016e7e | |||
| 2401eb5abc | |||
| 1923fd31a4 | |||
| b599090876 | |||
| 69a0d351fc | |||
| 9c5e76e03f | |||
| fc7e6a34a7 | |||
| 9d3ab5f0f3 | |||
| 9b503f0555 | |||
| c4caff85be | |||
| 7ba18f9114 | |||
| 76d85959f1 | |||
| 257858a1f3 | |||
| 30def13c55 | |||
| bc502df48a | |||
| c93ac46c37 | |||
| 9f4fd85db3 | |||
| eb1c13d82c | |||
| a34a8142cc |
@@ -16,5 +16,10 @@ frontend/tsconfig.tsbuildinfo
|
||||
# Local files
|
||||
local_files/
|
||||
|
||||
# Card file attachments (issue 0128) — binarios en disco; metadata en card_files
|
||||
uploads/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
name: kanban
|
||||
lang: go
|
||||
domain: tools
|
||||
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit) y tracking del tiempo que cada tarjeta pasa en cada columna. Frontend Vite + React + Mantine v9 embebido en el binario Go."
|
||||
version: 0.2.0
|
||||
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit), tracking del tiempo por columna y adjuntos de archivos por card (drag&drop en descripcion y chat). Frontend Vite + React + Mantine v9 embebido en el binario Go."
|
||||
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
|
||||
uses_functions:
|
||||
- random_hex_id_go_core
|
||||
@@ -43,6 +44,17 @@ uses_types:
|
||||
framework: "net/http + vite + react + mantine + dnd-kit"
|
||||
entry_point: "backend/main.go"
|
||||
dir_path: "apps/kanban"
|
||||
service:
|
||||
port: 8095
|
||||
health_endpoint: /api/board
|
||||
health_timeout_s: 3
|
||||
systemd_unit: kanban.service
|
||||
systemd_scope: user
|
||||
restart_policy: always
|
||||
runtime: systemd-user
|
||||
pc_targets:
|
||||
- aurgi-pc
|
||||
is_local_only: false
|
||||
|
||||
# Validacion end-to-end (fase 4 del bucle reactivo). Ver issue 0068.
|
||||
e2e_checks:
|
||||
@@ -69,6 +81,10 @@ e2e_checks:
|
||||
cmd: "go test -tags fts5 -count=1 ./..."
|
||||
timeout_s: 120
|
||||
expect_exit: 0
|
||||
- id: smoke_files
|
||||
cmd: "bash e2e/files_smoke.sh"
|
||||
timeout_s: 30
|
||||
expect_exit: 0
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
@@ -79,7 +95,7 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
|
||||
./kanban --port 8095 --db kanban.db
|
||||
```
|
||||
|
||||
### Schema SQLite (`migrations/001_init.sql`)
|
||||
### Schema SQLite (`migrations/001_init.sql` … `010_card_messages.sql`)
|
||||
|
||||
- **columns** — id, name, position, created_at
|
||||
- **cards** — id, title, description, column_id (FK), position, created_at, updated_at
|
||||
@@ -87,6 +103,7 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
|
||||
- Una entrada con `exited_at IS NULL` = posicion actual
|
||||
- Al mover una tarjeta a otra columna: cierra la entrada activa (`exited_at = now`) e inserta una nueva
|
||||
- El borrado de tarjeta hace CASCADE sobre el historial
|
||||
- **card_messages** (migration 010) — id, card_id (FK CASCADE), author_id (nullable), body, created_at. Comentarios humano-a-humano por card; distintos de `card_events` (sistema) y `/api/chat` (LLM global).
|
||||
|
||||
### API REST
|
||||
|
||||
@@ -101,7 +118,21 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
|
||||
| PATCH | `/api/cards/{id}` | `{title?, description?}` |
|
||||
| DELETE | `/api/cards/{id}` | — |
|
||||
| POST | `/api/cards/{id}/move` | `{column_id, ordered_ids: [...]}` |
|
||||
| POST | `/api/cards/{id}/duplicate` | — (clona la card en la misma columna al final; copia titulo+" (copia)", descripcion, color, requester, assignee, tags, stickers, deadline; NO copia historial ni mensajes) |
|
||||
| GET | `/api/cards/{id}/messages` | — (lista de comentarios humano-a-humano de la card) |
|
||||
| POST | `/api/cards/{id}/messages` | `{body}` (crea comentario; author = usuario de la sesion) |
|
||||
| DELETE | `/api/cards/{cid}/messages/{mid}` | — (solo el autor puede borrar su mensaje) |
|
||||
| GET | `/api/cards/{id}/history` | — (timeline con duraciones por columna) |
|
||||
| GET | `/api/flags` | — (retorna `{ <name>: bool }` con los feature flags efectivos en esta instancia) |
|
||||
| POST | `/api/auth/register` | `{username, password, display_name?}` (devuelve 403 `registration_disabled` si el flag `registration-enabled` esta en `false`) |
|
||||
|
||||
### Feature flags
|
||||
|
||||
`dev/feature_flags.json` (lado del repo) define los flags por instancia. Se cargan al arrancar (override con `--flags <path>`); fichero ausente equivale a "todos los flags en `false`". El endpoint `GET /api/flags` expone el estado actual para que el frontend oculte UI condicional (ej. el toggle de "Registrate" en `LoginPage` solo aparece cuando `registration-enabled` es `true`).
|
||||
|
||||
| Flag | Default | Efecto cuando esta en `true` |
|
||||
|---|---|---|
|
||||
| `registration-enabled` | `false` | Permite crear cuentas nuevas via `POST /api/auth/register` y muestra el toggle "Registrate" en la pantalla de login. |
|
||||
|
||||
### Frontend
|
||||
|
||||
@@ -110,6 +141,18 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
|
||||
- **Modales** con `@mantine/modals` (confirmacion borrado, history timeline).
|
||||
- Time-in-column live: `time_in_column_ms` del backend + tick local cada segundo para que el badge se actualice sin reload.
|
||||
- DnD con `closestCorners` + `DragOverlay` para feedback visual al arrastrar.
|
||||
- **Auto-refresh:** el board se recarga cada 30s (`api.getBoard`) sin interaccion del usuario; equivalente a pulsar el boton de refresco. El tick de 1s del time-in-column es independiente y no toca red.
|
||||
- **Modal de card en dos columnas** (`CardEditPanel`): izquierda mantiene `CardForm` (titulo, solicitante, descripcion, asignacion, tags); derecha es un `Tabs` con `Chat` (por defecto) | `Enlaces` | `Archivos` (proximamente). Tamaño del modal: 85% del viewport.
|
||||
- **Chat per-card** (`CardChatPanel`): lista de comentarios humano-a-humano persistidos en `card_messages`. Enter envia, Shift+Enter salto de linea. Solo el autor puede borrar su propio mensaje.
|
||||
- **Enlaces** (`CardLinksPanel`): extrae URLs (`https?://...`) de titulo, descripcion y cuerpo de cada mensaje del chat. Deduplica, muestra hostname + URL completa + badge de origen. Click abre en pestaña nueva (`target="_blank"`).
|
||||
- **Duplicar card:** click derecho sobre la card abre el menu contextual (mismo que el boton `⋮`), donde aparece el item "Duplicar". Al pulsarlo invoca `POST /api/cards/{id}/duplicate`. La copia se inserta al final de la misma columna con titulo + " (copia)".
|
||||
- **Sesion obligatoria para chat:** `POST/DELETE /api/cards/{id}/messages` exige sesion activa (401 si falta). `author_id` siempre poblado; no hay comentarios anonimos.
|
||||
- **Archivos** (`CardFilesPanel`): adjuntos por card almacenados en `apps/kanban/uploads/<card_id>/<random>__<safe_filename>` (filesystem, gitignored). Tabla `card_files` con soft-delete. Limite 10 MB por archivo. Tres vias de upload:
|
||||
1. Drag&drop en el editor de descripcion (`CardForm`) → inserta `` (imagen) o `[name](url)` (resto) en la posicion del cursor.
|
||||
2. Drag&drop o boton paperclip en el chat (`CardChatPanel`) → crea un mensaje cuyo cuerpo es la ref markdown.
|
||||
3. Boton "Subir" en el tab Archivos → sube sin embed.
|
||||
- El renderer de mensajes (`MessageBody`) reconoce `` -> `<Image>` thumb 220px y `[name](url)` -> `<Anchor>`. Texto plano se renderiza con `whiteSpace: pre-wrap`.
|
||||
- Endpoints: `POST /api/cards/{id}/files` (multipart, 10 MB max), `GET /api/cards/{id}/files`, `GET /api/files/{id}` (sirve binario con `inline` o `attachment` segun MIME), `DELETE /api/files/{id}` (soft delete).
|
||||
|
||||
### Build
|
||||
|
||||
@@ -138,3 +181,14 @@ cd frontend && pnpm dev
|
||||
- IDs de columnas y tarjetas: 16 chars hex (8 bytes random) via `random_hex_id_go_core`.
|
||||
- El historial conserva la cronologia exacta — incluso despues de cerrar y reabrir el server, los tiempos vivos siguen contando desde `entered_at`.
|
||||
- El borrado de columna hace CASCADE: las tarjetas se borran y su historial tambien. Si se quiere preservar el historial al borrar, deberia archivarse en lugar de borrar.
|
||||
|
||||
|
||||
## Capability growth log
|
||||
|
||||
Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
|
||||
- `major`: breaking observable (CLI args, schema BBDD propia, formato wire).
|
||||
- `minor`: feature aditiva (nuevo panel, endpoint, opcion).
|
||||
- `patch`: bugfix sin cambio observable.
|
||||
|
||||
- v0.1.0 (2026-05-18) — baseline.
|
||||
- v0.2.0 (2026-05-27) — adjuntos de archivos por card (issue 0128): tabla `card_files` con soft-delete, endpoints REST (`POST/GET/DELETE /api/cards/{id}/files`, `GET/DELETE /api/files/{id}`), tres vias de upload (drag&drop en descripcion y chat, boton en tab Archivos), render inline de imagenes via `MessageBody`. Limite 10 MB.
|
||||
|
||||
+5
-1
@@ -30,8 +30,12 @@ func tokenFromRequest(r *http.Request) string {
|
||||
}
|
||||
|
||||
// POST /api/auth/register {username, password, display_name?}
|
||||
func handleRegister(db *DB) http.HandlerFunc {
|
||||
func handleRegister(db *DB, flags *FeatureFlags) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !flags.Enabled("registration-enabled") {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "registration_disabled", Message: "user registration is disabled on this instance"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// seed_e2e_user creates or updates a deterministic test user for Playwright e2e.
|
||||
// Usage: go run ./backend/cmd/seed_e2e_user --db apps/kanban/operations.db
|
||||
//
|
||||
// Idempotent: safe to run repeatedly. The user "e2e_user" / password "e2e_test_pw_2026"
|
||||
// is intentional and used by apps/kanban/frontend/e2e/*.spec.ts when env vars are not set.
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbPath := flag.String("db", "operations.db", "path to kanban operations.db")
|
||||
username := flag.String("username", "e2e_user", "username")
|
||||
password := flag.String("password", "e2e_test_pw_2026", "password")
|
||||
displayName := flag.String("display", "E2E Test", "display name")
|
||||
flag.Parse()
|
||||
|
||||
db, err := sql.Open("sqlite3", *dbPath)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
id := "e2etest" + fmt.Sprintf("%x", time.Now().UnixNano())[:9]
|
||||
|
||||
// Try update first
|
||||
res, err := db.Exec(
|
||||
`UPDATE users SET password_hash=?, display_name=? WHERE username=?`,
|
||||
string(hash), *displayName, *username,
|
||||
)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
fmt.Printf("updated existing user %q\n", *username)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO users (id, username, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
id, *username, string(hash), *displayName, now,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
fail(err)
|
||||
}
|
||||
fail(err)
|
||||
}
|
||||
fmt.Printf("created user %q (id=%s)\n", *username, id)
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintln(os.Stderr, "seed_e2e_user:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 0089: tiempo maximo por columna.
|
||||
|
||||
func openTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
db, err := openDB(path)
|
||||
if err != nil {
|
||||
t.Fatalf("openDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Close()
|
||||
_ = os.Remove(path)
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestColumnMaxTimeMinutes_Defaults(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
c, err := db.CreateColumn("col1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateColumn: %v", err)
|
||||
}
|
||||
if c.MaxTimeMinutes != 0 {
|
||||
t.Fatalf("new column max_time_minutes = %d, want 0", c.MaxTimeMinutes)
|
||||
}
|
||||
|
||||
cols, err := db.ListColumns()
|
||||
if err != nil {
|
||||
t.Fatalf("ListColumns: %v", err)
|
||||
}
|
||||
if len(cols) == 0 || cols[0].MaxTimeMinutes != 0 {
|
||||
t.Fatalf("listed col max_time_minutes = %d, want 0", cols[0].MaxTimeMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColumnMaxTimeMinutes_Update(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
c, _ := db.CreateColumn("c")
|
||||
v := 30
|
||||
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &v}); err != nil {
|
||||
t.Fatalf("UpdateColumn set 30: %v", err)
|
||||
}
|
||||
|
||||
cols, _ := db.ListColumns()
|
||||
if cols[0].MaxTimeMinutes != 30 {
|
||||
t.Fatalf("after set max=30 got %d", cols[0].MaxTimeMinutes)
|
||||
}
|
||||
|
||||
// Negative clamps to 0.
|
||||
neg := -5
|
||||
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &neg}); err != nil {
|
||||
t.Fatalf("UpdateColumn neg: %v", err)
|
||||
}
|
||||
cols, _ = db.ListColumns()
|
||||
if cols[0].MaxTimeMinutes != 0 {
|
||||
t.Fatalf("negative should clamp to 0, got %d", cols[0].MaxTimeMinutes)
|
||||
}
|
||||
|
||||
// Other fields untouched.
|
||||
w := 555
|
||||
if err := db.UpdateColumn(c.ID, ColumnPatch{Width: &w}); err != nil {
|
||||
t.Fatalf("UpdateColumn width: %v", err)
|
||||
}
|
||||
cols, _ = db.ListColumns()
|
||||
if cols[0].MaxTimeMinutes != 0 {
|
||||
t.Fatalf("max_time should still be 0 after width update, got %d", cols[0].MaxTimeMinutes)
|
||||
}
|
||||
if cols[0].Width != 555 {
|
||||
t.Fatalf("width = %d, want 555", cols[0].Width)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+288
-19
@@ -17,14 +17,15 @@ import (
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type Column struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
Location string `json:"location"`
|
||||
Width int `json:"width"`
|
||||
WIPLimit int `json:"wip_limit"`
|
||||
IsDone bool `json:"is_done"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
Location string `json:"location"`
|
||||
Width int `json:"width"`
|
||||
WIPLimit int `json:"wip_limit"`
|
||||
IsDone bool `json:"is_done"`
|
||||
MaxTimeMinutes int `json:"max_time_minutes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Sticker struct {
|
||||
@@ -46,6 +47,7 @@ type Card struct {
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
Tags []string `json:"tags"`
|
||||
Stickers []Sticker `json:"stickers"`
|
||||
Deadline *string `json:"deadline"`
|
||||
@@ -305,7 +307,7 @@ func insertCardEvent(execer interface {
|
||||
// --- Columns ---
|
||||
|
||||
func (db *DB) ListColumns() ([]Column, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, created_at FROM columns ORDER BY position, created_at`)
|
||||
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, max_time_minutes, created_at FROM columns ORDER BY position, created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -314,7 +316,7 @@ func (db *DB) ListColumns() ([]Column, error) {
|
||||
for rows.Next() {
|
||||
var c Column
|
||||
var isDone int
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.MaxTimeMinutes, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.IsDone = isDone != 0
|
||||
@@ -344,12 +346,13 @@ func (db *DB) CreateColumn(name string) (*Column, error) {
|
||||
}
|
||||
|
||||
type ColumnPatch struct {
|
||||
Name *string
|
||||
Position *int
|
||||
Location *string
|
||||
Width *int
|
||||
WIPLimit *int
|
||||
IsDone *bool
|
||||
Name *string
|
||||
Position *int
|
||||
Location *string
|
||||
Width *int
|
||||
WIPLimit *int
|
||||
IsDone *bool
|
||||
MaxTimeMinutes *int
|
||||
}
|
||||
|
||||
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
||||
@@ -411,6 +414,15 @@ func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.MaxTimeMinutes != nil {
|
||||
m := *patch.MaxTimeMinutes
|
||||
if m < 0 {
|
||||
m = 0
|
||||
}
|
||||
if _, err := db.conn.Exec(`UPDATE columns SET max_time_minutes=? WHERE id=?`, m, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -437,7 +449,7 @@ func (db *DB) ReorderColumns(ids []string) error {
|
||||
|
||||
func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
|
||||
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
|
||||
h.entered_at, l.locked_at,
|
||||
COALESCE((
|
||||
SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
|
||||
@@ -448,7 +460,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
ON h.card_id = c.id AND h.exited_at IS NULL
|
||||
LEFT JOIN card_lock_history l
|
||||
ON l.card_id = c.id AND l.unlocked_at IS NULL
|
||||
WHERE c.deleted_at IS NULL
|
||||
WHERE c.deleted_at IS NULL AND c.archived_at IS NULL
|
||||
ORDER BY c.column_id, c.position, c.created_at
|
||||
`, time.Now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
@@ -463,12 +475,13 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
var assignee sql.NullString
|
||||
var completed sql.NullString
|
||||
var deleted sql.NullString
|
||||
var archived sql.NullString
|
||||
var tagsJSON string
|
||||
var stickersJSON string
|
||||
var deadline sql.NullString
|
||||
var lockedAt sql.NullString
|
||||
var locked int
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Stickers = parseStickers(stickersJSON)
|
||||
@@ -493,6 +506,10 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
s := deleted.String
|
||||
c.DeletedAt = &s
|
||||
}
|
||||
if archived.Valid && archived.String != "" {
|
||||
s := archived.String
|
||||
c.ArchivedAt = &s
|
||||
}
|
||||
c.Tags = parseTags(tagsJSON)
|
||||
if entered.Valid {
|
||||
c.EnteredAt = entered.String
|
||||
@@ -816,6 +833,90 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ArchiveCard moves a card to the archive (out of the board, retrievable).
|
||||
// Used both manually and by AutoArchiveDoneOlderThan.
|
||||
func (db *DB) ArchiveCard(id string) error {
|
||||
now := nowRFC3339()
|
||||
_, err := db.conn.Exec(`UPDATE cards SET archived_at=?, updated_at=? WHERE id=? AND archived_at IS NULL AND deleted_at IS NULL`, now, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UnarchiveCard pulls a card out of the archive back into its column.
|
||||
func (db *DB) UnarchiveCard(id string) error {
|
||||
now := nowRFC3339()
|
||||
_, err := db.conn.Exec(`UPDATE cards SET archived_at=NULL, updated_at=? WHERE id=? AND archived_at IS NOT NULL`, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// AutoArchiveDoneOlderThan archives every card whose column is is_done=1 AND
|
||||
// whose entered_at in that column is older than `older`. Idempotent: cards
|
||||
// already archived or deleted are skipped. Returns the count affected.
|
||||
func (db *DB) AutoArchiveDoneOlderThan(older time.Duration) (int64, error) {
|
||||
cutoff := time.Now().UTC().Add(-older).Format(time.RFC3339Nano)
|
||||
now := nowRFC3339()
|
||||
res, err := db.conn.Exec(`
|
||||
UPDATE cards SET archived_at=?, updated_at=?
|
||||
WHERE archived_at IS NULL
|
||||
AND deleted_at IS NULL
|
||||
AND column_id IN (SELECT id FROM columns WHERE is_done=1)
|
||||
AND id IN (
|
||||
SELECT card_id FROM card_column_history
|
||||
WHERE exited_at IS NULL AND entered_at < ?
|
||||
)
|
||||
`, now, now, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListArchivedCards returns cards in the archive, newest first.
|
||||
func (db *DB) ListArchivedCards() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
|
||||
FROM cards c
|
||||
WHERE c.archived_at IS NOT NULL AND c.deleted_at IS NULL
|
||||
ORDER BY c.archived_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Card{}
|
||||
for rows.Next() {
|
||||
var c Card
|
||||
var assignee, completed, deleted, archived sql.NullString
|
||||
var tagsJSON, stickersJSON string
|
||||
var deadline sql.NullString
|
||||
var locked int
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Stickers = parseStickers(stickersJSON)
|
||||
if deadline.Valid && deadline.String != "" {
|
||||
s := deadline.String
|
||||
c.Deadline = &s
|
||||
}
|
||||
c.Locked = locked != 0
|
||||
if assignee.Valid && assignee.String != "" {
|
||||
s := assignee.String
|
||||
c.AssigneeID = &s
|
||||
}
|
||||
if completed.Valid && completed.String != "" {
|
||||
s := completed.String
|
||||
c.CompletedAt = &s
|
||||
}
|
||||
if archived.Valid && archived.String != "" {
|
||||
s := archived.String
|
||||
c.ArchivedAt = &s
|
||||
}
|
||||
c.Tags = parseTags(tagsJSON)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MoveCard updates the card's column and/or position. If the column changes,
|
||||
// the open history entry is closed and a new one is opened.
|
||||
// orderedIDs is the new order of cards in the destination column (including this card).
|
||||
@@ -1031,3 +1132,171 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
|
||||
CurrentlyLock: currently,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type CardMessage struct {
|
||||
ID string `json:"id"`
|
||||
CardID string `json:"card_id"`
|
||||
AuthorID *string `json:"author_id"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (db *DB) ListCardMessages(cardID string) ([]CardMessage, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT id, card_id, author_id, body, created_at FROM card_messages WHERE card_id=? ORDER BY created_at`,
|
||||
cardID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []CardMessage{}
|
||||
for rows.Next() {
|
||||
var m CardMessage
|
||||
var author sql.NullString
|
||||
if err := rows.Scan(&m.ID, &m.CardID, &author, &m.Body, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if author.Valid && author.String != "" {
|
||||
s := author.String
|
||||
m.AuthorID = &s
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) CreateCardMessage(cardID, authorID, body string) (*CardMessage, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("body required")
|
||||
}
|
||||
if authorID == "" {
|
||||
return nil, fmt.Errorf("author required")
|
||||
}
|
||||
var exists int
|
||||
if err := db.conn.QueryRow(`SELECT 1 FROM cards WHERE id=?`, cardID).Scan(&exists); err != nil {
|
||||
return nil, fmt.Errorf("card not found: %w", err)
|
||||
}
|
||||
s := authorID
|
||||
m := &CardMessage{ID: newID(), CardID: cardID, AuthorID: &s, Body: body, CreatedAt: nowRFC3339()}
|
||||
if _, err := db.conn.Exec(
|
||||
`INSERT INTO card_messages (id, card_id, author_id, body, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
m.ID, m.CardID, authorID, m.Body, m.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteCardMessage(id, requesterID string) error {
|
||||
if requesterID == "" {
|
||||
return fmt.Errorf("session required")
|
||||
}
|
||||
res, err := db.conn.Exec(`DELETE FROM card_messages WHERE id=? AND author_id=?`, id, requesterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("not found or not author")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DuplicateCard clones a card into the same column at the end of the list.
|
||||
// Copies title, description, color, requester, assignee, tags, deadline, stickers.
|
||||
// Does NOT copy card_column_history, card_lock_history, card_events, card_messages.
|
||||
// Title gets " (copia)" suffix.
|
||||
func (db *DB) DuplicateCard(srcID, actorID string) (*Card, error) {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var src Card
|
||||
var assignee sql.NullString
|
||||
var deadline sql.NullString
|
||||
var tagsJSON, stickersJSON string
|
||||
if err := tx.QueryRow(
|
||||
`SELECT requester, title, description, color, column_id, assignee_id, tags, stickers, deadline
|
||||
FROM cards WHERE id=? AND deleted_at IS NULL`, srcID,
|
||||
).Scan(&src.Requester, &src.Title, &src.Description, &src.Color, &src.ColumnID, &assignee, &tagsJSON, &stickersJSON, &deadline); err != nil {
|
||||
return nil, fmt.Errorf("card not found: %w", err)
|
||||
}
|
||||
if assignee.Valid && assignee.String != "" {
|
||||
s := assignee.String
|
||||
src.AssigneeID = &s
|
||||
}
|
||||
if deadline.Valid && deadline.String != "" {
|
||||
s := deadline.String
|
||||
src.Deadline = &s
|
||||
}
|
||||
src.Tags = parseTags(tagsJSON)
|
||||
src.Stickers = parseStickers(stickersJSON)
|
||||
|
||||
var maxPos sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, src.ColumnID).Scan(&maxPos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pos := 0
|
||||
if maxPos.Valid {
|
||||
pos = int(maxPos.Int64) + 1
|
||||
}
|
||||
var maxSeq sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seqNum := 1
|
||||
if maxSeq.Valid {
|
||||
seqNum = int(maxSeq.Int64) + 1
|
||||
}
|
||||
now := nowRFC3339()
|
||||
newTitle := src.Title + " (copia)"
|
||||
c := Card{
|
||||
ID: newID(), SeqNum: seqNum, Requester: src.Requester, Title: newTitle,
|
||||
Description: src.Description, Color: src.Color, ColumnID: src.ColumnID, Position: pos,
|
||||
AssigneeID: src.AssigneeID, Tags: src.Tags, Stickers: src.Stickers, Deadline: src.Deadline,
|
||||
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
|
||||
}
|
||||
var assigneeVal any
|
||||
if c.AssigneeID != nil && *c.AssigneeID != "" {
|
||||
assigneeVal = *c.AssigneeID
|
||||
}
|
||||
var deadlineVal any
|
||||
if c.Deadline != nil && *c.Deadline != "" {
|
||||
deadlineVal = *c.Deadline
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, assignee_id, tags, stickers, deadline, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position,
|
||||
assigneeVal, encodeTags(c.Tags), encodeStickers(c.Stickers), deadlineVal, c.CreatedAt, c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var destDone int
|
||||
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, c.ColumnID).Scan(&destDone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if destDone == 1 {
|
||||
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CompletedAt = &now
|
||||
}
|
||||
if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": newTitle, "column_id": c.ColumnID, "duplicated_from": srcID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
-1151
File diff suppressed because one or more lines are too long
+1283
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
||||
<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-CPqSy0gZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
||||
<script type="module" crossorigin src="/assets/index-DT3pghXY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
// Issue 0128: adjuntos de archivos por card.
|
||||
|
||||
const (
|
||||
maxUploadBytes = 10 << 20 // 10 MiB
|
||||
uploadsSubdir = "uploads"
|
||||
)
|
||||
|
||||
type CardFile struct {
|
||||
ID string `json:"id"`
|
||||
CardID string `json:"card_id"`
|
||||
UploaderID string `json:"uploader_id"`
|
||||
Filename string `json:"filename"`
|
||||
MIME string `json:"mime"`
|
||||
Size int64 `json:"size"`
|
||||
Source string `json:"source"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (db *DB) CreateCardFile(cardID, uploaderID, filename, mimeType, storedPath, source string, size int64) (*CardFile, error) {
|
||||
id := newID()
|
||||
now := nowRFC3339()
|
||||
if source == "" {
|
||||
source = "upload"
|
||||
}
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT INTO card_files
|
||||
(id, card_id, uploader_id, filename, mime, size, stored_path, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, cardID, uploaderID, filename, mimeType, size, storedPath, source, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CardFile{
|
||||
ID: id,
|
||||
CardID: cardID,
|
||||
UploaderID: uploaderID,
|
||||
Filename: filename,
|
||||
MIME: mimeType,
|
||||
Size: size,
|
||||
Source: source,
|
||||
URL: "/api/files/" + id,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListCardFiles(cardID string) ([]CardFile, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT id, card_id, uploader_id, filename, mime, size, source, created_at
|
||||
FROM card_files
|
||||
WHERE card_id = ? AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`,
|
||||
cardID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []CardFile{}
|
||||
for rows.Next() {
|
||||
var f CardFile
|
||||
if err := rows.Scan(&f.ID, &f.CardID, &f.UploaderID, &f.Filename, &f.MIME, &f.Size, &f.Source, &f.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.URL = "/api/files/" + f.ID
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type storedCardFile struct {
|
||||
ID string
|
||||
CardID string
|
||||
Filename string
|
||||
MIME string
|
||||
Size int64
|
||||
StoredPath string
|
||||
}
|
||||
|
||||
func (db *DB) GetCardFile(id string) (*storedCardFile, error) {
|
||||
var f storedCardFile
|
||||
err := db.conn.QueryRow(
|
||||
`SELECT id, card_id, filename, mime, size, stored_path
|
||||
FROM card_files
|
||||
WHERE id = ? AND deleted_at IS NULL`,
|
||||
id,
|
||||
).Scan(&f.ID, &f.CardID, &f.Filename, &f.MIME, &f.Size, &f.StoredPath)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (db *DB) SoftDeleteCardFile(id string) (int64, error) {
|
||||
res, err := db.conn.Exec(
|
||||
`UPDATE card_files SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||
nowRFC3339(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func uploadsDir(workdir string) string {
|
||||
return filepath.Join(workdir, uploadsSubdir)
|
||||
}
|
||||
|
||||
func safeFilename(name string) string {
|
||||
name = filepath.Base(name)
|
||||
name = strings.ReplaceAll(name, string(os.PathSeparator), "_")
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "file"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func randomFilePrefix() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/files (multipart, field "file", optional "source")
|
||||
func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cardID := r.PathValue("id")
|
||||
if cardID == "" {
|
||||
badRequest(w, "card id required")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20)
|
||||
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
|
||||
badRequest(w, "multipart parse: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
badRequest(w, "missing 'file' field: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > maxUploadBytes {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{
|
||||
Status: http.StatusRequestEntityTooLarge,
|
||||
Code: "file_too_large",
|
||||
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
source := r.FormValue("source")
|
||||
switch source {
|
||||
case "", "upload":
|
||||
source = "upload"
|
||||
case "description", "chat":
|
||||
// keep
|
||||
default:
|
||||
source = "upload"
|
||||
}
|
||||
|
||||
dir := filepath.Join(uploadsDir(workdir), cardID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
serverError(w, fmt.Errorf("mkdir uploads: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
fname := safeFilename(header.Filename)
|
||||
storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname)
|
||||
|
||||
out, err := os.Create(storedPath)
|
||||
if err != nil {
|
||||
serverError(w, fmt.Errorf("create file: %w", err))
|
||||
return
|
||||
}
|
||||
written, copyErr := io.Copy(out, file)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
os.Remove(storedPath)
|
||||
serverError(w, fmt.Errorf("write file: %w", copyErr))
|
||||
return
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(storedPath)
|
||||
serverError(w, fmt.Errorf("close file: %w", closeErr))
|
||||
return
|
||||
}
|
||||
if written > maxUploadBytes {
|
||||
os.Remove(storedPath)
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{
|
||||
Status: http.StatusRequestEntityTooLarge,
|
||||
Code: "file_too_large",
|
||||
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
if mimeType == "" {
|
||||
mimeType = mime.TypeByExtension(filepath.Ext(fname))
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
|
||||
cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written)
|
||||
if err != nil {
|
||||
os.Remove(storedPath)
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
infra.HTTPJSONResponse(w, http.StatusCreated, cf)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/cards/{id}/files
|
||||
func handleListCardFiles(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cardID := r.PathValue("id")
|
||||
if cardID == "" {
|
||||
badRequest(w, "card id required")
|
||||
return
|
||||
}
|
||||
files, err := db.ListCardFiles(cardID)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, files)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/files/{id}
|
||||
func handleServeFile(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
badRequest(w, "file id required")
|
||||
return
|
||||
}
|
||||
f, err := db.GetCardFile(id)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
notFound(w, "file not found")
|
||||
return
|
||||
}
|
||||
fh, err := os.Open(f.StoredPath)
|
||||
if err != nil {
|
||||
notFound(w, "file missing on disk")
|
||||
return
|
||||
}
|
||||
defer fh.Close()
|
||||
if f.MIME != "" {
|
||||
w.Header().Set("Content-Type", f.MIME)
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||
disposition := "inline"
|
||||
if !isInlineMIME(f.MIME) {
|
||||
disposition = "attachment"
|
||||
}
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizeHeaderFilename(f.Filename)))
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
_, _ = io.Copy(w, fh)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/files/{id}
|
||||
func handleDeleteCardFile(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
badRequest(w, "file id required")
|
||||
return
|
||||
}
|
||||
n, err := db.SoftDeleteCardFile(id)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
notFound(w, "file not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func isInlineMIME(m string) bool {
|
||||
if m == "" {
|
||||
return false
|
||||
}
|
||||
m = strings.ToLower(m)
|
||||
switch {
|
||||
case strings.HasPrefix(m, "image/"):
|
||||
return true
|
||||
case m == "application/pdf":
|
||||
return true
|
||||
case strings.HasPrefix(m, "text/"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeHeaderFilename(name string) string {
|
||||
name = strings.ReplaceAll(name, `"`, "")
|
||||
name = strings.ReplaceAll(name, "\n", "")
|
||||
name = strings.ReplaceAll(name, "\r", "")
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
type FeatureFlag struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Added string `json:"added,omitempty"`
|
||||
EnabledAt string `json:"enabled_at,omitempty"`
|
||||
}
|
||||
|
||||
type FeatureFlags struct {
|
||||
Flags map[string]FeatureFlag `json:"flags"`
|
||||
}
|
||||
|
||||
func (f FeatureFlags) Enabled(name string) bool {
|
||||
flag, ok := f.Flags[name]
|
||||
return ok && flag.Enabled
|
||||
}
|
||||
|
||||
func loadFeatureFlags(path string) (FeatureFlags, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FeatureFlags{Flags: map[string]FeatureFlag{}}, nil
|
||||
}
|
||||
return FeatureFlags{}, err
|
||||
}
|
||||
var f FeatureFlags
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return FeatureFlags{}, err
|
||||
}
|
||||
if f.Flags == nil {
|
||||
f.Flags = map[string]FeatureFlag{}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// GET /api/flags → { "<name>": true/false, ... }
|
||||
func handleListFlags(flags *FeatureFlags) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
out := make(map[string]bool, len(flags.Flags))
|
||||
for name, fl := range flags.Flags {
|
||||
out[name] = fl.Enabled
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
+285
-9
@@ -1,14 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// Auto-archive: cards en columnas Done con >30 dias se mueven al cajon.
|
||||
// Issue 0092. Lo dispara handleGetBoard de forma "lazy" pero solo cada
|
||||
// archiveSweepEvery minutos para no martillear el UPDATE.
|
||||
const (
|
||||
archiveAfter = 30 * 24 * time.Hour
|
||||
archiveSweepEvery = 30 * time.Minute
|
||||
)
|
||||
|
||||
var lastArchiveSweepNs atomic.Int64
|
||||
|
||||
func maybeAutoArchive(db *DB) {
|
||||
now := time.Now().UnixNano()
|
||||
last := lastArchiveSweepNs.Load()
|
||||
if last != 0 && time.Duration(now-last) < archiveSweepEvery {
|
||||
return
|
||||
}
|
||||
if !lastArchiveSweepNs.CompareAndSwap(last, now) {
|
||||
return
|
||||
}
|
||||
n, err := db.AutoArchiveDoneOlderThan(archiveAfter)
|
||||
if err != nil {
|
||||
log.Printf("auto-archive failed: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("auto-archive moved %d done card(s) older than %s", n, archiveAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func badRequest(w http.ResponseWriter, msg string) {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg})
|
||||
}
|
||||
@@ -24,6 +56,7 @@ func serverError(w http.ResponseWriter, err error) {
|
||||
// GET /api/board → { columns: [...], cards: [...] }
|
||||
func handleGetBoard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
maybeAutoArchive(db)
|
||||
cols, err := db.ListColumns()
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
@@ -67,18 +100,19 @@ func handleUpdateColumn(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Position *int `json:"position"`
|
||||
Location *string `json:"location"`
|
||||
Width *int `json:"width"`
|
||||
WIPLimit *int `json:"wip_limit"`
|
||||
IsDone *bool `json:"is_done"`
|
||||
Name *string `json:"name"`
|
||||
Position *int `json:"position"`
|
||||
Location *string `json:"location"`
|
||||
Width *int `json:"width"`
|
||||
WIPLimit *int `json:"wip_limit"`
|
||||
IsDone *bool `json:"is_done"`
|
||||
MaxTimeMinutes *int `json:"max_time_minutes"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil {
|
||||
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone, MaxTimeMinutes: body.MaxTimeMinutes}); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -280,6 +314,91 @@ func handleMoveCard(db *DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/cards/{id}/messages → [CardMessage, ...]
|
||||
func handleListCardMessages(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
msgs, err := db.ListCardMessages(id)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/messages { body }
|
||||
func handleCreateCardMessage(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Body) == "" {
|
||||
badRequest(w, "body required")
|
||||
return
|
||||
}
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
if actor == "" {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
|
||||
return
|
||||
}
|
||||
m, err := db.CreateCardMessage(id, actor, body.Body)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
notFound(w, err.Error())
|
||||
return
|
||||
}
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusCreated, m)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cid}/messages/{mid}
|
||||
func handleDeleteCardMessage(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
mid := r.PathValue("mid")
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
if actor == "" {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
|
||||
return
|
||||
}
|
||||
if err := db.DeleteCardMessage(mid, actor); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
notFound(w, err.Error())
|
||||
return
|
||||
}
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/duplicate
|
||||
func handleDuplicateCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
c, err := db.DuplicateCard(id, actor)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
notFound(w, "card not found")
|
||||
return
|
||||
}
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusCreated, c)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/cards/{id}/history → [HistoryEntry, ...]
|
||||
func handleCardHistory(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -318,6 +437,145 @@ func handleRestoreCard(db *DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid
|
||||
func handleDailyReport(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"
|
||||
}
|
||||
rep, err := db.DailyReportFor(date, tz)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, rep)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
cards, err := db.ListArchivedCards()
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, cards)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/archive
|
||||
func handleArchiveCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := db.ArchiveCard(id); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/unarchive
|
||||
func handleUnarchiveCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := db.UnarchiveCard(id); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{id}/purge
|
||||
func handlePurgeCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -330,9 +588,10 @@ func handlePurgeCard(db *DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string) []infra.Route {
|
||||
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route {
|
||||
return []infra.Route{
|
||||
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
|
||||
{Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)},
|
||||
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)},
|
||||
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
|
||||
{Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)},
|
||||
{Method: "GET", Path: "/api/me", Handler: handleMe(db)},
|
||||
@@ -348,9 +607,21 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
|
||||
{Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db)},
|
||||
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/duplicate", Handler: handleDuplicateCard(db)},
|
||||
{Method: "GET", Path: "/api/cards/{id}/messages", Handler: handleListCardMessages(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/messages", Handler: handleCreateCardMessage(db)},
|
||||
{Method: "DELETE", Path: "/api/cards/{id}/messages/{mid}", Handler: handleDeleteCardMessage(db)},
|
||||
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
|
||||
{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)},
|
||||
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
|
||||
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
|
||||
{Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)},
|
||||
@@ -358,6 +629,11 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
|
||||
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
|
||||
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
|
||||
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
|
||||
// Issue 0128: adjuntos de archivos.
|
||||
{Method: "POST", Path: "/api/cards/{id}/files", Handler: handleUploadCardFile(db, chatWorkdir)},
|
||||
{Method: "GET", Path: "/api/cards/{id}/files", Handler: handleListCardFiles(db)},
|
||||
{Method: "GET", Path: "/api/files/{id}", Handler: handleServeFile(db)},
|
||||
{Method: "DELETE", Path: "/api/files/{id}", Handler: handleDeleteCardFile(db)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -35,8 +35,17 @@ func main() {
|
||||
port := flags.Int("port", 8095, "HTTP port")
|
||||
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
||||
initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)")
|
||||
flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)")
|
||||
flags.Parse(os.Args[1:])
|
||||
|
||||
featureFlags, err := loadFeatureFlags(*flagsPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load feature flags: %v", err)
|
||||
}
|
||||
for name, fl := range featureFlags.Flags {
|
||||
log.Printf("feature flag %q enabled=%v", name, fl.Enabled)
|
||||
}
|
||||
|
||||
db, err := openDB(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
@@ -54,7 +63,7 @@ func main() {
|
||||
wd := chatWorkdir(*dbPath)
|
||||
logger := newChatLogger(filepath.Join(wd, "chat.log"))
|
||||
log.Printf("chat tool log: %s", logger.path)
|
||||
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken))
|
||||
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags))
|
||||
|
||||
feHandler := frontendHandler()
|
||||
if feHandler != nil {
|
||||
@@ -67,7 +76,7 @@ func main() {
|
||||
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
|
||||
DB: db.conn,
|
||||
CookieName: cookieName,
|
||||
SkipPaths: []string{"/api/auth/", "/api/tool/", "/health", "/assets/", "/index.html"},
|
||||
SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/health", "/assets/", "/index.html"},
|
||||
UserCtxKey: userCtxKey,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Per-card chat messages (human-to-human comments).
|
||||
-- Distinct from card_events (which records system events like title_changed)
|
||||
-- and from /api/chat (which is the board-level LLM chat).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
card_id TEXT NOT NULL,
|
||||
author_id TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_card_messages_card ON card_messages(card_id, created_at);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Issue 0089: tiempo maximo por columna.
|
||||
-- NULL/0 = sin limite. >0 = minutos antes de marcar como vencida la card.
|
||||
-- Cards en columnas con is_done=1 nunca se marcan como vencidas.
|
||||
ALTER TABLE columns ADD COLUMN max_time_minutes INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Issue 0092: archivo automatico para cards en columnas Done con +30 dias.
|
||||
-- archived_at NULL = card activa. archived_at = timestamp ISO = card en cajon.
|
||||
-- Independiente de deleted_at (papelera): una card puede estar archived sin
|
||||
-- haber sido borrada. Restaurar = vuelve a su columna original sin deletear.
|
||||
ALTER TABLE cards ADD COLUMN archived_at TEXT;
|
||||
@@ -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,16 @@
|
||||
-- Issue 0128: adjuntos de archivos por card.
|
||||
CREATE TABLE IF NOT EXISTS card_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
card_id TEXT NOT NULL,
|
||||
uploader_id TEXT NOT NULL DEFAULT '',
|
||||
filename TEXT NOT NULL,
|
||||
mime TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
stored_path TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'upload',
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_card_files_card_active
|
||||
ON card_files(card_id, deleted_at);
|
||||
@@ -0,0 +1,588 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DailyReport — agregaciones por dia natural (TZ del servidor a menos que el
|
||||
// caller pase una TZ explicita). Issue 0093.
|
||||
type DailyReport struct {
|
||||
Date string `json:"date"`
|
||||
TZ string `json:"tz"`
|
||||
StartTs string `json:"start_ts"`
|
||||
EndTs string `json:"end_ts"`
|
||||
|
||||
KPIs DailyKPIs `json:"kpis"`
|
||||
|
||||
TopAssigneesDone []UserCount `json:"top_assignees_done"`
|
||||
TopAssigneesCreated []UserCount `json:"top_assignees_created"`
|
||||
TopRequestersAdded []NamedCount `json:"top_requesters_added"`
|
||||
TopRequestersDone []NamedCount `json:"top_requesters_done"`
|
||||
DoneCards []DoneCard `json:"done_cards"`
|
||||
ReopenedCards []ReopenedEntry `json:"reopened_cards"`
|
||||
StaleCards StaleBuckets `json:"stale_cards"`
|
||||
LeadTime LeadTimeStats `json:"lead_time"`
|
||||
HourlyMoves [24]int `json:"hourly_moves"`
|
||||
Deadlines DeadlineSummary `json:"deadlines"`
|
||||
TagsDone []NamedCount `json:"tags_done"`
|
||||
ArchivedToday int `json:"archived_today"`
|
||||
}
|
||||
|
||||
type DailyKPIs struct {
|
||||
Done int `json:"done"`
|
||||
Created int `json:"created"`
|
||||
Moves int `json:"moves"`
|
||||
BlockedMs int64 `json:"blocked_ms"`
|
||||
DeadlinesMet int `json:"deadlines_met"`
|
||||
DeadlinesMissed int `json:"deadlines_missed"`
|
||||
Reopened int `json:"reopened"`
|
||||
ArchivedAuto int `json:"archived_auto"`
|
||||
ArchivedManual int `json:"archived_manual"`
|
||||
}
|
||||
|
||||
type UserCount struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type NamedCount struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type DoneCard struct {
|
||||
ID string `json:"id"`
|
||||
SeqNum int `json:"seq_num"`
|
||||
Title string `json:"title"`
|
||||
Requester string `json:"requester"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
AssigneeName *string `json:"assignee_name"`
|
||||
Tags []string `json:"tags"`
|
||||
ColumnID string `json:"column_id"`
|
||||
ColumnName string `json:"column_name"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LeadTimeMs int64 `json:"lead_time_ms"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type ReopenedEntry struct {
|
||||
CardID string `json:"card_id"`
|
||||
Title string `json:"title"`
|
||||
SeqNum int `json:"seq_num"`
|
||||
FromColumn string `json:"from_column"`
|
||||
ToColumn string `json:"to_column"`
|
||||
Ts string `json:"ts"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
ActorName *string `json:"actor_name"`
|
||||
}
|
||||
|
||||
type StaleEntry struct {
|
||||
CardID string `json:"card_id"`
|
||||
Title string `json:"title"`
|
||||
SeqNum int `json:"seq_num"`
|
||||
ColumnID string `json:"column_id"`
|
||||
ColumnName string `json:"column_name"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
Days int `json:"days"`
|
||||
}
|
||||
|
||||
type StaleBuckets struct {
|
||||
D7 []StaleEntry `json:"d7"`
|
||||
D14 []StaleEntry `json:"d14"`
|
||||
D30 []StaleEntry `json:"d30"`
|
||||
}
|
||||
|
||||
type LeadTimeStats struct {
|
||||
AvgMs int64 `json:"avg_ms"`
|
||||
P50Ms int64 `json:"p50_ms"`
|
||||
P95Ms int64 `json:"p95_ms"`
|
||||
Samples int `json:"samples"`
|
||||
}
|
||||
|
||||
type DeadlineSummary struct {
|
||||
Met int `json:"met"`
|
||||
Missed int `json:"missed"`
|
||||
List []DeadlineMissEntry `json:"list"`
|
||||
}
|
||||
|
||||
type DeadlineMissEntry struct {
|
||||
CardID string `json:"card_id"`
|
||||
Title string `json:"title"`
|
||||
SeqNum int `json:"seq_num"`
|
||||
Deadline string `json:"deadline"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
LateMs int64 `json:"late_ms"`
|
||||
}
|
||||
|
||||
// DailyReportFor computes the report for the local day specified by date+tz.
|
||||
func (db *DB) DailyReportFor(date, tz string) (*DailyReport, error) {
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
tz = "UTC"
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02", date, loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid date %q: %w", date, err)
|
||||
}
|
||||
start := t
|
||||
end := t.Add(24 * time.Hour)
|
||||
startUTC := start.UTC().Format(time.RFC3339Nano)
|
||||
endUTC := end.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
r := &DailyReport{
|
||||
Date: date,
|
||||
TZ: tz,
|
||||
StartTs: startUTC,
|
||||
EndTs: endUTC,
|
||||
StaleCards: StaleBuckets{
|
||||
D7: []StaleEntry{},
|
||||
D14: []StaleEntry{},
|
||||
D30: []StaleEntry{},
|
||||
},
|
||||
Deadlines: DeadlineSummary{List: []DeadlineMissEntry{}},
|
||||
DoneCards: []DoneCard{},
|
||||
ReopenedCards: []ReopenedEntry{},
|
||||
TopAssigneesDone: []UserCount{},
|
||||
TopAssigneesCreated: []UserCount{},
|
||||
TopRequestersAdded: []NamedCount{},
|
||||
TopRequestersDone: []NamedCount{},
|
||||
TagsDone: []NamedCount{},
|
||||
}
|
||||
|
||||
users, err := db.userNameMap()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doneColIDs, doneColNames, err := db.doneColumnIDs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allColNames, err := db.allColumnNames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Done cards ----------------------------------------------------------
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.seq_num, c.title, c.requester, c.assignee_id, c.tags, c.column_id, c.completed_at, c.created_at, c.color, c.deadline
|
||||
FROM cards c
|
||||
WHERE c.completed_at IS NOT NULL
|
||||
AND c.completed_at >= ? AND c.completed_at < ?
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY c.completed_at DESC
|
||||
`, startUTC, endUTC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leadSamples := []int64{}
|
||||
assigneeDoneCount := map[string]int{}
|
||||
requesterDoneCount := map[string]int{}
|
||||
tagCount := map[string]int{}
|
||||
for rows.Next() {
|
||||
var c DoneCard
|
||||
var assignee, deadline sql.NullString
|
||||
var tagsJSON string
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Title, &c.Requester, &assignee, &tagsJSON, &c.ColumnID, &c.CompletedAt, &c.CreatedAt, &c.Color, &deadline); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
c.Tags = parseTags(tagsJSON)
|
||||
if assignee.Valid && assignee.String != "" {
|
||||
a := assignee.String
|
||||
c.AssigneeID = &a
|
||||
if n, ok := users[a]; ok {
|
||||
nm := n
|
||||
c.AssigneeName = &nm
|
||||
}
|
||||
assigneeDoneCount[a]++
|
||||
}
|
||||
if c.Requester != "" {
|
||||
requesterDoneCount[c.Requester]++
|
||||
}
|
||||
for _, tag := range c.Tags {
|
||||
tagCount[tag]++
|
||||
}
|
||||
c.ColumnName = allColNames[c.ColumnID]
|
||||
// Lead time created -> completed.
|
||||
if ct, err := time.Parse(time.RFC3339Nano, c.CreatedAt); err == nil {
|
||||
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
|
||||
c.LeadTimeMs = compt.Sub(ct).Milliseconds()
|
||||
if c.LeadTimeMs >= 0 {
|
||||
leadSamples = append(leadSamples, c.LeadTimeMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deadlines.
|
||||
if deadline.Valid && deadline.String != "" {
|
||||
if dlt, err := time.Parse(time.RFC3339Nano, deadline.String); err == nil {
|
||||
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
|
||||
if compt.After(dlt) {
|
||||
r.Deadlines.Missed++
|
||||
r.Deadlines.List = append(r.Deadlines.List, DeadlineMissEntry{
|
||||
CardID: c.ID, Title: c.Title, SeqNum: c.SeqNum,
|
||||
Deadline: deadline.String, CompletedAt: c.CompletedAt,
|
||||
LateMs: compt.Sub(dlt).Milliseconds(),
|
||||
})
|
||||
} else {
|
||||
r.Deadlines.Met++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.DoneCards = append(r.DoneCards, c)
|
||||
}
|
||||
rows.Close()
|
||||
r.KPIs.Done = len(r.DoneCards)
|
||||
r.LeadTime = computeLeadTime(leadSamples)
|
||||
r.TopAssigneesDone = topUsersFromCount(assigneeDoneCount, users, 5)
|
||||
r.TopRequestersDone = topNamedFromCount(requesterDoneCount, 5)
|
||||
r.TagsDone = topNamedFromCount(tagCount, 10)
|
||||
|
||||
_ = doneColIDs
|
||||
_ = doneColNames
|
||||
|
||||
// --- Created (card_events kind=created) ----------------------------------
|
||||
rows, err = db.conn.Query(`
|
||||
SELECT e.card_id, e.actor_id, COALESCE(c.requester, '')
|
||||
FROM card_events e
|
||||
LEFT JOIN cards c ON c.id = e.card_id
|
||||
WHERE e.kind = 'created'
|
||||
AND e.created_at >= ? AND e.created_at < ?
|
||||
`, startUTC, endUTC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assigneeCreatedCount := map[string]int{}
|
||||
requesterAddedCount := map[string]int{}
|
||||
createdN := 0
|
||||
for rows.Next() {
|
||||
var cardID string
|
||||
var actor sql.NullString
|
||||
var requester string
|
||||
if err := rows.Scan(&cardID, &actor, &requester); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
createdN++
|
||||
if actor.Valid && actor.String != "" {
|
||||
assigneeCreatedCount[actor.String]++
|
||||
}
|
||||
if requester != "" {
|
||||
requesterAddedCount[requester]++
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
r.KPIs.Created = createdN
|
||||
r.TopAssigneesCreated = topUsersFromCount(assigneeCreatedCount, users, 5)
|
||||
r.TopRequestersAdded = topNamedFromCount(requesterAddedCount, 5)
|
||||
|
||||
// --- Moves del dia + hourly + reopened -----------------------------------
|
||||
// Reopened = card que el dia X entro a una columna NO done HABIENDO estado
|
||||
// en una done previa. Detectable comparando entered_at del dia con la
|
||||
// entrada previa (mismo card_id).
|
||||
rows, err = db.conn.Query(`
|
||||
SELECT h.card_id, h.column_id, h.entered_at, h.actor_id, c.title, c.seq_num
|
||||
FROM card_column_history h
|
||||
JOIN cards c ON c.id = h.card_id
|
||||
WHERE h.entered_at >= ? AND h.entered_at < ?
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY h.entered_at ASC
|
||||
`, startUTC, endUTC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hourly := [24]int{}
|
||||
type moveRow struct {
|
||||
cardID, columnID, enteredAt, title string
|
||||
actor sql.NullString
|
||||
seqNum int
|
||||
}
|
||||
var moves []moveRow
|
||||
for rows.Next() {
|
||||
var m moveRow
|
||||
if err := rows.Scan(&m.cardID, &m.columnID, &m.enteredAt, &m.actor, &m.title, &m.seqNum); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
moves = append(moves, m)
|
||||
if ts, err := time.Parse(time.RFC3339Nano, m.enteredAt); err == nil {
|
||||
h := ts.In(loc).Hour()
|
||||
if h >= 0 && h < 24 {
|
||||
hourly[h]++
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
r.HourlyMoves = hourly
|
||||
r.KPIs.Moves = len(moves)
|
||||
for _, m := range moves {
|
||||
// Solo interesa si la columna actual NO es done.
|
||||
isDone := doneColIDs[m.columnID]
|
||||
if isDone {
|
||||
continue
|
||||
}
|
||||
// Hubo entrada previa en una columna done?
|
||||
prevWasDone, prevColID := db.previousColumnWasDone(m.cardID, m.enteredAt, doneColIDs)
|
||||
if prevWasDone {
|
||||
entry := ReopenedEntry{
|
||||
CardID: m.cardID,
|
||||
Title: m.title,
|
||||
SeqNum: m.seqNum,
|
||||
FromColumn: allColNames[prevColID],
|
||||
ToColumn: allColNames[m.columnID],
|
||||
Ts: m.enteredAt,
|
||||
}
|
||||
if m.actor.Valid && m.actor.String != "" {
|
||||
a := m.actor.String
|
||||
entry.ActorID = &a
|
||||
if n, ok := users[a]; ok {
|
||||
nm := n
|
||||
entry.ActorName = &nm
|
||||
}
|
||||
}
|
||||
r.ReopenedCards = append(r.ReopenedCards, entry)
|
||||
}
|
||||
}
|
||||
r.KPIs.Reopened = len(r.ReopenedCards)
|
||||
|
||||
// --- Stale buckets (cards activas hoy con N dias en misma columna) -------
|
||||
r.StaleCards = db.staleBucketsAt(end, doneColIDs, allColNames)
|
||||
|
||||
// --- Bloqueado ms (lock_history que solapa con el dia) -------------------
|
||||
r.KPIs.BlockedMs = db.blockedMsInRange(startUTC, endUTC)
|
||||
|
||||
// --- Archivadas hoy ------------------------------------------------------
|
||||
var autoN, manualN int
|
||||
if err := db.conn.QueryRow(`
|
||||
SELECT COUNT(*) FROM cards
|
||||
WHERE archived_at IS NOT NULL
|
||||
AND archived_at >= ? AND archived_at < ?
|
||||
AND deleted_at IS NULL
|
||||
`, startUTC, endUTC).Scan(&autoN); err == nil {
|
||||
// Heuristica: auto vs manual no se diferencia (no log explicito). Si
|
||||
// la columna actual es is_done, asumimos auto. Mejor que nada.
|
||||
_ = manualN
|
||||
r.KPIs.ArchivedAuto = autoN
|
||||
r.ArchivedToday = autoN
|
||||
}
|
||||
r.KPIs.DeadlinesMet = r.Deadlines.Met
|
||||
r.KPIs.DeadlinesMissed = r.Deadlines.Missed
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (db *DB) userNameMap() (map[string]string, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, COALESCE(display_name,''), username FROM users`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var id, dn, un string
|
||||
if err := rows.Scan(&id, &dn, &un); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dn != "" {
|
||||
out[id] = dn
|
||||
} else {
|
||||
out[id] = un
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (db *DB) doneColumnIDs() (map[string]bool, map[string]string, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, name FROM columns WHERE is_done=1`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := map[string]bool{}
|
||||
names := map[string]string{}
|
||||
for rows.Next() {
|
||||
var id, n string
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ids[id] = true
|
||||
names[id] = n
|
||||
}
|
||||
return ids, names, nil
|
||||
}
|
||||
|
||||
func (db *DB) allColumnNames() (map[string]string, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, name FROM columns`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var id, n string
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// previousColumnWasDone returns whether the entry of `cardID` immediately
|
||||
// before `enteredAt` was in a done column.
|
||||
func (db *DB) previousColumnWasDone(cardID, enteredAt string, doneColIDs map[string]bool) (bool, string) {
|
||||
var colID string
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT column_id FROM card_column_history
|
||||
WHERE card_id=? AND entered_at < ?
|
||||
ORDER BY entered_at DESC
|
||||
LIMIT 1
|
||||
`, cardID, enteredAt).Scan(&colID)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return doneColIDs[colID], colID
|
||||
}
|
||||
|
||||
func (db *DB) staleBucketsAt(asOf time.Time, doneColIDs map[string]bool, colNames map[string]string) StaleBuckets {
|
||||
out := StaleBuckets{D7: []StaleEntry{}, D14: []StaleEntry{}, D30: []StaleEntry{}}
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT h.card_id, c.title, c.seq_num, h.column_id, h.entered_at
|
||||
FROM card_column_history h
|
||||
JOIN cards c ON c.id = h.card_id
|
||||
WHERE h.exited_at IS NULL
|
||||
AND c.deleted_at IS NULL
|
||||
AND c.archived_at IS NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var s StaleEntry
|
||||
if err := rows.Scan(&s.CardID, &s.Title, &s.SeqNum, &s.ColumnID, &s.EnteredAt); err != nil {
|
||||
continue
|
||||
}
|
||||
// Skip done columns (esos se auto-archivan; no son "estancados" activos).
|
||||
if doneColIDs[s.ColumnID] {
|
||||
continue
|
||||
}
|
||||
entered, err := time.Parse(time.RFC3339Nano, s.EnteredAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
days := int(asOf.Sub(entered).Hours() / 24)
|
||||
if days < 7 {
|
||||
continue
|
||||
}
|
||||
s.Days = days
|
||||
s.ColumnName = colNames[s.ColumnID]
|
||||
switch {
|
||||
case days >= 30:
|
||||
out.D30 = append(out.D30, s)
|
||||
case days >= 14:
|
||||
out.D14 = append(out.D14, s)
|
||||
default:
|
||||
out.D7 = append(out.D7, s)
|
||||
}
|
||||
}
|
||||
sort.Slice(out.D7, func(i, j int) bool { return out.D7[i].Days > out.D7[j].Days })
|
||||
sort.Slice(out.D14, func(i, j int) bool { return out.D14[i].Days > out.D14[j].Days })
|
||||
sort.Slice(out.D30, func(i, j int) bool { return out.D30[i].Days > out.D30[j].Days })
|
||||
return out
|
||||
}
|
||||
|
||||
func (db *DB) blockedMsInRange(startUTC, endUTC string) int64 {
|
||||
// Para cada periodo de lock, contar la interseccion con [start,end].
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT locked_at, COALESCE(unlocked_at, ?) FROM card_lock_history
|
||||
WHERE locked_at < ? AND COALESCE(unlocked_at, ?) > ?
|
||||
`, endUTC, endUTC, endUTC, startUTC)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
start, _ := time.Parse(time.RFC3339Nano, startUTC)
|
||||
end, _ := time.Parse(time.RFC3339Nano, endUTC)
|
||||
var total time.Duration
|
||||
for rows.Next() {
|
||||
var lstr, ustr string
|
||||
if err := rows.Scan(&lstr, &ustr); err != nil {
|
||||
continue
|
||||
}
|
||||
l, err := time.Parse(time.RFC3339Nano, lstr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
u, err := time.Parse(time.RFC3339Nano, ustr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if l.Before(start) {
|
||||
l = start
|
||||
}
|
||||
if u.After(end) {
|
||||
u = end
|
||||
}
|
||||
if u.After(l) {
|
||||
total += u.Sub(l)
|
||||
}
|
||||
}
|
||||
return total.Milliseconds()
|
||||
}
|
||||
|
||||
func topUsersFromCount(m map[string]int, names map[string]string, k int) []UserCount {
|
||||
out := make([]UserCount, 0, len(m))
|
||||
for id, n := range m {
|
||||
out = append(out, UserCount{UserID: id, Name: names[id], Count: n})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
||||
if len(out) > k {
|
||||
out = out[:k]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func topNamedFromCount(m map[string]int, k int) []NamedCount {
|
||||
out := make([]NamedCount, 0, len(m))
|
||||
for n, c := range m {
|
||||
out = append(out, NamedCount{Name: n, Count: c})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
||||
if len(out) > k {
|
||||
out = out[:k]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func computeLeadTime(samples []int64) LeadTimeStats {
|
||||
if len(samples) == 0 {
|
||||
return LeadTimeStats{}
|
||||
}
|
||||
sorted := make([]int64, len(samples))
|
||||
copy(sorted, samples)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||||
var sum int64
|
||||
for _, v := range sorted {
|
||||
sum += v
|
||||
}
|
||||
p := func(q float64) int64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := int(float64(len(sorted)-1) * q)
|
||||
return sorted[idx]
|
||||
}
|
||||
return LeadTimeStats{
|
||||
AvgMs: sum / int64(len(sorted)),
|
||||
P50Ms: p(0.5),
|
||||
P95Ms: p(0.95),
|
||||
Samples: len(sorted),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"flags": {
|
||||
"registration-enabled": {
|
||||
"enabled": false,
|
||||
"issue": null,
|
||||
"description": "Allows new users to register via POST /api/auth/register and the LoginPage register toggle.",
|
||||
"added": "2026-05-12",
|
||||
"enabled_at": null
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# Issue 0128 — smoke test of card file attachments.
|
||||
# Builds kanban (assumes ./kanban present or builds it), boots on ephemeral port,
|
||||
# exercises POST upload, GET list, GET serve, DELETE, GET list-after-delete.
|
||||
# Exits 0 on success, non-zero on any failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PORT=${PORT:-18095}
|
||||
BASE="http://127.0.0.1:${PORT}"
|
||||
DB=$(mktemp /tmp/kanban_files_smoke.XXXXXX.db)
|
||||
COOKIE=$(mktemp /tmp/kanban_files_smoke.cookie.XXXXXX)
|
||||
UPLOAD_DIR=$(dirname "$DB")/uploads
|
||||
PNG=$(mktemp /tmp/kanban_files_smoke.XXXXXX.png)
|
||||
PID_FILE=$(mktemp /tmp/kanban_files_smoke.pid.XXXXXX)
|
||||
|
||||
cleanup() {
|
||||
if [ -s "$PID_FILE" ]; then
|
||||
kill "$(cat "$PID_FILE")" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$DB" "$DB-shm" "$DB-wal" "$COOKIE" "$PNG" "$PID_FILE"
|
||||
rm -rf "$UPLOAD_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Build if missing.
|
||||
if [ ! -x ./kanban ]; then
|
||||
echo "[smoke] building kanban binary..."
|
||||
(cd backend && CGO_ENABLED=1 go build -tags fts5 -o ../kanban .)
|
||||
fi
|
||||
|
||||
# Boot.
|
||||
./kanban --port "$PORT" --db "$DB" --initial-admin admin:adminpw \
|
||||
> /tmp/kanban_files_smoke.log 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
|
||||
# Wait for /api/board.
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -sf -o /dev/null "$BASE/api/board"; then break; fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# Login.
|
||||
curl -sf -c "$COOKIE" -X POST "$BASE/api/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"admin","password":"adminpw"}' > /dev/null
|
||||
|
||||
# Column + card.
|
||||
COL=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/columns" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"To Do"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||
CARD=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"column_id\":\"$COL\",\"title\":\"smoke\",\"requester\":\"r\"}" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||
|
||||
# Minimal PNG.
|
||||
printf '\x89PNG\r\n\x1a\n' > "$PNG"
|
||||
|
||||
# Upload.
|
||||
UP=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards/$CARD/files" \
|
||||
-F "file=@$PNG;type=image/png")
|
||||
FID=$(echo "$UP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||
[ -n "$FID" ] || { echo "[smoke] upload missing id"; exit 1; }
|
||||
|
||||
# List active.
|
||||
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
|
||||
[ "$N" = "1" ] || { echo "[smoke] expected 1 file, got $N"; exit 1; }
|
||||
|
||||
# Serve.
|
||||
CT=$(curl -sf -b "$COOKIE" -I "$BASE/api/files/$FID" | awk '/^[Cc]ontent-[Tt]ype/ {print $2}' | tr -d '\r\n')
|
||||
echo "$CT" | grep -q image/png || { echo "[smoke] wrong content-type: $CT"; exit 1; }
|
||||
|
||||
# Delete.
|
||||
HTTP=$(curl -sb "$COOKIE" -X DELETE -o /dev/null -w "%{http_code}" "$BASE/api/files/$FID")
|
||||
[ "$HTTP" = "204" ] || { echo "[smoke] expected 204 on delete, got $HTTP"; exit 1; }
|
||||
|
||||
# List after delete.
|
||||
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
|
||||
[ "$N" = "0" ] || { echo "[smoke] expected 0 after delete, got $N"; exit 1; }
|
||||
|
||||
echo "[smoke] OK"
|
||||
@@ -0,0 +1,57 @@
|
||||
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 0092: cards en columnas DONE con >30 dias se mueven al cajon "Hecho".
|
||||
* Test cubre: archivar via menu manual, listar archivo, des-archivar.
|
||||
*/
|
||||
test.describe("kanban archive (issue 0092)", () => {
|
||||
test("archiva una done card via menu y la des-archiva desde el cajon", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
|
||||
// Pick a card from a done column (queried directly from the API).
|
||||
const board = await page.request.get("/api/board").then((r) => r.json());
|
||||
const doneCol = (board.columns as Array<{ id: string; is_done: boolean }>).find((c) => c.is_done);
|
||||
if (!doneCol) test.skip(true, "no done column in board");
|
||||
const cardInDone = (board.cards as Array<{ id: string; column_id: string }>).find(
|
||||
(c) => c.column_id === doneCol!.id
|
||||
);
|
||||
if (!cardInDone) test.skip(true, "no card in a done column");
|
||||
const targetId = cardInDone!.id;
|
||||
|
||||
const cardSel = `[data-card-id="${targetId}"]`;
|
||||
const card = page.locator(cardSel).first();
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
// Open the per-card menu. Use dispatchEvent so we ignore viewport scroll constraints.
|
||||
await card.locator('button[aria-label="Acciones"]').dispatchEvent("click");
|
||||
const archiveItem = page.getByRole("menuitem", { name: /Archivar/i }).first();
|
||||
await expect(archiveItem).toBeVisible();
|
||||
await archiveItem.click();
|
||||
|
||||
// Card disappears from board.
|
||||
await expect(card).toHaveCount(0, { timeout: 5000 });
|
||||
|
||||
// Archive drawer toggle visible + opens.
|
||||
const archiveToggle = page.locator('[data-test="archive-toggle"]');
|
||||
await archiveToggle.scrollIntoViewIfNeeded();
|
||||
await archiveToggle.dispatchEvent("click");
|
||||
|
||||
// Archived row appears in the drawer.
|
||||
const archivedRow = page.locator(`[data-archived-card-id="${targetId}"]`);
|
||||
await expect(archivedRow).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Restore from archive (force click — sidebar can be scrollable / off-viewport).
|
||||
await archivedRow.locator("button").first().dispatchEvent("click");
|
||||
|
||||
// Back on board.
|
||||
await expect(page.locator(cardSel).first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// No longer in archive.
|
||||
await expect(archivedRow).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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 0093: reporte diario al pulsar numero del dia en el calendario.
|
||||
* Verifica: endpoint responde, calendario abre modal con titulo "Reporte diario",
|
||||
* KPIs visibles, tabla de hechas presente.
|
||||
*/
|
||||
test.describe("daily report (issue 0093)", () => {
|
||||
test("endpoint /api/reports/daily devuelve estructura esperada", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const res = await page.request.get(`/api/reports/daily?date=${today}`);
|
||||
expect(res.status()).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveProperty("kpis");
|
||||
expect(data).toHaveProperty("done_cards");
|
||||
expect(data).toHaveProperty("hourly_moves");
|
||||
expect(Array.isArray(data.hourly_moves)).toBe(true);
|
||||
expect(data.hourly_moves.length).toBe(24);
|
||||
expect(data).toHaveProperty("stale_cards");
|
||||
expect(data.stale_cards).toHaveProperty("d7");
|
||||
expect(data.stale_cards).toHaveProperty("d14");
|
||||
expect(data.stale_cards).toHaveProperty("d30");
|
||||
});
|
||||
|
||||
test("click en numero del dia del calendario abre modal del reporte", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
|
||||
// Switch to Calendario tab.
|
||||
await page.getByRole("tab", { name: /Calendario/i }).click();
|
||||
|
||||
// Wait until the calendar cells render.
|
||||
await page.waitForSelector('[data-test^="calendar-day-"]', { timeout: 5000 });
|
||||
|
||||
// Use yesterday — the seeded DB has activity there.
|
||||
const yesterday = new Date(Date.now() - 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
const cellBtn = page.locator(`[data-test="calendar-day-${yesterday}"]`);
|
||||
if ((await cellBtn.count()) === 0) {
|
||||
// Fallback: click any visible day.
|
||||
await page.locator('[data-test^="calendar-day-"]').first().dispatchEvent("click");
|
||||
} else {
|
||||
await cellBtn.dispatchEvent("click");
|
||||
}
|
||||
|
||||
// Modal opens.
|
||||
const modal = page.locator('[role="dialog"]').filter({ hasText: /Reporte diario/i });
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
await expect(modal.getByText("Hechas", { exact: false }).first()).toBeVisible();
|
||||
await expect(modal.getByText("Movimientos", { exact: false }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
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 followup: drag lag.
|
||||
* Capture per-frame durations via requestAnimationFrame while a card is dragged
|
||||
* across reorder positions inside a populated column. Asserts p50 < 32ms and
|
||||
* max < 120ms so a regression visibly slower than ~30 fps fails the suite.
|
||||
*
|
||||
* Read each measurement printed to console to track changes over time.
|
||||
*/
|
||||
test.describe("kanban drag perf", () => {
|
||||
test("reorder inside HACIENDO does not drop below 30 fps", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
|
||||
// Wait until at least one card is mounted.
|
||||
await page.waitForSelector("[data-card-id]", { timeout: 10_000 });
|
||||
|
||||
// Inject a tiny frame-time recorder.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
_frames: number[];
|
||||
_capturing: boolean;
|
||||
_startCapture: () => void;
|
||||
_stopCapture: () => number[];
|
||||
};
|
||||
w._frames = [];
|
||||
w._capturing = false;
|
||||
let prev = 0;
|
||||
const tick = (t: number) => {
|
||||
if (!w._capturing) return;
|
||||
if (prev !== 0) w._frames.push(t - prev);
|
||||
prev = t;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
w._startCapture = () => {
|
||||
w._frames = [];
|
||||
w._capturing = true;
|
||||
prev = 0;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
w._stopCapture = () => {
|
||||
w._capturing = false;
|
||||
return w._frames.slice();
|
||||
};
|
||||
});
|
||||
|
||||
// Pick the column with the MOST cards (worst-case reorder cost).
|
||||
const target = await page.evaluate(() => {
|
||||
const cols = Array.from(document.querySelectorAll<HTMLElement>("[data-column-id]"));
|
||||
let best: { columnId: string | null; cardIds: string[] } | null = null;
|
||||
for (const col of cols) {
|
||||
const cards = Array.from(col.querySelectorAll<HTMLElement>("[data-card-id]"));
|
||||
if (!best || cards.length > best.cardIds.length) {
|
||||
best = {
|
||||
columnId: col.getAttribute("data-column-id"),
|
||||
cardIds: cards
|
||||
.map((c) => c.getAttribute("data-card-id"))
|
||||
.filter((x): x is string => x !== null),
|
||||
};
|
||||
}
|
||||
}
|
||||
return best && best.cardIds.length >= 3 ? best : null;
|
||||
});
|
||||
if (!target) test.skip(true, "need a column with >= 3 cards");
|
||||
|
||||
const firstId = target!.cardIds[0]!;
|
||||
const lastId = target!.cardIds[target!.cardIds.length - 1]!;
|
||||
|
||||
const source = page.locator(`[data-card-id="${firstId}"]`);
|
||||
const targetEl = page.locator(`[data-card-id="${lastId}"]`);
|
||||
const sb = await source.boundingBox();
|
||||
const tb = await targetEl.boundingBox();
|
||||
if (!sb || !tb) throw new Error("no bounding box");
|
||||
|
||||
const sx = sb.x + sb.width / 2;
|
||||
const sy = sb.y + sb.height / 2;
|
||||
const tx = tb.x + tb.width / 2;
|
||||
const ty = tb.y + tb.height / 2;
|
||||
|
||||
await page.mouse.move(sx, sy);
|
||||
await page.mouse.down();
|
||||
// dnd-kit pointer-sensor activation threshold: 8px; nudge horizontally first.
|
||||
await page.mouse.move(sx + 12, sy, { steps: 2 });
|
||||
|
||||
// Probe how many KanbanCard renders happen during the drag.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { _cardRenderCount: number; _cardRenderProbe: boolean };
|
||||
w._cardRenderCount = 0;
|
||||
w._cardRenderProbe = true;
|
||||
});
|
||||
|
||||
await page.evaluate(() => (window as unknown as { _startCapture: () => void })._startCapture());
|
||||
|
||||
// Move slowly across the column to trigger reorder swaps; steps=40 gives
|
||||
// dnd-kit time to recompute positions.
|
||||
await page.mouse.move(tx, ty, { steps: 40 });
|
||||
// Hover so any final layout animation captures into the trace.
|
||||
await page.waitForTimeout(120);
|
||||
|
||||
const frames = (await page.evaluate(() =>
|
||||
(window as unknown as { _stopCapture: () => number[] })._stopCapture()
|
||||
)) as number[];
|
||||
const renderCount = (await page.evaluate(
|
||||
() => (window as unknown as { _cardRenderCount: number })._cardRenderCount
|
||||
)) as number;
|
||||
const bodyCount = (await page.evaluate(
|
||||
() => (window as unknown as { _cardBodyRenderCount: number })._cardBodyRenderCount || 0
|
||||
)) as number;
|
||||
console.log(`drag-perf wrapper-renders=${renderCount} body-renders=${bodyCount} over ${frames.length} frames`);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
const sorted = [...frames].sort((a, b) => a - b);
|
||||
const p50 = sorted[Math.floor(sorted.length * 0.5)] ?? 0;
|
||||
const p95 = sorted[Math.floor(sorted.length * 0.95)] ?? 0;
|
||||
const max = sorted.length > 0 ? sorted[sorted.length - 1] : 0;
|
||||
const avg = sorted.reduce((a, b) => a + b, 0) / Math.max(1, sorted.length);
|
||||
|
||||
console.log(
|
||||
`drag-perf frames=${sorted.length} avg=${avg.toFixed(1)}ms p50=${p50.toFixed(1)}ms p95=${p95.toFixed(1)}ms max=${max.toFixed(1)}ms`
|
||||
);
|
||||
|
||||
// Save a stable artefact so we can compare runs.
|
||||
test.info().annotations.push({
|
||||
type: "drag-perf",
|
||||
description: JSON.stringify({ count: sorted.length, avg, p50, p95, max }),
|
||||
});
|
||||
|
||||
// Thresholds tuned tras separar el body memoizado (issue dnd-lag-fix
|
||||
// followup). Pre-fix: p95=83ms / max=117ms. Post-fix: p95=33 / max=33.
|
||||
expect(p50).toBeLessThan(20);
|
||||
expect(p95).toBeLessThan(50);
|
||||
expect(max).toBeLessThan(60);
|
||||
// Body memoizado: durante el drag no debe re-renderizar.
|
||||
expect(bodyCount).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
|
||||
import { pw_keyboard_sequence } from "../../../../frontend/functions/browser/pw_keyboard_sequence";
|
||||
import { pw_wait_predicate } from "../../../../frontend/functions/browser/pw_wait_predicate";
|
||||
|
||||
const USER = process.env.KANBAN_USER || "egutierrez";
|
||||
const PWD = process.env.KANBAN_PWD || "egutierrez";
|
||||
|
||||
test.describe("Issue 0088 — requester input vacio + nav teclado", () => {
|
||||
test("input solicitante entra vacio y ArrowDown+Enter no cierra modal", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
|
||||
// Abrir Nueva tarjeta del primer "+" disponible en alguna columna del board.
|
||||
const addBtn = page.locator('[data-test="add-card"]').first();
|
||||
await addBtn.dispatchEvent("click");
|
||||
|
||||
// Modal de Mantine abierto.
|
||||
const dialog = page.locator("[role=dialog]");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Solicitante vacio.
|
||||
const requester = dialog.locator('input[data-field="requester"]');
|
||||
await expect(requester).toHaveValue("");
|
||||
|
||||
// Necesario titulo para que un eventual submit no se descarte por el guard.
|
||||
await dialog.locator("textarea").first().fill("e2e test card");
|
||||
|
||||
// Tipear + navegar dropdown + Enter.
|
||||
await requester.focus();
|
||||
await pw_keyboard_sequence(page, [
|
||||
{ kind: "type", text: "a", delayMs: 50 },
|
||||
{ kind: "wait", ms: 300 },
|
||||
{ kind: "press", key: "ArrowDown" },
|
||||
{ kind: "press", key: "Enter" },
|
||||
]);
|
||||
|
||||
// Modal sigue visible: Enter no ha cerrado el form.
|
||||
await page.waitForTimeout(300);
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Cancelar para limpiar estado.
|
||||
await dialog.locator("button:has-text('Cancelar')").click();
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test("Enter en requester con dropdown cerrado NO cierra modal", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
|
||||
const addBtn = page.locator('[data-test="add-card"]').first();
|
||||
await addBtn.dispatchEvent("click");
|
||||
const dialog = page.locator("[role=dialog]");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.locator("textarea").first().fill("e2e test card 2");
|
||||
const requester = dialog.locator('input[data-field="requester"]');
|
||||
await requester.focus();
|
||||
// Press Escape para asegurar dropdown cerrado, luego Enter.
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(200);
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.locator("button:has-text('Cancelar')").click();
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
|
||||
import { pw_drag_drop } from "../../../../frontend/functions/browser/pw_drag_drop";
|
||||
import { pw_wait_predicate } from "../../../../frontend/functions/browser/pw_wait_predicate";
|
||||
|
||||
const USER = process.env.KANBAN_USER || "e2e_user";
|
||||
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
|
||||
|
||||
interface BoardColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
location: "board" | "sidebar";
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface BoardCard {
|
||||
id: string;
|
||||
column_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface BoardResponse {
|
||||
columns: BoardColumn[];
|
||||
cards: BoardCard[];
|
||||
}
|
||||
|
||||
test.describe("Issue 0091 — sidebar drag dropzone", () => {
|
||||
test("drag near left edge opens sidebar and drop moves card to sidebar column", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
|
||||
// Pre-req: ensure there is at least one sidebar column and a card on the board.
|
||||
const initialBoard: BoardResponse = await page.request
|
||||
.get("/api/board")
|
||||
.then((r) => r.json());
|
||||
|
||||
let sidebarCol = initialBoard.columns.find((c) => c.location === "sidebar");
|
||||
if (!sidebarCol) {
|
||||
const created = await page.request
|
||||
.post("/api/columns", {
|
||||
data: { name: "E2E Sidebar", location: "sidebar" },
|
||||
})
|
||||
.then((r) => r.json());
|
||||
sidebarCol = created as BoardColumn;
|
||||
}
|
||||
|
||||
const boardCol = initialBoard.columns.find((c) => c.location !== "sidebar");
|
||||
if (!boardCol) {
|
||||
test.skip(true, "no board column to drag a card from");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure at least one card exists in a board column we can drag.
|
||||
let card = initialBoard.cards.find((c) => c.column_id === boardCol.id);
|
||||
if (!card) {
|
||||
const created = await page.request
|
||||
.post("/api/cards", {
|
||||
data: {
|
||||
column_id: boardCol.id,
|
||||
title: `e2e dropzone card ${Date.now()}`,
|
||||
requester: "e2e",
|
||||
},
|
||||
})
|
||||
.then((r) => r.json());
|
||||
card = created as BoardCard;
|
||||
// Reload UI so the new card appears.
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
// Sanity: side bar should start closed. The toggle button has aria-label="Toggle sidebar".
|
||||
const toggleBtn = page.locator('button[aria-label="Toggle sidebar"]');
|
||||
await expect(toggleBtn).toBeVisible();
|
||||
|
||||
// The Mantine Navbar has a known data attribute (data-mantine-component=AppShellNavbar)
|
||||
// but the simplest check is: when collapsed, the desktop navbar is hidden via display:none.
|
||||
// We use the strip element's visibility too.
|
||||
const strip = page.locator('[data-test="kanban-drag-edge"]');
|
||||
await expect(strip).toHaveCount(1);
|
||||
// While not dragging, strip is_active=0.
|
||||
await expect(strip).toHaveAttribute("data-active", "0");
|
||||
|
||||
const cardLocator = page.locator(`[data-card-id="${card!.id}"]`);
|
||||
await expect(cardLocator).toBeVisible();
|
||||
|
||||
// Build a "left edge" target by creating a 1x100 box near x=10 to drop on.
|
||||
// pw_drag_drop expects a Locator for the target; we use the strip itself
|
||||
// even though pointer-events:none — page.mouse.move works against the
|
||||
// viewport so its bounding box only drives where the pointer goes.
|
||||
// We override hoverMs=700 so the 400ms timer fires well within the hover.
|
||||
|
||||
// Get the card bounding box.
|
||||
const cardBox = await cardLocator.boundingBox();
|
||||
if (!cardBox) throw new Error("card has no bounding box");
|
||||
|
||||
// Manually drive the pointer: press down on card, drag to x=10, dwell 700ms,
|
||||
// assert sidebar opened (via predicate on toggle button aria-pressed OR the
|
||||
// strip's data-active attribute observed), then drop on sidebar column.
|
||||
const sx = cardBox.x + cardBox.width / 2;
|
||||
const sy = cardBox.y + cardBox.height / 2;
|
||||
|
||||
await page.mouse.move(sx, sy);
|
||||
await page.mouse.down();
|
||||
// Cross dnd-kit's 5px activation threshold (we configured PointerSensor distance:5).
|
||||
await page.mouse.move(sx + 15, sy, { steps: 4 });
|
||||
|
||||
// Glide towards x=10 (inside the 32px strip).
|
||||
const edgeX = 10;
|
||||
const edgeY = sy; // keep vertical, change horizontal.
|
||||
const steps = 25;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const xi = (sx + 15) + (edgeX - (sx + 15)) * t;
|
||||
const yi = sy + (edgeY - sy) * t;
|
||||
await page.mouse.move(xi, yi);
|
||||
await page.waitForTimeout(16);
|
||||
}
|
||||
|
||||
// Now dwell inside the strip — the 400ms timer should fire.
|
||||
// While dwelling, every ~50ms we nudge the mouse 1px to keep dnd-kit pointer events alive
|
||||
// but stay inside the strip.
|
||||
const dwellMs = 700;
|
||||
const nudgeStart = Date.now();
|
||||
while (Date.now() - nudgeStart < dwellMs) {
|
||||
await page.mouse.move(edgeX + ((Date.now() / 50) % 2), edgeY);
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
// Assert: the strip is now armed AND the sidebar opened.
|
||||
await expect(strip).toHaveAttribute("data-armed", "1");
|
||||
|
||||
// Wait for sidebar column header text to appear (sidebar opened).
|
||||
await pw_wait_predicate(
|
||||
page,
|
||||
(sidebarName: string) => {
|
||||
const els = Array.from(document.querySelectorAll('[data-column-location="sidebar"]'));
|
||||
// Element must be visible (offsetParent != null is a good proxy for display!=none).
|
||||
return els.some((el) => (el as HTMLElement).offsetParent !== null);
|
||||
},
|
||||
{
|
||||
arg: sidebarCol!.name,
|
||||
timeoutMs: 3000,
|
||||
pollMs: 100,
|
||||
message: "sidebar column did not become visible after dwell",
|
||||
}
|
||||
);
|
||||
|
||||
// Now move pointer to the sidebar column and release.
|
||||
const sidebarColLoc = page.locator(`[data-column-id="${sidebarCol!.id}"]`).first();
|
||||
await expect(sidebarColLoc).toBeVisible();
|
||||
const sbBox = await sidebarColLoc.boundingBox();
|
||||
if (!sbBox) throw new Error("sidebar column has no bounding box");
|
||||
const tx = sbBox.x + sbBox.width / 2;
|
||||
const ty = sbBox.y + sbBox.height / 2;
|
||||
const dropSteps = 15;
|
||||
let lastX = edgeX;
|
||||
let lastY = edgeY;
|
||||
for (let i = 1; i <= dropSteps; i++) {
|
||||
const t = i / dropSteps;
|
||||
const xi = lastX + (tx - lastX) * t;
|
||||
const yi = lastY + (ty - lastY) * t;
|
||||
await page.mouse.move(xi, yi);
|
||||
await page.waitForTimeout(20);
|
||||
}
|
||||
await page.waitForTimeout(150);
|
||||
await page.mouse.up();
|
||||
|
||||
// Validate via API the card moved to the sidebar column.
|
||||
await pw_wait_predicate(
|
||||
page,
|
||||
async (args: { id: string; col: string }) => {
|
||||
const res = await fetch("/api/board", { credentials: "same-origin" });
|
||||
const b = await res.json();
|
||||
const c = (b.cards as BoardCard[]).find((x) => x.id === args.id);
|
||||
return c?.column_id === args.col;
|
||||
},
|
||||
{
|
||||
arg: { id: card!.id, col: sidebarCol!.id },
|
||||
timeoutMs: 5000,
|
||||
pollMs: 200,
|
||||
message: "card did not land in sidebar column after drop",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("strip stays inactive when there is no drag", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
const strip = page.locator('[data-test="kanban-drag-edge"]');
|
||||
await expect(strip).toHaveCount(1);
|
||||
await expect(strip).toHaveAttribute("data-active", "0");
|
||||
await expect(strip).toHaveAttribute("data-armed", "0");
|
||||
// Move the pointer over the left edge — without a drag, strip must stay disarmed.
|
||||
await page.mouse.move(10, 200);
|
||||
await page.waitForTimeout(600);
|
||||
await expect(strip).toHaveAttribute("data-armed", "0");
|
||||
});
|
||||
});
|
||||
+11
-2
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -30,12 +32,19 @@
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitest/ui": "^4.1.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.4",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: process.env.KANBAN_BASE_URL || "http://localhost:5180",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
Generated
+841
File diff suppressed because it is too large
Load Diff
+291
-16
@@ -50,6 +50,7 @@ import {
|
||||
IconArrowBackUp,
|
||||
IconCalendar,
|
||||
IconChartBar,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconLayoutKanban,
|
||||
@@ -68,8 +69,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as api from "./api";
|
||||
import { useAuth } from "./auth";
|
||||
import { CardForm } from "./components/CardForm";
|
||||
import { CardEditPanel } from "./components/CardEditPanel";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { CalendarView } from "./components/CalendarView";
|
||||
import { DailyReportView } from "./components/DailyReport";
|
||||
import { Dashboard } from "./components/Dashboard";
|
||||
import { HistoryModal } from "./components/HistoryModal";
|
||||
import { KanbanCard } from "./components/KanbanCard";
|
||||
@@ -117,6 +120,8 @@ export function App() {
|
||||
const [activeTab, setActiveTab] = useState<string>("board");
|
||||
const [trash, setTrash] = useState<Card[]>([]);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [archive, setArchive] = useState<Card[]>([]);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
||||
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -171,6 +176,72 @@ export function App() {
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
// -------- Issue 0091 — drag-aware sidebar dropzone --------
|
||||
// While a card or column is being dragged, watch the global pointer.
|
||||
// If it dwells inside the 32px left strip for >=400ms, auto-open the sidebar.
|
||||
// We listen to mousemove globally because dnd-kit owns the pointer during
|
||||
// drag, and the strip itself has pointer-events:none so dnd-kit keeps
|
||||
// detecting drop targets underneath.
|
||||
const DRAG_EDGE_WIDTH = 32;
|
||||
const DRAG_EDGE_HOVER_MS = 400;
|
||||
const isDragging = activeCard !== null || activeColumnId !== null;
|
||||
const [edgeArmed, setEdgeArmed] = useState(false);
|
||||
const navOpenRef = useRef(navOpen);
|
||||
useEffect(() => {
|
||||
navOpenRef.current = navOpen;
|
||||
}, [navOpen]);
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setEdgeArmed(false);
|
||||
return;
|
||||
}
|
||||
let timer: number | null = null;
|
||||
let inside = false;
|
||||
// Para evitar que un drag iniciado dentro del sidebar abierto dispare un
|
||||
// cierre inmediato, exigimos que el puntero haya salido de la franja al
|
||||
// menos una vez tras empezar el drag. Asi: abrir = entrar a la franja
|
||||
// tras empezar fuera (que ya pasaba); cerrar = salir de la franja y
|
||||
// volver a entrar.
|
||||
let hasLeftStrip = false;
|
||||
const clear = () => {
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
|
||||
if (nowInside === inside) return;
|
||||
inside = nowInside;
|
||||
// Brillo visible siempre que el puntero este en la franja y haya drag.
|
||||
setEdgeArmed(nowInside);
|
||||
if (!nowInside) {
|
||||
hasLeftStrip = true;
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
// nowInside = true. Para cerrar (navOpen=true) exigimos que el puntero
|
||||
// haya salido al menos una vez de la franja desde que empezo el drag;
|
||||
// asi un drag que arranca dentro del sidebar abierto no auto-cierra.
|
||||
const armable = !navOpenRef.current || hasLeftStrip;
|
||||
if (!armable) return;
|
||||
clear();
|
||||
const willOpen = !navOpenRef.current;
|
||||
timer = window.setTimeout(() => {
|
||||
setNavOpen(willOpen);
|
||||
// Tras toggle, resetea el flag para no encadenar otra accion sin
|
||||
// que el usuario salga + vuelva.
|
||||
hasLeftStrip = false;
|
||||
}, DRAG_EDGE_HOVER_MS);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
clear();
|
||||
setEdgeArmed(false);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const b = await api.getBoard();
|
||||
@@ -202,6 +273,15 @@ export function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadArchive = useCallback(async () => {
|
||||
try {
|
||||
const a = await api.listArchive();
|
||||
setArchive(a);
|
||||
} catch (e) {
|
||||
console.warn("listArchive failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadTags = useCallback(async () => {
|
||||
try {
|
||||
const t = await api.listTags();
|
||||
@@ -228,15 +308,30 @@ export function App() {
|
||||
reloadTrash();
|
||||
}, [reloadTrash]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadArchive();
|
||||
}, [reloadArchive]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadTags();
|
||||
reloadRequesters();
|
||||
}, [reloadTags, reloadRequesters]);
|
||||
|
||||
// Tick de reloj para "tiempo en columna" en cards. Pausamos durante drag
|
||||
// porque dispara re-render de TODAS las cards cada segundo y el drag de
|
||||
// dnd-kit sufre tirones serios con muchos elementos.
|
||||
useEffect(() => {
|
||||
if (activeCard || activeColumnId) return;
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
}, [activeCard, activeColumnId]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
reload();
|
||||
}, 30000);
|
||||
return () => clearInterval(t);
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSticker) return;
|
||||
@@ -537,7 +632,7 @@ export function App() {
|
||||
users={users}
|
||||
requesterOptions={requesterOptions}
|
||||
tagOptions={tagOptions}
|
||||
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
|
||||
initial={{ requester: "" }}
|
||||
submitLabel="Crear"
|
||||
onCancel={() => modals.close(id)}
|
||||
onSubmit={async (v) => {
|
||||
@@ -566,20 +661,14 @@ export function App() {
|
||||
const openEditCard = useCallback((card: Card) => {
|
||||
const id = modals.open({
|
||||
title: "Editar tarjeta",
|
||||
size: "md",
|
||||
size: "85%",
|
||||
children: (
|
||||
<CardForm
|
||||
<CardEditPanel
|
||||
card={card}
|
||||
users={users}
|
||||
currentUserId={auth.user?.id}
|
||||
requesterOptions={requesterOptions}
|
||||
tagOptions={tagOptions}
|
||||
initial={{
|
||||
requester: card.requester,
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
assignee_id: card.assignee_id,
|
||||
tags: card.tags || [],
|
||||
}}
|
||||
submitLabel="Guardar"
|
||||
onCancel={() => modals.close(id)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
@@ -601,7 +690,17 @@ export function App() {
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [reload, users, requesterOptions, tagOptions]);
|
||||
}, [reload, users, auth.user, requesterOptions, tagOptions]);
|
||||
|
||||
const handleDuplicateCard = useCallback(async (cardId: string) => {
|
||||
try {
|
||||
const dup = await api.duplicateCard(cardId);
|
||||
await reload();
|
||||
notifications.show({ color: "teal", message: `Duplicada: ${dup.title}` });
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleSetRequester = useCallback(async (id: string, requester: string) => {
|
||||
setBoard((prev) => {
|
||||
@@ -622,6 +721,22 @@ export function App() {
|
||||
window.setTimeout(() => setHighlightCardId(null), 3000);
|
||||
}, []);
|
||||
|
||||
const handleOpenDailyReport = useCallback((date: string) => {
|
||||
const id = modals.open({
|
||||
title: "Reporte diario",
|
||||
size: "90%",
|
||||
children: (
|
||||
<DailyReportView
|
||||
date={date}
|
||||
onJumpToCard={(cardId) => {
|
||||
modals.close(id);
|
||||
handleJumpToCard(cardId);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [handleJumpToCard]);
|
||||
|
||||
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -668,6 +783,26 @@ export function App() {
|
||||
}
|
||||
}, [reload, reloadTrash]);
|
||||
|
||||
const handleUnarchiveCard = useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.unarchiveCard(id);
|
||||
reload();
|
||||
reloadArchive();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload, reloadArchive]);
|
||||
|
||||
const handleArchiveCard = useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.archiveCard(id);
|
||||
reload();
|
||||
reloadArchive();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload, reloadArchive]);
|
||||
|
||||
const handlePurgeCard = useCallback(async (id: string) => {
|
||||
modals.openConfirmModal({
|
||||
title: "Borrar permanentemente",
|
||||
@@ -769,9 +904,9 @@ export function App() {
|
||||
modals.open({
|
||||
title: card.title,
|
||||
size: "md",
|
||||
children: <HistoryModal card={card} />,
|
||||
children: <HistoryModal card={card} columns={board?.columns ?? []} />,
|
||||
});
|
||||
}, []);
|
||||
}, [board?.columns]);
|
||||
|
||||
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
|
||||
setBoard((prev) => {
|
||||
@@ -799,6 +934,81 @@ export function App() {
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
// Issue 0090: ruleta de seleccion aleatoria por columna.
|
||||
// Recorre las cards visibles (post-filtro) no bloqueadas con highlight
|
||||
// acelerado-decelerado y termina con flash verde sobre la ganadora.
|
||||
const handlePickRandom = useCallback((columnId: string) => {
|
||||
const cards = (cardsByColumn.get(columnId) || []).filter((c) => !c.locked);
|
||||
if (cards.length === 0) {
|
||||
notifications.show({ color: "yellow", message: "No hay cards disponibles (filtro y bloqueadas excluidas)" });
|
||||
return;
|
||||
}
|
||||
if (cards.length === 1) {
|
||||
const el = document.querySelector<HTMLElement>(`[data-card-id="${cards[0].id}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("kanban-roulette-winner");
|
||||
setTimeout(() => el.classList.remove("kanban-roulette-winner"), 1700);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Decide ganadora con seguridad criptografica.
|
||||
const winnerIdx = (() => {
|
||||
const buf = new Uint32Array(1);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf[0] % cards.length;
|
||||
})();
|
||||
|
||||
// Total steps: minimo 2 vueltas completas + offset hasta la ganadora.
|
||||
const baseLaps = 2;
|
||||
const totalSteps = baseLaps * cards.length + ((winnerIdx - 0 + cards.length) % cards.length);
|
||||
|
||||
// Decay temporal: empieza rapido (50ms), termina lento (220ms).
|
||||
const startMs = 50;
|
||||
const endMs = 220;
|
||||
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
let step = 0;
|
||||
const tick = () => {
|
||||
const idx = step % cards.length;
|
||||
const prevIdx = (idx - 1 + cards.length) % cards.length;
|
||||
const prevEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[prevIdx].id}"]`);
|
||||
const currEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[idx].id}"]`);
|
||||
if (prevEl) prevEl.classList.remove("kanban-roulette-active");
|
||||
if (currEl) {
|
||||
currEl.classList.add("kanban-roulette-active");
|
||||
currEl.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
step++;
|
||||
if (step > totalSteps) {
|
||||
if (currEl) {
|
||||
currEl.classList.remove("kanban-roulette-active");
|
||||
currEl.classList.add("kanban-roulette-winner");
|
||||
setTimeout(() => currEl.classList.remove("kanban-roulette-winner"), 1700);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const t = totalSteps > 0 ? step / totalSteps : 1;
|
||||
const delay = startMs + (endMs - startMs) * easeOut(t);
|
||||
setTimeout(tick, delay);
|
||||
};
|
||||
tick();
|
||||
}, [cardsByColumn]);
|
||||
|
||||
const handleSetMaxTimeMinutes = useCallback(async (id: string, max_time_minutes: number) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, max_time_minutes } : c)) };
|
||||
});
|
||||
try {
|
||||
await api.updateColumn(id, { max_time_minutes });
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -854,6 +1064,18 @@ export function App() {
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{/* Issue 0091 — drag-aware left edge strip; opens sidebar on hover>=400ms */}
|
||||
<div
|
||||
className={
|
||||
"kanban-drag-edge" +
|
||||
(isDragging ? " is-active" : "") +
|
||||
(edgeArmed ? " is-armed" : "")
|
||||
}
|
||||
data-test="kanban-drag-edge"
|
||||
data-active={isDragging ? "1" : "0"}
|
||||
data-armed={edgeArmed ? "1" : "0"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<AppShell
|
||||
header={headerConfig}
|
||||
navbar={navbarConfig}
|
||||
@@ -983,9 +1205,12 @@ export function App() {
|
||||
onMoveColumnLocation={handleMoveColumnLocation}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onSetWIPLimit={handleSetWIPLimit}
|
||||
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
||||
onPickRandom={handlePickRandom}
|
||||
onToggleDone={handleToggleDone}
|
||||
onEditCard={openEditCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onDuplicateCard={handleDuplicateCard}
|
||||
onChangeCardColor={handleChangeCardColor}
|
||||
onShowHistory={handleShowHistory}
|
||||
onToggleCardLock={handleToggleCardLock}
|
||||
@@ -993,6 +1218,7 @@ export function App() {
|
||||
onSetCardDeadline={handleSetCardDeadline}
|
||||
highlightCardId={highlightCardId}
|
||||
onSetRequester={handleSetRequester}
|
||||
onArchiveCard={handleArchiveCard}
|
||||
requesterOptions={requesterOptions}
|
||||
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
|
||||
activeSticker={activeSticker}
|
||||
@@ -1056,6 +1282,51 @@ export function App() {
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
rightSection={
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light" color={archive.length > 0 ? "teal" : "gray"}>
|
||||
{archive.length}
|
||||
</Badge>
|
||||
{archiveOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
|
||||
</Group>
|
||||
}
|
||||
onClick={() => setArchiveOpen((v) => !v)}
|
||||
data-test="archive-toggle"
|
||||
>
|
||||
Hecho (archivo)
|
||||
</Button>
|
||||
{archiveOpen && (
|
||||
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
|
||||
{archive.length === 0 && (
|
||||
<Text size="xs" c="dimmed" px="xs">
|
||||
Sin cards archivadas.
|
||||
</Text>
|
||||
)}
|
||||
{archive.map((c) => (
|
||||
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7" data-archived-card-id={c.id}>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
|
||||
{c.title}
|
||||
</Text>
|
||||
<Tooltip label="Sacar del archivo (volver a Hecho)" withArrow>
|
||||
<ActionIcon size="xs" variant="subtle" color="teal" onClick={() => handleUnarchiveCard(c.id)}>
|
||||
<IconArrowBackUp size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</AppShell.Navbar>
|
||||
|
||||
@@ -1070,7 +1341,7 @@ export function App() {
|
||||
</Box>
|
||||
) : activeTab === "calendar" ? (
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
|
||||
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} />
|
||||
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} onOpenDailyReport={handleOpenDailyReport} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
@@ -1250,9 +1521,12 @@ export function App() {
|
||||
onMoveColumnLocation={handleMoveColumnLocation}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onSetWIPLimit={handleSetWIPLimit}
|
||||
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
||||
onPickRandom={handlePickRandom}
|
||||
onToggleDone={handleToggleDone}
|
||||
onEditCard={openEditCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onDuplicateCard={handleDuplicateCard}
|
||||
onChangeCardColor={handleChangeCardColor}
|
||||
onShowHistory={handleShowHistory}
|
||||
onToggleCardLock={handleToggleCardLock}
|
||||
@@ -1260,6 +1534,7 @@ export function App() {
|
||||
onSetCardDeadline={handleSetCardDeadline}
|
||||
highlightCardId={highlightCardId}
|
||||
onSetRequester={handleSetRequester}
|
||||
onArchiveCard={handleArchiveCard}
|
||||
requesterOptions={requesterOptions}
|
||||
activeSticker={activeSticker}
|
||||
onAddSticker={handleAddSticker}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {
|
||||
Board,
|
||||
Card,
|
||||
CardFile,
|
||||
CardHistoryResponse,
|
||||
CardMessage,
|
||||
Column,
|
||||
Metrics,
|
||||
MetricsFilter,
|
||||
@@ -22,6 +24,10 @@ export function getBoard(): Promise<Board> {
|
||||
return fetchJSON("/board");
|
||||
}
|
||||
|
||||
export function getFlags(): Promise<Record<string, boolean>> {
|
||||
return fetchJSON("/flags");
|
||||
}
|
||||
|
||||
export function createColumn(name: string): Promise<Column> {
|
||||
return fetchJSON("/columns", { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
@@ -33,6 +39,7 @@ export interface UpdateColumnInput {
|
||||
width?: number;
|
||||
wip_limit?: number;
|
||||
is_done?: boolean;
|
||||
max_time_minutes?: number;
|
||||
}
|
||||
|
||||
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
|
||||
@@ -101,6 +108,133 @@ export function purgeCard(id: string): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function listArchive(): Promise<Card[]> {
|
||||
return fetchJSON("/archive");
|
||||
}
|
||||
|
||||
export function archiveCard(id: string): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}/archive`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function unarchiveCard(id: string): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}/unarchive`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface DailyReport {
|
||||
date: string;
|
||||
tz: string;
|
||||
start_ts: string;
|
||||
end_ts: string;
|
||||
kpis: {
|
||||
done: number;
|
||||
created: number;
|
||||
moves: number;
|
||||
blocked_ms: number;
|
||||
deadlines_met: number;
|
||||
deadlines_missed: number;
|
||||
reopened: number;
|
||||
archived_auto: number;
|
||||
archived_manual: number;
|
||||
};
|
||||
top_assignees_done: { user_id: string; name: string; count: number }[];
|
||||
top_assignees_created: { user_id: string; name: string; count: number }[];
|
||||
top_requesters_added: { name: string; count: number }[];
|
||||
top_requesters_done: { name: string; count: number }[];
|
||||
done_cards: {
|
||||
id: string;
|
||||
seq_num: number;
|
||||
title: string;
|
||||
requester: string;
|
||||
assignee_id: string | null;
|
||||
assignee_name: string | null;
|
||||
tags: string[];
|
||||
column_id: string;
|
||||
column_name: string;
|
||||
completed_at: string;
|
||||
created_at: string;
|
||||
lead_time_ms: number;
|
||||
color: string;
|
||||
}[];
|
||||
reopened_cards: {
|
||||
card_id: string;
|
||||
title: string;
|
||||
seq_num: number;
|
||||
from_column: string;
|
||||
to_column: string;
|
||||
ts: string;
|
||||
actor_id: string | null;
|
||||
actor_name: string | null;
|
||||
}[];
|
||||
stale_cards: {
|
||||
d7: StaleEntry[];
|
||||
d14: StaleEntry[];
|
||||
d30: StaleEntry[];
|
||||
};
|
||||
lead_time: { avg_ms: number; p50_ms: number; p95_ms: number; samples: number };
|
||||
hourly_moves: number[];
|
||||
deadlines: {
|
||||
met: number;
|
||||
missed: number;
|
||||
list: {
|
||||
card_id: string;
|
||||
title: string;
|
||||
seq_num: number;
|
||||
deadline: string;
|
||||
completed_at: string;
|
||||
late_ms: number;
|
||||
}[];
|
||||
};
|
||||
tags_done: { name: string; count: number }[];
|
||||
archived_today: number;
|
||||
}
|
||||
|
||||
export interface StaleEntry {
|
||||
card_id: string;
|
||||
title: string;
|
||||
seq_num: number;
|
||||
column_id: string;
|
||||
column_name: string;
|
||||
entered_at: string;
|
||||
days: number;
|
||||
}
|
||||
|
||||
export function dailyReport(date: string, tz?: string): Promise<DailyReport> {
|
||||
const params = new URLSearchParams({ date });
|
||||
if (tz) params.set("tz", tz);
|
||||
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> {
|
||||
return fetchJSON(`/cards/${id}/move`, {
|
||||
method: "POST",
|
||||
@@ -112,6 +246,25 @@ export function cardHistory(id: string): Promise<CardHistoryResponse> {
|
||||
return fetchJSON(`/cards/${id}/history`);
|
||||
}
|
||||
|
||||
export function listCardMessages(id: string): Promise<CardMessage[]> {
|
||||
return fetchJSON(`/cards/${id}/messages`);
|
||||
}
|
||||
|
||||
export function createCardMessage(id: string, body: string): Promise<CardMessage> {
|
||||
return fetchJSON(`/cards/${id}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCardMessage(cardId: string, messageId: string): Promise<void> {
|
||||
return fetchJSON(`/cards/${cardId}/messages/${messageId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function duplicateCard(id: string): Promise<Card> {
|
||||
return fetchJSON(`/cards/${id}/duplicate`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
@@ -228,6 +381,42 @@ export function listRequesters(): Promise<string[]> {
|
||||
return fetchJSON("/requesters");
|
||||
}
|
||||
|
||||
// --- Files (issue 0128) -----------------------------------------------------
|
||||
|
||||
export function listCardFiles(cardId: string): Promise<CardFile[]> {
|
||||
return fetchJSON(`/cards/${cardId}/files`);
|
||||
}
|
||||
|
||||
export async function uploadCardFile(
|
||||
cardId: string,
|
||||
file: File,
|
||||
source: "upload" | "description" | "chat" = "upload"
|
||||
): Promise<CardFile> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("source", source);
|
||||
const res = await fetch(`${BASE}/cards/${cardId}/files`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = `upload failed: ${res.status}`;
|
||||
try {
|
||||
const body = (await res.json()) as { Message?: string; message?: string };
|
||||
if (body.Message || body.message) msg = body.Message || body.message || msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new HTTPError(res.status, msg);
|
||||
}
|
||||
return (await res.json()) as CardFile;
|
||||
}
|
||||
|
||||
export function deleteCardFile(fileId: string): Promise<void> {
|
||||
return fetchJSON(`/files/${fileId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||
const qs = new URLSearchParams();
|
||||
if (f.from) qs.set("from", f.from);
|
||||
|
||||
@@ -19,15 +19,17 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { Card, Metrics, User } from "../types";
|
||||
|
||||
// Hace clickable el numero del dia para abrir el reporte diario (issue 0093).
|
||||
interface Props {
|
||||
users: User[];
|
||||
cards: Card[];
|
||||
onJumpToCard?: (cardId: string) => void;
|
||||
onOpenDailyReport?: (date: string) => void;
|
||||
}
|
||||
|
||||
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
|
||||
|
||||
export function CalendarView({ users, cards, onJumpToCard }: Props) {
|
||||
export function CalendarView({ users, cards, onJumpToCard, onOpenDailyReport }: Props) {
|
||||
const [openDate, setOpenDate] = useState<string | null>(null);
|
||||
const [month, setMonth] = useState<Date>(new Date());
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
||||
@@ -199,9 +201,22 @@ export function CalendarView({ users, cards, onJumpToCard }: Props) {
|
||||
}}
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
|
||||
{dayNum}
|
||||
</Text>
|
||||
<UnstyledButton
|
||||
onClick={() => cell.date && onOpenDailyReport?.(cell.date as string)}
|
||||
title="Ver reporte diario"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
data-test={`calendar-day-${cell.date}`}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isToday ? 700 : 500}
|
||||
c={isToday ? "blue" : undefined}
|
||||
td={onOpenDailyReport ? "underline" : undefined}
|
||||
style={{ cursor: onOpenDailyReport ? "pointer" : "default" }}
|
||||
>
|
||||
{dayNum}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
{stats.created > 0 && (
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { DragEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { CardMessage, User } from "../types";
|
||||
import { tagColor } from "./colors";
|
||||
import { formatDateTimeShort } from "./format";
|
||||
import { MessageBody } from "./MessageBody";
|
||||
|
||||
interface Props {
|
||||
cardId: string;
|
||||
users: User[];
|
||||
currentUserId?: string;
|
||||
onMessagesChange?: (messages: CardMessage[]) => void;
|
||||
onFileUploaded?: () => void;
|
||||
}
|
||||
|
||||
function refForFile(filename: string, url: string, mime: string): string {
|
||||
const safe = filename.replace(/]/g, "");
|
||||
return mime.startsWith("image/") ? `` : `[${safe}](${url})`;
|
||||
}
|
||||
|
||||
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange, onFileUploaded }: Props) {
|
||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [body, setBody] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const usersById = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const ms = await api.listCardMessages(cardId);
|
||||
setMessages(ms);
|
||||
onMessagesChange?.(ms);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cardId, onMessagesChange]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewportRef.current) {
|
||||
viewportRef.current.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
const send = async () => {
|
||||
const text = body.trim();
|
||||
if (!text || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const m = await api.createCardMessage(cardId, text);
|
||||
const next = [...messages, m];
|
||||
setMessages(next);
|
||||
onMessagesChange?.(next);
|
||||
setBody("");
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (mid: string) => {
|
||||
try {
|
||||
await api.deleteCardMessage(cardId, mid);
|
||||
const next = messages.filter((m) => m.id !== mid);
|
||||
setMessages(next);
|
||||
onMessagesChange?.(next);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiles = async (files: FileList | File[]) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const cf = await api.uploadCardFile(cardId, file, "chat");
|
||||
const ref = refForFile(cf.filename, cf.url, cf.mime);
|
||||
const m = await api.createCardMessage(cardId, ref);
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, m];
|
||||
onMessagesChange?.(next);
|
||||
return next;
|
||||
});
|
||||
onFileUploaded?.();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
|
||||
handleFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap="xs"
|
||||
style={{
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
|
||||
outlineOffset: dragOver ? -2 : undefined,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
>
|
||||
<ScrollArea
|
||||
viewportRef={viewportRef}
|
||||
style={{ flex: 1, minHeight: 200 }}
|
||||
type="auto"
|
||||
offsetScrollbars
|
||||
>
|
||||
{loading ? (
|
||||
<Group justify="center" p="md"><Loader size="sm" /></Group>
|
||||
) : messages.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||
Sin mensajes aun. Escribe el primero o arrastra un archivo.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6} p={4}>
|
||||
{messages.map((m) => {
|
||||
const author = m.author_id ? usersById.get(m.author_id) : null;
|
||||
const isMe = m.author_id && m.author_id === currentUserId;
|
||||
const label = author ? author.display_name || author.username : "Anonimo";
|
||||
return (
|
||||
<Paper
|
||||
key={m.id}
|
||||
withBorder
|
||||
p="xs"
|
||||
radius="sm"
|
||||
bg={isMe ? "var(--mantine-color-blue-light)" : undefined}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||
<Avatar size={22} radius="xl" color={author?.color || tagColor(label)}>
|
||||
{label.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600}>{label}</Text>
|
||||
<Text size="xs" c="dimmed">{formatDateTimeShort(m.created_at)}</Text>
|
||||
</Group>
|
||||
{isMe && (
|
||||
<Tooltip label="Borrar" withArrow>
|
||||
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => remove(m.id)}>
|
||||
<IconTrash size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap={4}>
|
||||
<MessageBody text={m.body} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Escribe un mensaje. Arrastra archivos o usa el clip."
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
style={{ flex: 1 }}
|
||||
disabled={sending}
|
||||
/>
|
||||
<FileButton onChange={(file) => file && handleFiles([file])} disabled={uploading}>
|
||||
{(props) => (
|
||||
<Tooltip label="Adjuntar archivo" withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Adjuntar"
|
||||
loading={uploading}
|
||||
{...props}
|
||||
>
|
||||
<IconPaperclip size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
<Tooltip label="Enviar" withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={send}
|
||||
disabled={!body.trim() || sending}
|
||||
aria-label="Enviar"
|
||||
>
|
||||
<IconSend size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
{(dragOver || uploading) && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(34,139,230,0.08)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={500} c="blue">
|
||||
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Box, Divider, Group, Tabs } from "@mantine/core";
|
||||
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import type { Card, CardMessage, User } from "../types";
|
||||
import { CardChatPanel } from "./CardChatPanel";
|
||||
import { CardFilesPanel } from "./CardFilesPanel";
|
||||
import { CardLinksPanel } from "./CardLinksPanel";
|
||||
import { CardForm, CardFormValues } from "./CardForm";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
users: User[];
|
||||
currentUserId?: string;
|
||||
requesterOptions: string[];
|
||||
tagOptions: string[];
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CardEditPanel({
|
||||
card,
|
||||
users,
|
||||
currentUserId,
|
||||
requesterOptions,
|
||||
tagOptions,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||
const [liveCard, setLiveCard] = useState(card);
|
||||
const [filesRefreshKey, setFilesRefreshKey] = useState(0);
|
||||
|
||||
const wrappedSubmit = async (v: CardFormValues) => {
|
||||
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
|
||||
await onSubmit(v);
|
||||
};
|
||||
|
||||
const bumpFiles = () => setFilesRefreshKey((k) => k + 1);
|
||||
|
||||
return (
|
||||
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
|
||||
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
|
||||
<CardForm
|
||||
users={users}
|
||||
requesterOptions={requesterOptions}
|
||||
tagOptions={tagOptions}
|
||||
initial={{
|
||||
requester: liveCard.requester,
|
||||
title: liveCard.title,
|
||||
description: liveCard.description,
|
||||
assignee_id: liveCard.assignee_id,
|
||||
tags: liveCard.tags || [],
|
||||
}}
|
||||
submitLabel="Guardar"
|
||||
cardId={liveCard.id}
|
||||
onFileUploaded={bumpFiles}
|
||||
onSubmit={wrappedSubmit}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
<Divider orientation="vertical" />
|
||||
<Box style={{ flex: "1 1 0", minWidth: 320, display: "flex", flexDirection: "column" }}>
|
||||
<Tabs defaultValue="chat" keepMounted={false} style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
|
||||
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
|
||||
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />}>Archivos</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||
<Box style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<CardChatPanel
|
||||
cardId={liveCard.id}
|
||||
users={users}
|
||||
currentUserId={currentUserId}
|
||||
onMessagesChange={setMessages}
|
||||
onFileUploaded={bumpFiles}
|
||||
/>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="links">
|
||||
<CardLinksPanel card={liveCard} messages={messages} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="files">
|
||||
<CardFilesPanel cardId={liveCard.id} refreshKey={filesRefreshKey} />
|
||||
</Tabs.Panel>
|
||||
</Box>
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconDownload,
|
||||
IconFile,
|
||||
IconFileSpreadsheet,
|
||||
IconFileText,
|
||||
IconFileTypePdf,
|
||||
IconPhoto,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { CardFile } from "../types";
|
||||
|
||||
interface Props {
|
||||
cardId: string;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function isImage(mime: string): boolean {
|
||||
return mime.startsWith("image/");
|
||||
}
|
||||
|
||||
function fileIcon(mime: string, size = 18) {
|
||||
const m = mime.toLowerCase();
|
||||
if (m.startsWith("image/")) return <IconPhoto size={size} />;
|
||||
if (m === "application/pdf") return <IconFileTypePdf size={size} />;
|
||||
if (
|
||||
m.includes("spreadsheet") ||
|
||||
m.includes("excel") ||
|
||||
m === "text/csv" ||
|
||||
m === "application/vnd.ms-excel"
|
||||
) {
|
||||
return <IconFileSpreadsheet size={size} />;
|
||||
}
|
||||
if (m.startsWith("text/")) return <IconFileText size={size} />;
|
||||
return <IconFile size={size} />;
|
||||
}
|
||||
|
||||
function sourceBadge(s: CardFile["source"]) {
|
||||
if (s === "description") return { color: "blue", label: "descripcion" };
|
||||
if (s === "chat") return { color: "teal", label: "chat" };
|
||||
return { color: "gray", label: "subido" };
|
||||
}
|
||||
|
||||
export function CardFilesPanel({ cardId, refreshKey }: Props) {
|
||||
const [files, setFiles] = useState<CardFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const list = await api.listCardFiles(cardId);
|
||||
setFiles(list);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cardId]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload, refreshKey]);
|
||||
|
||||
const onUpload = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const cf = await api.uploadCardFile(cardId, file, "upload");
|
||||
setFiles((prev) => [...prev, cf]);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
if (!window.confirm("¿Borrar este archivo?")) return;
|
||||
try {
|
||||
await api.deleteCardFile(id);
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs" p={4}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{files.length} archivo{files.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<FileButton onChange={onUpload} disabled={uploading}>
|
||||
{(props) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconUpload size={14} />}
|
||||
loading={uploading}
|
||||
{...props}
|
||||
>
|
||||
Subir
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
|
||||
{loading ? (
|
||||
<Group justify="center" p="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : files.length === 0 ? (
|
||||
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 160 }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Sin archivos
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Sube archivos con el boton, arrastra al chat o a la descripcion.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{files.map((f) => {
|
||||
const badge = sourceBadge(f.source);
|
||||
return (
|
||||
<Paper key={f.id} withBorder p="xs" radius="sm">
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
{isImage(f.mime) ? (
|
||||
<Anchor href={f.url} target="_blank" rel="noopener noreferrer">
|
||||
<Image
|
||||
src={f.url}
|
||||
alt={f.filename}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="cover"
|
||||
radius="sm"
|
||||
/>
|
||||
</Anchor>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{fileIcon(f.mime, 28)}
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Anchor href={f.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
|
||||
{f.filename}
|
||||
</Anchor>
|
||||
<Group gap={6} mt={4}>
|
||||
<Badge size="xs" variant="light" color={badge.color}>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">{formatSize(f.size)}</Text>
|
||||
<Text size="xs" c="dimmed">{f.mime || "?"}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Descargar" withArrow>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={f.url}
|
||||
download={f.filename}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label="Descargar"
|
||||
>
|
||||
<IconDownload size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Borrar" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => onDelete(f.id)}
|
||||
aria-label="Borrar"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { CardForm } from "./CardForm";
|
||||
|
||||
function renderForm(overrides: Partial<Parameters<typeof CardForm>[0]> = {}) {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<MantineProvider>
|
||||
<CardForm
|
||||
requesterOptions={["Alice", "Anna", "Bob", "Enmanuel"]}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
{...overrides}
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
return { onSubmit, onCancel };
|
||||
}
|
||||
|
||||
describe("CardForm — requester input (issue 0088)", () => {
|
||||
it("solicitante entra vacio cuando initial.requester no se pasa", () => {
|
||||
renderForm();
|
||||
const requesterInput = (document.querySelector('input[data-field="requester"]') as HTMLInputElement) as HTMLInputElement;
|
||||
expect(requesterInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("Enter dentro del requester NO dispara onSubmit (dropdown cerrado o abierto)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderForm();
|
||||
|
||||
// Necesita un titulo valido para que un eventual submit no se ignore por el guard.
|
||||
const title = screen.getByLabelText(/Tarea/i);
|
||||
await user.type(title, "Mi tarea");
|
||||
|
||||
const requester = (document.querySelector('input[data-field="requester"]') as HTMLInputElement);
|
||||
await user.click(requester);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(requester, "An");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Navegacion ArrowDown + Enter del dropdown la maneja Mantine internamente.
|
||||
// Validar eso en jsdom es fragil (portals + virtual focus). Cubierto en e2e
|
||||
// Playwright donde corre browser real.
|
||||
|
||||
it("submit solo via boton Crear", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderForm({ submitLabel: "Crear" });
|
||||
|
||||
const title = screen.getByLabelText(/Tarea/i);
|
||||
await user.type(title, "Mi tarea");
|
||||
const requester = (document.querySelector('input[data-field="requester"]') as HTMLInputElement);
|
||||
await user.type(requester, "Anna");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Crear/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onSubmit.mock.calls[0][0]).toMatchObject({
|
||||
title: "Mi tarea",
|
||||
requester: "Anna",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core";
|
||||
import { FormEvent, KeyboardEvent, useState } from "react";
|
||||
import { Autocomplete, Box, Button, Group, Select, Stack, TagsInput, Text, Textarea } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { DragEvent, FormEvent, KeyboardEvent, useRef, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { User } from "../types";
|
||||
|
||||
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
|
||||
// Enter dentro del Autocomplete deja que Mantine seleccione el item resaltado del
|
||||
// dropdown sin cerrar el formulario. Submit solo via boton "Crear" o Ctrl+Enter
|
||||
// en descripcion. Ver issue 0088.
|
||||
|
||||
export interface CardFormValues {
|
||||
requester: string;
|
||||
title: string;
|
||||
@@ -16,16 +23,25 @@ interface Props {
|
||||
users?: User[];
|
||||
requesterOptions?: string[];
|
||||
tagOptions?: string[];
|
||||
cardId?: string;
|
||||
onFileUploaded?: () => void;
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function markdownRef(filename: string, url: string, isImage: boolean): string {
|
||||
const safeName = filename.replace(/]/g, "");
|
||||
return isImage ? `` : `[${safeName}](${url})`;
|
||||
}
|
||||
|
||||
export function CardForm({
|
||||
initial,
|
||||
submitLabel = "Guardar",
|
||||
users = [],
|
||||
requesterOptions = [],
|
||||
tagOptions = [],
|
||||
cardId,
|
||||
onFileUploaded,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
@@ -34,6 +50,9 @@ export function CardForm({
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
||||
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
@@ -48,12 +67,6 @@ export function CardForm({
|
||||
});
|
||||
};
|
||||
|
||||
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
const textareaEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
@@ -61,6 +74,66 @@ export function CardForm({
|
||||
}
|
||||
};
|
||||
|
||||
const insertAtCursor = (snippet: string) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) {
|
||||
setDescription((d) => (d ? d + "\n" + snippet : snippet));
|
||||
return;
|
||||
}
|
||||
const start = ta.selectionStart ?? description.length;
|
||||
const end = ta.selectionEnd ?? description.length;
|
||||
const before = description.slice(0, start);
|
||||
const after = description.slice(end);
|
||||
const sep = before && !before.endsWith("\n") ? "\n" : "";
|
||||
const next = before + sep + snippet + after;
|
||||
setDescription(next);
|
||||
queueMicrotask(() => {
|
||||
ta.focus();
|
||||
const pos = (before + sep + snippet).length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFiles = async (files: FileList | File[]) => {
|
||||
if (!cardId) {
|
||||
notifications.show({ color: "yellow", message: "Guarda la tarjeta antes de subir archivos." });
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const cf = await api.uploadCardFile(cardId, file, "description");
|
||||
insertAtCursor(markdownRef(cf.filename, cf.url, cf.mime.startsWith("image/")));
|
||||
onFileUploaded?.();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
|
||||
handleFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!cardId) return;
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
@@ -89,21 +162,55 @@ export function CardForm({
|
||||
data={requesterOptions}
|
||||
tabIndex={2}
|
||||
autoComplete="off"
|
||||
onKeyDown={enterSubmit}
|
||||
data-field="requester"
|
||||
placeholder="Empieza a escribir y elige uno existente"
|
||||
limit={10}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
label="Descripcion"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
tabIndex={3}
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
onKeyDown={textareaEnter}
|
||||
description="Ctrl+Enter para guardar"
|
||||
/>
|
||||
<Box
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
style={{
|
||||
position: "relative",
|
||||
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
|
||||
outlineOffset: dragOver ? 2 : undefined,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
label="Descripcion"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
tabIndex={3}
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
onKeyDown={textareaEnter}
|
||||
description={cardId ? "Ctrl+Enter para guardar. Arrastra archivos para adjuntar." : "Ctrl+Enter para guardar"}
|
||||
/>
|
||||
{(dragOver || uploading) && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(34,139,230,0.08)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={500} c="blue">
|
||||
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Select
|
||||
label="Asignar a"
|
||||
placeholder="Sin asignar"
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Anchor, Badge, Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useMemo } from "react";
|
||||
import type { Card, CardMessage } from "../types";
|
||||
|
||||
interface ExtractedLink {
|
||||
url: string;
|
||||
source: "title" | "description" | "chat";
|
||||
context: string;
|
||||
}
|
||||
|
||||
const URL_RE = /(https?:\/\/[^\s<>()"']+)/gi;
|
||||
|
||||
function extract(source: ExtractedLink["source"], text: string): ExtractedLink[] {
|
||||
if (!text) return [];
|
||||
const out: ExtractedLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
URL_RE.lastIndex = 0;
|
||||
while ((m = URL_RE.exec(text)) !== null) {
|
||||
let url = m[1];
|
||||
// Strip common trailing punctuation that isn't part of a URL.
|
||||
url = url.replace(/[.,;:!?)\]}>]+$/, "");
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
out.push({ url, source, context: text });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hostname(u: string): string {
|
||||
try {
|
||||
return new URL(u).hostname;
|
||||
} catch {
|
||||
return u;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
messages: CardMessage[];
|
||||
}
|
||||
|
||||
export function CardLinksPanel({ card, messages }: Props) {
|
||||
const links = useMemo<ExtractedLink[]>(() => {
|
||||
const all: ExtractedLink[] = [
|
||||
...extract("title", card.title),
|
||||
...extract("description", card.description),
|
||||
...messages.flatMap((m) => extract("chat", m.body)),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return all.filter((l) => {
|
||||
if (seen.has(l.url)) return false;
|
||||
seen.add(l.url);
|
||||
return true;
|
||||
});
|
||||
}, [card.title, card.description, messages]);
|
||||
|
||||
if (links.length === 0) {
|
||||
return (
|
||||
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 200 }}>
|
||||
<Text size="sm" c="dimmed">Sin enlaces detectados</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Pega URLs en el titulo, descripcion o chat y apareceran aqui.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeColor = (s: ExtractedLink["source"]): string => {
|
||||
if (s === "title") return "grape";
|
||||
if (s === "description") return "blue";
|
||||
return "teal";
|
||||
};
|
||||
|
||||
const badgeLabel = (s: ExtractedLink["source"]): string => {
|
||||
if (s === "title") return "titulo";
|
||||
if (s === "description") return "descripcion";
|
||||
return "chat";
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6} p={4}>
|
||||
{links.map((l) => (
|
||||
<Paper key={l.url} withBorder p="xs" radius="sm">
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between" align="flex-start">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Anchor href={l.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
|
||||
<Group gap={4} wrap="nowrap" align="center">
|
||||
<IconExternalLink size={12} />
|
||||
<span>{hostname(l.url)}</span>
|
||||
</Group>
|
||||
</Anchor>
|
||||
<Text size="xs" c="dimmed" style={{ wordBreak: "break-all" }}>{l.url}</Text>
|
||||
</Box>
|
||||
<Badge size="xs" variant="light" color={badgeColor(l.source)}>
|
||||
{badgeLabel(l.source)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
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";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowBackUp,
|
||||
IconCalendarStats,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconHourglass,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconSparkles,
|
||||
IconTrendingUp,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
dailyReport,
|
||||
generateDailySummary,
|
||||
getDailySummary,
|
||||
getSetting,
|
||||
setSetting,
|
||||
type DailyReport as Report,
|
||||
type DailySummary,
|
||||
} from "../api";
|
||||
import { formatDuration } from "./format";
|
||||
import { tagColor } from "./colors";
|
||||
|
||||
interface Props {
|
||||
date: string; // YYYY-MM-DD
|
||||
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");
|
||||
return d.toLocaleDateString("es-ES", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function KPI({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
icon,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
icon?: React.ReactNode;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper p="sm" withBorder radius="md">
|
||||
<Group gap={6} mb={2} align="center">
|
||||
{icon}
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={28} fw={700} c={color}>
|
||||
{value}
|
||||
</Text>
|
||||
{sub && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{sub}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function RankingList<T extends { name: string; count: number; user_id?: string }>({
|
||||
title,
|
||||
rows,
|
||||
emptyText,
|
||||
withAvatar = false,
|
||||
}: {
|
||||
title: string;
|
||||
rows: T[];
|
||||
emptyText: string;
|
||||
withAvatar?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Text fw={600} size="sm" mb={6}>
|
||||
{title}
|
||||
</Text>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{emptyText}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
{rows.map((r, i) => (
|
||||
<Group key={(r.user_id || r.name) + i} gap={6} wrap="nowrap" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
|
||||
{withAvatar && (
|
||||
<Avatar size={22} radius="xl" color={tagColor(r.name || String(i))}>
|
||||
{(r.name || "?").slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
)}
|
||||
<Text size="sm" truncate>
|
||||
{r.name || "(sin nombre)"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge size="sm" variant="light" color={i === 0 ? "teal" : "gray"}>
|
||||
{r.count}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</MCard>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
setErr(null);
|
||||
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) => ({
|
||||
hora: String(h).padStart(2, "0") + ":00",
|
||||
movimientos: n,
|
||||
}));
|
||||
}, [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) {
|
||||
return (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={14} />}>
|
||||
{err}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const k = data.kpis;
|
||||
const onTimePct =
|
||||
k.deadlines_met + k.deadlines_missed > 0
|
||||
? Math.round((k.deadlines_met / (k.deadlines_met + k.deadlines_missed)) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap={6}>
|
||||
<IconCalendarStats size={20} />
|
||||
<Title order={4}>Reporte diario</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" tt="capitalize">
|
||||
{fmtDate(data.date)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4, md: 6 }} spacing="xs">
|
||||
<KPI label="Hechas" value={k.done} color="teal" icon={<IconCheck size={14} color="var(--mantine-color-teal-6)" />} />
|
||||
<KPI label="Creadas" value={k.created} icon={<IconPlus size={14} />} />
|
||||
<KPI label="Movimientos" value={k.moves} icon={<IconRefresh size={14} />} />
|
||||
<KPI
|
||||
label="Bloqueado"
|
||||
value={formatDuration(k.blocked_ms)}
|
||||
color="yellow"
|
||||
icon={<IconLock size={14} color="var(--mantine-color-yellow-6)" />}
|
||||
/>
|
||||
<KPI
|
||||
label="Reabiertas"
|
||||
value={k.reopened}
|
||||
color={k.reopened > 0 ? "orange" : undefined}
|
||||
icon={<IconArrowBackUp size={14} />}
|
||||
/>
|
||||
<KPI
|
||||
label="Deadlines"
|
||||
value={onTimePct != null ? `${onTimePct}%` : "—"}
|
||||
color={onTimePct == null ? "dimmed" : onTimePct >= 80 ? "teal" : "red"}
|
||||
sub={`${k.deadlines_met} on-time / ${k.deadlines_missed} vencidos`}
|
||||
icon={<IconHourglass size={14} />}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="xs">
|
||||
<RankingList
|
||||
title="Asignado: mas hechas"
|
||||
rows={data.top_assignees_done}
|
||||
emptyText="Sin hechas con asignado."
|
||||
withAvatar
|
||||
/>
|
||||
<RankingList
|
||||
title="Asignado: mas creadas"
|
||||
rows={data.top_assignees_created}
|
||||
emptyText="Sin actor en creadas."
|
||||
withAvatar
|
||||
/>
|
||||
<RankingList
|
||||
title="Solicitante: mas atendidas"
|
||||
rows={data.top_requesters_done}
|
||||
emptyText="Sin solicitantes con hechas."
|
||||
/>
|
||||
<RankingList
|
||||
title="Solicitante: mas aportadas"
|
||||
rows={data.top_requesters_added}
|
||||
emptyText="Sin nuevas con solicitante."
|
||||
/>
|
||||
</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" wrap="wrap" gap={6}>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text fw={600} size="sm">
|
||||
Tareas hechas
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
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{" "}
|
||||
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p50_ms) : "—"} · p95{" "}
|
||||
{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>
|
||||
{filteredDoneCards.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Sin hechas en este dia.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea style={{ maxHeight: 280 }} type="auto">
|
||||
<Table verticalSpacing={4} fz="xs" highlightOnHover striped="even">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 70 }}>#</Table.Th>
|
||||
<Table.Th>Titulo</Table.Th>
|
||||
<Table.Th>Solicitante</Table.Th>
|
||||
<Table.Th>Asignado</Table.Th>
|
||||
<Table.Th>Tags</Table.Th>
|
||||
<Table.Th style={{ width: 110 }}>Lead time</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredDoneCards.map((c) => (
|
||||
<Table.Tr key={c.id}>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{String(c.seq_num).padStart(5, "0")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UnstyledButton onClick={() => onJumpToCard?.(c.id)} style={{ textAlign: "left" }}>
|
||||
<Text size="xs" fw={500} td="underline">
|
||||
{c.title}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{c.requester || "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{c.assignee_name || "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={2} wrap="wrap">
|
||||
{(c.tags || []).slice(0, 3).map((t) => (
|
||||
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDuration(c.lead_time_ms)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</MCard>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text fw={600} size="sm">
|
||||
Movimientos por hora
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{k.moves}
|
||||
</Badge>
|
||||
</Group>
|
||||
{k.moves === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Sin movimientos.
|
||||
</Text>
|
||||
) : (
|
||||
<BarChart
|
||||
h={160}
|
||||
data={hourlyChartData}
|
||||
dataKey="hora"
|
||||
series={[{ name: "movimientos", color: "blue.6" }]}
|
||||
tickLine="y"
|
||||
withTooltip
|
||||
valueFormatter={(v: number) => String(v)}
|
||||
/>
|
||||
)}
|
||||
</MCard>
|
||||
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Text fw={600} size="sm" mb={6}>
|
||||
Tags trabajadas
|
||||
</Text>
|
||||
{data.tags_done.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Sin tags.
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{data.tags_done.map((t) => (
|
||||
<Badge key={t.name} variant="light" color={tagColor(t.name)} size="sm">
|
||||
{t.name} · {t.count}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</MCard>
|
||||
</SimpleGrid>
|
||||
|
||||
{data.reopened_cards.length > 0 && (
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Group gap={6} mb={6}>
|
||||
<IconArrowBackUp size={14} color="var(--mantine-color-orange-6)" />
|
||||
<Text fw={600} size="sm">
|
||||
Reabiertas (Done → otra)
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{data.reopened_cards.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap={4}>
|
||||
{data.reopened_cards.map((r) => (
|
||||
<Group key={r.card_id + r.ts} gap={6} wrap="nowrap" justify="space-between">
|
||||
<UnstyledButton onClick={() => onJumpToCard?.(r.card_id)} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="xs" truncate td="underline">
|
||||
{r.title}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
<Text size="xs" c="dimmed">
|
||||
{r.from_column} → {r.to_column}
|
||||
</Text>
|
||||
{r.actor_name && (
|
||||
<Badge size="xs" variant="light" color="cyan">
|
||||
{r.actor_name}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</MCard>
|
||||
)}
|
||||
|
||||
{(data.deadlines.missed > 0 || data.deadlines.met > 0) && (
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Group gap={6} mb={6}>
|
||||
<IconHourglass size={14} />
|
||||
<Text fw={600} size="sm">
|
||||
Deadlines
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
{data.deadlines.met} on-time
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="red">
|
||||
{data.deadlines.missed} vencidos
|
||||
</Badge>
|
||||
</Group>
|
||||
{data.deadlines.list.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{data.deadlines.list.map((d) => (
|
||||
<Group key={d.card_id} gap={6} justify="space-between" wrap="nowrap">
|
||||
<UnstyledButton onClick={() => onJumpToCard?.(d.card_id)} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="xs" truncate td="underline">
|
||||
{d.title}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
<Text size="xs" c="red">
|
||||
+{formatDuration(d.late_ms)} tarde
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</MCard>
|
||||
)}
|
||||
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Group gap={6} mb={6}>
|
||||
<IconTrendingUp size={14} />
|
||||
<Text fw={600} size="sm">
|
||||
Cards estancadas (al final del dia)
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{data.stale_cards.d7.length}d7
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="red">
|
||||
{data.stale_cards.d14.length}d14
|
||||
</Badge>
|
||||
<Badge size="xs" variant="filled" color="red">
|
||||
{data.stale_cards.d30.length}d30
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
<Box>
|
||||
<Text size="xs" fw={500} c="orange" mb={4}>
|
||||
7-13 dias
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{data.stale_cards.d7.slice(0, 8).map((s) => (
|
||||
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
|
||||
<Text size="xs" truncate>
|
||||
{s.title}{" "}
|
||||
<Text span c="dimmed" size="xs">
|
||||
· {s.column_name} · {s.days}d
|
||||
</Text>
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{data.stale_cards.d7.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Ninguna.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" fw={500} c="red" mb={4}>
|
||||
14-29 dias
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{data.stale_cards.d14.slice(0, 8).map((s) => (
|
||||
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
|
||||
<Text size="xs" truncate>
|
||||
{s.title}{" "}
|
||||
<Text span c="dimmed" size="xs">
|
||||
· {s.column_name} · {s.days}d
|
||||
</Text>
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{data.stale_cards.d14.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Ninguna.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" fw={500} c="red.8" mb={4}>
|
||||
30+ dias
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{data.stale_cards.d30.slice(0, 8).map((s) => (
|
||||
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
|
||||
<Text size="xs" truncate fw={600}>
|
||||
{s.title}{" "}
|
||||
<Text span c="dimmed" size="xs" fw={400}>
|
||||
· {s.column_name} · {s.days}d
|
||||
</Text>
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{data.stale_cards.d30.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Ninguna.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</MCard>
|
||||
|
||||
<Divider />
|
||||
<Group gap={6} justify="space-between">
|
||||
<Group gap={4}>
|
||||
<IconClock size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
TZ: {data.tz} · cards archivadas hoy: {data.archived_today}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import { Badge, Divider, Group, Loader, Stack, Table, Text, Timeline } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsHorizontal,
|
||||
IconCalendarDue,
|
||||
IconCalendarOff,
|
||||
IconCheck,
|
||||
IconColumns3,
|
||||
IconEdit,
|
||||
IconLock,
|
||||
@@ -16,11 +17,12 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cardHistory, listUsers } from "../api";
|
||||
import type { Card, CardEvent, CardHistoryResponse, User } from "../types";
|
||||
import type { Card, CardEvent, CardHistoryResponse, Column, User } from "../types";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
columns?: Column[];
|
||||
}
|
||||
|
||||
interface UnifiedEvent {
|
||||
@@ -31,6 +33,7 @@ interface UnifiedEvent {
|
||||
detail: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
doneColumn?: boolean;
|
||||
}
|
||||
|
||||
function parsePayload(p: string): Record<string, unknown> {
|
||||
@@ -67,10 +70,18 @@ function eventToUnified(e: CardEvent): UnifiedEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoryModal({ card }: Props) {
|
||||
export function HistoryModal({ card, columns = [] }: Props) {
|
||||
const [data, setData] = useState<CardHistoryResponse | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
const columnById = useMemo(() => {
|
||||
const m = new Map<string, Column>();
|
||||
for (const c of columns) m.set(c.id, c);
|
||||
return m;
|
||||
}, [columns]);
|
||||
|
||||
const isDoneColumn = (columnId: string) => columnById.get(columnId)?.is_done === true;
|
||||
|
||||
useEffect(() => {
|
||||
cardHistory(card.id)
|
||||
.then(setData)
|
||||
@@ -91,14 +102,16 @@ export function HistoryModal({ card }: Props) {
|
||||
const out: UnifiedEvent[] = [];
|
||||
for (const e of data.events || []) out.push(eventToUnified(e));
|
||||
for (const h of data.column_history || []) {
|
||||
const done = isDoneColumn(h.column_id);
|
||||
out.push({
|
||||
id: "h_in_" + h.id,
|
||||
ts: h.entered_at,
|
||||
kind: "Mueve a columna",
|
||||
kind: done ? "Hecho en columna" : "Mueve a columna",
|
||||
actorID: h.actor_id,
|
||||
detail: h.column_name || h.column_id,
|
||||
icon: <IconArrowsHorizontal size={12} />,
|
||||
color: "blue",
|
||||
icon: done ? <IconCheck size={12} /> : <IconArrowsHorizontal size={12} />,
|
||||
color: done ? "green" : "blue",
|
||||
doneColumn: done,
|
||||
});
|
||||
}
|
||||
for (const p of data.lock_periods || []) {
|
||||
@@ -108,7 +121,7 @@ export function HistoryModal({ card }: Props) {
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||
}, [data]);
|
||||
}, [data, columnById]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
@@ -124,6 +137,26 @@ export function HistoryModal({ card }: Props) {
|
||||
return <Text c="dimmed">Sin historial.</Text>;
|
||||
}
|
||||
|
||||
// Per-column time stats: sum duration_ms by column_id from column_history.
|
||||
// Currently-active entry (exited_at=null) gets duration_ms = now - entered_at.
|
||||
const nowMs = Date.now();
|
||||
const perColumnMs = new Map<string, { name: string; isDone: boolean; ms: number; visits: number }>();
|
||||
for (const h of column_history) {
|
||||
const dur = h.exited_at ? h.duration_ms : Math.max(0, nowMs - new Date(h.entered_at).getTime());
|
||||
const key = h.column_id;
|
||||
const prev = perColumnMs.get(key);
|
||||
const meta = columnById.get(key);
|
||||
perColumnMs.set(key, {
|
||||
name: h.column_name || meta?.name || key,
|
||||
isDone: meta?.is_done ?? false,
|
||||
ms: (prev?.ms ?? 0) + dur,
|
||||
visits: (prev?.visits ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
const perColumnRows = Array.from(perColumnMs.entries())
|
||||
.map(([id, v]) => ({ id, ...v }))
|
||||
.sort((a, b) => b.ms - a.ms);
|
||||
|
||||
const userLabel = (id: string | null): string => {
|
||||
if (!id) return "";
|
||||
const u = userById.get(id);
|
||||
@@ -140,6 +173,7 @@ export function HistoryModal({ card }: Props) {
|
||||
key={e.id}
|
||||
bullet={e.icon}
|
||||
color={e.color}
|
||||
lineVariant={e.doneColumn ? "solid" : undefined}
|
||||
title={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text fw={500} size="sm">{e.kind}</Text>
|
||||
@@ -163,16 +197,47 @@ export function HistoryModal({ card }: Props) {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group gap={6} align="center">
|
||||
<IconColumns3 size={14} />
|
||||
<Text fw={500} size="sm">Columnas visitadas</Text>
|
||||
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
|
||||
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
|
||||
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
|
||||
{formatDuration(total_locked_ms)}
|
||||
</Badge>
|
||||
{currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
<Group gap={6} align="center" wrap="wrap">
|
||||
<IconColumns3 size={14} />
|
||||
<Text fw={500} size="sm">Tiempo por columna</Text>
|
||||
<Badge size="xs" variant="light" color="gray">{column_history.length} entradas</Badge>
|
||||
<Text size="xs" c="dimmed" ml="auto">
|
||||
<IconLock size={11} style={{ verticalAlign: "middle" }} />{" "}
|
||||
<Text span size="xs" fw={500} c={total_locked_ms > 0 ? "yellow" : "dimmed"}>
|
||||
{formatDuration(total_locked_ms)}
|
||||
</Text>{" "}
|
||||
bloqueada{currently_locked ? " (en curso)" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{perColumnRows.length > 0 ? (
|
||||
<Table withTableBorder withColumnBorders striped="even" verticalSpacing={4} fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Columna</Table.Th>
|
||||
<Table.Th style={{ width: 60 }}>Visitas</Table.Th>
|
||||
<Table.Th style={{ width: 130 }}>Tiempo total</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{perColumnRows.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{r.isDone && <IconCheck size={12} color="var(--mantine-color-green-6)" />}
|
||||
<Text size="xs" fw={r.isDone ? 600 : 400}>{r.name}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.visits}</Table.Td>
|
||||
<Table.Td>{formatDuration(r.ms)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">Sin movimientos entre columnas.</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArchive,
|
||||
IconCalendarDue,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconGripVertical,
|
||||
@@ -31,7 +33,7 @@ import {
|
||||
IconUserCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Card, CardColor, User } from "../types";
|
||||
import { colorBg, colorBorder, tagColor } from "./colors";
|
||||
import { ColorPickerGrid } from "./ColorPickerGrid";
|
||||
@@ -42,12 +44,14 @@ interface Props {
|
||||
now: number;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (card: Card) => void;
|
||||
onDuplicate?: (id: string) => void;
|
||||
onChangeColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleLock: (id: string, locked: boolean) => void;
|
||||
onAssign: (id: string, assignee_id: string | null) => void;
|
||||
onSetDeadline?: (id: string, deadline: string | null) => void;
|
||||
onSetRequester?: (id: string, requester: string) => void;
|
||||
onArchive?: (id: string) => void;
|
||||
requesterOptions?: string[];
|
||||
onOpenCustomColor?: (cardId: string, current: string) => void;
|
||||
activeSticker?: string | null;
|
||||
@@ -58,69 +62,107 @@ interface Props {
|
||||
users: User[];
|
||||
assignee?: User;
|
||||
inDoneColumn?: boolean;
|
||||
columnOverdue?: boolean;
|
||||
isOverlay?: boolean;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
function KanbanCardImpl({
|
||||
// PERF debug helpers (gated): cuentan renders por capa durante drag.
|
||||
function _probeRender() {
|
||||
const w = window as unknown as { _cardRenderProbe?: boolean; _cardRenderCount?: number };
|
||||
if (w._cardRenderProbe) w._cardRenderCount = (w._cardRenderCount || 0) + 1;
|
||||
}
|
||||
function _probeBodyRender() {
|
||||
const w = window as unknown as { _cardRenderProbe?: boolean; _cardBodyRenderCount?: number };
|
||||
if (w._cardRenderProbe) w._cardBodyRenderCount = (w._cardBodyRenderCount || 0) + 1;
|
||||
}
|
||||
|
||||
// KanbanCardBody — contiene Stack + sticker overlay + states locales (popovers,
|
||||
// requesterDraft). Memoizado para que dnd-kit re-render del wrapper exterior
|
||||
// (provocado por useSortable cada pointermove) NO rebote a este tree.
|
||||
interface CardBodyProps {
|
||||
card: Card;
|
||||
isDone: boolean;
|
||||
isOverlay?: boolean;
|
||||
highlight?: boolean;
|
||||
activeSticker?: string | null;
|
||||
cardElRef: React.MutableRefObject<HTMLElement | null>;
|
||||
now: number;
|
||||
users: User[];
|
||||
assignee?: User;
|
||||
requesterOptions?: string[];
|
||||
menuOpen: boolean;
|
||||
setMenuOpen: (v: boolean | ((p: boolean) => boolean)) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (card: Card) => void;
|
||||
onDuplicate?: (id: string) => void;
|
||||
onChangeColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleLock: (id: string, locked: boolean) => void;
|
||||
onAssign: (id: string, assignee_id: string | null) => void;
|
||||
onSetDeadline?: (id: string, deadline: string | null) => void;
|
||||
onSetRequester?: (id: string, requester: string) => void;
|
||||
onArchive?: (id: string) => void;
|
||||
onOpenCustomColor?: (cardId: string, current: string) => void;
|
||||
onRemoveSticker?: (cardId: string, index: number) => void;
|
||||
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
|
||||
onCommitSticker?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
const KanbanCardBody = memo(function KanbanCardBody({
|
||||
card,
|
||||
isDone,
|
||||
isOverlay,
|
||||
activeSticker,
|
||||
cardElRef,
|
||||
now,
|
||||
users,
|
||||
assignee,
|
||||
requesterOptions,
|
||||
menuOpen,
|
||||
setMenuOpen,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onChangeColor,
|
||||
onShowHistory,
|
||||
onToggleLock,
|
||||
onAssign,
|
||||
onSetDeadline,
|
||||
onSetRequester,
|
||||
requesterOptions,
|
||||
onArchive,
|
||||
onOpenCustomColor,
|
||||
activeSticker,
|
||||
onAddSticker,
|
||||
onRemoveSticker,
|
||||
onMoveSticker,
|
||||
onCommitSticker,
|
||||
users,
|
||||
assignee,
|
||||
inDoneColumn,
|
||||
isOverlay,
|
||||
highlight,
|
||||
}: Props) {
|
||||
const isDone = inDoneColumn || !!card.completed_at;
|
||||
}: CardBodyProps) {
|
||||
_probeBodyRender();
|
||||
const stickerMode = !!activeSticker;
|
||||
const [colorPopOpen, setColorPopOpen] = useState(false);
|
||||
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
|
||||
const [requesterPopOpen, setRequesterPopOpen] = useState(false);
|
||||
const [deadlinePopOpen, setDeadlinePopOpen] = useState(false);
|
||||
const [requesterDraft, setRequesterDraft] = useState(card.requester || "");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const cardElRef = useRef<HTMLElement | null>(null);
|
||||
const draggingStickerRef = useRef<number | null>(null);
|
||||
const stickerMode = !!activeSticker;
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: card.id,
|
||||
data: { type: "card", columnId: card.column_id, locked: card.locked },
|
||||
disabled: stickerMode,
|
||||
});
|
||||
|
||||
const setCardRef = useCallback((el: HTMLElement | null) => {
|
||||
cardElRef.current = el;
|
||||
setNodeRef(el);
|
||||
}, [setNodeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlight && cardElRef.current) {
|
||||
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [highlight]);
|
||||
|
||||
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!stickerMode || !onAddSticker || isOverlay) return;
|
||||
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
onAddSticker(card.id, Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y)));
|
||||
};
|
||||
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
|
||||
const liveMs = Math.max(0, now - enteredAt);
|
||||
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
|
||||
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
|
||||
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
|
||||
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
|
||||
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
|
||||
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
|
||||
let dlColor: string = "blue";
|
||||
let dlVariant: "light" | "filled" = "light";
|
||||
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
|
||||
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
|
||||
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
|
||||
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
|
||||
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
|
||||
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
|
||||
const completedAt = card.completed_at ? new Date(card.completed_at).getTime() : 0;
|
||||
const totalDoneMs = isDone && createdAt && completedAt ? Math.max(0, completedAt - createdAt) : 0;
|
||||
|
||||
const startStickerDrag = (index: number) => (e: React.PointerEvent<HTMLSpanElement>) => {
|
||||
if (!stickerMode || isOverlay || !onMoveSticker) return;
|
||||
@@ -159,68 +201,18 @@ function KanbanCardImpl({
|
||||
onRemoveSticker?.(card.id, index);
|
||||
};
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
background: colorBg(card.color),
|
||||
borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
|
||||
borderWidth: highlight || card.locked ? 2 : 1,
|
||||
boxShadow: highlight ? "0 0 0 3px var(--mantine-color-blue-4)" : undefined,
|
||||
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
|
||||
};
|
||||
|
||||
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
|
||||
const liveMs = Math.max(0, now - enteredAt);
|
||||
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
|
||||
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
|
||||
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
|
||||
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
|
||||
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
|
||||
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
|
||||
let dlColor: string = "blue";
|
||||
let dlVariant: "light" | "filled" = "light";
|
||||
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
|
||||
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
|
||||
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
|
||||
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
|
||||
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
|
||||
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
|
||||
const completedAt = card.completed_at ? new Date(card.completed_at).getTime() : 0;
|
||||
const totalDoneMs = isDone && createdAt && completedAt ? Math.max(0, completedAt - createdAt) : 0;
|
||||
|
||||
const onContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
const menuItems = (
|
||||
const menuItems = !menuOpen ? null : (
|
||||
<>
|
||||
<Menu.Label>Acciones</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit(card);
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Menu.Item>
|
||||
<Popover
|
||||
opened={colorPopOpen}
|
||||
onChange={setColorPopOpen}
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => { setMenuOpen(false); onEdit(card); }}>Editar</Menu.Item>
|
||||
{onDuplicate && (
|
||||
<Menu.Item leftSection={<IconCopy size={14} />} onClick={() => { setMenuOpen(false); onDuplicate(card.id); }}>Duplicar</Menu.Item>
|
||||
)}
|
||||
<Popover opened={colorPopOpen} onChange={setColorPopOpen} position="right-start" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
leftSection={<IconPalette size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setColorPopOpen((v) => !v);
|
||||
}}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setColorPopOpen((v) => !v); }}
|
||||
closeMenuOnClick={false}
|
||||
>
|
||||
Color
|
||||
@@ -234,22 +226,11 @@ function KanbanCardImpl({
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Popover
|
||||
opened={assigneePopOpen}
|
||||
onChange={setAssigneePopOpen}
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover opened={assigneePopOpen} onChange={setAssigneePopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
leftSection={<IconUserCircle size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAssigneePopOpen((v) => !v);
|
||||
}}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setAssigneePopOpen((v) => !v); }}
|
||||
closeMenuOnClick={false}
|
||||
>
|
||||
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
|
||||
@@ -259,11 +240,7 @@ function KanbanCardImpl({
|
||||
<Select
|
||||
placeholder="Sin asignar"
|
||||
value={card.assignee_id ?? null}
|
||||
onChange={(v) => {
|
||||
onAssign(card.id, v);
|
||||
setAssigneePopOpen(false);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
onChange={(v) => { onAssign(card.id, v); setAssigneePopOpen(false); setMenuOpen(false); }}
|
||||
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
|
||||
clearable
|
||||
searchable
|
||||
@@ -272,23 +249,11 @@ function KanbanCardImpl({
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Popover
|
||||
opened={requesterPopOpen}
|
||||
onChange={setRequesterPopOpen}
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover opened={requesterPopOpen} onChange={setRequesterPopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
leftSection={<IconUserSquare size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRequesterDraft(card.requester || "");
|
||||
setRequesterPopOpen((v) => !v);
|
||||
}}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setRequesterDraft(card.requester || ""); setRequesterPopOpen((v) => !v); }}
|
||||
closeMenuOnClick={false}
|
||||
>
|
||||
Solicitante {card.requester ? `(${card.requester})` : "..."}
|
||||
@@ -312,51 +277,24 @@ function KanbanCardImpl({
|
||||
setRequesterPopOpen(false);
|
||||
}
|
||||
}}
|
||||
onOptionSubmit={(v) => {
|
||||
setRequesterDraft(v);
|
||||
onSetRequester?.(card.id, v);
|
||||
setRequesterPopOpen(false);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
onOptionSubmit={(v) => { setRequesterDraft(v); onSetRequester?.(card.id, v); setRequesterPopOpen(false); setMenuOpen(false); }}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Menu.Item
|
||||
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
|
||||
color={card.locked ? "yellow" : undefined}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onToggleLock(card.id, !card.locked);
|
||||
}}
|
||||
onClick={() => { setMenuOpen(false); onToggleLock(card.id, !card.locked); }}
|
||||
>
|
||||
{card.locked ? "Desbloquear" : "Bloquear"}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={14} />}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onShowHistory(card);
|
||||
}}
|
||||
>
|
||||
Historial
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconHistory size={14} />} onClick={() => { setMenuOpen(false); onShowHistory(card); }}>Historial</Menu.Item>
|
||||
{onSetDeadline && (
|
||||
<Popover
|
||||
opened={deadlinePopOpen}
|
||||
onChange={setDeadlinePopOpen}
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover opened={deadlinePopOpen} onChange={setDeadlinePopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
leftSection={<IconCalendarDue size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDeadlinePopOpen((v) => !v);
|
||||
}}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setDeadlinePopOpen((v) => !v); }}
|
||||
closeMenuOnClick={false}
|
||||
>
|
||||
{card.deadline ? `Deadline (${card.deadline.slice(0, 10)})` : "Deadline..."}
|
||||
@@ -379,17 +317,7 @@ function KanbanCardImpl({
|
||||
/>
|
||||
{card.deadline && (
|
||||
<Tooltip label="Quitar deadline" withArrow>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
mt={6}
|
||||
onClick={() => {
|
||||
onSetDeadline(card.id, null);
|
||||
setDeadlinePopOpen(false);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ActionIcon size="sm" variant="subtle" color="red" mt={6} onClick={() => { onSetDeadline(card.id, null); setDeadlinePopOpen(false); setMenuOpen(false); }}>
|
||||
<IconTrash size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -397,87 +325,46 @@ function KanbanCardImpl({
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
{isDone && onArchive && (
|
||||
<Menu.Item
|
||||
leftSection={<IconArchive size={14} />}
|
||||
color="teal"
|
||||
onClick={() => { setMenuOpen(false); onArchive(card.id); }}
|
||||
>
|
||||
Archivar
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={14} />}
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onDelete(card.id);
|
||||
}}
|
||||
>
|
||||
Borrar
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => { setMenuOpen(false); onDelete(card.id); }}>Borrar</Menu.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
ref={setCardRef}
|
||||
style={{ ...style, position: "relative", cursor: stickerMode ? "copy" : "grab", touchAction: "none" }}
|
||||
withBorder
|
||||
p="xs"
|
||||
shadow={isOverlay ? "lg" : "xs"}
|
||||
radius="md"
|
||||
onContextMenu={onContextMenu}
|
||||
onClick={onCardClickAddSticker}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(card);
|
||||
}}
|
||||
{...attributes}
|
||||
{...(stickerMode ? {} : listeners)}
|
||||
>
|
||||
<>
|
||||
<Stack gap={6} style={{ position: "relative", zIndex: 1, pointerEvents: stickerMode ? "none" : undefined }}>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap" align="flex-start">
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }} align="flex-start">
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
color="var(--mantine-color-dark-2)"
|
||||
style={{ flexShrink: 0, marginTop: 4 }}
|
||||
/>
|
||||
<IconGripVertical size={14} color="var(--mantine-color-dark-2)" style={{ flexShrink: 0, marginTop: 4 }} />
|
||||
{card.locked && (
|
||||
<Tooltip label="Bloqueada" withArrow>
|
||||
<IconLock
|
||||
size={14}
|
||||
color="var(--mantine-color-yellow-6)"
|
||||
style={{ flexShrink: 0, marginTop: 4 }}
|
||||
/>
|
||||
<IconLock size={14} color="var(--mantine-color-yellow-6)" style={{ flexShrink: 0, marginTop: 4 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{
|
||||
flex: 1,
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "normal",
|
||||
textDecoration: isDone ? "line-through" : "none",
|
||||
opacity: isDone ? 0.7 : 1,
|
||||
}}
|
||||
style={{ flex: 1, wordBreak: "break-word", whiteSpace: "normal", textDecoration: isDone ? "line-through" : "none", opacity: isDone ? 0.7 : 1 }}
|
||||
>
|
||||
{card.title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label="Acciones"
|
||||
style={{ flexShrink: 0 }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" style={{ flexShrink: 0 }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
|
||||
{menuItems}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
@@ -500,83 +387,53 @@ function KanbanCardImpl({
|
||||
<Avatar size={18} radius="xl" color={assignee.color || "blue"} style={{ flexShrink: 0 }}>
|
||||
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{assignee.display_name || assignee.username}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>{assignee.display_name || assignee.username}</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{card.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{card.description}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>{card.description}</Text>
|
||||
)}
|
||||
{card.tags && card.tags.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{card.tags.map((t) => (
|
||||
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
|
||||
{t}
|
||||
</Badge>
|
||||
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">{t}</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Group gap={4} wrap="wrap">
|
||||
{card.locked && (
|
||||
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
|
||||
{formatDuration(lockedMs)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>{formatDuration(lockedMs)}</Badge>
|
||||
)}
|
||||
{!card.locked && isDone && card.completed_at ? (
|
||||
<>
|
||||
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
{formatDateTimeShort(card.completed_at)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
Total: {formatDuration(totalDoneMs)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>{formatDateTimeShort(card.completed_at)}</Badge>
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>Total: {formatDuration(totalDoneMs)}</Badge>
|
||||
{card.total_locked_ms > 0 && (
|
||||
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
|
||||
{formatDuration(card.total_locked_ms)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>{formatDuration(card.total_locked_ms)}</Badge>
|
||||
)}
|
||||
</>
|
||||
) : !card.locked ? (
|
||||
card.deadline ? (
|
||||
<Tooltip label={`Vence: ${formatDateTimeShort(card.deadline)}`} withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={dlVariant}
|
||||
color={dlColor}
|
||||
leftSection={<IconHourglass size={10} />}
|
||||
>
|
||||
<Badge size="xs" variant={dlVariant} color={dlColor} leftSection={<IconHourglass size={10} />}>
|
||||
{overdue ? `-${formatDuration(-deadlineRemainingMs)}` : formatDuration(deadlineRemainingMs)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
{formatDuration(liveMs)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>{formatDuration(liveMs)}</Badge>
|
||||
)
|
||||
) : null}
|
||||
</Group>
|
||||
{card.seq_num > 0 && (
|
||||
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>
|
||||
#{String(card.seq_num).padStart(5, "0")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>#{String(card.seq_num).padStart(5, "0")}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{card.stickers && card.stickers.length > 0 && (
|
||||
<div
|
||||
data-sticker-overlay
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
borderRadius: "inherit",
|
||||
zIndex: 0,
|
||||
}}
|
||||
style={{ position: "absolute", inset: 0, pointerEvents: "none", overflow: "hidden", borderRadius: "inherit", zIndex: 0 }}
|
||||
>
|
||||
{card.stickers.map((s, i) => (
|
||||
<span
|
||||
@@ -603,6 +460,159 @@ function KanbanCardImpl({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function KanbanCardImpl({
|
||||
card,
|
||||
now,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onChangeColor,
|
||||
onShowHistory,
|
||||
onToggleLock,
|
||||
onAssign,
|
||||
onSetDeadline,
|
||||
onSetRequester,
|
||||
onArchive,
|
||||
requesterOptions,
|
||||
onOpenCustomColor,
|
||||
activeSticker,
|
||||
onAddSticker,
|
||||
onRemoveSticker,
|
||||
onMoveSticker,
|
||||
onCommitSticker,
|
||||
users,
|
||||
assignee,
|
||||
inDoneColumn,
|
||||
columnOverdue,
|
||||
isOverlay,
|
||||
highlight,
|
||||
}: Props) {
|
||||
_probeRender();
|
||||
const isDone = inDoneColumn || !!card.completed_at;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const cardElRef = useRef<HTMLElement | null>(null);
|
||||
const stickerMode = !!activeSticker;
|
||||
// Memo: useSortable es sensible a la identidad del objeto `data`. Si lo
|
||||
// re-creamos cada render, el setNodeRef interno se vuelve inestable y
|
||||
// dispara loops por useMergedRef de Mantine (Paper). Issue: maximum
|
||||
// update depth visto durante drag.
|
||||
const sortableData = useMemo(
|
||||
() => ({ type: "card" as const, columnId: card.column_id, locked: card.locked }),
|
||||
[card.column_id, card.locked]
|
||||
);
|
||||
// Perf: disable layout animations. dnd-kit's default animates the slide of
|
||||
// non-dragged items into their new sort position via an FLIP-like loop that
|
||||
// re-runs useSortable on every pointermove for ALL cards in the
|
||||
// SortableContext. With dozens of cards that drops frames hard (p95>=80ms).
|
||||
// Disabling animations keeps the visual shift driven only by the active
|
||||
// card's transform.
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: card.id,
|
||||
data: sortableData,
|
||||
disabled: stickerMode,
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
|
||||
const setCardRef = useCallback((el: HTMLElement | null) => {
|
||||
cardElRef.current = el;
|
||||
setNodeRef(el);
|
||||
}, [setNodeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlight && cardElRef.current) {
|
||||
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [highlight]);
|
||||
|
||||
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!stickerMode || !onAddSticker || isOverlay) return;
|
||||
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
onAddSticker(card.id, Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y)));
|
||||
};
|
||||
|
||||
const borderColorPicked = highlight
|
||||
? "var(--mantine-color-blue-5)"
|
||||
: columnOverdue
|
||||
? "var(--mantine-color-red-6)"
|
||||
: card.locked
|
||||
? "var(--mantine-color-yellow-6)"
|
||||
: colorBorder(card.color);
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
background: colorBg(card.color),
|
||||
borderColor: borderColorPicked,
|
||||
borderWidth: highlight || card.locked || columnOverdue ? 2 : 1,
|
||||
boxShadow: highlight
|
||||
? "0 0 0 3px var(--mantine-color-blue-4)"
|
||||
: columnOverdue
|
||||
? "0 0 0 2px var(--mantine-color-red-3)"
|
||||
: undefined,
|
||||
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
|
||||
};
|
||||
|
||||
const onContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Paper
|
||||
ref={setCardRef}
|
||||
style={{ ...style, position: "relative", cursor: stickerMode ? "copy" : "grab", touchAction: "none" }}
|
||||
withBorder
|
||||
p="xs"
|
||||
shadow={isOverlay ? "lg" : "xs"}
|
||||
radius="md"
|
||||
data-card-id={card.id}
|
||||
data-column-overdue={columnOverdue ? "true" : "false"}
|
||||
data-locked={card.locked ? "true" : "false"}
|
||||
onContextMenu={onContextMenu}
|
||||
onClick={onCardClickAddSticker}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(card);
|
||||
}}
|
||||
{...attributes}
|
||||
{...(stickerMode ? {} : listeners)}
|
||||
>
|
||||
<KanbanCardBody
|
||||
card={card}
|
||||
isDone={isDone}
|
||||
isOverlay={isOverlay}
|
||||
highlight={highlight}
|
||||
activeSticker={activeSticker}
|
||||
cardElRef={cardElRef}
|
||||
now={now}
|
||||
users={users}
|
||||
assignee={assignee}
|
||||
requesterOptions={requesterOptions}
|
||||
menuOpen={menuOpen}
|
||||
setMenuOpen={setMenuOpen}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onDuplicate={onDuplicate}
|
||||
onChangeColor={onChangeColor}
|
||||
onShowHistory={onShowHistory}
|
||||
onToggleLock={onToggleLock}
|
||||
onAssign={onAssign}
|
||||
onSetDeadline={onSetDeadline}
|
||||
onSetRequester={onSetRequester}
|
||||
onArchive={onArchive}
|
||||
onOpenCustomColor={onOpenCustomColor}
|
||||
onRemoveSticker={onRemoveSticker}
|
||||
onMoveSticker={onMoveSticker}
|
||||
onCommitSticker={onCommitSticker}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Paper,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
IconCheckbox,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconDice5,
|
||||
IconDotsVertical,
|
||||
IconGripVertical,
|
||||
IconPencil,
|
||||
@@ -32,10 +35,30 @@ import {
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
|
||||
import { memo, MouseEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Card, CardColor, Column, User } from "../types";
|
||||
import { KanbanCard } from "./KanbanCard";
|
||||
|
||||
type MaxTimeUnit = "minutes" | "hours" | "days" | "weeks" | "months";
|
||||
const MAX_TIME_UNIT_MIN: Record<MaxTimeUnit, number> = {
|
||||
minutes: 1,
|
||||
hours: 60,
|
||||
days: 60 * 24,
|
||||
weeks: 60 * 24 * 7,
|
||||
months: 60 * 24 * 30,
|
||||
};
|
||||
const MAX_TIME_UNIT_LABEL: Record<MaxTimeUnit, string> = {
|
||||
minutes: "minutos",
|
||||
hours: "horas",
|
||||
days: "dias",
|
||||
weeks: "semanas",
|
||||
months: "meses",
|
||||
};
|
||||
const MAX_TIME_UNIT_SELECT_DATA = (Object.keys(MAX_TIME_UNIT_LABEL) as MaxTimeUnit[]).map((u) => ({
|
||||
value: u,
|
||||
label: MAX_TIME_UNIT_LABEL[u],
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
column: Column;
|
||||
cards: Card[];
|
||||
@@ -47,15 +70,19 @@ interface Props {
|
||||
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
|
||||
onDeleteColumn: (id: string) => void;
|
||||
onSetWIPLimit: (id: string, limit: number) => void;
|
||||
onSetMaxTimeMinutes: (id: string, minutes: number) => void;
|
||||
onPickRandom: (columnId: string) => void;
|
||||
onToggleDone: (id: string, is_done: boolean) => void;
|
||||
onEditCard: (card: Card) => void;
|
||||
onDeleteCard: (id: string) => void;
|
||||
onDuplicateCard: (id: string) => void;
|
||||
onChangeCardColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleCardLock: (id: string, locked: boolean) => void;
|
||||
onAssignCard: (id: string, assignee_id: string | null) => void;
|
||||
onSetCardDeadline?: (id: string, deadline: string | null) => void;
|
||||
onSetRequester?: (id: string, requester: string) => void;
|
||||
onArchiveCard?: (id: string) => void;
|
||||
requesterOptions?: string[];
|
||||
onOpenCustomCardColor?: (cardId: string, current: string) => void;
|
||||
activeSticker?: string | null;
|
||||
@@ -79,15 +106,19 @@ function KanbanColumnImpl({
|
||||
onMoveColumnLocation,
|
||||
onDeleteColumn,
|
||||
onSetWIPLimit,
|
||||
onSetMaxTimeMinutes,
|
||||
onPickRandom,
|
||||
onToggleDone,
|
||||
onEditCard,
|
||||
onDeleteCard,
|
||||
onDuplicateCard,
|
||||
onChangeCardColor,
|
||||
onShowHistory,
|
||||
onToggleCardLock,
|
||||
onAssignCard,
|
||||
onSetCardDeadline,
|
||||
onSetRequester,
|
||||
onArchiveCard,
|
||||
requesterOptions,
|
||||
onOpenCustomCardColor,
|
||||
activeSticker,
|
||||
@@ -104,6 +135,24 @@ function KanbanColumnImpl({
|
||||
const [localWidth, setLocalWidth] = useState<number | null>(null);
|
||||
const [wipPopOpen, setWipPopOpen] = useState(false);
|
||||
const [wipDraft, setWipDraft] = useState<number | string>(column.wip_limit);
|
||||
const [maxTimePopOpen, setMaxTimePopOpen] = useState(false);
|
||||
// Initial unit picked from current value: largest unit that yields >=1
|
||||
const pickInitialUnit = (mins: number): MaxTimeUnit => {
|
||||
if (mins <= 0) return "minutes";
|
||||
if (mins % 43200 === 0) return "months";
|
||||
if (mins % 10080 === 0) return "weeks";
|
||||
if (mins % 1440 === 0) return "days";
|
||||
if (mins % 60 === 0) return "hours";
|
||||
return "minutes";
|
||||
};
|
||||
const minutesToUnit = (mins: number, u: MaxTimeUnit): number => {
|
||||
const div = MAX_TIME_UNIT_MIN[u];
|
||||
return mins > 0 ? Math.max(1, Math.round(mins / div)) : 0;
|
||||
};
|
||||
const [maxTimeUnit, setMaxTimeUnit] = useState<MaxTimeUnit>(() => pickInitialUnit(column.max_time_minutes || 0));
|
||||
const [maxTimeDraft, setMaxTimeDraft] = useState<number | string>(() =>
|
||||
minutesToUnit(column.max_time_minutes || 0, pickInitialUnit(column.max_time_minutes || 0))
|
||||
);
|
||||
const [bodyHidden, setBodyHidden] = useState(() => {
|
||||
if (!collapsed) return false;
|
||||
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
|
||||
@@ -122,9 +171,13 @@ function KanbanColumnImpl({
|
||||
setLocalWidth(null);
|
||||
}, [column.width]);
|
||||
|
||||
const sortableData = useMemo(
|
||||
() => ({ type: "column" as const, columnId: column.id, location: column.location }),
|
||||
[column.id, column.location]
|
||||
);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: `column-${column.id}`,
|
||||
data: { type: "column", columnId: column.id, location: column.location },
|
||||
data: sortableData,
|
||||
});
|
||||
|
||||
const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
|
||||
@@ -218,6 +271,8 @@ function KanbanColumnImpl({
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
data-column-id={column.id}
|
||||
data-column-location={column.location}
|
||||
>
|
||||
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -388,6 +443,120 @@ function KanbanColumnImpl({
|
||||
>
|
||||
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
|
||||
</Menu.Item>
|
||||
<Popover
|
||||
opened={maxTimePopOpen}
|
||||
onChange={(o) => {
|
||||
setMaxTimePopOpen(o);
|
||||
if (o) {
|
||||
const u = pickInitialUnit(column.max_time_minutes || 0);
|
||||
setMaxTimeUnit(u);
|
||||
setMaxTimeDraft(minutesToUnit(column.max_time_minutes || 0, u));
|
||||
}
|
||||
}}
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
leftSection={<IconClock size={14} />}
|
||||
data-test="column-max-time"
|
||||
closeMenuOnClick={false}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMaxTimePopOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
Tiempo maximo
|
||||
{column.max_time_minutes > 0
|
||||
? ` (${(() => {
|
||||
const u = pickInitialUnit(column.max_time_minutes);
|
||||
return `${minutesToUnit(column.max_time_minutes, u)} ${MAX_TIME_UNIT_LABEL[u]}`;
|
||||
})()})`
|
||||
: ""}
|
||||
</Menu.Item>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p="xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Stack gap={6} style={{ minWidth: 240 }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Cards que pasen este tiempo se pintaran con borde rojo. 0 = sin limite. Columnas Done no aplican.
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<NumberInput
|
||||
size="xs"
|
||||
min={0}
|
||||
max={999}
|
||||
value={maxTimeDraft}
|
||||
onChange={setMaxTimeDraft}
|
||||
placeholder="0"
|
||||
style={{ width: 90 }}
|
||||
data-test="column-max-time-input"
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
value={maxTimeUnit}
|
||||
onChange={(v) => v && setMaxTimeUnit(v as MaxTimeUnit)}
|
||||
data={MAX_TIME_UNIT_SELECT_DATA}
|
||||
style={{ width: 130 }}
|
||||
allowDeselect={false}
|
||||
data-test="column-max-time-unit"
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="space-between" gap={6}>
|
||||
<Tooltip label="Quitar limite" withArrow disabled={!column.max_time_minutes}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={!column.max_time_minutes}
|
||||
onClick={() => {
|
||||
onSetMaxTimeMinutes(column.id, 0);
|
||||
setMaxTimeDraft(0);
|
||||
setMaxTimePopOpen(false);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="xs"
|
||||
data-test="column-max-time-save"
|
||||
onClick={() => {
|
||||
const raw =
|
||||
typeof maxTimeDraft === "number"
|
||||
? maxTimeDraft
|
||||
: parseInt(String(maxTimeDraft), 10);
|
||||
const n = Number.isFinite(raw) && raw >= 0 ? raw : 0;
|
||||
const mins = n * MAX_TIME_UNIT_MIN[maxTimeUnit];
|
||||
if (mins !== column.max_time_minutes) {
|
||||
onSetMaxTimeMinutes(column.id, mins);
|
||||
}
|
||||
setMaxTimePopOpen(false);
|
||||
}}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
{!column.is_done && (
|
||||
<Menu.Item
|
||||
leftSection={<IconDice5 size={14} />}
|
||||
data-test="column-random-pick"
|
||||
disabled={cards.filter((c) => !c.locked).length === 0}
|
||||
onClick={() => onPickRandom(column.id)}
|
||||
>
|
||||
Seleccionar Aleatorio
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<ArchiveIcon size={14} />}
|
||||
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
||||
@@ -421,17 +590,24 @@ function KanbanColumnImpl({
|
||||
now={now}
|
||||
onDelete={onDeleteCard}
|
||||
onEdit={onEditCard}
|
||||
onDuplicate={onDuplicateCard}
|
||||
onChangeColor={onChangeCardColor}
|
||||
onShowHistory={onShowHistory}
|
||||
onToggleLock={onToggleCardLock}
|
||||
onAssign={onAssignCard}
|
||||
onSetDeadline={onSetCardDeadline}
|
||||
onSetRequester={onSetRequester}
|
||||
onArchive={onArchiveCard}
|
||||
requesterOptions={requesterOptions}
|
||||
onOpenCustomColor={onOpenCustomCardColor}
|
||||
users={users}
|
||||
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
|
||||
inDoneColumn={column.is_done}
|
||||
columnOverdue={
|
||||
!column.is_done &&
|
||||
column.max_time_minutes > 0 &&
|
||||
c.time_in_column_ms > column.max_time_minutes * 60_000
|
||||
}
|
||||
highlight={highlightCardId === c.id}
|
||||
activeSticker={activeSticker}
|
||||
onAddSticker={onAddSticker}
|
||||
@@ -452,6 +628,7 @@ function KanbanColumnImpl({
|
||||
onClick={() => onAddCard(column.id)}
|
||||
mt="xs"
|
||||
fullWidth
|
||||
data-test="add-card"
|
||||
>
|
||||
Anadir tarjeta
|
||||
</Button>
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconLayoutKanban } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "../auth";
|
||||
import * as api from "../api";
|
||||
|
||||
type Mode = "login" | "register";
|
||||
|
||||
@@ -23,6 +24,18 @@ export function LoginPage() {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getFlags()
|
||||
.then((f) => setRegistrationEnabled(!!f["registration-enabled"]))
|
||||
.catch(() => setRegistrationEnabled(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registrationEnabled && mode === "register") setMode("login");
|
||||
}, [registrationEnabled, mode]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -84,20 +97,26 @@ export function LoginPage() {
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{mode === "login" ? "Entrar" : "Registrar"}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
}}
|
||||
>
|
||||
{mode === "login" ? "Registrate" : "Inicia sesion"}
|
||||
</Anchor>
|
||||
</Text>
|
||||
{registrationEnabled ? (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
}}
|
||||
>
|
||||
{mode === "login" ? "Registrate" : "Inicia sesion"}
|
||||
</Anchor>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Registro de nuevos usuarios deshabilitado.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Anchor, Image, Text } from "@mantine/core";
|
||||
import { Fragment, ReactNode } from "react";
|
||||
|
||||
// Minimal markdown renderer for chat/desc embeds (issue 0128).
|
||||
// Recognises:
|
||||
//  -> <Image>
|
||||
// [name](url) -> <Anchor>name</Anchor>
|
||||
// Everything else is plain text with preserved whitespace.
|
||||
|
||||
interface ImgToken { kind: "img"; alt: string; url: string }
|
||||
interface LinkToken { kind: "link"; label: string; url: string }
|
||||
interface TextToken { kind: "text"; value: string }
|
||||
type Token = ImgToken | LinkToken | TextToken;
|
||||
|
||||
const TOKEN_RE = /(!\[([^\]\n]*)\]\(([^)\s]+)\))|(\[([^\]\n]+)\]\(([^)\s]+)\))/g;
|
||||
|
||||
// Allow only safe URL schemes. Reject javascript:, data:text/html, vbscript:, etc.
|
||||
// Accepts: absolute http(s), protocol-relative //, and same-origin paths (/...).
|
||||
function safeURL(url: string): string | null {
|
||||
const u = url.trim();
|
||||
if (u.startsWith("/")) return u;
|
||||
if (/^https?:\/\//i.test(u)) return u;
|
||||
return null;
|
||||
}
|
||||
|
||||
// data: scheme is allowed only when the MIME prefix is image/.
|
||||
function safeImageURL(url: string): string | null {
|
||||
const safe = safeURL(url);
|
||||
if (safe) return safe;
|
||||
const u = url.trim();
|
||||
if (/^data:image\/[a-z0-9.+-]+(;[a-z0-9-]+=[^,]+)*;base64,/i.test(u)) return u;
|
||||
return null;
|
||||
}
|
||||
|
||||
function tokenize(input: string): Token[] {
|
||||
const out: Token[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
TOKEN_RE.lastIndex = 0;
|
||||
while ((m = TOKEN_RE.exec(input)) !== null) {
|
||||
if (m.index > last) {
|
||||
out.push({ kind: "text", value: input.slice(last, m.index) });
|
||||
}
|
||||
if (m[1]) {
|
||||
const url = safeImageURL(m[3]);
|
||||
if (url) {
|
||||
out.push({ kind: "img", alt: m[2] || "", url });
|
||||
} else {
|
||||
out.push({ kind: "text", value: m[0] });
|
||||
}
|
||||
} else if (m[4]) {
|
||||
const url = safeURL(m[6]);
|
||||
if (url) {
|
||||
out.push({ kind: "link", label: m[5], url });
|
||||
} else {
|
||||
out.push({ kind: "text", value: m[0] });
|
||||
}
|
||||
}
|
||||
last = TOKEN_RE.lastIndex;
|
||||
}
|
||||
if (last < input.length) {
|
||||
out.push({ kind: "text", value: input.slice(last) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function MessageBody({ text, size = "sm" }: Props): ReactNode {
|
||||
const tokens = tokenize(text);
|
||||
if (tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlineNodes: ReactNode[] = [];
|
||||
const blocks: ReactNode[] = [];
|
||||
let key = 0;
|
||||
|
||||
const flushInline = () => {
|
||||
if (inlineNodes.length === 0) return;
|
||||
blocks.push(
|
||||
<Text
|
||||
key={`t-${key++}`}
|
||||
size={size}
|
||||
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||
>
|
||||
{inlineNodes.map((n, i) => (
|
||||
<Fragment key={i}>{n}</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
inlineNodes.length = 0;
|
||||
};
|
||||
|
||||
for (const t of tokens) {
|
||||
if (t.kind === "img") {
|
||||
flushInline();
|
||||
blocks.push(
|
||||
<Anchor key={`i-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
|
||||
<Image
|
||||
src={t.url}
|
||||
alt={t.alt}
|
||||
maw={220}
|
||||
mah={220}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
/>
|
||||
</Anchor>
|
||||
);
|
||||
} else if (t.kind === "link") {
|
||||
inlineNodes.push(
|
||||
<Anchor key={`l-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
|
||||
{t.label}
|
||||
</Anchor>
|
||||
);
|
||||
} else {
|
||||
inlineNodes.push(t.value);
|
||||
}
|
||||
}
|
||||
flushInline();
|
||||
|
||||
return <>{blocks}</>;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import "./styles/roulette.css";
|
||||
import "./styles/dropzone.css";
|
||||
import { MantineProvider, createTheme } from "@mantine/core";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* Drag-aware dropzone strip on the left edge.
|
||||
* Issue 0091 — auto-open sidebar when dragging a card near the left edge.
|
||||
*
|
||||
* The strip is only visible while a drag is active. When the pointer is
|
||||
* inside the strip, we add the `is-armed` class to show a subtle inset
|
||||
* glow that pulses, so the user knows the zone is going to fire.
|
||||
*/
|
||||
|
||||
.kanban-drag-edge {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 50px; /* AppShell.Header height */
|
||||
bottom: 0;
|
||||
width: 32px;
|
||||
z-index: 200;
|
||||
pointer-events: none; /* let dnd-kit keep capturing the pointer */
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease-out, box-shadow 160ms ease-out, background 160ms ease-out;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.kanban-drag-edge.is-active {
|
||||
opacity: 1;
|
||||
/* Very subtle hint that the strip exists during any drag. */
|
||||
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.kanban-drag-edge.is-armed {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 139, 230, 0.18) 0%,
|
||||
rgba(34, 139, 230, 0.06) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4);
|
||||
animation: kanban-drag-edge-pulse 1100ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes kanban-drag-edge-pulse {
|
||||
0% {
|
||||
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4),
|
||||
inset 0 0 0 0 rgba(34, 139, 230, 0.0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 4px 0 0 var(--mantine-color-blue-5),
|
||||
inset 16px 0 22px -10px rgba(34, 139, 230, 0.35);
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4),
|
||||
inset 0 0 0 0 rgba(34, 139, 230, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* Issue 0090: ruleta de seleccion aleatoria por columna. */
|
||||
|
||||
@keyframes kanban-roulette-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(34, 139, 230, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0); }
|
||||
}
|
||||
|
||||
@keyframes kanban-roulette-winner {
|
||||
0% { box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.95); transform: scale(1); }
|
||||
30% { box-shadow: 0 0 0 16px rgba(82, 196, 26, 0.55); transform: scale(1.03); }
|
||||
60% { box-shadow: 0 0 0 22px rgba(82, 196, 26, 0); transform: scale(1.05); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(82, 196, 26, 0); transform: scale(1); }
|
||||
}
|
||||
|
||||
.kanban-roulette-active {
|
||||
outline: 3px solid var(--mantine-color-blue-6) !important;
|
||||
outline-offset: -2px;
|
||||
animation: kanban-roulette-pulse 200ms ease-out 1;
|
||||
z-index: 5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kanban-roulette-winner {
|
||||
outline: 3px solid var(--mantine-color-green-7) !important;
|
||||
outline-offset: -2px;
|
||||
animation: kanban-roulette-winner 1600ms ease-out 1;
|
||||
z-index: 6;
|
||||
position: relative;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// jsdom does not implement matchMedia; Mantine reads it on mount.
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Mantine Textarea autosize reads window.visualViewport on mount; jsdom lacks it.
|
||||
if (typeof window !== "undefined" && !window.visualViewport) {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
writable: true,
|
||||
value: {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
pageLeft: 0,
|
||||
pageTop: 0,
|
||||
scale: 1,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// jsdom does not implement document.fonts; Mantine Autosize reads it on mount.
|
||||
if (typeof document !== "undefined" && !(document as Document & { fonts?: unknown }).fonts) {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
writable: true,
|
||||
value: {
|
||||
ready: Promise.resolve(),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ResizeObserver is used by some Mantine components and is not in jsdom.
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface Column {
|
||||
width: number;
|
||||
wip_limit: number;
|
||||
is_done: boolean;
|
||||
max_time_minutes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ export interface Card {
|
||||
assignee_id: string | null;
|
||||
completed_at: string | null;
|
||||
deleted_at: string | null;
|
||||
archived_at: string | null;
|
||||
tags: string[];
|
||||
stickers: Sticker[];
|
||||
deadline: string | null;
|
||||
@@ -44,6 +46,18 @@ export interface Card {
|
||||
total_locked_ms: number;
|
||||
}
|
||||
|
||||
export interface CardFile {
|
||||
id: string;
|
||||
card_id: string;
|
||||
uploader_id: string;
|
||||
filename: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
source: "upload" | "description" | "chat";
|
||||
url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -188,3 +202,11 @@ export interface CardHistoryResponse {
|
||||
total_locked_ms: number;
|
||||
currently_locked: boolean;
|
||||
}
|
||||
|
||||
export interface CardMessage {
|
||||
id: string;
|
||||
card_id: string;
|
||||
author_id: string | null;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fn_library": path.resolve(__dirname, "../../../frontend/functions"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
exclude: ["e2e/**", "node_modules/**"],
|
||||
},
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user