diff --git a/chat.log b/chat.log index 8bdde2e..af128f1 100644 --- a/chat.log +++ b/chat.log @@ -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"} diff --git a/db.go b/db.go index d180287..91aab22 100644 --- a/db.go +++ b/db.go @@ -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() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index def72e1..ffa0c56 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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("board"); const [trash, setTrash] = useState([]); const [trashOpen, setTrashOpen] = useState(false); + const [tagOptions, setTagOptions] = useState([]); + const [requesterOptions, setRequesterOptions] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [filterAssigneeId, setFilterAssigneeId] = useState(null); + const [filterRequester, setFilterRequester] = useState(null); + const [filterTags, setFilterTags] = useState([]); + const [filterUnassigned, setFilterUnassigned] = useState(false); + const [filterDateFrom, setFilterDateFrom] = useState(null); + const [filterDateTo, setFilterDateTo] = useState(null); const [navOpen, setNavOpen] = useState(false); const [navWidth, setNavWidth] = useState(() => { 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(); 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: ( 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: ( 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() { ) : ( - + + + } + placeholder="Buscar (titulo, descripcion, solicitante, tag)" + value={searchTerm} + onChange={(e) => setSearchTerm(e.currentTarget.value)} + rightSection={ + searchTerm ? ( + setSearchTerm("")} aria-label="Limpiar"> + + + ) : null + } + style={{ minWidth: 280, flex: 1 }} + size="xs" + /> + + + setFilterDateFrom(v ? new Date(v as unknown as string) : null)} + clearable + size="xs" + style={{ minWidth: 130 }} + valueFormat="DD/MM/YY" + /> + setFilterDateTo(v ? new Date(v as unknown as string) : null)} + clearable + size="xs" + style={{ minWidth: 130 }} + valueFormat="DD/MM/YY" + /> + + + + + + {filtersActive && ( + + )} + {boardColumns.map((col) => ( { @@ -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 { @@ -162,6 +164,14 @@ export function listUsers(): Promise { return fetchJSON("/users"); } +export function listTags(): Promise { + return fetchJSON("/tags"); +} + +export function listRequesters(): Promise { + return fetchJSON("/requesters"); +} + export function getMetrics(f: MetricsFilter): Promise { const qs = new URLSearchParams(); if (f.from) qs.set("from", f.from); diff --git a/frontend/src/components/CardForm.tsx b/frontend/src/components/CardForm.tsx index d1427a4..ff6276f 100644 --- a/frontend/src/components/CardForm.tsx +++ b/frontend/src/components/CardForm.tsx @@ -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; submitLabel?: string; users?: User[]; + requesterOptions?: string[]; + tagOptions?: string[]; onSubmit: (v: CardFormValues) => Promise | 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(initial?.assignee_id ?? null); + const [tags, setTags] = useState(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 (
- 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(); + } + }} /> - setRequester(e.currentTarget.value)} + onChange={setRequester} + data={requesterOptions} tabIndex={2} autoComplete="off" onKeyDown={enterSubmit} + placeholder="Empieza a escribir y elige uno existente" + limit={10} />