chore: auto-commit (10 archivos)
- chat.log - db.go - frontend/src/App.tsx - frontend/src/api.ts - frontend/src/components/CardForm.tsx - frontend/src/components/Dashboard.tsx - frontend/src/components/KanbanCard.tsx - frontend/src/types.ts - handlers.go - metrics.go Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,69 @@
|
||||
{"ts":"2026-05-06T22:48:54.982377303Z","tool":"delete_card","input":{"id":"1cdfc05e20c51430"},"ok":true}
|
||||
{"ts":"2026-05-06T22:48:54.982541766Z","tool":"delete_card","input":{"id":"0d4b8afab5344cbd"},"ok":true}
|
||||
{"ts":"2026-05-06T22:48:54.982583432Z","tool":"delete_card","input":{"id":"88551589d2f7abd0"},"ok":true}
|
||||
{"ts":"2026-05-08T11:05:19.870107956Z","tool":"create_column","input":{"name":"HACIENDO 🚧"},"ok":true,"result_summary":"column a5f7f05963bbf3ed name=\"HACIENDO 🚧\""}
|
||||
{"ts":"2026-05-08T11:05:19.879303459Z","tool":"create_column","input":{"name":"PNDNT FEEDBACK ▶️"},"ok":true,"result_summary":"column 61e44ab592ce223a name=\"PNDNT FEEDBACK ▶️\""}
|
||||
{"ts":"2026-05-08T11:05:19.879427883Z","tool":"create_column","input":{"name":"HECHO ✅"},"ok":true,"result_summary":"column 06ac391eb6d8ce8b name=\"HECHO ✅\""}
|
||||
{"ts":"2026-05-08T11:05:19.879530269Z","tool":"create_column","input":{"name":"IDEAS 💡"},"ok":true,"result_summary":"column 63974019466e3f1d name=\"IDEAS 💡\""}
|
||||
{"ts":"2026-05-08T11:05:19.879639469Z","tool":"create_column","input":{"name":"DEUDA TÉCNICA 🔄"},"ok":true,"result_summary":"column 635506c9aaac540a name=\"DEUDA TÉCNICA 🔄\""}
|
||||
{"ts":"2026-05-08T11:05:40.973634884Z","tool":"update_column","input":{"id":"06ac391eb6d8ce8b","is_done":true},"ok":true}
|
||||
{"ts":"2026-05-08T11:05:40.974205892Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Simon","title":"MCP","locked":true},"ok":true,"result_summary":"card f6efaa13146787dd title=\"MCP\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.974526775Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Alvaro Calvo","title":"Footprint: Arreglar centros"},"ok":true,"result_summary":"card cbc358b5c0cac316 title=\"Footprint: Arreglar centros\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.974963613Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Sofia","title":"Añadir precaweb + centros web a informe de venta v1.2 — Cuadrar con datos de Diego 😰"},"ok":true,"result_summary":"card 6a8f39dc0e8e7218 title=\"Añadir precaweb + centros web a informe de venta v1.2 — Cuadrar con datos de Diego 😰\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.975384857Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Alberto Frias","title":"Mejorar el informe de ventas (Nat)","description":"Todos los detalles menos rehacer indicadores. Preguntar a Andrés."},"ok":true,"result_summary":"card 5d44483861cbdda3 title=\"Mejorar el informe de ventas (Nat)\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.975700632Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"MariClaire","title":"Media de ticket medio por día, semana y hora (Enma)"},"ok":true,"result_summary":"card eed928c34ccb85a2 title=\"Media de ticket medio por día, semana y hora (Enma)\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.975969784Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Data","title":"Visualizaciones de dashboards (Alfon)"},"ok":true,"result_summary":"card f960cc196dd2ab0a title=\"Visualizaciones de dashboards (Alfon)\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.976229693Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Emilio","title":"Conversión OTRS centros de glass"},"ok":true,"result_summary":"card c3c867025281c088 title=\"Conversión OTRS centros de glass\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.976519357Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Alberto Frias","title":"Informe de Car"},"ok":true,"result_summary":"card 66ae0108656a731e title=\"Informe de Car\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.976869903Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Marta","title":"Lean n3, n2, n1: Dashboard"},"ok":true,"result_summary":"card bdd86aa84645b3f3 title=\"Lean n3, n2, n1: Dashboard\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.977235162Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Santiago","title":"% de callcenter sobre total (Alfon)"},"ok":true,"result_summary":"card 69615eb998a5705d title=\"% de callcenter sobre total (Alfon)\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.977586792Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Alvaro Calvo","title":"Tasaciones de Galicia"},"ok":true,"result_summary":"card 81e756341403a4d7 title=\"Tasaciones de Galicia\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.977901945Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Javi","title":"Añadir usuarios"},"ok":true,"result_summary":"card 1e13d5da79a9bae2 title=\"Añadir usuarios\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.978247494Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Pilar RRHH","title":"DNIs de trabajadores"},"ok":true,"result_summary":"card 56a4b2b4ac5e8251 title=\"DNIs de trabajadores\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.978587684Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Angel","title":"Dashboard de servicios"},"ok":true,"result_summary":"card 6017f8cb1d6c4d8c title=\"Dashboard de servicios\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.978902225Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Fer","title":"Venta de neu por día desde CallCenter y precio medio para obtener facturación real"},"ok":true,"result_summary":"card b1e820b29afa5cdf title=\"Venta de neu por día desde CallCenter y precio medio para obtener facturación real\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.979238803Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"MariClaire","title":"Tiempo de empleados por hora"},"ok":true,"result_summary":"card 2a67ec283a40dd1a title=\"Tiempo de empleados por hora\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.979544021Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Simon","title":"Promoción Ceat en mano de obra"},"ok":true,"result_summary":"card 1eba435104d4391a title=\"Promoción Ceat en mano de obra\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.979853172Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Data","title":"Preparar informe de transformación"},"ok":true,"result_summary":"card 47dc1a64d4811539 title=\"Preparar informe de transformación\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.980121612Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Andres","title":"Facturación Marcajes 2026"},"ok":true,"result_summary":"card 18fa5511fb0c8095 title=\"Facturación Marcajes 2026\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.980594215Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Paco","title":"Permisos Metabase (Enma)"},"ok":true,"result_summary":"card 442714f56f74b1f0 title=\"Permisos Metabase (Enma)\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.981228033Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","title":"Herramienta sencilla alternativa a Jira"},"ok":true,"result_summary":"card 2268e2cd44a587fb title=\"Herramienta sencilla alternativa a Jira\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.981582925Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Reinventar informe de CAR"},"ok":true,"result_summary":"card 7043c7f97b2e9c43 title=\"Reinventar informe de CAR\" col=635506c9aaac540a"}
|
||||
{"ts":"2026-05-08T11:05:40.981924049Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Limpiar tablas con datos erróneos"},"ok":true,"result_summary":"card e6863961ad8648f9 title=\"Limpiar tablas con datos erróneos\" col=635506c9aaac540a"}
|
||||
{"ts":"2026-05-08T11:05:40.982232277Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Unificar DATACLAW"},"ok":true,"result_summary":"card 3662ba02fdae93bf title=\"Unificar DATACLAW\" col=635506c9aaac540a"}
|
||||
{"ts":"2026-05-08T11:05:40.982524229Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Unificar la ontología"},"ok":true,"result_summary":"card 47237e4a0c55fcff title=\"Unificar la ontología\" col=635506c9aaac540a"}
|
||||
{"ts":"2026-05-08T11:05:54.670085952Z","tool":"update_card","input":{"id":"f6efaa13146787dd","locked":true},"ok":true}
|
||||
{"ts":"2026-05-08T11:05:54.670203761Z","tool":"update_column","input":{"id":"63974019466e3f1d","location":"sidebar"},"ok":true}
|
||||
{"ts":"2026-05-08T11:06:09.679110703Z","tool":"update_column","input":{"id":"635506c9aaac540a","location":"sidebar"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.355764942Z","tool":"update_card","input":{"id":"1e13d5da79a9bae2","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.355959344Z","tool":"update_card","input":{"id":"2a67ec283a40dd1a","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.35612212Z","tool":"update_card","input":{"id":"47dc1a64d4811539","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356239725Z","tool":"update_card","input":{"id":"442714f56f74b1f0","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356354693Z","tool":"update_card","input":{"id":"2268e2cd44a587fb","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356462581Z","tool":"update_card","input":{"id":"f6efaa13146787dd","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356572287Z","tool":"update_card","input":{"id":"6a8f39dc0e8e7218","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356699579Z","tool":"update_card","input":{"id":"eed928c34ccb85a2","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356824315Z","tool":"update_card","input":{"id":"56a4b2b4ac5e8251","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356935364Z","tool":"update_card","input":{"id":"6017f8cb1d6c4d8c","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357046515Z","tool":"update_card","input":{"id":"1eba435104d4391a","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357157261Z","tool":"update_card","input":{"id":"18fa5511fb0c8095","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.35726429Z","tool":"update_card","input":{"id":"81e756341403a4d7","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357372562Z","tool":"update_card","input":{"id":"b1e820b29afa5cdf","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357496076Z","tool":"update_card","input":{"id":"c3c867025281c088","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357605751Z","tool":"update_card","input":{"id":"66ae0108656a731e","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357748295Z","tool":"update_card","input":{"id":"69615eb998a5705d","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.35784585Z","tool":"update_card","input":{"id":"b83087eb4162fdac","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.358008545Z","tool":"update_card","input":{"id":"f960cc196dd2ab0a","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:23:48.494485056Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Enmanuel","title":"Dashboard con totales — líneas TPV/Quote/OTR/Invoice por vendedor, diag y mecánico","description":"Analizar tabla y sacar totales de forma sencilla.\nhttps://reports.autingo.es/question/9754-lineas-tpv-quote-otr-invoice-actores-vendedor-diag-mecanico-producto?con_mecanico=No\u0026fecha=\u0026con_diagnosticador=No\u0026producto_nav_id=","color":"orange"},"ok":true,"result_summary":"card a33c10a6600db235 title=\"Dashboard con totales — líneas TPV/Quote/OTR/Invoice por vendedor, diag y mecánico\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:23:59.15054959Z","tool":"list_users","input":{},"ok":true,"result_summary":"[{\"id\":\"039c97acf1869393\",\"username\":\"amassaguer\",\"display_name\":\"alfon\",\"created_at\":\"2026-05-08T11:03:27.358308764Z\"},{\"id\":\"6a75edc6e99d8405\",\"username\":\"egutierrez\",\"display_name\":\"Enmaa\",\"created..."}
|
||||
{"ts":"2026-05-08T11:24:05.428419675Z","tool":"assign_card","input":{"id":"a33c10a6600db235","assignee_id":"039c97acf1869393"},"ok":true}
|
||||
{"ts":"2026-05-08T11:28:42.163127804Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Danny Sanchez","title":"MB: estado y número de OTRs por presupuesto","description":"","assignee_id":"9e91db261084d529"},"ok":true,"result_summary":"card 11d55b6752f10bdd title=\"MB: estado y número de OTRs por presupuesto\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:28:50.498149425Z","tool":"assign_card","input":{"id":"11d55b6752f10bdd","assignee_id":"9e91db261084d529"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.549537256Z","tool":"update_card","input":{"id":"b1e820b29afa5cdf","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.54980371Z","tool":"update_card","input":{"id":"c3c867025281c088","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.550312442Z","tool":"update_card","input":{"id":"66ae0108656a731e","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.550427194Z","tool":"update_card","input":{"id":"69615eb998a5705d","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.550564181Z","tool":"update_card","input":{"id":"b83087eb4162fdac","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.550752616Z","tool":"update_card","input":{"id":"f960cc196dd2ab0a","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T12:46:10.901190181Z","tool":"move_card","input":{"id":"1e13d5da79a9bae2","column_id":"06ac391eb6d8ce8b","ordered_ids":["1e13d5da79a9bae2","2a67ec283a40dd1a","47dc1a64d4811539","442714f56f74b1f0","2268e2cd44a587fb","56a4b2b4ac5e8251","6017f8cb1d6c4d8c","1eba435104d4391a","18fa5511fb0c8095","c3c867025281c088","b1e820b29afa5cdf"]},"ok":true}
|
||||
{"ts":"2026-05-08T13:00:55.650201794Z","tool":"create_card","input":{"column_id":"63974019466e3f1d","title":"Mezclar dashboard de fichajes con productividad","description":"https://reports.autingo.es/dashboard/994?centro=\u0026dni=\u0026fecha=thisday\u0026provincia=\u0026tipo=\u0026usuario="},"ok":true,"result_summary":"card acf64523865f23d0 title=\"Mezclar dashboard de fichajes con productividad\" col=63974019466e3f1d"}
|
||||
|
||||
@@ -3,7 +3,10 @@ package main
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/core"
|
||||
@@ -25,21 +28,22 @@ type Column struct {
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID string `json:"id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Position int `json:"position"`
|
||||
Locked bool `json:"locked"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
TimeInColumn int64 `json:"time_in_column_ms"`
|
||||
ID string `json:"id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Position int `json:"position"`
|
||||
Locked bool `json:"locked"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
TimeInColumn int64 `json:"time_in_column_ms"`
|
||||
}
|
||||
|
||||
type HistoryEntry struct {
|
||||
@@ -101,6 +105,7 @@ func ensureColumns(conn *sql.DB) error {
|
||||
{"cards", "assignee_id", "TEXT"},
|
||||
{"cards", "completed_at", "TEXT"},
|
||||
{"cards", "deleted_at", "TEXT"},
|
||||
{"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"},
|
||||
{"card_column_history", "actor_id", "TEXT"},
|
||||
{"card_lock_history", "actor_id", "TEXT"},
|
||||
}
|
||||
@@ -156,6 +161,81 @@ func newID() string {
|
||||
|
||||
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func parseTags(s string) []string {
|
||||
out := []string{}
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeTags(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := []string{}
|
||||
for _, t := range in {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[t]; ok {
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeTags(in []string) string {
|
||||
b, _ := json.Marshal(normalizeTags(in))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (db *DB) ListAllTags() ([]string, error) {
|
||||
rows, err := db.conn.Query(`SELECT DISTINCT tags FROM cards WHERE deleted_at IS NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range parseTags(s) {
|
||||
seen[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListDistinctRequesters() ([]string, error) {
|
||||
rows, err := db.conn.Query(`SELECT DISTINCT requester FROM cards WHERE deleted_at IS NULL AND requester != '' ORDER BY requester`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullableActor(actorID string) any {
|
||||
if actorID == "" {
|
||||
return nil
|
||||
@@ -298,7 +378,7 @@ func (db *DB) ReorderColumns(ids []string) error {
|
||||
|
||||
func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, 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.created_at, c.updated_at,
|
||||
SELECT c.id, 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.created_at, c.updated_at,
|
||||
h.entered_at
|
||||
FROM cards c
|
||||
LEFT JOIN card_column_history h
|
||||
@@ -318,8 +398,9 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
var assignee sql.NullString
|
||||
var completed sql.NullString
|
||||
var deleted sql.NullString
|
||||
var tagsJSON string
|
||||
var locked int
|
||||
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Locked = locked != 0
|
||||
@@ -335,6 +416,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
s := deleted.String
|
||||
c.DeletedAt = &s
|
||||
}
|
||||
c.Tags = parseTags(tagsJSON)
|
||||
if entered.Valid {
|
||||
c.EnteredAt = entered.String
|
||||
if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil {
|
||||
@@ -358,6 +440,7 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
|
||||
now := nowRFC3339()
|
||||
c := Card{
|
||||
ID: newID(), Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
|
||||
Tags: []string{},
|
||||
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
|
||||
}
|
||||
tx, err := db.conn.Begin()
|
||||
@@ -366,8 +449,8 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO cards (id, requester, title, description, color, column_id, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, c.CreatedAt, c.UpdatedAt,
|
||||
`INSERT INTO cards (id, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, encodeTags(c.Tags), c.CreatedAt, c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -402,6 +485,7 @@ type CardPatch struct {
|
||||
Locked *bool
|
||||
AssigneeID *string // empty string clears assignment
|
||||
HasAssignee bool // distinguishes "set to null" from "not provided"
|
||||
Tags *[]string
|
||||
}
|
||||
|
||||
func (db *DB) UpdateCard(id string, patch CardPatch) error {
|
||||
@@ -445,6 +529,11 @@ func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) er
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Tags != nil {
|
||||
if _, err := tx.Exec(`UPDATE cards SET tags=?, updated_at=? WHERE id=?`, encodeTags(*patch.Tags), nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.Locked != nil {
|
||||
var current int
|
||||
if err := tx.QueryRow(`SELECT locked FROM cards WHERE id=?`, id).Scan(¤t); err != nil {
|
||||
@@ -500,7 +589,7 @@ func (db *DB) PurgeCard(id string) error {
|
||||
// ListDeletedCards returns cards in the trash, newest first.
|
||||
func (db *DB) ListDeletedCards() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, 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.created_at, c.updated_at
|
||||
SELECT c.id, 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.created_at, c.updated_at
|
||||
FROM cards c
|
||||
WHERE c.deleted_at IS NOT NULL
|
||||
ORDER BY c.deleted_at DESC
|
||||
@@ -515,8 +604,9 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
|
||||
var assignee sql.NullString
|
||||
var completed sql.NullString
|
||||
var deleted sql.NullString
|
||||
var tagsJSON string
|
||||
var locked int
|
||||
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Locked = locked != 0
|
||||
@@ -532,6 +622,7 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
|
||||
s := deleted.String
|
||||
c.DeletedAt = &s
|
||||
}
|
||||
c.Tags = parseTags(tagsJSON)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
+215
-5
@@ -28,10 +28,13 @@ import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -39,6 +42,8 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import "@mantine/dates/styles.css";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
@@ -53,6 +58,7 @@ import {
|
||||
IconMessageChatbot,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconTrashX,
|
||||
IconX,
|
||||
@@ -107,6 +113,15 @@ export function App() {
|
||||
const [activeTab, setActiveTab] = useState<string>("board");
|
||||
const [trash, setTrash] = useState<Card[]>([]);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
||||
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterAssigneeId, setFilterAssigneeId] = useState<string | null>(null);
|
||||
const [filterRequester, setFilterRequester] = useState<string | null>(null);
|
||||
const [filterTags, setFilterTags] = useState<string[]>([]);
|
||||
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
||||
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
|
||||
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [navWidth, setNavWidth] = useState<number>(() => {
|
||||
const stored = localStorage.getItem("kanban_nav_width");
|
||||
@@ -176,6 +191,24 @@ export function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadTags = useCallback(async () => {
|
||||
try {
|
||||
const t = await api.listTags();
|
||||
setTagOptions(t);
|
||||
} catch (e) {
|
||||
console.warn("listTags failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadRequesters = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.listRequesters();
|
||||
setRequesterOptions(r);
|
||||
} catch (e) {
|
||||
console.warn("listRequesters failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reloadUsers();
|
||||
}, [reloadUsers]);
|
||||
@@ -184,6 +217,11 @@ export function App() {
|
||||
reloadTrash();
|
||||
}, [reloadTrash]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadTags();
|
||||
reloadRequesters();
|
||||
}, [reloadTags, reloadRequesters]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
@@ -206,16 +244,61 @@ export function App() {
|
||||
const boardSortableIds = useMemo(() => boardColumns.map((c) => `${COL_PREFIX}${c.id}`), [boardColumns]);
|
||||
const sidebarSortableIds = useMemo(() => sidebarColumns.map((c) => `${COL_PREFIX}${c.id}`), [sidebarColumns]);
|
||||
|
||||
const cardMatches = useCallback(
|
||||
(c: Card): boolean => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
if (term) {
|
||||
const hay = [
|
||||
c.title,
|
||||
c.description,
|
||||
c.requester,
|
||||
...(c.tags || []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!hay.includes(term)) return false;
|
||||
}
|
||||
if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false;
|
||||
if (filterUnassigned && c.assignee_id) return false;
|
||||
if (filterRequester && c.requester !== filterRequester) return false;
|
||||
if (filterTags.length > 0) {
|
||||
const cardTags = new Set(c.tags || []);
|
||||
for (const t of filterTags) if (!cardTags.has(t)) return false;
|
||||
}
|
||||
if (filterDateFrom || filterDateTo) {
|
||||
const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity;
|
||||
const toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity;
|
||||
const created = c.created_at ? new Date(c.created_at).getTime() : NaN;
|
||||
const moved = c.entered_at ? new Date(c.entered_at).getTime() : NaN;
|
||||
const inRange = (t: number) => !isNaN(t) && t >= fromMs && t <= toMs;
|
||||
if (!inRange(created) && !inRange(moved)) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo]
|
||||
);
|
||||
|
||||
const cardsByColumn = useMemo(() => {
|
||||
const map = new Map<string, Card[]>();
|
||||
if (!board) return map;
|
||||
for (const col of board.columns) map.set(col.id, []);
|
||||
for (const c of [...board.cards].sort((a, b) => a.position - b.position)) {
|
||||
if (!cardMatches(c)) continue;
|
||||
const arr = map.get(c.column_id);
|
||||
if (arr) arr.push(c);
|
||||
}
|
||||
return map;
|
||||
}, [board]);
|
||||
}, [board, cardMatches]);
|
||||
|
||||
const filtersActive =
|
||||
!!searchTerm.trim() ||
|
||||
!!filterAssigneeId ||
|
||||
filterUnassigned ||
|
||||
!!filterRequester ||
|
||||
filterTags.length > 0 ||
|
||||
!!filterDateFrom ||
|
||||
!!filterDateTo;
|
||||
|
||||
const findCard = (id: string): Card | undefined => board?.cards.find((c) => c.id === id);
|
||||
const findColumn = (id: string): Column | undefined => board?.columns.find((c) => c.id === id);
|
||||
@@ -424,6 +507,8 @@ export function App() {
|
||||
children: (
|
||||
<CardForm
|
||||
users={users}
|
||||
requesterOptions={requesterOptions}
|
||||
tagOptions={tagOptions}
|
||||
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
|
||||
submitLabel="Crear"
|
||||
onCancel={() => modals.close(id)}
|
||||
@@ -435,9 +520,12 @@ export function App() {
|
||||
title: v.title,
|
||||
description: v.description,
|
||||
assignee_id: v.assignee_id,
|
||||
tags: v.tags,
|
||||
});
|
||||
modals.close(id);
|
||||
reload();
|
||||
reloadTags();
|
||||
reloadRequesters();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
@@ -445,7 +533,7 @@ export function App() {
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [reload, users, auth.user]);
|
||||
}, [reload, users, auth.user, requesterOptions, tagOptions]);
|
||||
|
||||
const openEditCard = useCallback((card: Card) => {
|
||||
const id = modals.open({
|
||||
@@ -454,11 +542,14 @@ export function App() {
|
||||
children: (
|
||||
<CardForm
|
||||
users={users}
|
||||
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)}
|
||||
@@ -469,9 +560,12 @@ export function App() {
|
||||
title: v.title,
|
||||
description: v.description,
|
||||
assignee_id: v.assignee_id,
|
||||
tags: v.tags,
|
||||
});
|
||||
modals.close(id);
|
||||
reload();
|
||||
reloadTags();
|
||||
reloadRequesters();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
@@ -479,7 +573,7 @@ export function App() {
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [reload, users]);
|
||||
}, [reload, users, requesterOptions, tagOptions]);
|
||||
|
||||
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
|
||||
setBoard((prev) => {
|
||||
@@ -822,14 +916,130 @@ export function App() {
|
||||
<CalendarView users={users} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden" }}>
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
<Group gap="xs" p="xs" wrap="wrap" align="end" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}>
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={14} />}
|
||||
placeholder="Buscar (titulo, descripcion, solicitante, tag)"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.currentTarget.value)}
|
||||
rightSection={
|
||||
searchTerm ? (
|
||||
<ActionIcon size="sm" variant="subtle" color="gray" onClick={() => setSearchTerm("")} aria-label="Limpiar">
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ minWidth: 280, flex: 1 }}
|
||||
size="xs"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Asignado"
|
||||
value={filterAssigneeId}
|
||||
onChange={setFilterAssigneeId}
|
||||
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
style={{ minWidth: 160 }}
|
||||
disabled={filterUnassigned}
|
||||
/>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label="Sin asignar"
|
||||
checked={filterUnassigned}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.checked;
|
||||
setFilterUnassigned(v);
|
||||
if (v) setFilterAssigneeId(null);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Solicitante"
|
||||
value={filterRequester}
|
||||
onChange={setFilterRequester}
|
||||
data={requesterOptions}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
<MultiSelect
|
||||
placeholder="Tags"
|
||||
value={filterTags}
|
||||
onChange={setFilterTags}
|
||||
data={tagOptions}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
<DatePickerInput
|
||||
placeholder="Desde"
|
||||
value={filterDateFrom}
|
||||
onChange={(v) => setFilterDateFrom(v ? new Date(v as unknown as string) : null)}
|
||||
clearable
|
||||
size="xs"
|
||||
style={{ minWidth: 130 }}
|
||||
valueFormat="DD/MM/YY"
|
||||
/>
|
||||
<DatePickerInput
|
||||
placeholder="Hasta"
|
||||
value={filterDateTo}
|
||||
onChange={(v) => setFilterDateTo(v ? new Date(v as unknown as string) : null)}
|
||||
clearable
|
||||
size="xs"
|
||||
style={{ minWidth: 130 }}
|
||||
valueFormat="DD/MM/YY"
|
||||
/>
|
||||
<Group gap={4}>
|
||||
<Button size="xs" variant="default" onClick={() => {
|
||||
const t = new Date();
|
||||
setFilterDateFrom(t);
|
||||
setFilterDateTo(t);
|
||||
}}>Hoy</Button>
|
||||
<Button size="xs" variant="default" onClick={() => {
|
||||
const t = new Date();
|
||||
const f = new Date();
|
||||
f.setDate(f.getDate() - 7);
|
||||
setFilterDateFrom(f);
|
||||
setFilterDateTo(t);
|
||||
}}>7d</Button>
|
||||
<Button size="xs" variant="default" onClick={() => {
|
||||
const t = new Date();
|
||||
const f = new Date();
|
||||
f.setDate(f.getDate() - 30);
|
||||
setFilterDateFrom(f);
|
||||
setFilterDateTo(t);
|
||||
}}>30d</Button>
|
||||
</Group>
|
||||
{filtersActive && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconX size={12} />}
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setFilterAssigneeId(null);
|
||||
setFilterUnassigned(false);
|
||||
setFilterRequester(null);
|
||||
setFilterTags([]);
|
||||
setFilterDateFrom(null);
|
||||
setFilterDateTo(null);
|
||||
}}
|
||||
>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
|
||||
<Group
|
||||
align="stretch"
|
||||
wrap="nowrap"
|
||||
gap="md"
|
||||
p="md"
|
||||
style={{ height: "100%", overflowX: "auto" }}
|
||||
style={{ flex: 1, overflowX: "auto", overflowY: "hidden" }}
|
||||
>
|
||||
{boardColumns.map((col) => (
|
||||
<KanbanColumn
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface CreateCardInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
assignee_id?: string | null;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export function createCard(input: CreateCardInput): Promise<Card> {
|
||||
@@ -81,6 +82,7 @@ export interface UpdateCardInput {
|
||||
color?: string;
|
||||
locked?: boolean;
|
||||
assignee_id?: string | null;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
|
||||
@@ -162,6 +164,14 @@ export function listUsers(): Promise<User[]> {
|
||||
return fetchJSON("/users");
|
||||
}
|
||||
|
||||
export function listTags(): Promise<string[]> {
|
||||
return fetchJSON("/tags");
|
||||
}
|
||||
|
||||
export function listRequesters(): Promise<string[]> {
|
||||
return fetchJSON("/requesters");
|
||||
}
|
||||
|
||||
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||
const qs = new URLSearchParams();
|
||||
if (f.from) qs.set("from", f.from);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Group, Select, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core";
|
||||
import { FormEvent, KeyboardEvent, useState } from "react";
|
||||
import type { User } from "../types";
|
||||
|
||||
@@ -7,21 +7,33 @@ export interface CardFormValues {
|
||||
title: string;
|
||||
description: string;
|
||||
assignee_id: string | null;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initial?: Partial<CardFormValues>;
|
||||
submitLabel?: string;
|
||||
users?: User[];
|
||||
requesterOptions?: string[];
|
||||
tagOptions?: string[];
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmit, onCancel }: Props) {
|
||||
export function CardForm({
|
||||
initial,
|
||||
submitLabel = "Guardar",
|
||||
users = [],
|
||||
requesterOptions = [],
|
||||
tagOptions = [],
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [requester, setRequester] = useState(initial?.requester ?? "");
|
||||
const [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
||||
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
@@ -32,6 +44,7 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
|
||||
title: t,
|
||||
description,
|
||||
assignee_id: assigneeId,
|
||||
tags,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -51,7 +64,7 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
<Textarea
|
||||
label="Tarea"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
@@ -59,15 +72,26 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
|
||||
required
|
||||
autoComplete="off"
|
||||
data-autofocus
|
||||
onKeyDown={enterSubmit}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
<Autocomplete
|
||||
label="Solicitante"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.currentTarget.value)}
|
||||
onChange={setRequester}
|
||||
data={requesterOptions}
|
||||
tabIndex={2}
|
||||
autoComplete="off"
|
||||
onKeyDown={enterSubmit}
|
||||
placeholder="Empieza a escribir y elige uno existente"
|
||||
limit={10}
|
||||
/>
|
||||
<Textarea
|
||||
label="Descripcion"
|
||||
@@ -93,11 +117,21 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
|
||||
searchable
|
||||
tabIndex={4}
|
||||
/>
|
||||
<TagsInput
|
||||
label="Tags"
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
data={tagOptions}
|
||||
clearable
|
||||
tabIndex={5}
|
||||
placeholder="Enter para añadir; sugiere existentes"
|
||||
splitChars={[",", " "]}
|
||||
/>
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<Button variant="subtle" color="gray" tabIndex={6} type="button" onClick={onCancel}>
|
||||
<Button variant="subtle" color="gray" tabIndex={7} type="button" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button tabIndex={5} type="submit" disabled={!title.trim()}>
|
||||
<Button tabIndex={6} type="submit" disabled={!title.trim()}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -178,7 +178,8 @@ export function Dashboard({ users }: Props) {
|
||||
if (!data) return [];
|
||||
return data.top_requesters.map((r) => ({
|
||||
solicitante: r.requester,
|
||||
tarjetas: r.total,
|
||||
activas: r.active,
|
||||
completadas: r.completed_in_range,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
@@ -250,18 +251,25 @@ export function Dashboard({ users }: Props) {
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
|
||||
<SimpleGrid cols={{ base: 2, md: 5 }} spacing="md">
|
||||
<KPI
|
||||
icon={<IconClipboardList size={14} />}
|
||||
label="Tarjetas totales"
|
||||
label="Totales"
|
||||
value={data.totals.cards}
|
||||
hint={`${data.totals.columns} columnas, ${data.totals.users} usuarios`}
|
||||
/>
|
||||
<KPI
|
||||
icon={<IconClipboardList size={14} />}
|
||||
label="Activas"
|
||||
value={data.totals.cards_active}
|
||||
hint={`Sin completar`}
|
||||
color="blue"
|
||||
/>
|
||||
<KPI
|
||||
icon={<IconCheckbox size={14} />}
|
||||
label="Completadas (rango)"
|
||||
value={data.totals.cards_completed_in_range}
|
||||
hint={`${data.totals.cards_created_in_range} creadas en rango`}
|
||||
hint={`${data.totals.cards_done} completadas total · ${data.totals.cards_created_in_range} creadas rango`}
|
||||
color="green"
|
||||
/>
|
||||
<KPI
|
||||
@@ -426,12 +434,17 @@ export function Dashboard({ users }: Props) {
|
||||
</Text>
|
||||
) : (
|
||||
<BarChart
|
||||
h={240}
|
||||
h={Math.max(240, topRequesterSeries.length * 32)}
|
||||
data={topRequesterSeries}
|
||||
dataKey="solicitante"
|
||||
orientation="vertical"
|
||||
yAxisProps={{ width: 120 }}
|
||||
series={[{ name: "tarjetas", label: "Tarjetas", color: "violet.6" }]}
|
||||
yAxisProps={{ width: 160, interval: 0 }}
|
||||
withLegend
|
||||
series={[
|
||||
{ name: "completadas", label: "Completadas", color: "green.6" },
|
||||
{ name: "activas", label: "Activas", color: "violet.6" },
|
||||
]}
|
||||
type="stacked"
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
@@ -149,6 +149,7 @@ function KanbanCardImpl({
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
@@ -176,6 +177,7 @@ function KanbanCardImpl({
|
||||
clearable
|
||||
searchable
|
||||
autoFocus
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
@@ -298,6 +300,15 @@ function KanbanCardImpl({
|
||||
{card.description}
|
||||
</Text>
|
||||
)}
|
||||
{card.tags && card.tags.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{card.tags.map((t) => (
|
||||
<Badge key={t} size="xs" variant="outline" color="violet" radius="sm">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
{formatDuration(liveMs)}
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface Card {
|
||||
assignee_id: string | null;
|
||||
completed_at: string | null;
|
||||
deleted_at: string | null;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
entered_at: string;
|
||||
@@ -47,6 +48,8 @@ export interface MetricsTotals {
|
||||
cards: number;
|
||||
cards_completed_in_range: number;
|
||||
cards_created_in_range: number;
|
||||
cards_active: number;
|
||||
cards_done: number;
|
||||
columns: number;
|
||||
users: number;
|
||||
active_locks: number;
|
||||
@@ -90,6 +93,8 @@ export interface MetricsAssignee {
|
||||
export interface MetricsRequester {
|
||||
requester: string;
|
||||
total: number;
|
||||
active: number;
|
||||
completed_in_range: number;
|
||||
}
|
||||
|
||||
export interface MetricsMovement {
|
||||
|
||||
+48
-5
@@ -118,11 +118,12 @@ func handleReorderColumns(db *DB) http.HandlerFunc {
|
||||
func handleCreateCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
ColumnID string `json:"column_id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
@@ -140,6 +141,13 @@ func handleCreateCard(db *DB) http.HandlerFunc {
|
||||
c.AssigneeID = body.AssigneeID
|
||||
}
|
||||
}
|
||||
if err == nil && len(body.Tags) > 0 {
|
||||
tags := body.Tags
|
||||
err = db.UpdateCardWithActor(c.ID, CardPatch{Tags: &tags}, actor)
|
||||
if err == nil {
|
||||
c.Tags = tags
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
@@ -182,6 +190,17 @@ func handleUpdateCard(db *DB) http.HandlerFunc {
|
||||
patch.AssigneeID = &s
|
||||
}
|
||||
}
|
||||
if v, present := raw["tags"]; present {
|
||||
tags := []string{}
|
||||
if arr, ok := v.([]any); ok {
|
||||
for _, t := range arr {
|
||||
if s, ok := t.(string); ok {
|
||||
tags = append(tags, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
patch.Tags = &tags
|
||||
}
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
if err := db.UpdateCardWithActor(id, patch, actor); err != nil {
|
||||
serverError(w, err)
|
||||
@@ -303,5 +322,29 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
|
||||
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
|
||||
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
|
||||
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
|
||||
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
|
||||
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
|
||||
}
|
||||
}
|
||||
|
||||
func handleListTags(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tags, err := db.ListAllTags()
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, tags)
|
||||
}
|
||||
}
|
||||
|
||||
func handleListRequesters(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := db.ListDistinctRequesters()
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
+54
-6
@@ -39,6 +39,8 @@ type Totals struct {
|
||||
Cards int `json:"cards"`
|
||||
CardsCompleted int `json:"cards_completed_in_range"`
|
||||
CardsCreated int `json:"cards_created_in_range"`
|
||||
CardsActive int `json:"cards_active"`
|
||||
CardsDone int `json:"cards_done"`
|
||||
Columns int `json:"columns"`
|
||||
Users int `json:"users"`
|
||||
ActiveLocks int `json:"active_locks"`
|
||||
@@ -82,6 +84,8 @@ type AssigneeStat struct {
|
||||
type RequesterStat struct {
|
||||
Requester string `json:"requester"`
|
||||
Total int `json:"total"`
|
||||
Active int `json:"active"`
|
||||
Completed int `json:"completed_in_range"`
|
||||
}
|
||||
|
||||
type MovementStat struct {
|
||||
@@ -137,12 +141,25 @@ func parseDateOrDefault(s string, dflt time.Time) time.Time {
|
||||
return dflt
|
||||
}
|
||||
|
||||
func parseEndDateOrDefault(s string, dflt time.Time) time.Time {
|
||||
if s == "" {
|
||||
return dflt
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||
return t.Add(24*time.Hour - time.Nanosecond)
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t
|
||||
}
|
||||
return dflt
|
||||
}
|
||||
|
||||
// GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=...
|
||||
func handleMetrics(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().UTC()
|
||||
from := parseDateOrDefault(r.URL.Query().Get("from"), now.AddDate(0, 0, -30))
|
||||
to := parseDateOrDefault(r.URL.Query().Get("to"), now)
|
||||
to := parseEndDateOrDefault(r.URL.Query().Get("to"), now)
|
||||
assignee := r.URL.Query().Get("assignee_id")
|
||||
requester := r.URL.Query().Get("requester")
|
||||
|
||||
@@ -160,7 +177,15 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
|
||||
toStr := to.Format(time.RFC3339Nano)
|
||||
|
||||
m := &Metrics{
|
||||
Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")},
|
||||
Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")},
|
||||
ByColumn: []ColumnCount{},
|
||||
ThroughputDaily: []DailyCount{},
|
||||
CreatedDaily: []DailyCount{},
|
||||
CycleTimeColumn: []ColumnDuration{},
|
||||
TopAssignees: []AssigneeStat{},
|
||||
TopRequesters: []RequesterStat{},
|
||||
MovementsByUser: []MovementStat{},
|
||||
CumulativeFlow: []CumulativePoint{},
|
||||
}
|
||||
|
||||
cardWhere := "WHERE deleted_at IS NULL"
|
||||
@@ -190,6 +215,18 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
|
||||
).Scan(&m.Totals.CardsCreated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.conn.QueryRow(
|
||||
`SELECT COUNT(*) FROM cards `+cardWhere+` AND (completed_at IS NULL OR completed_at='')`,
|
||||
args...,
|
||||
).Scan(&m.Totals.CardsActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.conn.QueryRow(
|
||||
`SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at!=''`,
|
||||
args...,
|
||||
).Scan(&m.Totals.CardsDone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = completedArgs
|
||||
|
||||
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM columns`).Scan(&m.Totals.Columns); err != nil {
|
||||
@@ -276,12 +313,18 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
|
||||
}
|
||||
colRows.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
cap := to
|
||||
if now.Before(cap) {
|
||||
cap = now
|
||||
}
|
||||
capStr := cap.Format(time.RFC3339Nano)
|
||||
for _, ci := range cols {
|
||||
histArgs := []any{ci.id, fromStr, toStr}
|
||||
histQ := `SELECT (julianday(COALESCE(h.exited_at, ?)) - julianday(h.entered_at)) * 86400000
|
||||
FROM card_column_history h JOIN cards c ON c.id=h.card_id
|
||||
WHERE h.column_id=? AND h.entered_at>=? AND h.entered_at<=?`
|
||||
histArgs = append([]any{toStr}, histArgs...)
|
||||
histArgs = append([]any{capStr}, histArgs...)
|
||||
if assignee != "" {
|
||||
histQ += ` AND c.assignee_id=?`
|
||||
histArgs = append(histArgs, assignee)
|
||||
@@ -325,9 +368,14 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
|
||||
|
||||
// Top requesters.
|
||||
reqRows, err := db.conn.Query(
|
||||
`SELECT requester, COUNT(*) as n FROM cards WHERE deleted_at IS NULL AND requester != '' AND created_at>=? AND created_at<=?`+
|
||||
`SELECT requester,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN completed_at IS NULL OR completed_at='' THEN 1 ELSE 0 END) as active,
|
||||
SUM(CASE WHEN completed_at IS NOT NULL AND completed_at>=? AND completed_at<=? THEN 1 ELSE 0 END) as completed
|
||||
FROM cards
|
||||
WHERE deleted_at IS NULL AND requester != ''`+
|
||||
condFromCard(assignee, "", "", "AND")+
|
||||
` GROUP BY requester ORDER BY n DESC LIMIT 10`,
|
||||
` GROUP BY requester ORDER BY total DESC LIMIT 10`,
|
||||
topReqArgs(fromStr, toStr, assignee)...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -335,7 +383,7 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
|
||||
}
|
||||
for reqRows.Next() {
|
||||
var s RequesterStat
|
||||
if err := reqRows.Scan(&s.Requester, &s.Total); err != nil {
|
||||
if err := reqRows.Scan(&s.Requester, &s.Total, &s.Active, &s.Completed); err != nil {
|
||||
reqRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user