diff --git a/app.md b/app.md index b62460e..20788ca 100644 --- a/app.md +++ b/app.md @@ -16,6 +16,11 @@ uses_functions: - http_json_response_go_infra - http_error_response_go_infra - http_parse_body_go_infra + - http_session_cookie_middleware_go_infra + - password_hash_go_infra + - password_verify_go_infra + - session_create_go_infra + - session_cleanup_go_infra uses_types: [] framework: "net/http + vite + react + mantine + dnd-kit" entry_point: "main.go" diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..202df5e --- /dev/null +++ b/auth.go @@ -0,0 +1,143 @@ +package main + +import ( + "errors" + "net/http" + "time" + + "fn-registry/functions/infra" +) + +const ( + cookieName = "kanban_session" + sessionTTL = 7 * 24 * time.Hour +) + +type ctxKey string + +const userCtxKey ctxKey = "kanban_user_id" + +func setSessionCookie(w http.ResponseWriter, token string, expiresAt int64) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Unix(expiresAt, 0), + }) +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +func tokenFromRequest(r *http.Request) string { + if c, err := r.Cookie(cookieName); err == nil && c.Value != "" { + return c.Value + } + auth := r.Header.Get("Authorization") + if len(auth) > 7 && auth[:7] == "Bearer " { + return auth[7:] + } + return "" +} + +// POST /api/auth/register {username, password, display_name?} +func handleRegister(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"display_name"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + u, err := db.CreateUser(body.Username, body.Password, body.DisplayName) + if err != nil { + if errors.Is(err, errUserAlreadyExists) { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusConflict, Code: "user_exists", Message: err.Error()}) + return + } + badRequest(w, err.Error()) + return + } + infra.HTTPJSONResponse(w, http.StatusCreated, u) + } +} + +// POST /api/auth/login {username, password} +func handleLogin(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + u, err := db.Authenticate(body.Username, body.Password) + if err != nil { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "invalid_credentials", Message: "invalid username or password"}) + return + } + sess, err := infra.SessionCreate(db.conn, u.ID, sessionTTL, map[string]any{"username": u.Username}) + if err != nil { + serverError(w, err) + return + } + setSessionCookie(w, sess.Token, sess.ExpiresAt) + infra.HTTPJSONResponse(w, http.StatusOK, u) + } +} + +// POST /api/auth/logout +func handleLogout(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := tokenFromRequest(r) + if token != "" { + _ = db.DeleteSessionByToken(token) + } + clearSessionCookie(w) + w.WriteHeader(http.StatusNoContent) + } +} + +// GET /api/me +func handleMe(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, ok := infra.UserIDFromContext(r.Context(), userCtxKey) + if !ok { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "no session"}) + return + } + u, err := db.GetUserByID(uid) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, u) + } +} + +// GET /api/users +func handleListUsers(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + users, err := db.ListUsers() + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, users) + } +} diff --git a/chat.go b/chat.go index 567ef25..b30ea04 100644 --- a/chat.go +++ b/chat.go @@ -30,15 +30,17 @@ Ejemplo: Tools disponibles (todas con sus inputs): - list_board {} -> {columns, cards} - create_column {name} -- update_column {id, name?, location?, width?} // location: "board" | "sidebar". width: 200..800 px. +- update_column {id, name?, location?, width?, wip_limit?, is_done?} // location: "board" | "sidebar". width: 200..800 px. wip_limit: max tarjetas (0 = sin limite). is_done: marca columna como terminal (cards dentro se cuentan como completadas para metricas y se muestran tachadas). - delete_column {id} - reorder_columns {ids:[...]} - create_card {column_id, requester?, title, description?} -- update_card {id, requester?, title?, description?, color?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default) +- update_card {id, requester?, title?, description?, color?, locked?, assignee_id?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default). locked: true bloquea la tarjeta (no se puede mover entre columnas hasta desbloquear). assignee_id: ID del usuario asignado o null para desasignar. - delete_card {id} - move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final - card_history {id} - find_cards {query?, column_id?, requester?} +- list_users {} -> [{id, username, display_name}] +- assign_card {id, assignee_id} // alias rapido de update_card. assignee_id puede ser null para desasignar. Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque . diff --git a/chat.log b/chat.log new file mode 100644 index 0000000..8bdde2e --- /dev/null +++ b/chat.log @@ -0,0 +1,3 @@ +{"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} diff --git a/db.go b/db.go index 1485d00..d180287 100644 --- a/db.go +++ b/db.go @@ -19,21 +19,27 @@ type Column struct { 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"` } 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"` - 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"` + 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 { @@ -46,6 +52,21 @@ type HistoryEntry struct { DurationMs int64 `json:"duration_ms"` } +type LockPeriod struct { + ID string `json:"id"` + CardID string `json:"card_id"` + LockedAt string `json:"locked_at"` + UnlockedAt *string `json:"unlocked_at"` + DurationMs int64 `json:"duration_ms"` +} + +type CardHistoryResponse struct { + ColumnHistory []HistoryEntry `json:"column_history"` + LockPeriods []LockPeriod `json:"lock_periods"` + TotalLockedMs int64 `json:"total_locked_ms"` + CurrentlyLock bool `json:"currently_locked"` +} + type DB struct{ conn *sql.DB } func openDB(path string) (*DB, error) { @@ -73,7 +94,15 @@ func ensureColumns(conn *sql.DB) error { specs := []colSpec{ {"columns", "location", "TEXT NOT NULL DEFAULT 'board'"}, {"columns", "width", "INTEGER NOT NULL DEFAULT 300"}, + {"columns", "wip_limit", "INTEGER NOT NULL DEFAULT 0"}, + {"columns", "is_done", "INTEGER NOT NULL DEFAULT 0"}, {"cards", "color", "TEXT NOT NULL DEFAULT ''"}, + {"cards", "locked", "INTEGER NOT NULL DEFAULT 0"}, + {"cards", "assignee_id", "TEXT"}, + {"cards", "completed_at", "TEXT"}, + {"cards", "deleted_at", "TEXT"}, + {"card_column_history", "actor_id", "TEXT"}, + {"card_lock_history", "actor_id", "TEXT"}, } for _, s := range specs { exists, err := columnExists(conn, s.table, s.name) @@ -87,6 +116,9 @@ func ensureColumns(conn *sql.DB) error { return fmt.Errorf("add %s.%s: %w", s.table, s.name, err) } } + if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id)`); err != nil { + return fmt.Errorf("create assignee index: %w", err) + } return nil } @@ -124,10 +156,17 @@ func newID() string { func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) } +func nullableActor(actorID string) any { + if actorID == "" { + return nil + } + return actorID +} + // --- Columns --- func (db *DB) ListColumns() ([]Column, error) { - rows, err := db.conn.Query(`SELECT id, name, position, location, width, created_at FROM columns ORDER BY position, created_at`) + rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, created_at FROM columns ORDER BY position, created_at`) if err != nil { return nil, err } @@ -135,9 +174,11 @@ func (db *DB) ListColumns() ([]Column, error) { out := []Column{} for rows.Next() { var c Column - if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.CreatedAt); err != nil { + var isDone int + if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.CreatedAt); err != nil { return nil, err } + c.IsDone = isDone != 0 out = append(out, c) } return out, rows.Err() @@ -152,10 +193,10 @@ func (db *DB) CreateColumn(name string) (*Column, error) { if maxPos.Valid { pos = int(maxPos.Int64) + 1 } - c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, CreatedAt: nowRFC3339()} + c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, WIPLimit: 0, IsDone: false, CreatedAt: nowRFC3339()} _, err := db.conn.Exec( - `INSERT INTO columns (id, name, position, location, width, created_at) VALUES (?, ?, ?, ?, ?, ?)`, - c.ID, c.Name, c.Position, c.Location, c.Width, c.CreatedAt, + `INSERT INTO columns (id, name, position, location, width, wip_limit, is_done, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, c.Name, c.Position, c.Location, c.Width, c.WIPLimit, 0, c.CreatedAt, ) if err != nil { return nil, err @@ -168,6 +209,8 @@ type ColumnPatch struct { Position *int Location *string Width *int + WIPLimit *int + IsDone *bool } func (db *DB) UpdateColumn(id string, patch ColumnPatch) error { @@ -200,6 +243,35 @@ func (db *DB) UpdateColumn(id string, patch ColumnPatch) error { return err } } + if patch.WIPLimit != nil { + l := *patch.WIPLimit + if l < 0 { + l = 0 + } + if _, err := db.conn.Exec(`UPDATE columns SET wip_limit=? WHERE id=?`, l, id); err != nil { + return err + } + } + if patch.IsDone != nil { + v := 0 + if *patch.IsDone { + v = 1 + } + if _, err := db.conn.Exec(`UPDATE columns SET is_done=? WHERE id=?`, v, id); err != nil { + return err + } + // Re-evaluate completed_at for cards in this column. + now := nowRFC3339() + if v == 1 { + if _, err := db.conn.Exec(`UPDATE cards SET completed_at=? WHERE column_id=? AND completed_at IS NULL`, now, id); err != nil { + return err + } + } else { + if _, err := db.conn.Exec(`UPDATE cards SET completed_at=NULL WHERE column_id=?`, id); err != nil { + return err + } + } + } return nil } @@ -226,11 +298,12 @@ 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.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.created_at, c.updated_at, h.entered_at FROM cards c LEFT JOIN card_column_history h ON h.card_id = c.id AND h.exited_at IS NULL + WHERE c.deleted_at IS NULL ORDER BY c.column_id, c.position, c.created_at `) if err != nil { @@ -242,9 +315,26 @@ func (db *DB) ListCardsWithTime() ([]Card, error) { for rows.Next() { var c Card var entered sql.NullString - if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil { + var assignee sql.NullString + var completed sql.NullString + var deleted sql.NullString + 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 { return nil, err } + 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 deleted.Valid && deleted.String != "" { + s := deleted.String + c.DeletedAt = &s + } if entered.Valid { c.EnteredAt = entered.String if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil { @@ -256,7 +346,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) { return out, rows.Err() } -func (db *DB) CreateCard(columnID, requester, title, description string) (*Card, error) { +func (db *DB) CreateCard(columnID, requester, title, description, actorID string) (*Card, error) { var maxPos sql.NullInt64 if err := db.conn.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, columnID).Scan(&maxPos); err != nil { return nil, err @@ -282,11 +372,22 @@ func (db *DB) CreateCard(columnID, requester, title, description string) (*Card, return nil, err } if _, err := tx.Exec( - `INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`, - newID(), c.ID, c.ColumnID, now, + `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 } + // If the destination column is_done, set completed_at. + var destDone int + if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, 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 := tx.Commit(); err != nil { return nil, err } @@ -298,9 +399,16 @@ type CardPatch struct { Title *string Description *string Color *string + Locked *bool + AssigneeID *string // empty string clears assignment + HasAssignee bool // distinguishes "set to null" from "not provided" } func (db *DB) UpdateCard(id string, patch CardPatch) error { + return db.UpdateCardWithActor(id, patch, "") +} + +func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) error { tx, err := db.conn.Begin() if err != nil { return err @@ -326,18 +434,114 @@ func (db *DB) UpdateCard(id string, patch CardPatch) error { return err } } + if patch.HasAssignee { + if patch.AssigneeID == nil || *patch.AssigneeID == "" { + if _, err := tx.Exec(`UPDATE cards SET assignee_id=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil { + return err + } + } else { + if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, 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 { + return err + } + desired := 0 + if *patch.Locked { + desired = 1 + } + if current != desired { + now := nowRFC3339() + if _, err := tx.Exec(`UPDATE cards SET locked=?, updated_at=? WHERE id=?`, desired, now, id); err != nil { + return err + } + if desired == 1 { + if _, err := tx.Exec( + `INSERT INTO card_lock_history (id, card_id, locked_at, actor_id) VALUES (?, ?, ?, ?)`, + newID(), id, now, nullableActor(actorID), + ); err != nil { + return err + } + } else { + if _, err := tx.Exec( + `UPDATE card_lock_history SET unlocked_at=? WHERE card_id=? AND unlocked_at IS NULL`, + now, id, + ); err != nil { + return err + } + } + } + } return tx.Commit() } +// DeleteCard soft-deletes the card (moves it to trash). func (db *DB) DeleteCard(id string) error { + _, err := db.conn.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id) + return err +} + +// RestoreCard removes the deleted_at flag. +func (db *DB) RestoreCard(id string) error { + _, err := db.conn.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id) + return err +} + +// PurgeCard permanently removes the card from the DB. +func (db *DB) PurgeCard(id string) error { _, err := db.conn.Exec(`DELETE FROM cards WHERE id=?`, id) return err } +// 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 + FROM cards c + WHERE c.deleted_at IS NOT NULL + ORDER BY c.deleted_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Card{} + for rows.Next() { + var c Card + var assignee sql.NullString + var completed sql.NullString + var deleted sql.NullString + 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 { + return nil, err + } + 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 deleted.Valid { + s := deleted.String + c.DeletedAt = &s + } + 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). -func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error { +// actorID is the user performing the move (empty string for system/anonymous). +func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string, actorID string) error { tx, err := db.conn.Begin() if err != nil { return err @@ -345,9 +549,13 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error { defer tx.Rollback() var srcColumnID string - if err := tx.QueryRow(`SELECT column_id FROM cards WHERE id=?`, cardID).Scan(&srcColumnID); err != nil { + var locked int + if err := tx.QueryRow(`SELECT column_id, locked FROM cards WHERE id=?`, cardID).Scan(&srcColumnID, &locked); err != nil { return fmt.Errorf("card not found: %w", err) } + if locked != 0 && srcColumnID != destColumnID { + return fmt.Errorf("card locked: cannot move between columns") + } now := nowRFC3339() @@ -359,17 +567,38 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error { return err } if _, err := tx.Exec( - `INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`, - newID(), cardID, destColumnID, now, + `INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`, + newID(), cardID, destColumnID, now, nullableActor(actorID), ); err != nil { return err } + _ = actorID if _, err := tx.Exec( `UPDATE cards SET column_id=?, updated_at=? WHERE id=?`, destColumnID, now, cardID, ); err != nil { return err } + // Recompute completed_at based on destination column's is_done flag. + var destDone int + if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, destColumnID).Scan(&destDone); err != nil { + return err + } + if destDone == 1 { + if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=? AND completed_at IS NULL`, now, cardID); err != nil { + return err + } + // Auto-assign: if card had no assignee and an actor is moving it, claim it. + if actorID != "" { + if _, err := tx.Exec(`UPDATE cards SET assignee_id=? WHERE id=? AND (assignee_id IS NULL OR assignee_id='')`, actorID, cardID); err != nil { + return err + } + } + } else { + if _, err := tx.Exec(`UPDATE cards SET completed_at=NULL WHERE id=?`, cardID); err != nil { + return err + } + } } for i, id := range orderedIDs { @@ -404,7 +633,7 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error { return tx.Commit() } -func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) { +func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) { rows, err := db.conn.Query(` SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at FROM card_column_history h @@ -417,7 +646,7 @@ func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) { } defer rows.Close() now := time.Now().UTC() - out := []HistoryEntry{} + cols := []HistoryEntry{} for rows.Next() { var h HistoryEntry var exited sql.NullString @@ -436,7 +665,55 @@ func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) { end = now } h.DurationMs = end.Sub(entered).Milliseconds() - out = append(out, h) + cols = append(cols, h) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + + lockRows, err := db.conn.Query(` + SELECT id, card_id, locked_at, unlocked_at + FROM card_lock_history + WHERE card_id=? + ORDER BY locked_at + `, cardID) + if err != nil { + return nil, err + } + defer lockRows.Close() + locks := []LockPeriod{} + var totalLocked int64 + currently := false + for lockRows.Next() { + var lp LockPeriod + var unlocked sql.NullString + if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked); err != nil { + return nil, err + } + start, err := time.Parse(time.RFC3339Nano, lp.LockedAt) + if err != nil { + return nil, err + } + var end time.Time + if unlocked.Valid { + lp.UnlockedAt = &unlocked.String + end, _ = time.Parse(time.RFC3339Nano, unlocked.String) + } else { + end = now + currently = true + } + lp.DurationMs = end.Sub(start).Milliseconds() + totalLocked += lp.DurationMs + locks = append(locks, lp) + } + if err := lockRows.Err(); err != nil { + return nil, err + } + + return &CardHistoryResponse{ + ColumnHistory: cols, + LockPeriods: locks, + TotalLockedMs: totalLocked, + CurrentlyLock: currently, + }, nil } diff --git a/frontend/package.json b/frontend/package.json index 61200c6..6e60935 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,14 +12,18 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@mantine/charts": "^9.1.1", "@mantine/core": "^9.0.2", + "@mantine/dates": "^9.1.1", "@mantine/hooks": "^9.0.2", "@mantine/modals": "^9.0.2", "@mantine/notifications": "^9.0.2", "@tabler/icons-react": "^3.31.0", + "dayjs": "^1.11.20", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", + "recharts": "^2.15.4", "remark-gfm": "^4.0.1" }, "devDependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 8132ee0..6cf276c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -17,9 +17,15 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.5) + '@mantine/charts': + specifier: ^9.1.1 + version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@mantine/core': specifier: ^9.0.2 version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/dates': + specifier: ^9.1.1 + version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(dayjs@1.11.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@mantine/hooks': specifier: ^9.0.2 version: 9.1.1(react@19.2.5) @@ -32,6 +38,9 @@ importers: '@tabler/icons-react': specifier: ^3.31.0 version: 3.42.0(react@19.2.5) + dayjs: + specifier: ^1.11.20 + version: 1.11.20 react: specifier: ^19.1.0 version: 19.2.5 @@ -41,6 +50,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.5) + recharts: + specifier: ^2.15.4 + version: 2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -371,6 +383,15 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mantine/charts@9.1.1': + resolution: {integrity: sha512-f+RbCe9ULHGmhF2KYw8PIwL3xWUDulhVrZ2lNW/2sKUTaAobSBNmwBXy4kbN6gOHtxAbZx8YnVXH2F6mSaYtcg==} + peerDependencies: + '@mantine/core': 9.1.1 + '@mantine/hooks': 9.1.1 + react: ^19.2.0 + react-dom: ^19.2.0 + recharts: '>=3.2.1' + '@mantine/core@9.1.1': resolution: {integrity: sha512-vClOZdCeZ4oLYuA/3jAOgKGQ6dXbF6ZkzpYz09Gied9nZpB7HcQeb3dcMh8UPBE4f+EM7KlYWk6dch7GoASeaA==} peerDependencies: @@ -378,6 +399,15 @@ packages: react: ^19.2.0 react-dom: ^19.2.0 + '@mantine/dates@9.1.1': + resolution: {integrity: sha512-P1tr/Hr+EVxppbOVpTLvaZZnM1W/r0TNpqNNMeM81xfyuKYzd7zt2/SQYb6BuudgEQfRJnAee+7bIJLEsrb0uA==} + peerDependencies: + '@mantine/core': 9.1.1 + '@mantine/hooks': 9.1.1 + dayjs: '>=1.0.0' + react: ^19.2.0 + react-dom: ^19.2.0 + '@mantine/hooks@9.1.1': resolution: {integrity: sha512-tTJK73nGFyy1v214TLdvBq0be7QCoc6osfbXVuJgOH3YG85lWk9Mvvor6k+w6hC6HXSqKMqLKePyiGm83xGcMg==} peerDependencies: @@ -552,6 +582,33 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -646,6 +703,53 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -655,6 +759,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -690,9 +797,16 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -727,6 +841,10 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -756,6 +874,9 @@ packages: engines: {node: '>=6'} hasBin: true + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -974,6 +1095,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -1010,6 +1134,12 @@ packages: '@types/react': optional: true + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -1030,6 +1160,16 @@ packages: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -1083,6 +1223,9 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -1158,6 +1301,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite@6.4.2: resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1467,6 +1613,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mantine/charts@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + dependencies: + '@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': 9.1.1(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + recharts: 2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1480,6 +1634,15 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@mantine/dates@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(dayjs@1.11.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': 9.1.1(react@19.2.5) + clsx: 2.1.1 + dayjs: 1.11.20 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + '@mantine/hooks@9.1.1(react@19.2.5)': dependencies: react: 19.2.5 @@ -1609,6 +1772,30 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -1691,10 +1878,52 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + dayjs@1.11.20: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -1749,8 +1978,12 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + eventemitter3@4.0.7: {} + extend@3.0.2: {} + fast-equals@5.4.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1790,6 +2023,8 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@2.0.3: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -1809,6 +2044,8 @@ snapshots: json5@2.2.3: {} + lodash@4.18.1: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -2241,6 +2478,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.3.1: {} + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): dependencies: '@types/hast': 3.0.4 @@ -2285,6 +2524,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-smooth@4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + fast-equals: 5.4.0 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): dependencies: get-nonce: 1.0.1 @@ -2304,6 +2551,23 @@ snapshots: react@19.2.5: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.18.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -2398,6 +2662,8 @@ snapshots: tagged-tag@1.0.0: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -2481,6 +2747,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@6.4.2(sugarss@5.0.1(postcss@8.5.14)): dependencies: esbuild: 0.25.12 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6c7635b..def72e1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,11 +24,16 @@ import { import { ActionIcon, AppShell, + Avatar, + Badge, Box, Button, Group, Loader, + Menu, + Paper, Stack, + Tabs, Text, TextInput, Title, @@ -37,65 +42,36 @@ import { import { modals } from "@mantine/modals"; import { notifications } from "@mantine/notifications"; import { - IconColumnInsertRight, + IconArrowBackUp, + IconCalendar, + IconChartBar, + IconChevronDown, + IconChevronRight, IconLayoutKanban, + IconLogout, IconMenu2, IconMessageChatbot, IconPlus, IconRefresh, + IconTrash, + IconTrashX, IconX, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as api from "./api"; +import { useAuth } from "./auth"; import { CardForm } from "./components/CardForm"; import { ChatPanel } from "./components/ChatPanel"; +import { CalendarView } from "./components/CalendarView"; +import { Dashboard } from "./components/Dashboard"; import { HistoryModal } from "./components/HistoryModal"; import { KanbanCard } from "./components/KanbanCard"; import { KanbanColumn } from "./components/KanbanColumn"; import { colorBg, colorBorder } from "./components/colors"; -import type { Board, Card, CardColor, Column, ColumnLocation } from "./types"; +import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types"; const COL_PREFIX = "column-"; -function AddColumnDialog({ - onSubmit, - onCancel, -}: { - onSubmit: (name: string) => Promise | void; - onCancel: () => void; -}) { - const [name, setName] = useState(""); - const submit = () => { - const n = name.trim(); - if (n) onSubmit(n); - }; - return ( - - setName(e.currentTarget.value)} - data-autofocus - autoComplete="off" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - submit(); - } - }} - /> - - - - - - ); -} - // Custom collision detection: prefiere otras columnas como destino al arrastrar // columnas; al arrastrar cards prefiere cards/columnas via closestCorners. function makeCollisionDetection(activeType: string | undefined): CollisionDetection { @@ -118,7 +94,9 @@ function makeCollisionDetection(activeType: string | undefined): CollisionDetect } export function App() { + const auth = useAuth(); const [board, setBoard] = useState(null); + const [users, setUsers] = useState([]); const [activeCard, setActiveCard] = useState(null); const [activeColumnId, setActiveColumnId] = useState(null); const [activeType, setActiveType] = useState(undefined); @@ -126,6 +104,9 @@ export function App() { const [colName, setColName] = useState(""); const [now, setNow] = useState(Date.now()); const [chatOpen, setChatOpen] = useState(false); + const [activeTab, setActiveTab] = useState("board"); + const [trash, setTrash] = useState([]); + const [trashOpen, setTrashOpen] = useState(false); const [navOpen, setNavOpen] = useState(false); const [navWidth, setNavWidth] = useState(() => { const stored = localStorage.getItem("kanban_nav_width"); @@ -177,11 +158,43 @@ export function App() { reload(); }, [reload]); + const reloadUsers = useCallback(async () => { + try { + const us = await api.listUsers(); + setUsers(us); + } catch (e) { + console.warn("listUsers failed", e); + } + }, []); + + const reloadTrash = useCallback(async () => { + try { + const t = await api.listTrash(); + setTrash(t); + } catch (e) { + console.warn("listTrash failed", e); + } + }, []); + + useEffect(() => { + reloadUsers(); + }, [reloadUsers]); + + useEffect(() => { + reloadTrash(); + }, [reloadTrash]); + useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []); + const usersById = useMemo(() => { + const m = new Map(); + for (const u of users) m.set(u.id, u); + return m; + }, [users]); + const sortedColumns = useMemo(() => { if (!board) return []; return [...board.columns].sort((a, b) => a.position - b.position); @@ -360,22 +373,6 @@ export function App() { } }; - const openAddColumnModal = useCallback(() => { - const id = modals.open({ - title: "Nueva columna", - size: "sm", - children: { - try { - await api.createColumn(name); - modals.close(id); - reload(); - } catch (e) { - notifications.show({ color: "red", message: (e as Error).message }); - } - }} onCancel={() => modals.close(id)} />, - }); - }, [reload]); - const handleRenameColumn = useCallback(async (id: string, name: string) => { try { await api.updateColumn(id, { name }); @@ -426,6 +423,8 @@ export function App() { size: "md", children: ( modals.close(id)} onSubmit={async (v) => { @@ -435,6 +434,7 @@ export function App() { requester: v.requester, title: v.title, description: v.description, + assignee_id: v.assignee_id, }); modals.close(id); reload(); @@ -445,7 +445,7 @@ export function App() { /> ), }); - }, [reload]); + }, [reload, users, auth.user]); const openEditCard = useCallback((card: Card) => { const id = modals.open({ @@ -453,7 +453,13 @@ export function App() { size: "md", children: ( modals.close(id)} onSubmit={async (v) => { @@ -462,6 +468,7 @@ export function App() { requester: v.requester, title: v.title, description: v.description, + assignee_id: v.assignee_id, }); modals.close(id); reload(); @@ -472,16 +479,57 @@ export function App() { /> ), }); + }, [reload, users]); + + const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => { + setBoard((prev) => { + if (!prev) return prev; + return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, assignee_id } : c)) }; + }); + try { + await api.updateCard(id, { assignee_id }); + } catch (e) { + notifications.show({ color: "red", message: (e as Error).message }); + reload(); + } }, [reload]); const handleDeleteCard = useCallback(async (id: string) => { try { await api.deleteCard(id); reload(); + reloadTrash(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } - }, [reload]); + }, [reload, reloadTrash]); + + const handleRestoreCard = useCallback(async (id: string) => { + try { + await api.restoreCard(id); + reload(); + reloadTrash(); + } catch (e) { + notifications.show({ color: "red", message: (e as Error).message }); + } + }, [reload, reloadTrash]); + + const handlePurgeCard = useCallback(async (id: string) => { + modals.openConfirmModal({ + title: "Borrar permanentemente", + children: Esta accion no se puede deshacer., + labels: { confirm: "Borrar", cancel: "Cancelar" }, + confirmProps: { color: "red" }, + onConfirm: async () => { + try { + await api.purgeCard(id); + reloadTrash(); + } catch (e) { + notifications.show({ color: "red", message: (e as Error).message }); + } + }, + }); + }, [reloadTrash]); const handleChangeCardColor = useCallback(async (id: string, color: CardColor) => { setBoard((prev) => { @@ -504,6 +552,46 @@ export function App() { }); }, []); + const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => { + setBoard((prev) => { + if (!prev) return prev; + return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, locked } : c)) }; + }); + try { + await api.updateCard(id, { locked }); + } catch (e) { + notifications.show({ color: "red", message: (e as Error).message }); + reload(); + } + }, [reload]); + + const handleSetWIPLimit = useCallback(async (id: string, wip_limit: number) => { + setBoard((prev) => { + if (!prev) return prev; + return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, wip_limit } : c)) }; + }); + try { + await api.updateColumn(id, { wip_limit }); + } 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; + return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, is_done } : c)) }; + }); + try { + await api.updateColumn(id, { is_done }); + reload(); + } catch (e) { + notifications.show({ color: "red", message: (e as Error).message }); + reload(); + } + }, [reload]); + const headerConfig = useMemo(() => ({ height: 50 }), []); const navbarConfig = useMemo( () => ({ @@ -564,13 +652,21 @@ export function App() { Kanban + v && setActiveTab(v)} variant="pills" ml="md"> + + }> + Tablero + + }> + Dashboard + + }> + Calendario + + + - - - - - @@ -581,6 +677,27 @@ export function App() { > + {auth.user && ( + + + + + {(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()} + + + + + {auth.user.display_name || auth.user.username} + } + color="red" + onClick={() => auth.logout()} + > + Cerrar sesion + + + + )} @@ -624,15 +741,70 @@ export function App() { onResizeColumn={handleResizeColumn} onMoveColumnLocation={handleMoveColumnLocation} onDeleteColumn={handleDeleteColumn} + onSetWIPLimit={handleSetWIPLimit} + onToggleDone={handleToggleDone} onEditCard={openEditCard} onDeleteCard={handleDeleteCard} onChangeCardColor={handleChangeCardColor} onShowHistory={handleShowHistory} + onToggleCardLock={handleToggleCardLock} + onAssignCard={handleAssignCard} + users={users} + usersById={usersById} /> ))} + + + {trashOpen && ( + + {trash.length === 0 && ( + + Vacia. + + )} + {trash.map((c) => ( + + + + {c.title} + + + handleRestoreCard(c.id)}> + + + + + handlePurgeCard(c.id)}> + + + + + + ))} + + )} + @@ -641,6 +813,15 @@ export function App() { + {activeTab === "dashboard" ? ( + + + + ) : activeTab === "calendar" ? ( + + + + ) : ( ))} @@ -708,6 +895,7 @@ export function App() { + )} @@ -721,6 +909,10 @@ export function App() { onEdit={() => {}} onChangeColor={() => {}} onShowHistory={() => {}} + onToggleLock={() => {}} + onAssign={() => {}} + users={users} + assignee={dragOverlayCard.assignee_id ? usersById.get(dragOverlayCard.assignee_id) : undefined} isOverlay /> ) : dragOverlayColumn ? ( diff --git a/frontend/src/Root.tsx b/frontend/src/Root.tsx new file mode 100644 index 0000000..f648369 --- /dev/null +++ b/frontend/src/Root.tsx @@ -0,0 +1,17 @@ +import { Center, Loader } from "@mantine/core"; +import { App } from "./App"; +import { useAuth } from "./auth"; +import { LoginPage } from "./components/LoginPage"; + +export function Root() { + const { user, loading } = useAuth(); + if (loading) { + return ( +
+ +
+ ); + } + if (!user) return ; + return ; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 61593fe..968b215 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,15 +1,30 @@ -import type { Board, Card, Column, HistoryEntry } from "./types"; +import type { + Board, + Card, + CardHistoryResponse, + Column, + Metrics, + MetricsFilter, + User, +} from "./types"; const BASE = "/api"; +export class HTTPError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + async function fetchJSON(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE}${path}`, { + credentials: "include", ...init, headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, }); if (!res.ok) { const err = await res.json().catch(() => ({ Message: res.statusText })); - throw new Error(err.Message || err.message || res.statusText); + throw new HTTPError(res.status, err.Message || err.message || res.statusText); } if (res.status === 204) return undefined as T; return res.json(); @@ -28,6 +43,8 @@ export interface UpdateColumnInput { position?: number; location?: "board" | "sidebar"; width?: number; + wip_limit?: number; + is_done?: boolean; } export function updateColumn(id: string, patch: UpdateColumnInput): Promise { @@ -50,6 +67,7 @@ export interface CreateCardInput { requester?: string; title: string; description?: string; + assignee_id?: string | null; } export function createCard(input: CreateCardInput): Promise { @@ -61,6 +79,8 @@ export interface UpdateCardInput { title?: string; description?: string; color?: string; + locked?: boolean; + assignee_id?: string | null; } export function updateCard(id: string, patch: UpdateCardInput): Promise { @@ -71,6 +91,18 @@ export function deleteCard(id: string): Promise { return fetchJSON(`/cards/${id}`, { method: "DELETE" }); } +export function listTrash(): Promise { + return fetchJSON("/trash"); +} + +export function restoreCard(id: string): Promise { + return fetchJSON(`/cards/${id}/restore`, { method: "POST" }); +} + +export function purgeCard(id: string): Promise { + return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" }); +} + export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise { return fetchJSON(`/cards/${id}/move`, { method: "POST", @@ -78,7 +110,7 @@ export function moveCard(id: string, column_id: string, ordered_ids: string[]): }); } -export function cardHistory(id: string): Promise { +export function cardHistory(id: string): Promise { return fetchJSON(`/cards/${id}/history`); } @@ -103,3 +135,39 @@ export interface ChatResponse { export function sendChat(messages: ChatMessage[]): Promise { return fetchJSON("/chat", { method: "POST", body: JSON.stringify({ messages }) }); } + +export function login(username: string, password: string): Promise { + return fetchJSON("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export function register(username: string, password: string, display_name?: string): Promise { + return fetchJSON("/auth/register", { + method: "POST", + body: JSON.stringify({ username, password, display_name }), + }); +} + +export function logout(): Promise { + return fetchJSON("/auth/logout", { method: "POST" }); +} + +export function getMe(): Promise { + return fetchJSON("/me"); +} + +export function listUsers(): Promise { + return fetchJSON("/users"); +} + +export function getMetrics(f: MetricsFilter): Promise { + const qs = new URLSearchParams(); + if (f.from) qs.set("from", f.from); + if (f.to) qs.set("to", f.to); + if (f.assignee_id) qs.set("assignee_id", f.assignee_id); + if (f.requester) qs.set("requester", f.requester); + const q = qs.toString(); + return fetchJSON(`/metrics${q ? `?${q}` : ""}`); +} diff --git a/frontend/src/auth.tsx b/frontend/src/auth.tsx new file mode 100644 index 0000000..13f48f2 --- /dev/null +++ b/frontend/src/auth.tsx @@ -0,0 +1,55 @@ +import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react"; +import * as api from "./api"; +import { HTTPError } from "./api"; +import type { User } from "./types"; + +interface AuthCtx { + user: User | null; + loading: boolean; + login: (username: string, password: string) => Promise; + register: (username: string, password: string, displayName: string) => Promise; + logout: () => Promise; +} + +const Ctx = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api + .getMe() + .then(setUser) + .catch((e) => { + if (!(e instanceof HTTPError) || e.status !== 401) { + console.warn("getMe failed", e); + } + }) + .finally(() => setLoading(false)); + }, []); + + const login = useCallback(async (username: string, password: string) => { + const u = await api.login(username, password); + setUser(u); + }, []); + + const register = useCallback(async (username: string, password: string, displayName: string) => { + await api.register(username, password, displayName); + const u = await api.login(username, password); + setUser(u); + }, []); + + const logout = useCallback(async () => { + await api.logout(); + setUser(null); + }, []); + + return {children}; +} + +export function useAuth(): AuthCtx { + const v = useContext(Ctx); + if (!v) throw new Error("useAuth: missing AuthProvider"); + return v; +} diff --git a/frontend/src/components/CalendarView.tsx b/frontend/src/components/CalendarView.tsx new file mode 100644 index 0000000..7f7f704 --- /dev/null +++ b/frontend/src/components/CalendarView.tsx @@ -0,0 +1,216 @@ +import { + Box, + Center, + Group, + Loader, + Paper, + Select, + SimpleGrid, + Stack, + Text, + Title, +} from "@mantine/core"; +import { MonthPickerInput } from "@mantine/dates"; +import { IconCheckbox, IconPlus } from "@tabler/icons-react"; +import dayjs from "dayjs"; +import { useEffect, useMemo, useState } from "react"; +import * as api from "../api"; +import type { Metrics, User } from "../types"; + +interface Props { + users: User[]; +} + +const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"]; + +export function CalendarView({ users }: Props) { + const [month, setMonth] = useState(new Date()); + const [assigneeId, setAssigneeId] = useState(null); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + const start = dayjs(month).startOf("month").format("YYYY-MM-DD"); + const end = dayjs(month).endOf("month").format("YYYY-MM-DD"); + api + .getMetrics({ from: start, to: end, assignee_id: assigneeId || undefined }) + .then((m) => { + if (!cancelled) setData(m); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [month, assigneeId]); + + const userOptions = useMemo( + () => users.map((u) => ({ value: u.id, label: u.display_name || u.username })), + [users] + ); + + const dayMap = useMemo(() => { + const m = new Map(); + if (!data) return m; + for (const d of data.created_daily) { + const cur = m.get(d.date) ?? { created: 0, done: 0 }; + cur.created = d.count; + m.set(d.date, cur); + } + for (const d of data.throughput_daily) { + const cur = m.get(d.date) ?? { created: 0, done: 0 }; + cur.done = d.count; + m.set(d.date, cur); + } + return m; + }, [data]); + + // Build month grid (Mon-first). + const grid = useMemo(() => { + const start = dayjs(month).startOf("month"); + const end = dayjs(month).endOf("month"); + // Day-of-week, ISO Mon=1..Sun=7. We want first cell to be Mon. + const firstDow = (start.day() + 6) % 7; // 0=Mon + const cells: { date: string | null; inMonth: boolean }[] = []; + for (let i = 0; i < firstDow; i++) cells.push({ date: null, inMonth: false }); + for (let d = start; !d.isAfter(end); d = d.add(1, "day")) { + cells.push({ date: d.format("YYYY-MM-DD"), inMonth: true }); + } + while (cells.length % 7 !== 0) cells.push({ date: null, inMonth: false }); + return cells; + }, [month]); + + const totalCreated = useMemo( + () => Array.from(dayMap.values()).reduce((s, v) => s + v.created, 0), + [dayMap] + ); + const totalDone = useMemo( + () => Array.from(dayMap.values()).reduce((s, v) => s + v.done, 0), + [dayMap] + ); + + return ( + + + + Calendario + + v && setMonth(typeof v === "string" ? new Date(v) : v)} + style={{ minWidth: 160 }} + clearable={false} + /> + setAssigneeId(v)} + data={users.map((u) => ({ + value: u.id, + label: u.display_name || u.username, + }))} + clearable + searchable + tabIndex={4} + /> - - diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx new file mode 100644 index 0000000..a63eeee --- /dev/null +++ b/frontend/src/components/Dashboard.tsx @@ -0,0 +1,467 @@ +import { AreaChart, BarChart, LineChart } from "@mantine/charts"; +import "@mantine/charts/styles.css"; +import { + Badge, + Box, + Center, + Grid, + Group, + Loader, + Paper, + Select, + SimpleGrid, + Stack, + Table, + Text, + Title, +} from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import "@mantine/dates/styles.css"; +import { + IconCheckbox, + IconClipboardList, + IconClockHour4, + IconLock, + IconTrendingUp, +} from "@tabler/icons-react"; +import dayjs from "dayjs"; +import { useEffect, useMemo, useState } from "react"; +import * as api from "../api"; +import type { Metrics, User } from "../types"; +import { formatDuration } from "./format"; + +interface Props { + users: User[]; +} + +function fmtDate(d: Date | null): string | undefined { + if (!d) return undefined; + return dayjs(d).format("YYYY-MM-DD"); +} + +function KPI({ + icon, + label, + value, + hint, + color, +}: { + icon: React.ReactNode; + label: string; + value: string | number; + hint?: string; + color?: string; +}) { + return ( + + + + {icon} + + {label} + + + + {value} + + {hint && ( + + {hint} + + )} + + + ); +} + +export function Dashboard({ users }: Props) { + const [from, setFrom] = useState(() => dayjs().subtract(30, "day").toDate()); + const [to, setTo] = useState(() => new Date()); + const [assigneeId, setAssigneeId] = useState(null); + const [requester, setRequester] = useState(null); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [requesterOptions, setRequesterOptions] = useState([]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + api + .getMetrics({ + from: fmtDate(from), + to: fmtDate(to), + assignee_id: assigneeId || undefined, + requester: requester || undefined, + }) + .then((m) => { + if (cancelled) return; + setData(m); + setRequesterOptions((prev) => { + const set = new Set(prev); + for (const r of m.top_requesters) set.add(r.requester); + return Array.from(set).sort(); + }); + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [from, to, assigneeId, requester]); + + const userOptions = useMemo( + () => users.map((u) => ({ value: u.id, label: u.display_name || u.username })), + [users] + ); + + const cumulativeFlow = useMemo(() => { + if (!data) return []; + const arr = data.cumulative_flow; + const firstIdx = arr.findIndex((p) => p.total > 0 || p.done > 0); + if (firstIdx <= 0) return arr; + return arr.slice(Math.max(0, firstIdx - 1)); + }, [data]); + + const throughputSeries = useMemo(() => { + if (!data) return []; + const map = new Map(); + for (const d of data.throughput_daily) { + map.set(d.date, { date: d.date, completed: d.count, created: 0 }); + } + for (const d of data.created_daily) { + const cur = map.get(d.date) ?? { date: d.date, completed: 0, created: 0 }; + cur.created = d.count; + map.set(d.date, cur); + } + return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date)); + }, [data]); + + const byColumnSeries = useMemo(() => { + if (!data) return []; + return data.by_column.map((c) => ({ + column: c.name + (c.is_done ? " ✓" : ""), + tarjetas: c.count, + })); + }, [data]); + + const topAssigneeSeries = useMemo(() => { + if (!data) return []; + return data.top_assignees + .slice() + .sort((a, b) => b.completed_in_range + b.active - (a.completed_in_range + a.active)) + .slice(0, 8) + .map((a) => ({ + usuario: a.display_name || a.username, + completadas: a.completed_in_range, + activas: a.active, + })); + }, [data]); + + const topRequesterSeries = useMemo(() => { + if (!data) return []; + return data.top_requesters.map((r) => ({ + solicitante: r.requester, + tarjetas: r.total, + })); + }, [data]); + + const movementsSeries = useMemo(() => { + if (!data) return []; + return data.movements_by_user + .filter((m) => m.moves > 0) + .slice(0, 8) + .map((m) => ({ + usuario: m.display_name || m.username, + movimientos: m.moves, + })); + }, [data]); + + return ( + + + + Dashboard + + setFrom(v as Date | null)} + size="xs" + clearable={false} + valueFormat="YYYY-MM-DD" + style={{ minWidth: 140 }} + /> + setTo(v as Date | null)} + size="xs" + clearable={false} + valueFormat="YYYY-MM-DD" + style={{ minWidth: 140 }} + /> + ({ value: r, label: r }))} + clearable + searchable + style={{ minWidth: 160 }} + /> + + + + {loading && !data && ( +
+ +
+ )} + + {data && ( + <> + + } + label="Tarjetas totales" + value={data.totals.cards} + hint={`${data.totals.columns} columnas, ${data.totals.users} usuarios`} + /> + } + label="Completadas (rango)" + value={data.totals.cards_completed_in_range} + hint={`${data.totals.cards_created_in_range} creadas en rango`} + color="green" + /> + } + label="Lead time p50" + value={formatDuration(data.lead_time.p50_ms)} + hint={`p90 ${formatDuration(data.lead_time.p90_ms)} · n=${data.lead_time.n}`} + /> + } + label="Bloqueos activos" + value={data.totals.active_locks} + hint={`Total bloqueado: ${formatDuration(data.lock_total_ms)}`} + color={data.totals.active_locks > 0 ? "yellow" : undefined} + /> + + + + + + Cumulative Flow Diagram + + total vs hechas (acumulado) + + + {cumulativeFlow.length === 0 ? ( + + Sin datos. + + ) : ( + + )} + + + + + + + + Throughput diario + + {throughputSeries.length === 0 ? ( + + Sin datos en el rango. + + ) : ( + + )} + + + + + + Tarjetas por columna + + {byColumnSeries.length === 0 ? ( + + Sin columnas. + + ) : ( + + )} + + + + + + + + + Top asignados + + {topAssigneeSeries.length === 0 ? ( + + Sin asignaciones. + + ) : ( + + )} + + + + + + Top solicitantes + + {topRequesterSeries.length === 0 ? ( + + Sin solicitantes en el rango. + + ) : ( + + )} + + + + + + + + + Movimientos por usuario (rango) + + {movementsSeries.length === 0 ? ( + + Sin movimientos registrados. + + ) : ( + + )} + + + + + + Tiempo en columna (cycle time) + + + + + Columna + n + p50 + p90 + avg + + + + {data.cycle_time_per_column.map((c) => ( + + + + + {c.name} + + {c.is_done && ( + + done + + )} + + + {c.stats.n} + {c.stats.n > 0 ? formatDuration(c.stats.p50_ms) : "—"} + {c.stats.n > 0 ? formatDuration(c.stats.p90_ms) : "—"} + {c.stats.n > 0 ? formatDuration(c.stats.avg_ms) : "—"} + + ))} + +
+
+
+
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/HistoryModal.tsx b/frontend/src/components/HistoryModal.tsx index c5c1512..779654d 100644 --- a/frontend/src/components/HistoryModal.tsx +++ b/frontend/src/components/HistoryModal.tsx @@ -1,8 +1,8 @@ -import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core"; -import { IconColumns3 } from "@tabler/icons-react"; +import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core"; +import { IconColumns3, IconLock } from "@tabler/icons-react"; import { useEffect, useState } from "react"; import { cardHistory } from "../api"; -import type { Card, HistoryEntry } from "../types"; +import type { Card, CardHistoryResponse } from "../types"; import { formatDuration } from "./format"; interface Props { @@ -10,13 +10,17 @@ interface Props { } export function HistoryModal({ card }: Props) { - const [entries, setEntries] = useState(null); + const [data, setData] = useState(null); useEffect(() => { - cardHistory(card.id).then(setEntries).catch(() => setEntries([])); + cardHistory(card.id) + .then(setData) + .catch(() => + setData({ column_history: [], lock_periods: [], total_locked_ms: 0, currently_locked: false }) + ); }, [card.id]); - if (!entries) { + if (!data) { return ( @@ -24,7 +28,9 @@ export function HistoryModal({ card }: Props) { ); } - if (entries.length === 0) { + const { column_history, lock_periods, total_locked_ms, currently_locked } = data; + + if (column_history.length === 0 && lock_periods.length === 0) { return Sin historial.; } @@ -33,8 +39,8 @@ export function HistoryModal({ card }: Props) { Tiempo total en cada columna desde que se creo la tarjeta. - - {entries.map((e) => ( + + {column_history.map((e) => ( } @@ -61,6 +67,59 @@ export function HistoryModal({ card }: Props) { ))} + + + + + + + Tiempo bloqueada + + 0 ? "yellow" : "gray"}> + {formatDuration(total_locked_ms)} + + {currently_locked && ( + + actualmente bloqueada + + )} + + + {lock_periods.length === 0 ? ( + + Nunca ha sido bloqueada. + + ) : ( + + {lock_periods.map((p) => ( + } + title={ + + + {formatDuration(p.duration_ms)} + + {!p.unlocked_at && ( + + en curso + + )} + + } + > + + {new Date(p.locked_at).toLocaleString()} + {p.unlocked_at && ` -> ${new Date(p.unlocked_at).toLocaleString()}`} + + + ))} + + )}
); } diff --git a/frontend/src/components/KanbanCard.tsx b/frontend/src/components/KanbanCard.tsx index 6a78479..f07cb1c 100644 --- a/frontend/src/components/KanbanCard.tsx +++ b/frontend/src/components/KanbanCard.tsx @@ -2,25 +2,32 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { ActionIcon, + Avatar, Badge, Group, + Menu, Paper, Popover, + Select, Stack, Text, Tooltip, } from "@mantine/core"; import { IconClock, + IconDotsVertical, IconEdit, IconGripVertical, IconHistory, + IconLock, + IconLockOpen, IconPalette, IconTrash, IconUser, + IconUserCircle, } from "@tabler/icons-react"; import { memo, useState } from "react"; -import type { Card, CardColor } from "../types"; +import type { Card, CardColor, User } from "../types"; import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors"; import { formatDuration } from "./format"; @@ -31,14 +38,36 @@ interface Props { onEdit: (card: Card) => 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; + users: User[]; + assignee?: User; + inDoneColumn?: boolean; isOverlay?: boolean; } -function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHistory, isOverlay }: Props) { - const [popOpen, setPopOpen] = useState(false); +function KanbanCardImpl({ + card, + now, + onDelete, + onEdit, + onChangeColor, + onShowHistory, + onToggleLock, + onAssign, + users, + assignee, + inDoneColumn, + isOverlay, +}: Props) { + const isDone = inDoneColumn || !!card.completed_at; + const [colorPopOpen, setColorPopOpen] = useState(false); + const [assigneePopOpen, setAssigneePopOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: card.id, data: { type: "card", columnId: card.column_id }, + disabled: card.locked, }); const style: React.CSSProperties = { @@ -46,84 +75,205 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist transition, opacity: isDragging ? 0.4 : 1, background: colorBg(card.color), - borderColor: colorBorder(card.color), + borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color), + borderWidth: card.locked ? 2 : 1, }; const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now; const liveMs = Math.max(0, now - enteredAt); + const onContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + setMenuOpen(true); + }; + + const menuItems = ( + <> + Acciones + } + onClick={() => { + setMenuOpen(false); + onEdit(card); + }} + > + Editar + + + + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setColorPopOpen((v) => !v); + }} + closeMenuOnClick={false} + > + Color + + + + + {CARD_COLORS.map((c) => ( + + { + onChangeColor(card.id, c.value); + setColorPopOpen(false); + setMenuOpen(false); + }} + aria-label={c.label} + /> + + ))} + + + + + + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setAssigneePopOpen((v) => !v); + }} + closeMenuOnClick={false} + > + Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."} + + + +