chore: auto-commit (28 archivos)

- app.md
- auth.go
- chat.go
- chat.log
- db.go
- frontend/package.json
- frontend/pnpm-lock.yaml
- frontend/src/App.tsx
- frontend/src/Root.tsx
- frontend/src/api.ts
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 00:27:18 +02:00
parent c915e721af
commit bee688e574
28 changed files with 3601 additions and 300 deletions
+5
View File
@@ -16,6 +16,11 @@ uses_functions:
- http_json_response_go_infra - http_json_response_go_infra
- http_error_response_go_infra - http_error_response_go_infra
- http_parse_body_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: [] uses_types: []
framework: "net/http + vite + react + mantine + dnd-kit" framework: "net/http + vite + react + mantine + dnd-kit"
entry_point: "main.go" entry_point: "main.go"
+143
View File
@@ -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)
}
}
+4 -2
View File
@@ -30,15 +30,17 @@ Ejemplo:
Tools disponibles (todas con sus inputs): Tools disponibles (todas con sus inputs):
- list_board {} -> {columns, cards} - list_board {} -> {columns, cards}
- create_column {name} - 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} - delete_column {id}
- reorder_columns {ids:[...]} - reorder_columns {ids:[...]}
- create_card {column_id, requester?, title, description?} - 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} - delete_card {id}
- move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final - move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final
- card_history {id} - card_history {id}
- find_cards {query?, column_id?, requester?} - 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 <actions>. Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque <actions>.
+3
View File
@@ -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}
+306 -29
View File
@@ -19,21 +19,27 @@ type Column struct {
Position int `json:"position"` Position int `json:"position"`
Location string `json:"location"` Location string `json:"location"`
Width int `json:"width"` Width int `json:"width"`
WIPLimit int `json:"wip_limit"`
IsDone bool `json:"is_done"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
type Card struct { type Card struct {
ID string `json:"id"` ID string `json:"id"`
Requester string `json:"requester"` Requester string `json:"requester"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Color string `json:"color"` Color string `json:"color"`
ColumnID string `json:"column_id"` ColumnID string `json:"column_id"`
Position int `json:"position"` Position int `json:"position"`
CreatedAt string `json:"created_at"` Locked bool `json:"locked"`
UpdatedAt string `json:"updated_at"` AssigneeID *string `json:"assignee_id"`
EnteredAt string `json:"entered_at"` CompletedAt *string `json:"completed_at"`
TimeInColumn int64 `json:"time_in_column_ms"` 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 { type HistoryEntry struct {
@@ -46,6 +52,21 @@ type HistoryEntry struct {
DurationMs int64 `json:"duration_ms"` 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 } type DB struct{ conn *sql.DB }
func openDB(path string) (*DB, error) { func openDB(path string) (*DB, error) {
@@ -73,7 +94,15 @@ func ensureColumns(conn *sql.DB) error {
specs := []colSpec{ specs := []colSpec{
{"columns", "location", "TEXT NOT NULL DEFAULT 'board'"}, {"columns", "location", "TEXT NOT NULL DEFAULT 'board'"},
{"columns", "width", "INTEGER NOT NULL DEFAULT 300"}, {"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", "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 { for _, s := range specs {
exists, err := columnExists(conn, s.table, s.name) 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) 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 return nil
} }
@@ -124,10 +156,17 @@ func newID() string {
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) } func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) }
func nullableActor(actorID string) any {
if actorID == "" {
return nil
}
return actorID
}
// --- Columns --- // --- Columns ---
func (db *DB) ListColumns() ([]Column, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -135,9 +174,11 @@ func (db *DB) ListColumns() ([]Column, error) {
out := []Column{} out := []Column{}
for rows.Next() { for rows.Next() {
var c Column 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 return nil, err
} }
c.IsDone = isDone != 0
out = append(out, c) out = append(out, c)
} }
return out, rows.Err() return out, rows.Err()
@@ -152,10 +193,10 @@ func (db *DB) CreateColumn(name string) (*Column, error) {
if maxPos.Valid { if maxPos.Valid {
pos = int(maxPos.Int64) + 1 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( _, err := db.conn.Exec(
`INSERT INTO columns (id, name, position, location, width, created_at) VALUES (?, ?, ?, ?, ?, ?)`, `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.CreatedAt, c.ID, c.Name, c.Position, c.Location, c.Width, c.WIPLimit, 0, c.CreatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -168,6 +209,8 @@ type ColumnPatch struct {
Position *int Position *int
Location *string Location *string
Width *int Width *int
WIPLimit *int
IsDone *bool
} }
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error { func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
@@ -200,6 +243,35 @@ func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
return err 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 return nil
} }
@@ -226,11 +298,12 @@ func (db *DB) ReorderColumns(ids []string) error {
func (db *DB) ListCardsWithTime() ([]Card, error) { func (db *DB) ListCardsWithTime() ([]Card, error) {
rows, err := db.conn.Query(` 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 h.entered_at
FROM cards c FROM cards c
LEFT JOIN card_column_history h LEFT JOIN card_column_history h
ON h.card_id = c.id AND h.exited_at IS NULL 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 ORDER BY c.column_id, c.position, c.created_at
`) `)
if err != nil { if err != nil {
@@ -242,9 +315,26 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
for rows.Next() { for rows.Next() {
var c Card var c Card
var entered sql.NullString 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 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 { if entered.Valid {
c.EnteredAt = entered.String c.EnteredAt = entered.String
if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil { 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() 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 var maxPos sql.NullInt64
if err := db.conn.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, columnID).Scan(&maxPos); err != nil { if err := db.conn.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, columnID).Scan(&maxPos); err != nil {
return nil, err return nil, err
@@ -282,11 +372,22 @@ func (db *DB) CreateCard(columnID, requester, title, description string) (*Card,
return nil, err return nil, err
} }
if _, err := tx.Exec( if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`, `INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), c.ID, c.ColumnID, now, newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
); err != nil { ); err != nil {
return nil, err 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 { if err := tx.Commit(); err != nil {
return nil, err return nil, err
} }
@@ -298,9 +399,16 @@ type CardPatch struct {
Title *string Title *string
Description *string Description *string
Color *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 { 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() tx, err := db.conn.Begin()
if err != nil { if err != nil {
return err return err
@@ -326,18 +434,114 @@ func (db *DB) UpdateCard(id string, patch CardPatch) error {
return err 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(&current); 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() return tx.Commit()
} }
// DeleteCard soft-deletes the card (moves it to trash).
func (db *DB) DeleteCard(id string) error { 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) _, err := db.conn.Exec(`DELETE FROM cards WHERE id=?`, id)
return err 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, // 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. // 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). // 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() tx, err := db.conn.Begin()
if err != nil { if err != nil {
return err return err
@@ -345,9 +549,13 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
defer tx.Rollback() defer tx.Rollback()
var srcColumnID string 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) return fmt.Errorf("card not found: %w", err)
} }
if locked != 0 && srcColumnID != destColumnID {
return fmt.Errorf("card locked: cannot move between columns")
}
now := nowRFC3339() now := nowRFC3339()
@@ -359,17 +567,38 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
return err return err
} }
if _, err := tx.Exec( if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`, `INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), cardID, destColumnID, now, newID(), cardID, destColumnID, now, nullableActor(actorID),
); err != nil { ); err != nil {
return err return err
} }
_ = actorID
if _, err := tx.Exec( if _, err := tx.Exec(
`UPDATE cards SET column_id=?, updated_at=? WHERE id=?`, `UPDATE cards SET column_id=?, updated_at=? WHERE id=?`,
destColumnID, now, cardID, destColumnID, now, cardID,
); err != nil { ); err != nil {
return err 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 { for i, id := range orderedIDs {
@@ -404,7 +633,7 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
return tx.Commit() return tx.Commit()
} }
func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) { func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
rows, err := db.conn.Query(` rows, err := db.conn.Query(`
SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at
FROM card_column_history h FROM card_column_history h
@@ -417,7 +646,7 @@ func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) {
} }
defer rows.Close() defer rows.Close()
now := time.Now().UTC() now := time.Now().UTC()
out := []HistoryEntry{} cols := []HistoryEntry{}
for rows.Next() { for rows.Next() {
var h HistoryEntry var h HistoryEntry
var exited sql.NullString var exited sql.NullString
@@ -436,7 +665,55 @@ func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) {
end = now end = now
} }
h.DurationMs = end.Sub(entered).Milliseconds() 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
} }
+4
View File
@@ -12,14 +12,18 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@mantine/charts": "^9.1.1",
"@mantine/core": "^9.0.2", "@mantine/core": "^9.0.2",
"@mantine/dates": "^9.1.1",
"@mantine/hooks": "^9.0.2", "@mantine/hooks": "^9.0.2",
"@mantine/modals": "^9.0.2", "@mantine/modals": "^9.0.2",
"@mantine/notifications": "^9.0.2", "@mantine/notifications": "^9.0.2",
"@tabler/icons-react": "^3.31.0", "@tabler/icons-react": "^3.31.0",
"dayjs": "^1.11.20",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
+283
View File
@@ -17,9 +17,15 @@ importers:
'@dnd-kit/utilities': '@dnd-kit/utilities':
specifier: ^3.2.2 specifier: ^3.2.2
version: 3.2.2(react@19.2.5) 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': '@mantine/core':
specifier: ^9.0.2 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) 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': '@mantine/hooks':
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.1.1(react@19.2.5) version: 9.1.1(react@19.2.5)
@@ -32,6 +38,9 @@ importers:
'@tabler/icons-react': '@tabler/icons-react':
specifier: ^3.31.0 specifier: ^3.31.0
version: 3.42.0(react@19.2.5) version: 3.42.0(react@19.2.5)
dayjs:
specifier: ^1.11.20
version: 1.11.20
react: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.2.5 version: 19.2.5
@@ -41,6 +50,9 @@ importers:
react-markdown: react-markdown:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5) 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: remark-gfm:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -371,6 +383,15 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@mantine/core@9.1.1':
resolution: {integrity: sha512-vClOZdCeZ4oLYuA/3jAOgKGQ6dXbF6ZkzpYz09Gied9nZpB7HcQeb3dcMh8UPBE4f+EM7KlYWk6dch7GoASeaA==} resolution: {integrity: sha512-vClOZdCeZ4oLYuA/3jAOgKGQ6dXbF6ZkzpYz09Gied9nZpB7HcQeb3dcMh8UPBE4f+EM7KlYWk6dch7GoASeaA==}
peerDependencies: peerDependencies:
@@ -378,6 +399,15 @@ packages:
react: ^19.2.0 react: ^19.2.0
react-dom: ^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': '@mantine/hooks@9.1.1':
resolution: {integrity: sha512-tTJK73nGFyy1v214TLdvBq0be7QCoc6osfbXVuJgOH3YG85lWk9Mvvor6k+w6hC6HXSqKMqLKePyiGm83xGcMg==} resolution: {integrity: sha512-tTJK73nGFyy1v214TLdvBq0be7QCoc6osfbXVuJgOH3YG85lWk9Mvvor6k+w6hC6HXSqKMqLKePyiGm83xGcMg==}
peerDependencies: peerDependencies:
@@ -552,6 +582,33 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} 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': '@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -646,6 +703,53 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 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: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -655,6 +759,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decode-named-character-reference@1.3.0: decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -690,9 +797,16 @@ packages:
estree-util-is-identifier-name@3.0.0: estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
extend@3.0.2: extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} 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: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -727,6 +841,10 @@ packages:
inline-style-parser@0.2.7: inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} 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: is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@@ -756,6 +874,9 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
longest-streak@3.1.0: longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -974,6 +1095,9 @@ packages:
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} 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: react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies: peerDependencies:
@@ -1010,6 +1134,12 @@ packages:
'@types/react': '@types/react':
optional: true 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: react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1030,6 +1160,16 @@ packages:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'} 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: remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -1083,6 +1223,9 @@ packages:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'} engines: {node: '>=20'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.16: tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -1158,6 +1301,9 @@ packages:
vfile@6.0.3: vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
vite@6.4.2: vite@6.4.2:
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -1467,6 +1613,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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)': '@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: dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@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: transitivePeerDependencies:
- '@types/react' - '@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)': '@mantine/hooks@9.1.1(react@19.2.5)':
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
@@ -1609,6 +1772,30 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@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': '@types/debug@4.1.13':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
@@ -1691,10 +1878,52 @@ snapshots:
csstype@3.2.3: {} 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: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js-light@2.5.1: {}
decode-named-character-reference@1.3.0: decode-named-character-reference@1.3.0:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
@@ -1749,8 +1978,12 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {} estree-util-is-identifier-name@3.0.0: {}
eventemitter3@4.0.7: {}
extend@3.0.2: {} extend@3.0.2: {}
fast-equals@5.4.0: {}
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -1790,6 +2023,8 @@ snapshots:
inline-style-parser@0.2.7: {} inline-style-parser@0.2.7: {}
internmap@2.0.3: {}
is-alphabetical@2.0.1: {} is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1: is-alphanumerical@2.0.1:
@@ -1809,6 +2044,8 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
lodash@4.18.1: {}
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
loose-envify@1.4.0: loose-envify@1.4.0:
@@ -2241,6 +2478,8 @@ snapshots:
react-is@16.13.1: {} react-is@16.13.1: {}
react-is@18.3.1: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@@ -2285,6 +2524,14 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@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): react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5):
dependencies: dependencies:
get-nonce: 1.0.1 get-nonce: 1.0.1
@@ -2304,6 +2551,23 @@ snapshots:
react@19.2.5: {} 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: remark-gfm@4.0.1:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
@@ -2398,6 +2662,8 @@ snapshots:
tagged-tag@1.0.0: {} tagged-tag@1.0.0: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.16: tinyglobby@0.2.16:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
@@ -2481,6 +2747,23 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.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)): vite@6.4.2(sugarss@5.0.1(postcss@8.5.14)):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
+257 -65
View File
@@ -24,11 +24,16 @@ import {
import { import {
ActionIcon, ActionIcon,
AppShell, AppShell,
Avatar,
Badge,
Box, Box,
Button, Button,
Group, Group,
Loader, Loader,
Menu,
Paper,
Stack, Stack,
Tabs,
Text, Text,
TextInput, TextInput,
Title, Title,
@@ -37,65 +42,36 @@ import {
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { import {
IconColumnInsertRight, IconArrowBackUp,
IconCalendar,
IconChartBar,
IconChevronDown,
IconChevronRight,
IconLayoutKanban, IconLayoutKanban,
IconLogout,
IconMenu2, IconMenu2,
IconMessageChatbot, IconMessageChatbot,
IconPlus, IconPlus,
IconRefresh, IconRefresh,
IconTrash,
IconTrashX,
IconX, IconX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "./api"; import * as api from "./api";
import { useAuth } from "./auth";
import { CardForm } from "./components/CardForm"; import { CardForm } from "./components/CardForm";
import { ChatPanel } from "./components/ChatPanel"; import { ChatPanel } from "./components/ChatPanel";
import { CalendarView } from "./components/CalendarView";
import { Dashboard } from "./components/Dashboard";
import { HistoryModal } from "./components/HistoryModal"; import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard"; import { KanbanCard } from "./components/KanbanCard";
import { KanbanColumn } from "./components/KanbanColumn"; import { KanbanColumn } from "./components/KanbanColumn";
import { colorBg, colorBorder } from "./components/colors"; 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-"; const COL_PREFIX = "column-";
function AddColumnDialog({
onSubmit,
onCancel,
}: {
onSubmit: (name: string) => Promise<void> | void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const submit = () => {
const n = name.trim();
if (n) onSubmit(n);
};
return (
<Stack gap="sm">
<TextInput
label="Nombre"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
data-autofocus
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
/>
<Group justify="flex-end" gap="xs">
<Button variant="subtle" color="gray" onClick={onCancel}>
Cancelar
</Button>
<Button onClick={submit} disabled={!name.trim()}>
Crear
</Button>
</Group>
</Stack>
);
}
// Custom collision detection: prefiere otras columnas como destino al arrastrar // Custom collision detection: prefiere otras columnas como destino al arrastrar
// columnas; al arrastrar cards prefiere cards/columnas via closestCorners. // columnas; al arrastrar cards prefiere cards/columnas via closestCorners.
function makeCollisionDetection(activeType: string | undefined): CollisionDetection { function makeCollisionDetection(activeType: string | undefined): CollisionDetection {
@@ -118,7 +94,9 @@ function makeCollisionDetection(activeType: string | undefined): CollisionDetect
} }
export function App() { export function App() {
const auth = useAuth();
const [board, setBoard] = useState<Board | null>(null); const [board, setBoard] = useState<Board | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [activeCard, setActiveCard] = useState<Card | null>(null); const [activeCard, setActiveCard] = useState<Card | null>(null);
const [activeColumnId, setActiveColumnId] = useState<string | null>(null); const [activeColumnId, setActiveColumnId] = useState<string | null>(null);
const [activeType, setActiveType] = useState<string | undefined>(undefined); const [activeType, setActiveType] = useState<string | undefined>(undefined);
@@ -126,6 +104,9 @@ export function App() {
const [colName, setColName] = useState(""); const [colName, setColName] = useState("");
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [chatOpen, setChatOpen] = useState(false); const [chatOpen, setChatOpen] = useState(false);
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [navWidth, setNavWidth] = useState<number>(() => { const [navWidth, setNavWidth] = useState<number>(() => {
const stored = localStorage.getItem("kanban_nav_width"); const stored = localStorage.getItem("kanban_nav_width");
@@ -177,11 +158,43 @@ export function App() {
reload(); reload();
}, [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(() => { useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000); const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t); return () => clearInterval(t);
}, []); }, []);
const usersById = useMemo(() => {
const m = new Map<string, User>();
for (const u of users) m.set(u.id, u);
return m;
}, [users]);
const sortedColumns = useMemo(() => { const sortedColumns = useMemo(() => {
if (!board) return []; if (!board) return [];
return [...board.columns].sort((a, b) => a.position - b.position); 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: <AddColumnDialog onSubmit={async (name) => {
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) => { const handleRenameColumn = useCallback(async (id: string, name: string) => {
try { try {
await api.updateColumn(id, { name }); await api.updateColumn(id, { name });
@@ -426,6 +423,8 @@ export function App() {
size: "md", size: "md",
children: ( children: (
<CardForm <CardForm
users={users}
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
submitLabel="Crear" submitLabel="Crear"
onCancel={() => modals.close(id)} onCancel={() => modals.close(id)}
onSubmit={async (v) => { onSubmit={async (v) => {
@@ -435,6 +434,7 @@ export function App() {
requester: v.requester, requester: v.requester,
title: v.title, title: v.title,
description: v.description, description: v.description,
assignee_id: v.assignee_id,
}); });
modals.close(id); modals.close(id);
reload(); reload();
@@ -445,7 +445,7 @@ export function App() {
/> />
), ),
}); });
}, [reload]); }, [reload, users, auth.user]);
const openEditCard = useCallback((card: Card) => { const openEditCard = useCallback((card: Card) => {
const id = modals.open({ const id = modals.open({
@@ -453,7 +453,13 @@ export function App() {
size: "md", size: "md",
children: ( children: (
<CardForm <CardForm
initial={{ requester: card.requester, title: card.title, description: card.description }} users={users}
initial={{
requester: card.requester,
title: card.title,
description: card.description,
assignee_id: card.assignee_id,
}}
submitLabel="Guardar" submitLabel="Guardar"
onCancel={() => modals.close(id)} onCancel={() => modals.close(id)}
onSubmit={async (v) => { onSubmit={async (v) => {
@@ -462,6 +468,7 @@ export function App() {
requester: v.requester, requester: v.requester,
title: v.title, title: v.title,
description: v.description, description: v.description,
assignee_id: v.assignee_id,
}); });
modals.close(id); modals.close(id);
reload(); 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]); }, [reload]);
const handleDeleteCard = useCallback(async (id: string) => { const handleDeleteCard = useCallback(async (id: string) => {
try { try {
await api.deleteCard(id); await api.deleteCard(id);
reload(); reload();
reloadTrash();
} catch (e) { } catch (e) {
notifications.show({ color: "red", message: (e as Error).message }); 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: <Text size="sm">Esta accion no se puede deshacer.</Text>,
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) => { const handleChangeCardColor = useCallback(async (id: string, color: CardColor) => {
setBoard((prev) => { 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 headerConfig = useMemo(() => ({ height: 50 }), []);
const navbarConfig = useMemo( const navbarConfig = useMemo(
() => ({ () => ({
@@ -564,13 +652,21 @@ export function App() {
</ActionIcon> </ActionIcon>
<IconLayoutKanban size={22} /> <IconLayoutKanban size={22} />
<Title order={4}>Kanban</Title> <Title order={4}>Kanban</Title>
<Tabs value={activeTab} onChange={(v) => v && setActiveTab(v)} variant="pills" ml="md">
<Tabs.List>
<Tabs.Tab value="board" leftSection={<IconLayoutKanban size={14} />}>
Tablero
</Tabs.Tab>
<Tabs.Tab value="dashboard" leftSection={<IconChartBar size={14} />}>
Dashboard
</Tabs.Tab>
<Tabs.Tab value="calendar" leftSection={<IconCalendar size={14} />}>
Calendario
</Tabs.Tab>
</Tabs.List>
</Tabs>
</Group> </Group>
<Group gap={4}> <Group gap={4}>
<Tooltip label="Nueva columna" withArrow>
<ActionIcon variant="subtle" onClick={openAddColumnModal} aria-label="Add column">
<IconColumnInsertRight size={16} />
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={reload} aria-label="Refresh"> <ActionIcon variant="subtle" onClick={reload} aria-label="Refresh">
<IconRefresh size={16} /> <IconRefresh size={16} />
</ActionIcon> </ActionIcon>
@@ -581,6 +677,27 @@ export function App() {
> >
<IconMessageChatbot size={16} /> <IconMessageChatbot size={16} />
</ActionIcon> </ActionIcon>
{auth.user && (
<Menu position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" aria-label="Usuario">
<Avatar size={26} radius="xl" color="blue">
{(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()}
</Avatar>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{auth.user.display_name || auth.user.username}</Menu.Label>
<Menu.Item
leftSection={<IconLogout size={14} />}
color="red"
onClick={() => auth.logout()}
>
Cerrar sesion
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group> </Group>
</Group> </Group>
</AppShell.Header> </AppShell.Header>
@@ -624,15 +741,70 @@ export function App() {
onResizeColumn={handleResizeColumn} onResizeColumn={handleResizeColumn}
onMoveColumnLocation={handleMoveColumnLocation} onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn} onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onToggleDone={handleToggleDone}
onEditCard={openEditCard} onEditCard={openEditCard}
onDeleteCard={handleDeleteCard} onDeleteCard={handleDeleteCard}
onChangeCardColor={handleChangeCardColor} onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory} onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
users={users}
usersById={usersById}
/> />
))} ))}
</Stack> </Stack>
</SortableContext> </SortableContext>
</Box> </Box>
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
<Button
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="space-between"
leftSection={<IconTrash size={14} />}
rightSection={
<Group gap={4}>
<Badge size="xs" variant="light" color={trash.length > 0 ? "red" : "gray"}>
{trash.length}
</Badge>
{trashOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
</Group>
}
onClick={() => setTrashOpen((v) => !v)}
>
Papelera
</Button>
{trashOpen && (
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
{trash.length === 0 && (
<Text size="xs" c="dimmed" px="xs">
Vacia.
</Text>
)}
{trash.map((c) => (
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7">
<Group justify="space-between" gap={4} wrap="nowrap">
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
{c.title}
</Text>
<Tooltip label="Restaurar" withArrow>
<ActionIcon size="xs" variant="subtle" color="green" onClick={() => handleRestoreCard(c.id)}>
<IconArrowBackUp size={12} />
</ActionIcon>
</Tooltip>
<Tooltip label="Borrar permanentemente" withArrow>
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => handlePurgeCard(c.id)}>
<IconTrashX size={12} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
))}
</Stack>
)}
</Box>
</Stack> </Stack>
</AppShell.Navbar> </AppShell.Navbar>
@@ -641,6 +813,15 @@ export function App() {
</AppShell.Aside> </AppShell.Aside>
<AppShell.Main> <AppShell.Main>
{activeTab === "dashboard" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<Dashboard users={users} />
</Box>
) : activeTab === "calendar" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<CalendarView users={users} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden" }}> <Box style={{ height: "calc(100vh - 50px)", overflow: "hidden" }}>
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}> <SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
<Group <Group
@@ -661,10 +842,16 @@ export function App() {
onResizeColumn={handleResizeColumn} onResizeColumn={handleResizeColumn}
onMoveColumnLocation={handleMoveColumnLocation} onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn} onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onToggleDone={handleToggleDone}
onEditCard={openEditCard} onEditCard={openEditCard}
onDeleteCard={handleDeleteCard} onDeleteCard={handleDeleteCard}
onChangeCardColor={handleChangeCardColor} onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory} onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
users={users}
usersById={usersById}
/> />
))} ))}
@@ -708,6 +895,7 @@ export function App() {
</Group> </Group>
</SortableContext> </SortableContext>
</Box> </Box>
)}
</AppShell.Main> </AppShell.Main>
</AppShell> </AppShell>
@@ -721,6 +909,10 @@ export function App() {
onEdit={() => {}} onEdit={() => {}}
onChangeColor={() => {}} onChangeColor={() => {}}
onShowHistory={() => {}} onShowHistory={() => {}}
onToggleLock={() => {}}
onAssign={() => {}}
users={users}
assignee={dragOverlayCard.assignee_id ? usersById.get(dragOverlayCard.assignee_id) : undefined}
isOverlay isOverlay
/> />
) : dragOverlayColumn ? ( ) : dragOverlayColumn ? (
+17
View File
@@ -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 (
<Center style={{ minHeight: "100vh" }}>
<Loader />
</Center>
);
}
if (!user) return <LoginPage />;
return <App />;
}
+71 -3
View File
@@ -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"; const BASE = "/api";
export class HTTPError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> { async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
credentials: "include",
...init, ...init,
headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, headers: { "Content-Type": "application/json", ...(init?.headers || {}) },
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ Message: res.statusText })); 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; if (res.status === 204) return undefined as T;
return res.json(); return res.json();
@@ -28,6 +43,8 @@ export interface UpdateColumnInput {
position?: number; position?: number;
location?: "board" | "sidebar"; location?: "board" | "sidebar";
width?: number; width?: number;
wip_limit?: number;
is_done?: boolean;
} }
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> { export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
@@ -50,6 +67,7 @@ export interface CreateCardInput {
requester?: string; requester?: string;
title: string; title: string;
description?: string; description?: string;
assignee_id?: string | null;
} }
export function createCard(input: CreateCardInput): Promise<Card> { export function createCard(input: CreateCardInput): Promise<Card> {
@@ -61,6 +79,8 @@ export interface UpdateCardInput {
title?: string; title?: string;
description?: string; description?: string;
color?: string; color?: string;
locked?: boolean;
assignee_id?: string | null;
} }
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> { export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
@@ -71,6 +91,18 @@ export function deleteCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}`, { method: "DELETE" }); return fetchJSON(`/cards/${id}`, { method: "DELETE" });
} }
export function listTrash(): Promise<Card[]> {
return fetchJSON("/trash");
}
export function restoreCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/restore`, { method: "POST" });
}
export function purgeCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
}
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> { export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
return fetchJSON(`/cards/${id}/move`, { return fetchJSON(`/cards/${id}/move`, {
method: "POST", method: "POST",
@@ -78,7 +110,7 @@ export function moveCard(id: string, column_id: string, ordered_ids: string[]):
}); });
} }
export function cardHistory(id: string): Promise<HistoryEntry[]> { export function cardHistory(id: string): Promise<CardHistoryResponse> {
return fetchJSON(`/cards/${id}/history`); return fetchJSON(`/cards/${id}/history`);
} }
@@ -103,3 +135,39 @@ export interface ChatResponse {
export function sendChat(messages: ChatMessage[]): Promise<ChatResponse> { export function sendChat(messages: ChatMessage[]): Promise<ChatResponse> {
return fetchJSON("/chat", { method: "POST", body: JSON.stringify({ messages }) }); return fetchJSON("/chat", { method: "POST", body: JSON.stringify({ messages }) });
} }
export function login(username: string, password: string): Promise<User> {
return fetchJSON("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export function register(username: string, password: string, display_name?: string): Promise<User> {
return fetchJSON("/auth/register", {
method: "POST",
body: JSON.stringify({ username, password, display_name }),
});
}
export function logout(): Promise<void> {
return fetchJSON("/auth/logout", { method: "POST" });
}
export function getMe(): Promise<User> {
return fetchJSON("/me");
}
export function listUsers(): Promise<User[]> {
return fetchJSON("/users");
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
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}` : ""}`);
}
+55
View File
@@ -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<void>;
register: (username: string, password: string, displayName: string) => Promise<void>;
logout: () => Promise<void>;
}
const Ctx = createContext<AuthCtx | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(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 <Ctx.Provider value={{ user, loading, login, register, logout }}>{children}</Ctx.Provider>;
}
export function useAuth(): AuthCtx {
const v = useContext(Ctx);
if (!v) throw new Error("useAuth: missing AuthProvider");
return v;
}
+216
View File
@@ -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<Date>(new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [data, setData] = useState<Metrics | null>(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<string, { created: number; done: number }>();
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 (
<Box p="md">
<Stack gap="md">
<Group justify="space-between">
<Title order={3}>Calendario</Title>
<Group gap="xs" wrap="nowrap">
<MonthPickerInput
label="Mes"
size="xs"
value={month}
onChange={(v) => v && setMonth(typeof v === "string" ? new Date(v) : v)}
style={{ minWidth: 160 }}
clearable={false}
/>
<Select
label="Asignado"
size="xs"
placeholder="Todos"
value={assigneeId}
onChange={setAssigneeId}
data={userOptions}
clearable
searchable
style={{ minWidth: 180 }}
/>
</Group>
</Group>
<Group gap="md">
<Paper withBorder p="sm" radius="md">
<Group gap={6}>
<IconPlus size={14} color="var(--mantine-color-blue-5)" />
<Text size="sm" fw={600}>
{totalCreated}
</Text>
<Text size="xs" c="dimmed">
creadas
</Text>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group gap={6}>
<IconCheckbox size={14} color="var(--mantine-color-green-5)" />
<Text size="sm" fw={600}>
{totalDone}
</Text>
<Text size="xs" c="dimmed">
hechas
</Text>
</Group>
</Paper>
</Group>
{loading && !data ? (
<Center p="xl">
<Loader />
</Center>
) : (
<Paper withBorder p="md" radius="md">
<SimpleGrid cols={7} spacing={4} mb={4}>
{DAY_LABELS.map((d) => (
<Text key={d} size="xs" c="dimmed" ta="center" fw={600}>
{d}
</Text>
))}
</SimpleGrid>
<SimpleGrid cols={7} spacing={4}>
{grid.map((cell, i) => {
if (!cell.date) {
return <Box key={i} style={{ minHeight: 72 }} />;
}
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0 };
const dayNum = parseInt(cell.date.slice(8, 10), 10);
const isToday = cell.date === dayjs().format("YYYY-MM-DD");
return (
<Paper
key={i}
p={6}
withBorder
radius="sm"
style={{
minHeight: 72,
borderColor: isToday ? "var(--mantine-color-blue-5)" : undefined,
background:
stats.done > 0
? "rgba(81, 207, 102, 0.08)"
: stats.created > 0
? "rgba(34, 139, 230, 0.06)"
: undefined,
}}
>
<Stack gap={2}>
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
{dayNum}
</Text>
{stats.created > 0 && (
<Group gap={3} wrap="nowrap">
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
<Text size="xs" c="blue">
{stats.created}
</Text>
</Group>
)}
{stats.done > 0 && (
<Group gap={3} wrap="nowrap">
<IconCheckbox size={10} color="var(--mantine-color-green-5)" />
<Text size="xs" c="green">
{stats.done}
</Text>
</Group>
)}
</Stack>
</Paper>
);
})}
</SimpleGrid>
</Paper>
)}
</Stack>
</Box>
);
}
+34 -13
View File
@@ -1,32 +1,40 @@
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { Button, Group, Select, Stack, Textarea, TextInput } from "@mantine/core";
import { FormEvent, KeyboardEvent, useState } from "react"; import { FormEvent, KeyboardEvent, useState } from "react";
import type { User } from "../types";
export interface CardFormValues { export interface CardFormValues {
requester: string; requester: string;
title: string; title: string;
description: string; description: string;
assignee_id: string | null;
} }
interface Props { interface Props {
initial?: Partial<CardFormValues>; initial?: Partial<CardFormValues>;
submitLabel?: string; submitLabel?: string;
users?: User[];
onSubmit: (v: CardFormValues) => Promise<void> | void; onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void; onCancel: () => void;
} }
export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel }: Props) { export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmit, onCancel }: Props) {
const [requester, setRequester] = useState(initial?.requester ?? ""); const [requester, setRequester] = useState(initial?.requester ?? "");
const [title, setTitle] = useState(initial?.title ?? ""); const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? ""); const [description, setDescription] = useState(initial?.description ?? "");
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
const submit = async (e?: FormEvent) => { const submit = async (e?: FormEvent) => {
e?.preventDefault(); e?.preventDefault();
const t = title.trim(); const t = title.trim();
if (!t) return; if (!t) return;
await onSubmit({ requester: requester.trim(), title: t, description }); await onSubmit({
requester: requester.trim(),
title: t,
description,
assignee_id: assigneeId,
});
}; };
// Enter en TextInput envia el form. Enter en Textarea inserta newline; Ctrl/Cmd+Enter envia.
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => { const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -44,20 +52,20 @@ export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel
<form onSubmit={submit}> <form onSubmit={submit}>
<Stack gap="sm"> <Stack gap="sm">
<TextInput <TextInput
label="Solicitante" label="Tarea"
value={requester} value={title}
onChange={(e) => setRequester(e.currentTarget.value)} onChange={(e) => setTitle(e.currentTarget.value)}
tabIndex={1} tabIndex={1}
required
autoComplete="off" autoComplete="off"
data-autofocus data-autofocus
onKeyDown={enterSubmit} onKeyDown={enterSubmit}
/> />
<TextInput <TextInput
label="Tarea" label="Solicitante"
value={title} value={requester}
onChange={(e) => setTitle(e.currentTarget.value)} onChange={(e) => setRequester(e.currentTarget.value)}
tabIndex={2} tabIndex={2}
required
autoComplete="off" autoComplete="off"
onKeyDown={enterSubmit} onKeyDown={enterSubmit}
/> />
@@ -72,11 +80,24 @@ export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel
onKeyDown={textareaEnter} onKeyDown={textareaEnter}
description="Ctrl+Enter para guardar" description="Ctrl+Enter para guardar"
/> />
<Select
label="Asignar a"
placeholder="Sin asignar"
value={assigneeId}
onChange={(v) => setAssigneeId(v)}
data={users.map((u) => ({
value: u.id,
label: u.display_name || u.username,
}))}
clearable
searchable
tabIndex={4}
/>
<Group justify="flex-end" gap="xs" mt="xs"> <Group justify="flex-end" gap="xs" mt="xs">
<Button variant="subtle" color="gray" tabIndex={5} type="button" onClick={onCancel}> <Button variant="subtle" color="gray" tabIndex={6} type="button" onClick={onCancel}>
Cancelar Cancelar
</Button> </Button>
<Button tabIndex={4} type="submit" disabled={!title.trim()}> <Button tabIndex={5} type="submit" disabled={!title.trim()}>
{submitLabel} {submitLabel}
</Button> </Button>
</Group> </Group>
+467
View File
@@ -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 (
<Paper withBorder p="md" radius="md">
<Stack gap={4}>
<Group gap={6} c="dimmed">
{icon}
<Text size="xs" tt="uppercase" fw={600}>
{label}
</Text>
</Group>
<Text size="xl" fw={700} c={color}>
{value}
</Text>
{hint && (
<Text size="xs" c="dimmed">
{hint}
</Text>
)}
</Stack>
</Paper>
);
}
export function Dashboard({ users }: Props) {
const [from, setFrom] = useState<Date | null>(() => dayjs().subtract(30, "day").toDate());
const [to, setTo] = useState<Date | null>(() => new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [requester, setRequester] = useState<string | null>(null);
const [data, setData] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(false);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
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<string, { date: string; completed: number; created: number }>();
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 (
<Box p="md">
<Stack gap="md">
<Group justify="space-between">
<Title order={3}>Dashboard</Title>
<Group gap="xs" wrap="nowrap">
<DatePickerInput
label="Desde"
value={from}
onChange={(v) => setFrom(v as Date | null)}
size="xs"
clearable={false}
valueFormat="YYYY-MM-DD"
style={{ minWidth: 140 }}
/>
<DatePickerInput
label="Hasta"
value={to}
onChange={(v) => setTo(v as Date | null)}
size="xs"
clearable={false}
valueFormat="YYYY-MM-DD"
style={{ minWidth: 140 }}
/>
<Select
label="Asignado"
size="xs"
placeholder="Todos"
value={assigneeId}
onChange={setAssigneeId}
data={userOptions}
clearable
searchable
style={{ minWidth: 160 }}
/>
<Select
label="Solicitante"
size="xs"
placeholder="Todos"
value={requester}
onChange={setRequester}
data={requesterOptions.map((r) => ({ value: r, label: r }))}
clearable
searchable
style={{ minWidth: 160 }}
/>
</Group>
</Group>
{loading && !data && (
<Center p="xl">
<Loader />
</Center>
)}
{data && (
<>
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
<KPI
icon={<IconClipboardList size={14} />}
label="Tarjetas totales"
value={data.totals.cards}
hint={`${data.totals.columns} columnas, ${data.totals.users} usuarios`}
/>
<KPI
icon={<IconCheckbox size={14} />}
label="Completadas (rango)"
value={data.totals.cards_completed_in_range}
hint={`${data.totals.cards_created_in_range} creadas en rango`}
color="green"
/>
<KPI
icon={<IconClockHour4 size={14} />}
label="Lead time p50"
value={formatDuration(data.lead_time.p50_ms)}
hint={`p90 ${formatDuration(data.lead_time.p90_ms)} · n=${data.lead_time.n}`}
/>
<KPI
icon={<IconLock size={14} />}
label="Bloqueos activos"
value={data.totals.active_locks}
hint={`Total bloqueado: ${formatDuration(data.lock_total_ms)}`}
color={data.totals.active_locks > 0 ? "yellow" : undefined}
/>
</SimpleGrid>
<Paper withBorder p="md" radius="md">
<Group gap={6} mb="sm">
<IconTrendingUp size={16} />
<Text fw={600}>Cumulative Flow Diagram</Text>
<Text size="xs" c="dimmed">
total vs hechas (acumulado)
</Text>
</Group>
{cumulativeFlow.length === 0 ? (
<Text c="dimmed" size="sm">
Sin datos.
</Text>
) : (
<AreaChart
h={260}
data={cumulativeFlow}
dataKey="date"
withLegend
withDots
withGradient
fillOpacity={0.5}
strokeWidth={2.5}
curveType="monotone"
gridAxis="xy"
series={[
{ name: "total", label: "Total", color: "blue.6" },
{ name: "done", label: "Hechas", color: "green.6" },
]}
yAxisProps={{ allowDecimals: false }}
/>
)}
</Paper>
<Grid>
<Grid.Col span={{ base: 12, md: 8 }}>
<Paper withBorder p="md" radius="md">
<Group gap={6} mb="sm">
<IconTrendingUp size={16} />
<Text fw={600}>Throughput diario</Text>
</Group>
{throughputSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin datos en el rango.
</Text>
) : (
<LineChart
h={240}
data={throughputSeries}
dataKey="date"
curveType="monotone"
withLegend
series={[
{ name: "completed", label: "Completadas", color: "green.6" },
{ name: "created", label: "Creadas", color: "blue.6" },
]}
/>
)}
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Tarjetas por columna
</Text>
{byColumnSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin columnas.
</Text>
) : (
<BarChart
h={240}
data={byColumnSeries}
dataKey="column"
orientation="vertical"
yAxisProps={{ width: 100 }}
series={[{ name: "tarjetas", label: "Tarjetas", color: "blue.6" }]}
/>
)}
</Paper>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Top asignados
</Text>
{topAssigneeSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin asignaciones.
</Text>
) : (
<BarChart
h={240}
data={topAssigneeSeries}
dataKey="usuario"
orientation="vertical"
yAxisProps={{ width: 120 }}
withLegend
series={[
{ name: "completadas", label: "Completadas", color: "green.6" },
{ name: "activas", label: "Activas", color: "blue.6" },
]}
type="stacked"
/>
)}
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Top solicitantes
</Text>
{topRequesterSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin solicitantes en el rango.
</Text>
) : (
<BarChart
h={240}
data={topRequesterSeries}
dataKey="solicitante"
orientation="vertical"
yAxisProps={{ width: 120 }}
series={[{ name: "tarjetas", label: "Tarjetas", color: "violet.6" }]}
/>
)}
</Paper>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Movimientos por usuario (rango)
</Text>
{movementsSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin movimientos registrados.
</Text>
) : (
<BarChart
h={240}
data={movementsSeries}
dataKey="usuario"
orientation="vertical"
yAxisProps={{ width: 120 }}
series={[{ name: "movimientos", label: "Movimientos", color: "orange.6" }]}
/>
)}
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Tiempo en columna (cycle time)
</Text>
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Columna</Table.Th>
<Table.Th>n</Table.Th>
<Table.Th>p50</Table.Th>
<Table.Th>p90</Table.Th>
<Table.Th>avg</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.cycle_time_per_column.map((c) => (
<Table.Tr key={c.column_id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={500}>
{c.name}
</Text>
{c.is_done && (
<Badge size="xs" color="green" variant="light">
done
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>{c.stats.n}</Table.Td>
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.p50_ms) : "—"}</Table.Td>
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.p90_ms) : "—"}</Table.Td>
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.avg_ms) : "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Grid.Col>
</Grid>
</>
)}
</Stack>
</Box>
);
}
+68 -9
View File
@@ -1,8 +1,8 @@
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core"; import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { IconColumns3 } from "@tabler/icons-react"; import { IconColumns3, IconLock } from "@tabler/icons-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { cardHistory } from "../api"; import { cardHistory } from "../api";
import type { Card, HistoryEntry } from "../types"; import type { Card, CardHistoryResponse } from "../types";
import { formatDuration } from "./format"; import { formatDuration } from "./format";
interface Props { interface Props {
@@ -10,13 +10,17 @@ interface Props {
} }
export function HistoryModal({ card }: Props) { export function HistoryModal({ card }: Props) {
const [entries, setEntries] = useState<HistoryEntry[] | null>(null); const [data, setData] = useState<CardHistoryResponse | null>(null);
useEffect(() => { 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]); }, [card.id]);
if (!entries) { if (!data) {
return ( return (
<Group justify="center" p="xl"> <Group justify="center" p="xl">
<Loader size="sm" /> <Loader size="sm" />
@@ -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 <Text c="dimmed">Sin historial.</Text>; return <Text c="dimmed">Sin historial.</Text>;
} }
@@ -33,8 +39,8 @@ export function HistoryModal({ card }: Props) {
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Tiempo total en cada columna desde que se creo la tarjeta. Tiempo total en cada columna desde que se creo la tarjeta.
</Text> </Text>
<Timeline active={entries.length} bulletSize={22} lineWidth={2}> <Timeline active={column_history.length} bulletSize={22} lineWidth={2}>
{entries.map((e) => ( {column_history.map((e) => (
<Timeline.Item <Timeline.Item
key={e.id} key={e.id}
bullet={<IconColumns3 size={12} />} bullet={<IconColumns3 size={12} />}
@@ -61,6 +67,59 @@ export function HistoryModal({ card }: Props) {
</Timeline.Item> </Timeline.Item>
))} ))}
</Timeline> </Timeline>
<Divider />
<Group gap={6} align="center">
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
<Text fw={500} size="sm">
Tiempo bloqueada
</Text>
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
{formatDuration(total_locked_ms)}
</Badge>
{currently_locked && (
<Badge size="xs" variant="filled" color="yellow">
actualmente bloqueada
</Badge>
)}
</Group>
{lock_periods.length === 0 ? (
<Text size="xs" c="dimmed">
Nunca ha sido bloqueada.
</Text>
) : (
<Timeline active={lock_periods.length} bulletSize={22} lineWidth={2}>
{lock_periods.map((p) => (
<Timeline.Item
key={p.id}
bullet={<IconLock size={12} />}
title={
<Group gap={6}>
<Badge
size="xs"
variant="light"
color={p.unlocked_at ? "gray" : "yellow"}
>
{formatDuration(p.duration_ms)}
</Badge>
{!p.unlocked_at && (
<Badge size="xs" variant="filled" color="yellow">
en curso
</Badge>
)}
</Group>
}
>
<Text size="xs" c="dimmed">
{new Date(p.locked_at).toLocaleString()}
{p.unlocked_at && ` -> ${new Date(p.unlocked_at).toLocaleString()}`}
</Text>
</Timeline.Item>
))}
</Timeline>
)}
</Stack> </Stack>
); );
} }
+229 -71
View File
@@ -2,25 +2,32 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { import {
ActionIcon, ActionIcon,
Avatar,
Badge, Badge,
Group, Group,
Menu,
Paper, Paper,
Popover, Popover,
Select,
Stack, Stack,
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconClock, IconClock,
IconDotsVertical,
IconEdit, IconEdit,
IconGripVertical, IconGripVertical,
IconHistory, IconHistory,
IconLock,
IconLockOpen,
IconPalette, IconPalette,
IconTrash, IconTrash,
IconUser, IconUser,
IconUserCircle,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { memo, useState } from "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 { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
import { formatDuration } from "./format"; import { formatDuration } from "./format";
@@ -31,14 +38,36 @@ interface Props {
onEdit: (card: Card) => void; onEdit: (card: Card) => void;
onChangeColor: (id: string, color: CardColor) => void; onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => 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; isOverlay?: boolean;
} }
function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHistory, isOverlay }: Props) { function KanbanCardImpl({
const [popOpen, setPopOpen] = useState(false); 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({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id, id: card.id,
data: { type: "card", columnId: card.column_id }, data: { type: "card", columnId: card.column_id },
disabled: card.locked,
}); });
const style: React.CSSProperties = { const style: React.CSSProperties = {
@@ -46,84 +75,205 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
transition, transition,
opacity: isDragging ? 0.4 : 1, opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color), 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 enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt); const liveMs = Math.max(0, now - enteredAt);
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenuOpen(true);
};
const menuItems = (
<>
<Menu.Label>Acciones</Menu.Label>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={() => {
setMenuOpen(false);
onEdit(card);
}}
>
Editar
</Menu.Item>
<Popover
opened={colorPopOpen}
onChange={setColorPopOpen}
position="right-start"
withArrow
shadow="md"
>
<Popover.Target>
<Menu.Item
leftSection={<IconPalette size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setColorPopOpen((v) => !v);
}}
closeMenuOnClick={false}
>
Color
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group gap={4} maw={200}>
{CARD_COLORS.map((c) => (
<Tooltip key={c.value} label={c.label} withArrow>
<ActionIcon
variant={card.color === c.value ? "filled" : "default"}
size="md"
radius="xl"
style={{
background: colorSwatch(c.value),
borderColor: colorBorder(c.value),
}}
onClick={() => {
onChangeColor(card.id, c.value);
setColorPopOpen(false);
setMenuOpen(false);
}}
aria-label={c.label}
/>
</Tooltip>
))}
</Group>
</Popover.Dropdown>
</Popover>
<Popover
opened={assigneePopOpen}
onChange={setAssigneePopOpen}
position="right-start"
withArrow
shadow="md"
>
<Popover.Target>
<Menu.Item
leftSection={<IconUserCircle size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAssigneePopOpen((v) => !v);
}}
closeMenuOnClick={false}
>
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs">
<Select
placeholder="Sin asignar"
value={card.assignee_id ?? null}
onChange={(v) => {
onAssign(card.id, v);
setAssigneePopOpen(false);
setMenuOpen(false);
}}
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
clearable
searchable
autoFocus
/>
</Popover.Dropdown>
</Popover>
<Menu.Item
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
color={card.locked ? "yellow" : undefined}
onClick={() => {
setMenuOpen(false);
onToggleLock(card.id, !card.locked);
}}
>
{card.locked ? "Desbloquear" : "Bloquear"}
</Menu.Item>
<Menu.Item
leftSection={<IconHistory size={14} />}
onClick={() => {
setMenuOpen(false);
onShowHistory(card);
}}
>
Historial
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={() => {
setMenuOpen(false);
onDelete(card.id);
}}
>
Borrar
</Menu.Item>
</>
);
return ( return (
<Paper ref={setNodeRef} style={style} withBorder p="xs" shadow={isOverlay ? "lg" : "xs"} radius="md"> <Paper
ref={setNodeRef}
style={{ ...style, cursor: card.locked ? "default" : "grab", touchAction: "none" }}
withBorder
p="xs"
shadow={isOverlay ? "lg" : "xs"}
radius="md"
onContextMenu={onContextMenu}
onDoubleClick={(e) => {
e.stopPropagation();
onEdit(card);
}}
{...attributes}
{...(card.locked ? {} : listeners)}
>
<Stack gap={6}> <Stack gap={6}>
<Group justify="space-between" gap={4} wrap="nowrap"> <Group justify="space-between" gap={4} wrap="nowrap" align="flex-start">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}> <Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }} align="flex-start">
<ActionIcon <IconGripVertical
variant="subtle" size={14}
color="gray" color="var(--mantine-color-dark-2)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
{card.locked && (
<Tooltip label="Bloqueada" withArrow>
<IconLock
size={14}
color="var(--mantine-color-yellow-6)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
</Tooltip>
)}
<Text
size="sm" size="sm"
{...attributes} fw={500}
{...listeners} style={{
style={{ cursor: "grab" }} flex: 1,
aria-label="Drag" wordBreak: "break-word",
whiteSpace: "normal",
textDecoration: isDone ? "line-through" : "none",
opacity: isDone ? 0.7 : 1,
}}
> >
<IconGripVertical size={14} />
</ActionIcon>
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
{card.title} {card.title}
</Text> </Text>
</Group> </Group>
<Group gap={2} wrap="nowrap"> <Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Popover opened={popOpen} onChange={setPopOpen} withArrow shadow="md" position="bottom-end"> <Menu.Target>
<Popover.Target> <ActionIcon
<ActionIcon variant="subtle"
variant="subtle" color="gray"
color="gray" size="sm"
size="sm" aria-label="Acciones"
onClick={() => setPopOpen((v) => !v)} style={{ flexShrink: 0 }}
aria-label="Color" onPointerDown={(e) => e.stopPropagation()}
> >
<IconPalette size={14} /> <IconDotsVertical size={14} />
</ActionIcon> </ActionIcon>
</Popover.Target> </Menu.Target>
<Popover.Dropdown p="xs"> <Menu.Dropdown>{menuItems}</Menu.Dropdown>
<Group gap={4} maw={200}> </Menu>
{CARD_COLORS.map((c) => (
<Tooltip key={c.value} label={c.label} withArrow>
<ActionIcon
variant={card.color === c.value ? "filled" : "default"}
size="md"
radius="xl"
style={{
background: colorSwatch(c.value),
borderColor: colorBorder(c.value),
}}
onClick={() => {
onChangeColor(card.id, c.value);
setPopOpen(false);
}}
aria-label={c.label}
/>
</Tooltip>
))}
</Group>
</Popover.Dropdown>
</Popover>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => onEdit(card)} aria-label="Edit">
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => onShowHistory(card)}
aria-label="History"
>
<IconHistory size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(card.id)} aria-label="Delete">
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group> </Group>
{card.requester && ( {card.requester && (
<Group gap={4}> <Group gap={4}>
@@ -133,6 +283,16 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
</Text> </Text>
</Group> </Group>
)} )}
{assignee && (
<Group gap={6} wrap="nowrap">
<Avatar size={18} radius="xl" color="blue">
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
</Avatar>
<Text size="xs" c="dimmed">
{assignee.display_name || assignee.username}
</Text>
</Group>
)}
{card.description && ( {card.description && (
<Text size="xs" c="dimmed" lineClamp={3}> <Text size="xs" c="dimmed" lineClamp={3}>
{card.description} {card.description}
@@ -148,6 +308,4 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
); );
} }
// memo: re-render solo cuando cambian props relevantes (card, now). Evita rerenders
// en cascada cuando otra columna cambia durante drag-over.
export const KanbanCard = memo(KanbanCardImpl); export const KanbanCard = memo(KanbanCardImpl);
+233 -78
View File
@@ -7,7 +7,10 @@ import {
Box, Box,
Button, Button,
Group, Group,
Menu,
NumberInput,
Paper, Paper,
Popover,
ScrollArea, ScrollArea,
Stack, Stack,
Text, Text,
@@ -17,7 +20,12 @@ import {
import { import {
IconArchive, IconArchive,
IconArchiveOff, IconArchiveOff,
IconAlertTriangle,
IconCheck, IconCheck,
IconCheckbox,
IconChevronDown,
IconChevronRight,
IconDotsVertical,
IconGripVertical, IconGripVertical,
IconPencil, IconPencil,
IconPlus, IconPlus,
@@ -25,7 +33,7 @@ import {
IconX, IconX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { memo, MouseEvent, useEffect, useRef, useState } from "react"; import { memo, MouseEvent, useEffect, useRef, useState } from "react";
import type { Card, CardColor, Column } from "../types"; import type { Card, CardColor, Column, User } from "../types";
import { KanbanCard } from "./KanbanCard"; import { KanbanCard } from "./KanbanCard";
interface Props { interface Props {
@@ -38,10 +46,16 @@ interface Props {
onResizeColumn: (id: string, width: number) => void; onResizeColumn: (id: string, width: number) => void;
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void; onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
onDeleteColumn: (id: string) => void; onDeleteColumn: (id: string) => void;
onSetWIPLimit: (id: string, limit: number) => void;
onToggleDone: (id: string, is_done: boolean) => void;
onEditCard: (card: Card) => void; onEditCard: (card: Card) => void;
onDeleteCard: (id: string) => void; onDeleteCard: (id: string) => void;
onChangeCardColor: (id: string, color: CardColor) => void; onChangeCardColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void; onShowHistory: (card: Card) => void;
onToggleCardLock: (id: string, locked: boolean) => void;
onAssignCard: (id: string, assignee_id: string | null) => void;
users: User[];
usersById: Map<string, User>;
} }
function KanbanColumnImpl({ function KanbanColumnImpl({
@@ -54,14 +68,34 @@ function KanbanColumnImpl({
onResizeColumn, onResizeColumn,
onMoveColumnLocation, onMoveColumnLocation,
onDeleteColumn, onDeleteColumn,
onSetWIPLimit,
onToggleDone,
onEditCard, onEditCard,
onDeleteCard, onDeleteCard,
onChangeCardColor, onChangeCardColor,
onShowHistory, onShowHistory,
onToggleCardLock,
onAssignCard,
users,
usersById,
}: Props) { }: Props) {
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(column.name); const [name, setName] = useState(column.name);
const [localWidth, setLocalWidth] = useState<number | null>(null); const [localWidth, setLocalWidth] = useState<number | null>(null);
const [wipPopOpen, setWipPopOpen] = useState(false);
const [wipDraft, setWipDraft] = useState<number | string>(column.wip_limit);
const [bodyHidden, setBodyHidden] = useState(() => {
if (!collapsed) return false;
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
});
useEffect(() => {
if (collapsed) {
localStorage.setItem(`kanban_col_body_${column.id}`, bodyHidden ? "1" : "0");
}
}, [bodyHidden, collapsed, column.id]);
const wipLimit = column.wip_limit;
const overLimit = wipLimit > 0 && cards.length > wipLimit;
// sync local width when column.width changes from outside (other clients). // sync local width when column.width changes from outside (other clients).
useEffect(() => { useEffect(() => {
@@ -73,20 +107,32 @@ function KanbanColumnImpl({
data: { type: "column", columnId: column.id, location: column.location }, data: { type: "column", columnId: column.id, location: column.location },
}); });
const effectiveWidth = collapsed ? 220 : localWidth ?? column.width; const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
const style: React.CSSProperties = { const style: React.CSSProperties = collapsed
transform: CSS.Transform.toString(transform), ? {
transition, transform: CSS.Transform.toString(transform),
opacity: isDragging ? 0.4 : 1, transition,
width: effectiveWidth, opacity: isDragging ? 0.4 : 1,
minWidth: effectiveWidth, width: "100%",
maxWidth: effectiveWidth, display: "flex",
display: "flex", flexDirection: "column",
flexDirection: "column", position: "relative",
height: "100%", flex: bodyHidden ? "0 0 auto" : "1 1 auto",
position: "relative", minHeight: 0,
}; }
: {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
width: effectiveWidth,
minWidth: effectiveWidth,
maxWidth: effectiveWidth,
display: "flex",
flexDirection: "column",
height: "100%",
position: "relative",
};
const cardIds = cards.map((c) => c.id); const cardIds = cards.map((c) => c.id);
@@ -135,8 +181,24 @@ function KanbanColumnImpl({
const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar"; const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar";
const ArchiveIcon = isInSidebar ? IconArchiveOff : IconArchive; const ArchiveIcon = isInSidebar ? IconArchiveOff : IconArchive;
const submitWIP = () => {
const n = typeof wipDraft === "number" ? wipDraft : parseInt(String(wipDraft), 10);
const safe = Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
if (safe !== column.wip_limit) onSetWIPLimit(column.id, safe);
setWipPopOpen(false);
};
const paperBg = overLimit ? "var(--mantine-color-red-9)" : "var(--mantine-color-dark-7)";
const paperBorderColor = overLimit ? "var(--mantine-color-red-6)" : undefined;
return ( return (
<Paper ref={setNodeRef} style={style} withBorder radius="md" p="sm" bg="dark.7"> <Paper
ref={setNodeRef}
style={{ ...style, background: paperBg, borderColor: paperBorderColor, borderWidth: overLimit ? 2 : 1 }}
withBorder
radius="md"
p="sm"
>
<Group justify="space-between" mb="xs" wrap="nowrap"> <Group justify="space-between" mb="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}> <Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<ActionIcon <ActionIcon
@@ -181,9 +243,65 @@ function KanbanColumnImpl({
{column.name} {column.name}
</Text> </Text>
)} )}
<Badge size="xs" variant="light" color="gray"> <Popover
{cards.length} opened={wipPopOpen}
</Badge> onChange={(o) => {
setWipPopOpen(o);
if (o) setWipDraft(column.wip_limit);
}}
position="bottom"
withArrow
shadow="md"
>
<Popover.Target>
<Tooltip
label={
wipLimit > 0
? `WIP ${cards.length}/${wipLimit}${overLimit ? " (excedido)" : ""}`
: "Click para limitar WIP"
}
withArrow
>
<Badge
size="xs"
variant={overLimit ? "filled" : "light"}
color={overLimit ? "red" : wipLimit > 0 ? "yellow" : "gray"}
leftSection={overLimit ? <IconAlertTriangle size={10} /> : null}
style={{ cursor: "pointer" }}
onClick={() => setWipPopOpen((v) => !v)}
>
{wipLimit > 0 ? `${cards.length}/${wipLimit}` : cards.length}
</Badge>
</Tooltip>
</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap="xs">
<Text size="xs" c="dimmed">
Maximo de tarjetas (0 = sin limite)
</Text>
<NumberInput
size="xs"
value={wipDraft}
onChange={setWipDraft}
min={0}
max={999}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") submitWIP();
if (e.key === "Escape") setWipPopOpen(false);
}}
/>
<Group justify="flex-end" gap={4}>
<Button size="xs" variant="subtle" onClick={() => setWipPopOpen(false)}>
Cancelar
</Button>
<Button size="xs" onClick={submitWIP}>
Guardar
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
</Group> </Group>
<Group gap={2} wrap="nowrap"> <Group gap={2} wrap="nowrap">
{renaming ? ( {renaming ? (
@@ -206,72 +324,109 @@ function KanbanColumnImpl({
</> </>
) : ( ) : (
<> <>
<ActionIcon {collapsed && (
variant="subtle" <Tooltip label={bodyHidden ? "Expandir" : "Colapsar"} withArrow>
color="gray" <ActionIcon
size="sm" variant="subtle"
onClick={() => { color="gray"
setName(column.name); size="sm"
setRenaming(true); onClick={() => setBodyHidden((v) => !v)}
}} aria-label={bodyHidden ? "Expandir columna" : "Colapsar columna"}
aria-label="Rename" >
> {bodyHidden ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
<IconPencil size={14} /> </ActionIcon>
</ActionIcon> </Tooltip>
<Tooltip label={archiveLabel} withArrow> )}
<ActionIcon {column.is_done && (
variant="subtle" <Tooltip label="Columna Done" withArrow>
color="blue" <Badge size="xs" color="green" variant="filled" leftSection={<IconCheckbox size={10} />}>
size="sm" done
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")} </Badge>
aria-label={archiveLabel} </Tooltip>
> )}
<ArchiveIcon size={14} /> <Menu position="bottom-end" shadow="md" withArrow>
</ActionIcon> <Menu.Target>
</Tooltip> <ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones columna">
<ActionIcon <IconDotsVertical size={14} />
variant="subtle" </ActionIcon>
color="red" </Menu.Target>
size="sm" <Menu.Dropdown>
onClick={() => onDeleteColumn(column.id)} <Menu.Label>Columna</Menu.Label>
aria-label="Delete column" <Menu.Item
> leftSection={<IconPencil size={14} />}
<IconTrash size={14} /> onClick={() => {
</ActionIcon> setName(column.name);
setRenaming(true);
}}
>
Renombrar
</Menu.Item>
<Menu.Item
leftSection={<IconCheckbox size={14} />}
color={column.is_done ? "yellow" : "green"}
onClick={() => onToggleDone(column.id, !column.is_done)}
>
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
</Menu.Item>
<Menu.Item
leftSection={<ArchiveIcon size={14} />}
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
>
{archiveLabel}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={() => onDeleteColumn(column.id)}
>
Borrar columna
</Menu.Item>
</Menu.Dropdown>
</Menu>
</> </>
)} )}
</Group> </Group>
</Group> </Group>
<ScrollArea style={{ flex: 1 }} type="auto"> {!(collapsed && bodyHidden) && (
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}> <>
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}> <ScrollArea style={{ flex: 1 }} type="auto">
{cards.map((c) => ( <SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
<KanbanCard <Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
key={c.id} {cards.map((c) => (
card={c} <KanbanCard
now={now} key={c.id}
onDelete={onDeleteCard} card={c}
onEdit={onEditCard} now={now}
onChangeColor={onChangeCardColor} onDelete={onDeleteCard}
onShowHistory={onShowHistory} onEdit={onEditCard}
/> onChangeColor={onChangeCardColor}
))} onShowHistory={onShowHistory}
</Stack> onToggleLock={onToggleCardLock}
</SortableContext> onAssign={onAssignCard}
</ScrollArea> users={users}
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
inDoneColumn={column.is_done}
/>
))}
</Stack>
</SortableContext>
</ScrollArea>
<Button <Button
variant="subtle" variant="subtle"
color="gray" color="gray"
size="xs" size="xs"
leftSection={<IconPlus size={14} />} leftSection={<IconPlus size={14} />}
onClick={() => onAddCard(column.id)} onClick={() => onAddCard(column.id)}
mt="xs" mt="xs"
fullWidth fullWidth
> >
Anadir tarjeta Anadir tarjeta
</Button> </Button>
</>
)}
{/* Resize handle (only on board, not sidebar) */} {/* Resize handle (only on board, not sidebar) */}
{!isInSidebar && ( {!isInSidebar && (
+106
View File
@@ -0,0 +1,106 @@
import {
Anchor,
Button,
Center,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { IconLayoutKanban } from "@tabler/icons-react";
import { useState } from "react";
import { useAuth } from "../auth";
type Mode = "login" | "register";
export function LoginPage() {
const auth = useAuth();
const [mode, setMode] = useState<Mode>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
if (mode === "login") {
await auth.login(username.trim(), password);
} else {
await auth.register(username.trim(), password, displayName.trim() || username.trim());
}
} catch (err) {
setError((err as Error).message);
} finally {
setSubmitting(false);
}
};
return (
<Center style={{ minHeight: "100vh" }} p="md">
<Paper p="xl" withBorder radius="md" shadow="md" style={{ width: 360, maxWidth: "100%" }}>
<form onSubmit={submit}>
<Stack gap="md">
<Stack gap={4} align="center">
<IconLayoutKanban size={36} />
<Title order={3}>Kanban</Title>
<Text size="sm" c="dimmed">
{mode === "login" ? "Inicia sesion" : "Crea una cuenta"}
</Text>
</Stack>
<TextInput
label="Usuario"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
required
autoFocus
autoComplete="username"
/>
{mode === "register" && (
<TextInput
label="Nombre (opcional)"
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
autoComplete="name"
/>
)}
<PasswordInput
label="Contrasena"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
{error && (
<Text size="sm" c="red">
{error}
</Text>
)}
<Button type="submit" loading={submitting} fullWidth>
{mode === "login" ? "Entrar" : "Registrar"}
</Button>
<Text size="xs" c="dimmed" ta="center">
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
<Anchor
component="button"
type="button"
size="xs"
onClick={() => {
setError(null);
setMode(mode === "login" ? "register" : "login");
}}
>
{mode === "login" ? "Registrate" : "Inicia sesion"}
</Anchor>
</Text>
</Stack>
</form>
</Paper>
</Center>
);
}
+5 -2
View File
@@ -4,7 +4,8 @@ import { MantineProvider, createTheme } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals"; import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { AuthProvider } from "./auth";
import { Root } from "./Root";
const theme = createTheme({ const theme = createTheme({
primaryColor: "blue", primaryColor: "blue",
@@ -15,7 +16,9 @@ createRoot(document.getElementById("root")!).render(
<MantineProvider theme={theme} defaultColorScheme="dark"> <MantineProvider theme={theme} defaultColorScheme="dark">
<ModalsProvider> <ModalsProvider>
<Notifications position="top-right" /> <Notifications position="top-right" />
<App /> <AuthProvider>
<Root />
</AuthProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
); );
+118
View File
@@ -6,6 +6,8 @@ export interface Column {
position: number; position: number;
location: ColumnLocation; location: ColumnLocation;
width: number; width: number;
wip_limit: number;
is_done: boolean;
created_at: string; created_at: string;
} }
@@ -19,12 +21,113 @@ export interface Card {
color: CardColor; color: CardColor;
column_id: string; column_id: string;
position: number; position: number;
locked: boolean;
assignee_id: string | null;
completed_at: string | null;
deleted_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
entered_at: string; entered_at: string;
time_in_column_ms: number; time_in_column_ms: number;
} }
export interface User {
id: string;
username: string;
display_name: string;
created_at: string;
}
export interface MetricsRange {
from: string;
to: string;
}
export interface MetricsTotals {
cards: number;
cards_completed_in_range: number;
cards_created_in_range: number;
columns: number;
users: number;
active_locks: number;
}
export interface MetricsColumnCount {
column_id: string;
name: string;
is_done: boolean;
count: number;
}
export interface MetricsDailyCount {
date: string;
count: number;
}
export interface MetricsDurationStats {
n: number;
avg_ms: number;
p50_ms: number;
p90_ms: number;
p99_ms: number;
}
export interface MetricsColumnDuration {
column_id: string;
name: string;
is_done: boolean;
stats: MetricsDurationStats;
}
export interface MetricsAssignee {
user_id: string;
username: string;
display_name: string;
active: number;
completed_in_range: number;
}
export interface MetricsRequester {
requester: string;
total: number;
}
export interface MetricsMovement {
user_id: string;
username: string;
display_name: string;
moves: number;
}
export interface MetricsCumulativePoint {
date: string;
total: number;
done: number;
}
export interface Metrics {
range: MetricsRange;
totals: MetricsTotals;
by_column: MetricsColumnCount[];
throughput_daily: MetricsDailyCount[];
created_daily: MetricsDailyCount[];
lead_time: MetricsDurationStats;
cycle_time_per_column: MetricsColumnDuration[];
top_assignees: MetricsAssignee[];
top_requesters: MetricsRequester[];
movements_by_user: MetricsMovement[];
lock_total_ms: number;
lock_active_count: number;
cumulative_flow: MetricsCumulativePoint[];
}
export interface MetricsFilter {
from?: string;
to?: string;
assignee_id?: string;
requester?: string;
}
export interface Board { export interface Board {
columns: Column[]; columns: Column[];
cards: Card[]; cards: Card[];
@@ -39,3 +142,18 @@ export interface HistoryEntry {
exited_at: string | null; exited_at: string | null;
duration_ms: number; duration_ms: number;
} }
export interface LockPeriod {
id: string;
card_id: string;
locked_at: string;
unlocked_at: string | null;
duration_ms: number;
}
export interface CardHistoryResponse {
column_history: HistoryEntry[];
lock_periods: LockPeriod[];
total_locked_ms: number;
currently_locked: boolean;
}
+6 -1
View File
@@ -14,7 +14,12 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@fn_library": ["../../../frontend/functions/ui"],
"@fn_library/*": ["../../../frontend/functions/ui/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }
+6
View File
@@ -1,8 +1,14 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
"@fn_library": path.resolve(__dirname, "../../../frontend/functions/ui"),
},
},
server: { server: {
port: 5180, port: 5180,
proxy: { proxy: {
+92 -15
View File
@@ -71,12 +71,14 @@ func handleUpdateColumn(db *DB) http.HandlerFunc {
Position *int `json:"position"` Position *int `json:"position"`
Location *string `json:"location"` Location *string `json:"location"`
Width *int `json:"width"` Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
} }
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error()) badRequest(w, err.Error())
return return
} }
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width}); err != nil { if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil {
serverError(w, err) serverError(w, err)
return return
} }
@@ -116,10 +118,11 @@ func handleReorderColumns(db *DB) http.HandlerFunc {
func handleCreateCard(db *DB) http.HandlerFunc { func handleCreateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
ColumnID string `json:"column_id"` ColumnID string `json:"column_id"`
Requester string `json:"requester"` Requester string `json:"requester"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
AssigneeID *string `json:"assignee_id"`
} }
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error()) badRequest(w, err.Error())
@@ -129,7 +132,14 @@ func handleCreateCard(db *DB) http.HandlerFunc {
badRequest(w, "column_id and title required") badRequest(w, "column_id and title required")
return return
} }
c, err := db.CreateCard(body.ColumnID, body.Requester, body.Title, body.Description) actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
c, err := db.CreateCard(body.ColumnID, body.Requester, body.Title, body.Description, actor)
if err == nil && body.AssigneeID != nil && *body.AssigneeID != "" {
err = db.UpdateCardWithActor(c.ID, CardPatch{AssigneeID: body.AssigneeID, HasAssignee: true}, actor)
if err == nil {
c.AssigneeID = body.AssigneeID
}
}
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
return return
@@ -142,17 +152,38 @@ func handleCreateCard(db *DB) http.HandlerFunc {
func handleUpdateCard(db *DB) http.HandlerFunc { func handleUpdateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
var body struct { var raw map[string]any
Requester *string `json:"requester"` if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil {
Title *string `json:"title"`
Description *string `json:"description"`
Color *string `json:"color"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error()) badRequest(w, err.Error())
return return
} }
if err := db.UpdateCard(id, CardPatch{Requester: body.Requester, Title: body.Title, Description: body.Description, Color: body.Color}); err != nil { patch := CardPatch{}
if v, ok := raw["requester"].(string); ok {
patch.Requester = &v
}
if v, ok := raw["title"].(string); ok {
patch.Title = &v
}
if v, ok := raw["description"].(string); ok {
patch.Description = &v
}
if v, ok := raw["color"].(string); ok {
patch.Color = &v
}
if v, ok := raw["locked"].(bool); ok {
patch.Locked = &v
}
if v, present := raw["assignee_id"]; present {
patch.HasAssignee = true
if v == nil {
empty := ""
patch.AssigneeID = &empty
} else if s, ok := v.(string); ok {
patch.AssigneeID = &s
}
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.UpdateCardWithActor(id, patch, actor); err != nil {
serverError(w, err) serverError(w, err)
return return
} }
@@ -188,7 +219,8 @@ func handleMoveCard(db *DB) http.HandlerFunc {
badRequest(w, "column_id required") badRequest(w, "column_id required")
return return
} }
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs); err != nil { actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil {
if strings.Contains(err.Error(), "not found") { if strings.Contains(err.Error(), "not found") {
notFound(w, "card not found") notFound(w, "card not found")
return return
@@ -213,8 +245,49 @@ func handleCardHistory(db *DB) http.HandlerFunc {
} }
} }
// GET /api/trash
func handleListTrash(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cards, err := db.ListDeletedCards()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, cards)
}
}
// POST /api/cards/{id}/restore
func handleRestoreCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.RestoreCard(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}/purge
func handlePurgeCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.PurgeCard(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route { func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
return []infra.Route{ return []infra.Route{
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
{Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)},
{Method: "GET", Path: "/api/me", Handler: handleMe(db)},
{Method: "GET", Path: "/api/users", Handler: handleListUsers(db)},
{Method: "GET", Path: "/api/board", Handler: handleGetBoard(db)}, {Method: "GET", Path: "/api/board", Handler: handleGetBoard(db)},
{Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)}, {Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)},
{Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db)}, {Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db)},
@@ -225,6 +298,10 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)}, {Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)},
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)}, {Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)},
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)}, {Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)}, {Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
} }
} }
+69
View File
@@ -11,7 +11,9 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"time"
"fn-registry/functions/infra" "fn-registry/functions/infra"
) )
@@ -23,6 +25,7 @@ func main() {
flags := flag.NewFlagSet("kanban", flag.ExitOnError) flags := flag.NewFlagSet("kanban", flag.ExitOnError)
port := flags.Int("port", 8095, "HTTP port") port := flags.Int("port", 8095, "HTTP port")
dbPath := flags.String("db", "operations.db", "SQLite database path") dbPath := flags.String("db", "operations.db", "SQLite database path")
initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)")
flags.Parse(os.Args[1:]) flags.Parse(os.Args[1:])
db, err := openDB(*dbPath) db, err := openDB(*dbPath)
@@ -31,6 +34,9 @@ func main() {
} }
defer db.Close() defer db.Close()
bootstrapAdmin(db, *initialAdmin)
startSessionCleanup(db)
wd := chatWorkdir(*dbPath) wd := chatWorkdir(*dbPath)
logger := newChatLogger(filepath.Join(wd, "chat.log")) logger := newChatLogger(filepath.Join(wd, "chat.log"))
log.Printf("chat tool log: %s", logger.path) log.Printf("chat tool log: %s", logger.path)
@@ -44,9 +50,17 @@ func main() {
log.Printf("no frontend build found, API-only mode") log.Printf("no frontend build found, API-only mode")
} }
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
DB: db.conn,
CookieName: cookieName,
SkipPaths: []string{"/api/auth/", "/health", "/assets/", "/index.html"},
UserCtxKey: userCtxKey,
})
chain := infra.HTTPMiddlewareChain( chain := infra.HTTPMiddlewareChain(
infra.HTTPLoggerMiddleware(os.Stdout), infra.HTTPLoggerMiddleware(os.Stdout),
infra.HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}), infra.HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}),
apiOnlyAuth(authMW),
) )
handler := chain(mux) handler := chain(mux)
@@ -62,6 +76,61 @@ func main() {
} }
} }
// apiOnlyAuth applies auth middleware only to /api/* paths so the SPA shell
// can be served without a session (the SPA itself handles login UI).
func apiOnlyAuth(mw infra.Middleware) infra.Middleware {
return func(next http.Handler) http.Handler {
gated := mw(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
gated.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}
func bootstrapAdmin(db *DB, spec string) {
spec = strings.TrimSpace(spec)
if spec == "" {
return
}
count, err := db.CountUsers()
if err != nil {
log.Printf("bootstrap admin: count users: %v", err)
return
}
if count > 0 {
return
}
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
log.Printf("bootstrap admin: invalid spec, expected user:pass")
return
}
u, err := db.CreateUser(parts[0], parts[1], parts[0])
if err != nil {
log.Printf("bootstrap admin: %v", err)
return
}
log.Printf("bootstrap admin: created user %q", u.Username)
}
func startSessionCleanup(db *DB) {
go func() {
t := time.NewTicker(1 * time.Hour)
defer t.Stop()
for range t.C {
if n, err := infra.SessionCleanup(db.conn); err != nil {
log.Printf("session cleanup: %v", err)
} else if n > 0 {
log.Printf("session cleanup: purged %d expired", n)
}
}
}()
}
func frontendHandler() http.Handler { func frontendHandler() http.Handler {
sub, err := fs.Sub(frontendDist, "frontend/dist") sub, err := fs.Sub(frontendDist, "frontend/dist")
if err != nil { if err != nil {
+592
View File
@@ -0,0 +1,592 @@
package main
import (
"net/http"
"sort"
"time"
"fn-registry/functions/infra"
)
type Metrics struct {
Range DateRange `json:"range"`
Totals Totals `json:"totals"`
ByColumn []ColumnCount `json:"by_column"`
ThroughputDaily []DailyCount `json:"throughput_daily"`
CreatedDaily []DailyCount `json:"created_daily"`
LeadTime DurationStats `json:"lead_time"`
CycleTimeColumn []ColumnDuration `json:"cycle_time_per_column"`
TopAssignees []AssigneeStat `json:"top_assignees"`
TopRequesters []RequesterStat `json:"top_requesters"`
MovementsByUser []MovementStat `json:"movements_by_user"`
LockTotalMs int64 `json:"lock_total_ms"`
LockActiveCount int `json:"lock_active_count"`
CumulativeFlow []CumulativePoint `json:"cumulative_flow"`
}
type CumulativePoint struct {
Date string `json:"date"`
Total int `json:"total"`
Done int `json:"done"`
}
type DateRange struct {
From string `json:"from"`
To string `json:"to"`
}
type Totals struct {
Cards int `json:"cards"`
CardsCompleted int `json:"cards_completed_in_range"`
CardsCreated int `json:"cards_created_in_range"`
Columns int `json:"columns"`
Users int `json:"users"`
ActiveLocks int `json:"active_locks"`
}
type ColumnCount struct {
ColumnID string `json:"column_id"`
Name string `json:"name"`
IsDone bool `json:"is_done"`
Count int `json:"count"`
}
type DailyCount struct {
Date string `json:"date"`
Count int `json:"count"`
}
type DurationStats struct {
N int `json:"n"`
AvgMs int64 `json:"avg_ms"`
P50Ms int64 `json:"p50_ms"`
P90Ms int64 `json:"p90_ms"`
P99Ms int64 `json:"p99_ms"`
}
type ColumnDuration struct {
ColumnID string `json:"column_id"`
Name string `json:"name"`
IsDone bool `json:"is_done"`
Stats DurationStats `json:"stats"`
}
type AssigneeStat struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Active int `json:"active"`
Completed int `json:"completed_in_range"`
}
type RequesterStat struct {
Requester string `json:"requester"`
Total int `json:"total"`
}
type MovementStat struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Moves int `json:"moves"`
}
func percentile(sorted []int64, p float64) int64 {
if len(sorted) == 0 {
return 0
}
idx := int(float64(len(sorted)-1) * p)
if idx < 0 {
idx = 0
}
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
func computeStats(durations []int64) DurationStats {
n := len(durations)
if n == 0 {
return DurationStats{}
}
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
var sum int64
for _, d := range durations {
sum += d
}
return DurationStats{
N: n,
AvgMs: sum / int64(n),
P50Ms: percentile(durations, 0.5),
P90Ms: percentile(durations, 0.9),
P99Ms: percentile(durations, 0.99),
}
}
func parseDateOrDefault(s string, dflt time.Time) time.Time {
if s == "" {
return dflt
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
return dflt
}
// GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=...
func handleMetrics(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
from := parseDateOrDefault(r.URL.Query().Get("from"), now.AddDate(0, 0, -30))
to := parseDateOrDefault(r.URL.Query().Get("to"), now)
assignee := r.URL.Query().Get("assignee_id")
requester := r.URL.Query().Get("requester")
m, err := computeMetrics(db, from, to, assignee, requester)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, m)
}
}
func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Metrics, error) {
fromStr := from.Format(time.RFC3339Nano)
toStr := to.Format(time.RFC3339Nano)
m := &Metrics{
Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")},
}
cardWhere := "WHERE deleted_at IS NULL"
args := []any{}
if assignee != "" {
cardWhere += " AND assignee_id=?"
args = append(args, assignee)
}
if requester != "" {
cardWhere += " AND requester=?"
args = append(args, requester)
}
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM cards `+cardWhere, args...).Scan(&m.Totals.Cards); err != nil {
return nil, err
}
completedArgs := append([]any{fromStr, toStr}, args...)
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`,
append(args, fromStr, toStr)...,
).Scan(&m.Totals.CardsCompleted); err != nil {
return nil, err
}
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND created_at>=? AND created_at<=?`,
append(args, fromStr, toStr)...,
).Scan(&m.Totals.CardsCreated); err != nil {
return nil, err
}
_ = completedArgs
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM columns`).Scan(&m.Totals.Columns); err != nil {
return nil, err
}
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&m.Totals.Users); err != nil {
return nil, err
}
lockActiveQ := `SELECT COUNT(*) FROM card_lock_history h JOIN cards c ON c.id=h.card_id WHERE h.unlocked_at IS NULL AND c.deleted_at IS NULL`
if assignee != "" {
lockActiveQ += ` AND c.assignee_id=?`
}
if requester != "" {
lockActiveQ += ` AND c.requester=?`
}
if err := db.conn.QueryRow(lockActiveQ, args...).Scan(&m.Totals.ActiveLocks); err != nil {
return nil, err
}
// By column.
rows, err := db.conn.Query(
`SELECT col.id, col.name, col.is_done, COUNT(c.id)
FROM columns col
LEFT JOIN cards c ON c.column_id=col.id`+
condFromCard(assignee, requester, "c", "WHERE")+
` GROUP BY col.id ORDER BY col.position`,
colArgs(assignee, requester)...,
)
if err != nil {
return nil, err
}
for rows.Next() {
var cc ColumnCount
var isDone int
if err := rows.Scan(&cc.ColumnID, &cc.Name, &isDone, &cc.Count); err != nil {
rows.Close()
return nil, err
}
cc.IsDone = isDone != 0
m.ByColumn = append(m.ByColumn, cc)
}
rows.Close()
// Throughput daily (completed_at within range).
m.ThroughputDaily, err = dailyBucket(db, "completed_at", fromStr, toStr, assignee, requester, true)
if err != nil {
return nil, err
}
m.CreatedDaily, err = dailyBucket(db, "created_at", fromStr, toStr, assignee, requester, false)
if err != nil {
return nil, err
}
// Lead time (cards completed in range, completed_at - created_at).
leadDurs, err := collectDurations(db,
`SELECT (julianday(completed_at) - julianday(created_at)) * 86400000 FROM cards `+
cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`,
append(args, fromStr, toStr)...,
)
if err != nil {
return nil, err
}
m.LeadTime = computeStats(leadDurs)
// Cycle time per column.
colRows, err := db.conn.Query(`SELECT id, name, is_done FROM columns ORDER BY position`)
if err != nil {
return nil, err
}
type colInfo struct {
id, name string
isDone bool
}
var cols []colInfo
for colRows.Next() {
var ci colInfo
var d int
if err := colRows.Scan(&ci.id, &ci.name, &d); err != nil {
colRows.Close()
return nil, err
}
ci.isDone = d != 0
cols = append(cols, ci)
}
colRows.Close()
for _, ci := range cols {
histArgs := []any{ci.id, fromStr, toStr}
histQ := `SELECT (julianday(COALESCE(h.exited_at, ?)) - julianday(h.entered_at)) * 86400000
FROM card_column_history h JOIN cards c ON c.id=h.card_id
WHERE h.column_id=? AND h.entered_at>=? AND h.entered_at<=?`
histArgs = append([]any{toStr}, histArgs...)
if assignee != "" {
histQ += ` AND c.assignee_id=?`
histArgs = append(histArgs, assignee)
}
if requester != "" {
histQ += ` AND c.requester=?`
histArgs = append(histArgs, requester)
}
durs, err := collectDurations(db, histQ, histArgs...)
if err != nil {
return nil, err
}
m.CycleTimeColumn = append(m.CycleTimeColumn, ColumnDuration{
ColumnID: ci.id, Name: ci.name, IsDone: ci.isDone,
Stats: computeStats(durs),
})
}
// Top assignees.
asRows, err := db.conn.Query(
`SELECT u.id, u.username, u.display_name,
SUM(CASE WHEN c.completed_at IS NULL OR c.completed_at='' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN c.completed_at IS NOT NULL AND c.completed_at>=? AND c.completed_at<=? THEN 1 ELSE 0 END) as completed
FROM users u
LEFT JOIN cards c ON c.assignee_id=u.id` + cardJoinFilter(requester) +
` GROUP BY u.id ORDER BY completed DESC, active DESC`,
topAssigneeArgs(fromStr, toStr, requester)...,
)
if err != nil {
return nil, err
}
for asRows.Next() {
var s AssigneeStat
if err := asRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Active, &s.Completed); err != nil {
asRows.Close()
return nil, err
}
m.TopAssignees = append(m.TopAssignees, s)
}
asRows.Close()
// Top requesters.
reqRows, err := db.conn.Query(
`SELECT requester, COUNT(*) as n FROM cards WHERE deleted_at IS NULL AND requester != '' AND created_at>=? AND created_at<=?`+
condFromCard(assignee, "", "", "AND")+
` GROUP BY requester ORDER BY n DESC LIMIT 10`,
topReqArgs(fromStr, toStr, assignee)...,
)
if err != nil {
return nil, err
}
for reqRows.Next() {
var s RequesterStat
if err := reqRows.Scan(&s.Requester, &s.Total); err != nil {
reqRows.Close()
return nil, err
}
m.TopRequesters = append(m.TopRequesters, s)
}
reqRows.Close()
// Movements by user.
mvRows, err := db.conn.Query(
`SELECT u.id, u.username, u.display_name, COUNT(h.id) as moves
FROM users u
LEFT JOIN card_column_history h ON h.actor_id=u.id AND h.entered_at>=? AND h.entered_at<=?
GROUP BY u.id ORDER BY moves DESC`,
fromStr, toStr,
)
if err != nil {
return nil, err
}
for mvRows.Next() {
var s MovementStat
if err := mvRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Moves); err != nil {
mvRows.Close()
return nil, err
}
m.MovementsByUser = append(m.MovementsByUser, s)
}
mvRows.Close()
// Lock total in range.
var lockMs float64
if err := db.conn.QueryRow(
`SELECT COALESCE(SUM(
(julianday(COALESCE(h.unlocked_at, ?)) - julianday(h.locked_at)) * 86400000
), 0) FROM card_lock_history h JOIN cards c ON c.id=h.card_id
WHERE h.locked_at>=? AND h.locked_at<=?`+condFromCard(assignee, requester, "c", "AND"),
append([]any{toStr, fromStr, toStr}, colArgs(assignee, requester)...)...,
).Scan(&lockMs); err != nil {
return nil, err
}
m.LockTotalMs = int64(lockMs)
// Cumulative flow: walk daily from→to, count cards created<=day and done<=day.
cfd, err := computeCumulativeFlow(db, from, to, assignee, requester)
if err != nil {
return nil, err
}
m.CumulativeFlow = cfd
return m, nil
}
func computeCumulativeFlow(db *DB, from, to time.Time, assignee, requester string) ([]CumulativePoint, error) {
creates := map[string]int{}
dones := map[string]int{}
cardWhere := "WHERE deleted_at IS NULL"
args := []any{}
if assignee != "" {
cardWhere += " AND assignee_id=?"
args = append(args, assignee)
}
if requester != "" {
cardWhere += " AND requester=?"
args = append(args, requester)
}
rows, err := db.conn.Query(`SELECT substr(created_at,1,10), COUNT(*) FROM cards `+cardWhere+` GROUP BY substr(created_at,1,10)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
creates[d] = n
}
rows.Close()
rows, err = db.conn.Query(`SELECT substr(completed_at,1,10), COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at != '' GROUP BY substr(completed_at,1,10)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
dones[d] = n
}
rows.Close()
out := []CumulativePoint{}
totalAcc := 0
doneAcc := 0
day := from
end := to
if end.Before(day) {
return out, nil
}
for d := day; !d.After(end); d = d.AddDate(0, 0, 1) {
ds := d.Format("2006-01-02")
// Sum all creates with key <= ds, all dones with key <= ds.
// Optimize: track keys already accounted; here we just do once per loop using map sums.
_ = ds
}
// Simpler: collect and sort all create/done dates, sweep.
type ev struct {
date string
creates int
dones int
}
all := map[string]*ev{}
for d, n := range creates {
all[d] = &ev{date: d, creates: n}
}
for d, n := range dones {
if e, ok := all[d]; ok {
e.dones = n
} else {
all[d] = &ev{date: d, dones: n}
}
}
dates := make([]string, 0, len(all))
for d := range all {
dates = append(dates, d)
}
sort.Strings(dates)
// Accumulate up to `from` first.
fromS := from.Format("2006-01-02")
idx := 0
for idx < len(dates) && dates[idx] < fromS {
totalAcc += all[dates[idx]].creates
doneAcc += all[dates[idx]].dones
idx++
}
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
ds := d.Format("2006-01-02")
for idx < len(dates) && dates[idx] <= ds {
totalAcc += all[dates[idx]].creates
doneAcc += all[dates[idx]].dones
idx++
}
out = append(out, CumulativePoint{Date: ds, Total: totalAcc, Done: doneAcc})
}
return out, nil
}
func condFromCard(assignee, requester, alias, leadKw string) string {
pref := alias
if pref != "" {
pref += "."
}
out := ""
if assignee != "" {
out += " " + leadKw + " " + pref + "assignee_id=?"
leadKw = "AND"
}
if requester != "" {
out += " " + leadKw + " " + pref + "requester=?"
}
return out
}
func colArgs(assignee, requester string) []any {
args := []any{}
if assignee != "" {
args = append(args, assignee)
}
if requester != "" {
args = append(args, requester)
}
return args
}
func cardJoinFilter(requester string) string {
if requester != "" {
return " AND c.requester=?"
}
return ""
}
func topAssigneeArgs(fromStr, toStr, requester string) []any {
args := []any{fromStr, toStr}
if requester != "" {
args = append(args, requester)
}
return args
}
func topReqArgs(fromStr, toStr, assignee string) []any {
args := []any{fromStr, toStr}
if assignee != "" {
args = append(args, assignee)
}
return args
}
func collectDurations(db *DB, query string, args ...any) ([]int64, error) {
rows, err := db.conn.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []int64{}
for rows.Next() {
var v float64
if err := rows.Scan(&v); err != nil {
return nil, err
}
if v < 0 {
v = 0
}
out = append(out, int64(v))
}
return out, rows.Err()
}
func dailyBucket(db *DB, dateCol, fromStr, toStr, assignee, requester string, requireNonNull bool) ([]DailyCount, error) {
cardWhere := "deleted_at IS NULL"
if requireNonNull {
cardWhere += " AND " + dateCol + " IS NOT NULL AND " + dateCol + " != ''"
}
cardWhere += " AND " + dateCol + ">=? AND " + dateCol + "<=?"
args := []any{fromStr, toStr}
if assignee != "" {
cardWhere += " AND assignee_id=?"
args = append(args, assignee)
}
if requester != "" {
cardWhere += " AND requester=?"
args = append(args, requester)
}
q := `SELECT substr(` + dateCol + `, 1, 10) as d, COUNT(*) FROM cards WHERE ` + cardWhere + ` GROUP BY d ORDER BY d`
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DailyCount{}
for rows.Next() {
var dc DailyCount
if err := rows.Scan(&dc.Date, &dc.Count); err != nil {
return nil, err
}
out = append(out, dc)
}
return out, rows.Err()
}
+18
View File
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS columns (
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
location TEXT NOT NULL DEFAULT 'board' CHECK(location IN ('board','sidebar')), location TEXT NOT NULL DEFAULT 'board' CHECK(location IN ('board','sidebar')),
width INTEGER NOT NULL DEFAULT 300, width INTEGER NOT NULL DEFAULT 300,
wip_limit INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -15,6 +16,7 @@ CREATE TABLE IF NOT EXISTS cards (
color TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '',
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE, column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
locked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -27,7 +29,23 @@ CREATE TABLE IF NOT EXISTS card_column_history (
exited_at TEXT exited_at TEXT
); );
CREATE TABLE IF NOT EXISTS card_lock_history (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
locked_at TEXT NOT NULL,
unlocked_at TEXT
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cards_column ON cards(column_id); CREATE INDEX IF NOT EXISTS idx_cards_column ON cards(column_id);
CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(column_id, position); CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(column_id, position);
CREATE INDEX IF NOT EXISTS idx_history_card ON card_column_history(card_id); CREATE INDEX IF NOT EXISTS idx_history_card ON card_column_history(card_id);
CREATE INDEX IF NOT EXISTS idx_columns_position ON columns(position); CREATE INDEX IF NOT EXISTS idx_columns_position ON columns(position);
CREATE INDEX IF NOT EXISTS idx_lock_history_card ON card_lock_history(card_id);
+71 -12
View File
@@ -46,6 +46,10 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
return toolCardHistory(db, input) return toolCardHistory(db, input)
case "find_cards": case "find_cards":
return toolFindCards(db, input) return toolFindCards(db, input)
case "list_users":
return toolListUsers(db)
case "assign_card":
return toolAssignCard(db, input)
default: default:
return errMsg("unknown tool: " + name) return errMsg("unknown tool: " + name)
} }
@@ -55,7 +59,7 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
func toolMutates(name string) bool { func toolMutates(name string) bool {
switch name { switch name {
case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns", case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
"create_card", "update_card", "delete_card", "move_card": "create_card", "update_card", "delete_card", "move_card", "assign_card":
return true return true
} }
return false return false
@@ -94,6 +98,8 @@ func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult {
Name *string `json:"name"` Name *string `json:"name"`
Location *string `json:"location"` Location *string `json:"location"`
Width *int `json:"width"` Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
} }
if err := json.Unmarshal(input, &in); err != nil { if err := json.Unmarshal(input, &in); err != nil {
return errResult(err) return errResult(err)
@@ -101,10 +107,10 @@ func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult {
if in.ID == "" { if in.ID == "" {
return errMsg("id required") return errMsg("id required")
} }
if in.Name == nil && in.Location == nil && in.Width == nil { if in.Name == nil && in.Location == nil && in.Width == nil && in.WIPLimit == nil && in.IsDone == nil {
return errMsg("at least one of name/location/width required") return errMsg("at least one of name/location/width/wip_limit/is_done required")
} }
if err := db.UpdateColumn(in.ID, ColumnPatch{Name: in.Name, Location: in.Location, Width: in.Width}); err != nil { if err := db.UpdateColumn(in.ID, ColumnPatch{Name: in.Name, Location: in.Location, Width: in.Width, WIPLimit: in.WIPLimit, IsDone: in.IsDone}); err != nil {
return errResult(err) return errResult(err)
} }
return okResult(nil) return okResult(nil)
@@ -151,7 +157,7 @@ func toolCreateCard(db *DB, input json.RawMessage) ToolResult {
if in.ColumnID == "" || strings.TrimSpace(in.Title) == "" { if in.ColumnID == "" || strings.TrimSpace(in.Title) == "" {
return errMsg("column_id and title required") return errMsg("column_id and title required")
} }
c, err := db.CreateCard(in.ColumnID, in.Requester, in.Title, in.Description) c, err := db.CreateCard(in.ColumnID, in.Requester, in.Title, in.Description, "")
if err != nil { if err != nil {
return errResult(err) return errResult(err)
} }
@@ -159,12 +165,57 @@ func toolCreateCard(db *DB, input json.RawMessage) ToolResult {
} }
func toolUpdateCard(db *DB, input json.RawMessage) ToolResult { func toolUpdateCard(db *DB, input json.RawMessage) ToolResult {
var raw map[string]any
if err := json.Unmarshal(input, &raw); err != nil {
return errResult(err)
}
id, _ := raw["id"].(string)
if id == "" {
return errMsg("id required")
}
patch := CardPatch{}
if v, ok := raw["requester"].(string); ok {
patch.Requester = &v
}
if v, ok := raw["title"].(string); ok {
patch.Title = &v
}
if v, ok := raw["description"].(string); ok {
patch.Description = &v
}
if v, ok := raw["color"].(string); ok {
patch.Color = &v
}
if v, ok := raw["locked"].(bool); ok {
patch.Locked = &v
}
if v, present := raw["assignee_id"]; present {
patch.HasAssignee = true
if v == nil {
empty := ""
patch.AssigneeID = &empty
} else if s, ok := v.(string); ok {
patch.AssigneeID = &s
}
}
if err := db.UpdateCard(id, patch); err != nil {
return errResult(err)
}
return okResult(nil)
}
func toolListUsers(db *DB) ToolResult {
users, err := db.ListUsers()
if err != nil {
return errResult(err)
}
return okResult(users)
}
func toolAssignCard(db *DB, input json.RawMessage) ToolResult {
var in struct { var in struct {
ID string `json:"id"` ID string `json:"id"`
Requester *string `json:"requester"` AssigneeID *string `json:"assignee_id"`
Title *string `json:"title"`
Description *string `json:"description"`
Color *string `json:"color"`
} }
if err := json.Unmarshal(input, &in); err != nil { if err := json.Unmarshal(input, &in); err != nil {
return errResult(err) return errResult(err)
@@ -172,7 +223,14 @@ func toolUpdateCard(db *DB, input json.RawMessage) ToolResult {
if in.ID == "" { if in.ID == "" {
return errMsg("id required") return errMsg("id required")
} }
if err := db.UpdateCard(in.ID, CardPatch{Requester: in.Requester, Title: in.Title, Description: in.Description, Color: in.Color}); err != nil { patch := CardPatch{HasAssignee: true}
if in.AssigneeID == nil {
empty := ""
patch.AssigneeID = &empty
} else {
patch.AssigneeID = in.AssigneeID
}
if err := db.UpdateCard(in.ID, patch); err != nil {
return errResult(err) return errResult(err)
} }
return okResult(nil) return okResult(nil)
@@ -225,7 +283,7 @@ func toolMoveCard(db *DB, input json.RawMessage) ToolResult {
ids = append(ids, in.ID) ids = append(ids, in.ID)
in.OrderedIDs = ids in.OrderedIDs = ids
} }
if err := db.MoveCard(in.ID, in.ColumnID, in.OrderedIDs); err != nil { if err := db.MoveCard(in.ID, in.ColumnID, in.OrderedIDs, ""); err != nil {
return errResult(err) return errResult(err)
} }
return okResult(nil) return okResult(nil)
@@ -309,6 +367,7 @@ func validateToolName(name string) error {
"delete_column": true, "reorder_columns": true, "create_card": true, "delete_column": true, "reorder_columns": true, "create_card": true,
"update_card": true, "delete_card": true, "move_card": true, "update_card": true, "delete_card": true, "move_card": true,
"card_history": true, "find_cards": true, "card_history": true, "find_cards": true,
"list_users": true, "assign_card": true,
} }
if !known[name] { if !known[name] {
return fmt.Errorf("unknown tool: %s", name) return fmt.Errorf("unknown tool: %s", name)
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"database/sql"
"errors"
"fmt"
"strings"
"fn-registry/functions/infra"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
CreatedAt string `json:"created_at"`
}
var (
errUserNotFound = errors.New("user not found")
errUserAlreadyExists = errors.New("username already exists")
errInvalidCredentials = errors.New("invalid credentials")
)
func (db *DB) CreateUser(username, password, displayName string) (*User, error) {
username = strings.TrimSpace(strings.ToLower(username))
if username == "" {
return nil, fmt.Errorf("username required")
}
if len(password) < 4 {
return nil, fmt.Errorf("password must be at least 4 characters")
}
hash, err := infra.PasswordHash(password, 0)
if err != nil {
return nil, fmt.Errorf("hash: %w", err)
}
u := User{ID: newID(), Username: username, DisplayName: displayName, CreatedAt: nowRFC3339()}
_, err = db.conn.Exec(
`INSERT INTO users (id, username, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`,
u.ID, u.Username, hash, u.DisplayName, u.CreatedAt,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return nil, errUserAlreadyExists
}
return nil, err
}
return &u, nil
}
func (db *DB) GetUserByID(id string) (*User, error) {
var u User
err := db.conn.QueryRow(
`SELECT id, username, display_name, created_at FROM users WHERE id=?`, id,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, errUserNotFound
}
if err != nil {
return nil, err
}
return &u, nil
}
func (db *DB) GetUserByUsername(username string) (*User, string, error) {
username = strings.TrimSpace(strings.ToLower(username))
var u User
var hash string
err := db.conn.QueryRow(
`SELECT id, username, display_name, created_at, password_hash FROM users WHERE username=?`, username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt, &hash)
if errors.Is(err, sql.ErrNoRows) {
return nil, "", errUserNotFound
}
if err != nil {
return nil, "", err
}
return &u, hash, nil
}
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.conn.Query(`SELECT id, username, display_name, created_at FROM users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (db *DB) Authenticate(username, password string) (*User, error) {
u, hash, err := db.GetUserByUsername(username)
if err != nil {
if errors.Is(err, errUserNotFound) {
return nil, errInvalidCredentials
}
return nil, err
}
if err := infra.PasswordVerify(password, hash); err != nil {
return nil, errInvalidCredentials
}
return u, nil
}
func (db *DB) CountUsers() (int, error) {
var n int
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
return 0, err
}
return n, nil
}
func (db *DB) DeleteSessionByToken(token string) error {
_, err := db.conn.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}