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:
@@ -16,6 +16,11 @@ uses_functions:
|
||||
- http_json_response_go_infra
|
||||
- http_error_response_go_infra
|
||||
- http_parse_body_go_infra
|
||||
- http_session_cookie_middleware_go_infra
|
||||
- password_hash_go_infra
|
||||
- password_verify_go_infra
|
||||
- session_create_go_infra
|
||||
- session_cleanup_go_infra
|
||||
uses_types: []
|
||||
framework: "net/http + vite + react + mantine + dnd-kit"
|
||||
entry_point: "main.go"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -30,15 +30,17 @@ Ejemplo:
|
||||
Tools disponibles (todas con sus inputs):
|
||||
- list_board {} -> {columns, cards}
|
||||
- create_column {name}
|
||||
- update_column {id, name?, location?, width?} // location: "board" | "sidebar". width: 200..800 px.
|
||||
- update_column {id, name?, location?, width?, wip_limit?, is_done?} // location: "board" | "sidebar". width: 200..800 px. wip_limit: max tarjetas (0 = sin limite). is_done: marca columna como terminal (cards dentro se cuentan como completadas para metricas y se muestran tachadas).
|
||||
- delete_column {id}
|
||||
- reorder_columns {ids:[...]}
|
||||
- create_card {column_id, requester?, title, description?}
|
||||
- update_card {id, requester?, title?, description?, color?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default)
|
||||
- update_card {id, requester?, title?, description?, color?, locked?, assignee_id?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default). locked: true bloquea la tarjeta (no se puede mover entre columnas hasta desbloquear). assignee_id: ID del usuario asignado o null para desasignar.
|
||||
- delete_card {id}
|
||||
- move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final
|
||||
- card_history {id}
|
||||
- find_cards {query?, column_id?, requester?}
|
||||
- list_users {} -> [{id, username, display_name}]
|
||||
- assign_card {id, assignee_id} // alias rapido de update_card. assignee_id puede ser null para desasignar.
|
||||
|
||||
Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque <actions>.
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -19,21 +19,27 @@ type Column struct {
|
||||
Position int `json:"position"`
|
||||
Location string `json:"location"`
|
||||
Width int `json:"width"`
|
||||
WIPLimit int `json:"wip_limit"`
|
||||
IsDone bool `json:"is_done"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID string `json:"id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
TimeInColumn int64 `json:"time_in_column_ms"`
|
||||
ID string `json:"id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Position int `json:"position"`
|
||||
Locked bool `json:"locked"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
TimeInColumn int64 `json:"time_in_column_ms"`
|
||||
}
|
||||
|
||||
type HistoryEntry struct {
|
||||
@@ -46,6 +52,21 @@ type HistoryEntry struct {
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
type LockPeriod struct {
|
||||
ID string `json:"id"`
|
||||
CardID string `json:"card_id"`
|
||||
LockedAt string `json:"locked_at"`
|
||||
UnlockedAt *string `json:"unlocked_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
type CardHistoryResponse struct {
|
||||
ColumnHistory []HistoryEntry `json:"column_history"`
|
||||
LockPeriods []LockPeriod `json:"lock_periods"`
|
||||
TotalLockedMs int64 `json:"total_locked_ms"`
|
||||
CurrentlyLock bool `json:"currently_locked"`
|
||||
}
|
||||
|
||||
type DB struct{ conn *sql.DB }
|
||||
|
||||
func openDB(path string) (*DB, error) {
|
||||
@@ -73,7 +94,15 @@ func ensureColumns(conn *sql.DB) error {
|
||||
specs := []colSpec{
|
||||
{"columns", "location", "TEXT NOT NULL DEFAULT 'board'"},
|
||||
{"columns", "width", "INTEGER NOT NULL DEFAULT 300"},
|
||||
{"columns", "wip_limit", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"columns", "is_done", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"cards", "color", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"cards", "locked", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"cards", "assignee_id", "TEXT"},
|
||||
{"cards", "completed_at", "TEXT"},
|
||||
{"cards", "deleted_at", "TEXT"},
|
||||
{"card_column_history", "actor_id", "TEXT"},
|
||||
{"card_lock_history", "actor_id", "TEXT"},
|
||||
}
|
||||
for _, s := range specs {
|
||||
exists, err := columnExists(conn, s.table, s.name)
|
||||
@@ -87,6 +116,9 @@ func ensureColumns(conn *sql.DB) error {
|
||||
return fmt.Errorf("add %s.%s: %w", s.table, s.name, err)
|
||||
}
|
||||
}
|
||||
if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id)`); err != nil {
|
||||
return fmt.Errorf("create assignee index: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -124,10 +156,17 @@ func newID() string {
|
||||
|
||||
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func nullableActor(actorID string) any {
|
||||
if actorID == "" {
|
||||
return nil
|
||||
}
|
||||
return actorID
|
||||
}
|
||||
|
||||
// --- Columns ---
|
||||
|
||||
func (db *DB) ListColumns() ([]Column, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, name, position, location, width, created_at FROM columns ORDER BY position, created_at`)
|
||||
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, created_at FROM columns ORDER BY position, created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -135,9 +174,11 @@ func (db *DB) ListColumns() ([]Column, error) {
|
||||
out := []Column{}
|
||||
for rows.Next() {
|
||||
var c Column
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.CreatedAt); err != nil {
|
||||
var isDone int
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.IsDone = isDone != 0
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -152,10 +193,10 @@ func (db *DB) CreateColumn(name string) (*Column, error) {
|
||||
if maxPos.Valid {
|
||||
pos = int(maxPos.Int64) + 1
|
||||
}
|
||||
c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, CreatedAt: nowRFC3339()}
|
||||
c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, WIPLimit: 0, IsDone: false, CreatedAt: nowRFC3339()}
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT INTO columns (id, name, position, location, width, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.Name, c.Position, c.Location, c.Width, c.CreatedAt,
|
||||
`INSERT INTO columns (id, name, position, location, width, wip_limit, is_done, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.Name, c.Position, c.Location, c.Width, c.WIPLimit, 0, c.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -168,6 +209,8 @@ type ColumnPatch struct {
|
||||
Position *int
|
||||
Location *string
|
||||
Width *int
|
||||
WIPLimit *int
|
||||
IsDone *bool
|
||||
}
|
||||
|
||||
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
||||
@@ -200,6 +243,35 @@ func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.WIPLimit != nil {
|
||||
l := *patch.WIPLimit
|
||||
if l < 0 {
|
||||
l = 0
|
||||
}
|
||||
if _, err := db.conn.Exec(`UPDATE columns SET wip_limit=? WHERE id=?`, l, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.IsDone != nil {
|
||||
v := 0
|
||||
if *patch.IsDone {
|
||||
v = 1
|
||||
}
|
||||
if _, err := db.conn.Exec(`UPDATE columns SET is_done=? WHERE id=?`, v, id); err != nil {
|
||||
return err
|
||||
}
|
||||
// Re-evaluate completed_at for cards in this column.
|
||||
now := nowRFC3339()
|
||||
if v == 1 {
|
||||
if _, err := db.conn.Exec(`UPDATE cards SET completed_at=? WHERE column_id=? AND completed_at IS NULL`, now, id); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := db.conn.Exec(`UPDATE cards SET completed_at=NULL WHERE column_id=?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -226,11 +298,12 @@ func (db *DB) ReorderColumns(ids []string) error {
|
||||
|
||||
func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.created_at, c.updated_at,
|
||||
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.created_at, c.updated_at,
|
||||
h.entered_at
|
||||
FROM cards c
|
||||
LEFT JOIN card_column_history h
|
||||
ON h.card_id = c.id AND h.exited_at IS NULL
|
||||
WHERE c.deleted_at IS NULL
|
||||
ORDER BY c.column_id, c.position, c.created_at
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -242,9 +315,26 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
for rows.Next() {
|
||||
var c Card
|
||||
var entered sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
|
||||
var assignee sql.NullString
|
||||
var completed sql.NullString
|
||||
var deleted sql.NullString
|
||||
var locked int
|
||||
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Locked = locked != 0
|
||||
if assignee.Valid && assignee.String != "" {
|
||||
s := assignee.String
|
||||
c.AssigneeID = &s
|
||||
}
|
||||
if completed.Valid && completed.String != "" {
|
||||
s := completed.String
|
||||
c.CompletedAt = &s
|
||||
}
|
||||
if deleted.Valid && deleted.String != "" {
|
||||
s := deleted.String
|
||||
c.DeletedAt = &s
|
||||
}
|
||||
if entered.Valid {
|
||||
c.EnteredAt = entered.String
|
||||
if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil {
|
||||
@@ -256,7 +346,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) CreateCard(columnID, requester, title, description string) (*Card, error) {
|
||||
func (db *DB) CreateCard(columnID, requester, title, description, actorID string) (*Card, error) {
|
||||
var maxPos sql.NullInt64
|
||||
if err := db.conn.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, columnID).Scan(&maxPos); err != nil {
|
||||
return nil, err
|
||||
@@ -282,11 +372,22 @@ func (db *DB) CreateCard(columnID, requester, title, description string) (*Card,
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`,
|
||||
newID(), c.ID, c.ColumnID, now,
|
||||
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If the destination column is_done, set completed_at.
|
||||
var destDone int
|
||||
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, columnID).Scan(&destDone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if destDone == 1 {
|
||||
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CompletedAt = &now
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -298,9 +399,16 @@ type CardPatch struct {
|
||||
Title *string
|
||||
Description *string
|
||||
Color *string
|
||||
Locked *bool
|
||||
AssigneeID *string // empty string clears assignment
|
||||
HasAssignee bool // distinguishes "set to null" from "not provided"
|
||||
}
|
||||
|
||||
func (db *DB) UpdateCard(id string, patch CardPatch) error {
|
||||
return db.UpdateCardWithActor(id, patch, "")
|
||||
}
|
||||
|
||||
func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) error {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -326,18 +434,114 @@ func (db *DB) UpdateCard(id string, patch CardPatch) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.HasAssignee {
|
||||
if patch.AssigneeID == nil || *patch.AssigneeID == "" {
|
||||
if _, err := tx.Exec(`UPDATE cards SET assignee_id=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Locked != nil {
|
||||
var current int
|
||||
if err := tx.QueryRow(`SELECT locked FROM cards WHERE id=?`, id).Scan(¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
desired := 0
|
||||
if *patch.Locked {
|
||||
desired = 1
|
||||
}
|
||||
if current != desired {
|
||||
now := nowRFC3339()
|
||||
if _, err := tx.Exec(`UPDATE cards SET locked=?, updated_at=? WHERE id=?`, desired, now, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if desired == 1 {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO card_lock_history (id, card_id, locked_at, actor_id) VALUES (?, ?, ?, ?)`,
|
||||
newID(), id, now, nullableActor(actorID),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE card_lock_history SET unlocked_at=? WHERE card_id=? AND unlocked_at IS NULL`,
|
||||
now, id,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteCard soft-deletes the card (moves it to trash).
|
||||
func (db *DB) DeleteCard(id string) error {
|
||||
_, err := db.conn.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RestoreCard removes the deleted_at flag.
|
||||
func (db *DB) RestoreCard(id string) error {
|
||||
_, err := db.conn.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// PurgeCard permanently removes the card from the DB.
|
||||
func (db *DB) PurgeCard(id string) error {
|
||||
_, err := db.conn.Exec(`DELETE FROM cards WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListDeletedCards returns cards in the trash, newest first.
|
||||
func (db *DB) ListDeletedCards() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.created_at, c.updated_at
|
||||
FROM cards c
|
||||
WHERE c.deleted_at IS NOT NULL
|
||||
ORDER BY c.deleted_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Card{}
|
||||
for rows.Next() {
|
||||
var c Card
|
||||
var assignee sql.NullString
|
||||
var completed sql.NullString
|
||||
var deleted sql.NullString
|
||||
var locked int
|
||||
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Locked = locked != 0
|
||||
if assignee.Valid && assignee.String != "" {
|
||||
s := assignee.String
|
||||
c.AssigneeID = &s
|
||||
}
|
||||
if completed.Valid && completed.String != "" {
|
||||
s := completed.String
|
||||
c.CompletedAt = &s
|
||||
}
|
||||
if deleted.Valid {
|
||||
s := deleted.String
|
||||
c.DeletedAt = &s
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MoveCard updates the card's column and/or position. If the column changes,
|
||||
// the open history entry is closed and a new one is opened.
|
||||
// orderedIDs is the new order of cards in the destination column (including this card).
|
||||
func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
|
||||
// actorID is the user performing the move (empty string for system/anonymous).
|
||||
func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string, actorID string) error {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -345,9 +549,13 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
|
||||
defer tx.Rollback()
|
||||
|
||||
var srcColumnID string
|
||||
if err := tx.QueryRow(`SELECT column_id FROM cards WHERE id=?`, cardID).Scan(&srcColumnID); err != nil {
|
||||
var locked int
|
||||
if err := tx.QueryRow(`SELECT column_id, locked FROM cards WHERE id=?`, cardID).Scan(&srcColumnID, &locked); err != nil {
|
||||
return fmt.Errorf("card not found: %w", err)
|
||||
}
|
||||
if locked != 0 && srcColumnID != destColumnID {
|
||||
return fmt.Errorf("card locked: cannot move between columns")
|
||||
}
|
||||
|
||||
now := nowRFC3339()
|
||||
|
||||
@@ -359,17 +567,38 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`,
|
||||
newID(), cardID, destColumnID, now,
|
||||
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
newID(), cardID, destColumnID, now, nullableActor(actorID),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = actorID
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE cards SET column_id=?, updated_at=? WHERE id=?`,
|
||||
destColumnID, now, cardID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
// Recompute completed_at based on destination column's is_done flag.
|
||||
var destDone int
|
||||
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, destColumnID).Scan(&destDone); err != nil {
|
||||
return err
|
||||
}
|
||||
if destDone == 1 {
|
||||
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=? AND completed_at IS NULL`, now, cardID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Auto-assign: if card had no assignee and an actor is moving it, claim it.
|
||||
if actorID != "" {
|
||||
if _, err := tx.Exec(`UPDATE cards SET assignee_id=? WHERE id=? AND (assignee_id IS NULL OR assignee_id='')`, actorID, cardID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(`UPDATE cards SET completed_at=NULL WHERE id=?`, cardID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, id := range orderedIDs {
|
||||
@@ -404,7 +633,7 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) {
|
||||
func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at
|
||||
FROM card_column_history h
|
||||
@@ -417,7 +646,7 @@ func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
now := time.Now().UTC()
|
||||
out := []HistoryEntry{}
|
||||
cols := []HistoryEntry{}
|
||||
for rows.Next() {
|
||||
var h HistoryEntry
|
||||
var exited sql.NullString
|
||||
@@ -436,7 +665,55 @@ func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) {
|
||||
end = now
|
||||
}
|
||||
h.DurationMs = end.Sub(entered).Milliseconds()
|
||||
out = append(out, h)
|
||||
cols = append(cols, h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lockRows, err := db.conn.Query(`
|
||||
SELECT id, card_id, locked_at, unlocked_at
|
||||
FROM card_lock_history
|
||||
WHERE card_id=?
|
||||
ORDER BY locked_at
|
||||
`, cardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer lockRows.Close()
|
||||
locks := []LockPeriod{}
|
||||
var totalLocked int64
|
||||
currently := false
|
||||
for lockRows.Next() {
|
||||
var lp LockPeriod
|
||||
var unlocked sql.NullString
|
||||
if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, err := time.Parse(time.RFC3339Nano, lp.LockedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var end time.Time
|
||||
if unlocked.Valid {
|
||||
lp.UnlockedAt = &unlocked.String
|
||||
end, _ = time.Parse(time.RFC3339Nano, unlocked.String)
|
||||
} else {
|
||||
end = now
|
||||
currently = true
|
||||
}
|
||||
lp.DurationMs = end.Sub(start).Milliseconds()
|
||||
totalLocked += lp.DurationMs
|
||||
locks = append(locks, lp)
|
||||
}
|
||||
if err := lockRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &CardHistoryResponse{
|
||||
ColumnHistory: cols,
|
||||
LockPeriods: locks,
|
||||
TotalLockedMs: totalLocked,
|
||||
CurrentlyLock: currently,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -12,14 +12,18 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@mantine/charts": "^9.1.1",
|
||||
"@mantine/core": "^9.0.2",
|
||||
"@mantine/dates": "^9.1.1",
|
||||
"@mantine/hooks": "^9.0.2",
|
||||
"@mantine/modals": "^9.0.2",
|
||||
"@mantine/notifications": "^9.0.2",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+283
@@ -17,9 +17,15 @@ importers:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.5)
|
||||
'@mantine/charts':
|
||||
specifier: ^9.1.1
|
||||
version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
|
||||
'@mantine/core':
|
||||
specifier: ^9.0.2
|
||||
version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@mantine/dates':
|
||||
specifier: ^9.1.1
|
||||
version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(dayjs@1.11.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@mantine/hooks':
|
||||
specifier: ^9.0.2
|
||||
version: 9.1.1(react@19.2.5)
|
||||
@@ -32,6 +38,9 @@ importers:
|
||||
'@tabler/icons-react':
|
||||
specifier: ^3.31.0
|
||||
version: 3.42.0(react@19.2.5)
|
||||
dayjs:
|
||||
specifier: ^1.11.20
|
||||
version: 1.11.20
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.5
|
||||
@@ -41,6 +50,9 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
|
||||
recharts:
|
||||
specifier: ^2.15.4
|
||||
version: 2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -371,6 +383,15 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@mantine/charts@9.1.1':
|
||||
resolution: {integrity: sha512-f+RbCe9ULHGmhF2KYw8PIwL3xWUDulhVrZ2lNW/2sKUTaAobSBNmwBXy4kbN6gOHtxAbZx8YnVXH2F6mSaYtcg==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 9.1.1
|
||||
'@mantine/hooks': 9.1.1
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
recharts: '>=3.2.1'
|
||||
|
||||
'@mantine/core@9.1.1':
|
||||
resolution: {integrity: sha512-vClOZdCeZ4oLYuA/3jAOgKGQ6dXbF6ZkzpYz09Gied9nZpB7HcQeb3dcMh8UPBE4f+EM7KlYWk6dch7GoASeaA==}
|
||||
peerDependencies:
|
||||
@@ -378,6 +399,15 @@ packages:
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
'@mantine/dates@9.1.1':
|
||||
resolution: {integrity: sha512-P1tr/Hr+EVxppbOVpTLvaZZnM1W/r0TNpqNNMeM81xfyuKYzd7zt2/SQYb6BuudgEQfRJnAee+7bIJLEsrb0uA==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 9.1.1
|
||||
'@mantine/hooks': 9.1.1
|
||||
dayjs: '>=1.0.0'
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
'@mantine/hooks@9.1.1':
|
||||
resolution: {integrity: sha512-tTJK73nGFyy1v214TLdvBq0be7QCoc6osfbXVuJgOH3YG85lWk9Mvvor6k+w6hC6HXSqKMqLKePyiGm83xGcMg==}
|
||||
peerDependencies:
|
||||
@@ -552,6 +582,33 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-path@3.1.1':
|
||||
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
@@ -646,6 +703,53 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dayjs@1.11.20:
|
||||
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -655,6 +759,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||
|
||||
@@ -690,9 +797,16 @@ packages:
|
||||
estree-util-is-identifier-name@3.0.0:
|
||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
fast-equals@5.4.0:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -727,6 +841,10 @@ packages:
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-alphabetical@2.0.1:
|
||||
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
|
||||
|
||||
@@ -756,6 +874,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
@@ -974,6 +1095,9 @@ packages:
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
@@ -1010,6 +1134,12 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-smooth@4.0.4:
|
||||
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1030,6 +1160,16 @@ packages:
|
||||
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
||||
|
||||
recharts@2.15.4:
|
||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
@@ -1083,6 +1223,9 @@ packages:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1158,6 +1301,9 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
vite@6.4.2:
|
||||
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -1467,6 +1613,14 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@mantine/charts@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5))':
|
||||
dependencies:
|
||||
'@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@mantine/hooks': 9.1.1(react@19.2.5)
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
recharts: 2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
|
||||
'@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -1480,6 +1634,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/dates@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(dayjs@1.11.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@mantine/hooks': 9.1.1(react@19.2.5)
|
||||
clsx: 2.1.1
|
||||
dayjs: 1.11.20
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
'@mantine/hooks@9.1.1(react@19.2.5)':
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
@@ -1609,6 +1772,30 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-path@3.1.1': {}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
'@types/d3-time@3.0.4': {}
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
@@ -1691,10 +1878,52 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
dayjs@1.11.20: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@@ -1749,8 +1978,12 @@ snapshots:
|
||||
|
||||
estree-util-is-identifier-name@3.0.0: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
fast-equals@5.4.0: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -1790,6 +2023,8 @@ snapshots:
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
|
||||
is-alphanumerical@2.0.1:
|
||||
@@ -1809,6 +2044,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
@@ -2241,6 +2478,8 @@ snapshots:
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -2285,6 +2524,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
react-smooth@4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
fast-equals: 5.4.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
@@ -2304,6 +2551,23 @@ snapshots:
|
||||
|
||||
react@19.2.5: {}
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
dependencies:
|
||||
decimal.js-light: 2.5.1
|
||||
|
||||
recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
eventemitter3: 4.0.7
|
||||
lodash: 4.18.1
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
react-is: 18.3.1
|
||||
react-smooth: 4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
recharts-scale: 0.4.5
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -2398,6 +2662,8 @@ snapshots:
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
@@ -2481,6 +2747,23 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
victory-vendor@36.9.2:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite@6.4.2(sugarss@5.0.1(postcss@8.5.14)):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
|
||||
+257
-65
@@ -24,11 +24,16 @@ import {
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
@@ -37,65 +42,36 @@ import {
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconColumnInsertRight,
|
||||
IconArrowBackUp,
|
||||
IconCalendar,
|
||||
IconChartBar,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconLayoutKanban,
|
||||
IconLogout,
|
||||
IconMenu2,
|
||||
IconMessageChatbot,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
IconTrashX,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as api from "./api";
|
||||
import { useAuth } from "./auth";
|
||||
import { CardForm } from "./components/CardForm";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { CalendarView } from "./components/CalendarView";
|
||||
import { Dashboard } from "./components/Dashboard";
|
||||
import { HistoryModal } from "./components/HistoryModal";
|
||||
import { KanbanCard } from "./components/KanbanCard";
|
||||
import { KanbanColumn } from "./components/KanbanColumn";
|
||||
import { colorBg, colorBorder } from "./components/colors";
|
||||
import type { Board, Card, CardColor, Column, ColumnLocation } from "./types";
|
||||
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
|
||||
|
||||
const COL_PREFIX = "column-";
|
||||
|
||||
function AddColumnDialog({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (name: string) => Promise<void> | 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
|
||||
// columnas; al arrastrar cards prefiere cards/columnas via closestCorners.
|
||||
function makeCollisionDetection(activeType: string | undefined): CollisionDetection {
|
||||
@@ -118,7 +94,9 @@ function makeCollisionDetection(activeType: string | undefined): CollisionDetect
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const auth = useAuth();
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [activeCard, setActiveCard] = useState<Card | null>(null);
|
||||
const [activeColumnId, setActiveColumnId] = useState<string | null>(null);
|
||||
const [activeType, setActiveType] = useState<string | undefined>(undefined);
|
||||
@@ -126,6 +104,9 @@ export function App() {
|
||||
const [colName, setColName] = useState("");
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<string>("board");
|
||||
const [trash, setTrash] = useState<Card[]>([]);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [navWidth, setNavWidth] = useState<number>(() => {
|
||||
const stored = localStorage.getItem("kanban_nav_width");
|
||||
@@ -177,11 +158,43 @@ export function App() {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const reloadUsers = useCallback(async () => {
|
||||
try {
|
||||
const us = await api.listUsers();
|
||||
setUsers(us);
|
||||
} catch (e) {
|
||||
console.warn("listUsers failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadTrash = useCallback(async () => {
|
||||
try {
|
||||
const t = await api.listTrash();
|
||||
setTrash(t);
|
||||
} catch (e) {
|
||||
console.warn("listTrash failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reloadUsers();
|
||||
}, [reloadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadTrash();
|
||||
}, [reloadTrash]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const usersById = useMemo(() => {
|
||||
const m = new Map<string, User>();
|
||||
for (const u of users) m.set(u.id, u);
|
||||
return m;
|
||||
}, [users]);
|
||||
|
||||
const sortedColumns = useMemo(() => {
|
||||
if (!board) return [];
|
||||
return [...board.columns].sort((a, b) => a.position - b.position);
|
||||
@@ -360,22 +373,6 @@ export function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const openAddColumnModal = useCallback(() => {
|
||||
const id = modals.open({
|
||||
title: "Nueva columna",
|
||||
size: "sm",
|
||||
children: <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) => {
|
||||
try {
|
||||
await api.updateColumn(id, { name });
|
||||
@@ -426,6 +423,8 @@ export function App() {
|
||||
size: "md",
|
||||
children: (
|
||||
<CardForm
|
||||
users={users}
|
||||
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
|
||||
submitLabel="Crear"
|
||||
onCancel={() => modals.close(id)}
|
||||
onSubmit={async (v) => {
|
||||
@@ -435,6 +434,7 @@ export function App() {
|
||||
requester: v.requester,
|
||||
title: v.title,
|
||||
description: v.description,
|
||||
assignee_id: v.assignee_id,
|
||||
});
|
||||
modals.close(id);
|
||||
reload();
|
||||
@@ -445,7 +445,7 @@ export function App() {
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [reload]);
|
||||
}, [reload, users, auth.user]);
|
||||
|
||||
const openEditCard = useCallback((card: Card) => {
|
||||
const id = modals.open({
|
||||
@@ -453,7 +453,13 @@ export function App() {
|
||||
size: "md",
|
||||
children: (
|
||||
<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"
|
||||
onCancel={() => modals.close(id)}
|
||||
onSubmit={async (v) => {
|
||||
@@ -462,6 +468,7 @@ export function App() {
|
||||
requester: v.requester,
|
||||
title: v.title,
|
||||
description: v.description,
|
||||
assignee_id: v.assignee_id,
|
||||
});
|
||||
modals.close(id);
|
||||
reload();
|
||||
@@ -472,16 +479,57 @@ export function App() {
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [reload, users]);
|
||||
|
||||
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, assignee_id } : c)) };
|
||||
});
|
||||
try {
|
||||
await api.updateCard(id, { assignee_id });
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleDeleteCard = useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.deleteCard(id);
|
||||
reload();
|
||||
reloadTrash();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload]);
|
||||
}, [reload, reloadTrash]);
|
||||
|
||||
const handleRestoreCard = useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.restoreCard(id);
|
||||
reload();
|
||||
reloadTrash();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload, reloadTrash]);
|
||||
|
||||
const handlePurgeCard = useCallback(async (id: string) => {
|
||||
modals.openConfirmModal({
|
||||
title: "Borrar permanentemente",
|
||||
children: <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) => {
|
||||
setBoard((prev) => {
|
||||
@@ -504,6 +552,46 @@ export function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, locked } : c)) };
|
||||
});
|
||||
try {
|
||||
await api.updateCard(id, { locked });
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleSetWIPLimit = useCallback(async (id: string, wip_limit: number) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, wip_limit } : c)) };
|
||||
});
|
||||
try {
|
||||
await api.updateColumn(id, { wip_limit });
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, is_done } : c)) };
|
||||
});
|
||||
try {
|
||||
await api.updateColumn(id, { is_done });
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const headerConfig = useMemo(() => ({ height: 50 }), []);
|
||||
const navbarConfig = useMemo(
|
||||
() => ({
|
||||
@@ -564,13 +652,21 @@ export function App() {
|
||||
</ActionIcon>
|
||||
<IconLayoutKanban size={22} />
|
||||
<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 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">
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
@@ -581,6 +677,27 @@ export function App() {
|
||||
>
|
||||
<IconMessageChatbot size={16} />
|
||||
</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>
|
||||
</AppShell.Header>
|
||||
@@ -624,15 +741,70 @@ export function App() {
|
||||
onResizeColumn={handleResizeColumn}
|
||||
onMoveColumnLocation={handleMoveColumnLocation}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onSetWIPLimit={handleSetWIPLimit}
|
||||
onToggleDone={handleToggleDone}
|
||||
onEditCard={openEditCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onChangeCardColor={handleChangeCardColor}
|
||||
onShowHistory={handleShowHistory}
|
||||
onToggleCardLock={handleToggleCardLock}
|
||||
onAssignCard={handleAssignCard}
|
||||
users={users}
|
||||
usersById={usersById}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</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>
|
||||
</AppShell.Navbar>
|
||||
|
||||
@@ -641,6 +813,15 @@ export function App() {
|
||||
</AppShell.Aside>
|
||||
|
||||
<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" }}>
|
||||
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
|
||||
<Group
|
||||
@@ -661,10 +842,16 @@ export function App() {
|
||||
onResizeColumn={handleResizeColumn}
|
||||
onMoveColumnLocation={handleMoveColumnLocation}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onSetWIPLimit={handleSetWIPLimit}
|
||||
onToggleDone={handleToggleDone}
|
||||
onEditCard={openEditCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onChangeCardColor={handleChangeCardColor}
|
||||
onShowHistory={handleShowHistory}
|
||||
onToggleCardLock={handleToggleCardLock}
|
||||
onAssignCard={handleAssignCard}
|
||||
users={users}
|
||||
usersById={usersById}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -708,6 +895,7 @@ export function App() {
|
||||
</Group>
|
||||
</SortableContext>
|
||||
</Box>
|
||||
)}
|
||||
</AppShell.Main>
|
||||
|
||||
</AppShell>
|
||||
@@ -721,6 +909,10 @@ export function App() {
|
||||
onEdit={() => {}}
|
||||
onChangeColor={() => {}}
|
||||
onShowHistory={() => {}}
|
||||
onToggleLock={() => {}}
|
||||
onAssign={() => {}}
|
||||
users={users}
|
||||
assignee={dragOverlayCard.assignee_id ? usersById.get(dragOverlayCard.assignee_id) : undefined}
|
||||
isOverlay
|
||||
/>
|
||||
) : dragOverlayColumn ? (
|
||||
|
||||
@@ -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
@@ -1,15 +1,30 @@
|
||||
import type { Board, Card, Column, HistoryEntry } from "./types";
|
||||
import type {
|
||||
Board,
|
||||
Card,
|
||||
CardHistoryResponse,
|
||||
Column,
|
||||
Metrics,
|
||||
MetricsFilter,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
export class HTTPError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers || {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ Message: res.statusText }));
|
||||
throw new Error(err.Message || err.message || res.statusText);
|
||||
throw new HTTPError(res.status, err.Message || err.message || res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
@@ -28,6 +43,8 @@ export interface UpdateColumnInput {
|
||||
position?: number;
|
||||
location?: "board" | "sidebar";
|
||||
width?: number;
|
||||
wip_limit?: number;
|
||||
is_done?: boolean;
|
||||
}
|
||||
|
||||
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
|
||||
@@ -50,6 +67,7 @@ export interface CreateCardInput {
|
||||
requester?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
assignee_id?: string | null;
|
||||
}
|
||||
|
||||
export function createCard(input: CreateCardInput): Promise<Card> {
|
||||
@@ -61,6 +79,8 @@ export interface UpdateCardInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
locked?: boolean;
|
||||
assignee_id?: string | null;
|
||||
}
|
||||
|
||||
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
|
||||
@@ -71,6 +91,18 @@ export function deleteCard(id: string): Promise<void> {
|
||||
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> {
|
||||
return fetchJSON(`/cards/${id}/move`, {
|
||||
method: "POST",
|
||||
@@ -78,7 +110,7 @@ export function moveCard(id: string, column_id: string, ordered_ids: string[]):
|
||||
});
|
||||
}
|
||||
|
||||
export function cardHistory(id: string): Promise<HistoryEntry[]> {
|
||||
export function cardHistory(id: string): Promise<CardHistoryResponse> {
|
||||
return fetchJSON(`/cards/${id}/history`);
|
||||
}
|
||||
|
||||
@@ -103,3 +135,39 @@ export interface ChatResponse {
|
||||
export function sendChat(messages: ChatMessage[]): Promise<ChatResponse> {
|
||||
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}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 type { User } from "../types";
|
||||
|
||||
export interface CardFormValues {
|
||||
requester: string;
|
||||
title: string;
|
||||
description: string;
|
||||
assignee_id: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initial?: Partial<CardFormValues>;
|
||||
submitLabel?: string;
|
||||
users?: User[];
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | 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 [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const t = title.trim();
|
||||
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>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -44,20 +52,20 @@ export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Solicitante"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.currentTarget.value)}
|
||||
label="Tarea"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
tabIndex={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
data-autofocus
|
||||
onKeyDown={enterSubmit}
|
||||
/>
|
||||
<TextInput
|
||||
label="Tarea"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
label="Solicitante"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.currentTarget.value)}
|
||||
tabIndex={2}
|
||||
required
|
||||
autoComplete="off"
|
||||
onKeyDown={enterSubmit}
|
||||
/>
|
||||
@@ -72,11 +80,24 @@ export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel
|
||||
onKeyDown={textareaEnter}
|
||||
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">
|
||||
<Button variant="subtle" color="gray" tabIndex={5} type="button" onClick={onCancel}>
|
||||
<Button variant="subtle" color="gray" tabIndex={6} type="button" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button tabIndex={4} type="submit" disabled={!title.trim()}>
|
||||
<Button tabIndex={5} type="submit" disabled={!title.trim()}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import { IconColumns3 } from "@tabler/icons-react";
|
||||
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import { IconColumns3, IconLock } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cardHistory } from "../api";
|
||||
import type { Card, HistoryEntry } from "../types";
|
||||
import type { Card, CardHistoryResponse } from "../types";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
@@ -10,13 +10,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export function HistoryModal({ card }: Props) {
|
||||
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
|
||||
const [data, setData] = useState<CardHistoryResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cardHistory(card.id).then(setEntries).catch(() => setEntries([]));
|
||||
cardHistory(card.id)
|
||||
.then(setData)
|
||||
.catch(() =>
|
||||
setData({ column_history: [], lock_periods: [], total_locked_ms: 0, currently_locked: false })
|
||||
);
|
||||
}, [card.id]);
|
||||
|
||||
if (!entries) {
|
||||
if (!data) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<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>;
|
||||
}
|
||||
|
||||
@@ -33,8 +39,8 @@ export function HistoryModal({ card }: Props) {
|
||||
<Text size="sm" c="dimmed">
|
||||
Tiempo total en cada columna desde que se creo la tarjeta.
|
||||
</Text>
|
||||
<Timeline active={entries.length} bulletSize={22} lineWidth={2}>
|
||||
{entries.map((e) => (
|
||||
<Timeline active={column_history.length} bulletSize={22} lineWidth={2}>
|
||||
{column_history.map((e) => (
|
||||
<Timeline.Item
|
||||
key={e.id}
|
||||
bullet={<IconColumns3 size={12} />}
|
||||
@@ -61,6 +67,59 @@ export function HistoryModal({ card }: Props) {
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,32 @@ import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
Popover,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconClock,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconGripVertical,
|
||||
IconHistory,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPalette,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, useState } from "react";
|
||||
import type { Card, CardColor } from "../types";
|
||||
import type { Card, CardColor, User } from "../types";
|
||||
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
@@ -31,14 +38,36 @@ interface Props {
|
||||
onEdit: (card: Card) => void;
|
||||
onChangeColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleLock: (id: string, locked: boolean) => void;
|
||||
onAssign: (id: string, assignee_id: string | null) => void;
|
||||
users: User[];
|
||||
assignee?: User;
|
||||
inDoneColumn?: boolean;
|
||||
isOverlay?: boolean;
|
||||
}
|
||||
|
||||
function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHistory, isOverlay }: Props) {
|
||||
const [popOpen, setPopOpen] = useState(false);
|
||||
function KanbanCardImpl({
|
||||
card,
|
||||
now,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onChangeColor,
|
||||
onShowHistory,
|
||||
onToggleLock,
|
||||
onAssign,
|
||||
users,
|
||||
assignee,
|
||||
inDoneColumn,
|
||||
isOverlay,
|
||||
}: Props) {
|
||||
const isDone = inDoneColumn || !!card.completed_at;
|
||||
const [colorPopOpen, setColorPopOpen] = useState(false);
|
||||
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: card.id,
|
||||
data: { type: "card", columnId: card.column_id },
|
||||
disabled: card.locked,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
@@ -46,84 +75,205 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
background: colorBg(card.color),
|
||||
borderColor: colorBorder(card.color),
|
||||
borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
|
||||
borderWidth: card.locked ? 2 : 1,
|
||||
};
|
||||
|
||||
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
|
||||
const liveMs = Math.max(0, now - enteredAt);
|
||||
|
||||
const onContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
const menuItems = (
|
||||
<>
|
||||
<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 (
|
||||
<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}>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
<Group justify="space-between" gap={4} wrap="nowrap" align="flex-start">
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }} align="flex-start">
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
color="var(--mantine-color-dark-2)"
|
||||
style={{ flexShrink: 0, marginTop: 4 }}
|
||||
/>
|
||||
{card.locked && (
|
||||
<Tooltip label="Bloqueada" withArrow>
|
||||
<IconLock
|
||||
size={14}
|
||||
color="var(--mantine-color-yellow-6)"
|
||||
style={{ flexShrink: 0, marginTop: 4 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text
|
||||
size="sm"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ cursor: "grab" }}
|
||||
aria-label="Drag"
|
||||
fw={500}
|
||||
style={{
|
||||
flex: 1,
|
||||
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}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<Popover opened={popOpen} onChange={setPopOpen} withArrow shadow="md" position="bottom-end">
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => setPopOpen((v) => !v)}
|
||||
aria-label="Color"
|
||||
>
|
||||
<IconPalette size={14} />
|
||||
</ActionIcon>
|
||||
</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);
|
||||
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>
|
||||
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label="Acciones"
|
||||
style={{ flexShrink: 0 }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>{menuItems}</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
{card.requester && (
|
||||
<Group gap={4}>
|
||||
@@ -133,6 +283,16 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
|
||||
</Text>
|
||||
</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 && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{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);
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Menu,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -17,7 +20,12 @@ import {
|
||||
import {
|
||||
IconArchive,
|
||||
IconArchiveOff,
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconCheckbox,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDotsVertical,
|
||||
IconGripVertical,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
@@ -25,7 +33,7 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-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";
|
||||
|
||||
interface Props {
|
||||
@@ -38,10 +46,16 @@ interface Props {
|
||||
onResizeColumn: (id: string, width: number) => void;
|
||||
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
|
||||
onDeleteColumn: (id: string) => void;
|
||||
onSetWIPLimit: (id: string, limit: number) => void;
|
||||
onToggleDone: (id: string, is_done: boolean) => void;
|
||||
onEditCard: (card: Card) => void;
|
||||
onDeleteCard: (id: string) => void;
|
||||
onChangeCardColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleCardLock: (id: string, locked: boolean) => void;
|
||||
onAssignCard: (id: string, assignee_id: string | null) => void;
|
||||
users: User[];
|
||||
usersById: Map<string, User>;
|
||||
}
|
||||
|
||||
function KanbanColumnImpl({
|
||||
@@ -54,14 +68,34 @@ function KanbanColumnImpl({
|
||||
onResizeColumn,
|
||||
onMoveColumnLocation,
|
||||
onDeleteColumn,
|
||||
onSetWIPLimit,
|
||||
onToggleDone,
|
||||
onEditCard,
|
||||
onDeleteCard,
|
||||
onChangeCardColor,
|
||||
onShowHistory,
|
||||
onToggleCardLock,
|
||||
onAssignCard,
|
||||
users,
|
||||
usersById,
|
||||
}: Props) {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState(column.name);
|
||||
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).
|
||||
useEffect(() => {
|
||||
@@ -73,20 +107,32 @@ function KanbanColumnImpl({
|
||||
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 = {
|
||||
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 style: React.CSSProperties = collapsed
|
||||
? {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: bodyHidden ? "0 0 auto" : "1 1 auto",
|
||||
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);
|
||||
|
||||
@@ -135,8 +181,24 @@ function KanbanColumnImpl({
|
||||
const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar";
|
||||
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 (
|
||||
<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 gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<ActionIcon
|
||||
@@ -181,9 +243,65 @@ function KanbanColumnImpl({
|
||||
{column.name}
|
||||
</Text>
|
||||
)}
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{cards.length}
|
||||
</Badge>
|
||||
<Popover
|
||||
opened={wipPopOpen}
|
||||
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 gap={2} wrap="nowrap">
|
||||
{renaming ? (
|
||||
@@ -206,72 +324,109 @@ function KanbanColumnImpl({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setName(column.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
aria-label="Rename"
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</ActionIcon>
|
||||
<Tooltip label={archiveLabel} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
||||
aria-label={archiveLabel}
|
||||
>
|
||||
<ArchiveIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => onDeleteColumn(column.id)}
|
||||
aria-label="Delete column"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
{collapsed && (
|
||||
<Tooltip label={bodyHidden ? "Expandir" : "Colapsar"} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => setBodyHidden((v) => !v)}
|
||||
aria-label={bodyHidden ? "Expandir columna" : "Colapsar columna"}
|
||||
>
|
||||
{bodyHidden ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{column.is_done && (
|
||||
<Tooltip label="Columna Done" withArrow>
|
||||
<Badge size="xs" color="green" variant="filled" leftSection={<IconCheckbox size={10} />}>
|
||||
done
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu position="bottom-end" shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones columna">
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Columna</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={14} />}
|
||||
onClick={() => {
|
||||
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>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} type="auto">
|
||||
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
|
||||
{cards.map((c) => (
|
||||
<KanbanCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
now={now}
|
||||
onDelete={onDeleteCard}
|
||||
onEdit={onEditCard}
|
||||
onChangeColor={onChangeCardColor}
|
||||
onShowHistory={onShowHistory}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</ScrollArea>
|
||||
{!(collapsed && bodyHidden) && (
|
||||
<>
|
||||
<ScrollArea style={{ flex: 1 }} type="auto">
|
||||
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
|
||||
{cards.map((c) => (
|
||||
<KanbanCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
now={now}
|
||||
onDelete={onDeleteCard}
|
||||
onEdit={onEditCard}
|
||||
onChangeColor={onChangeCardColor}
|
||||
onShowHistory={onShowHistory}
|
||||
onToggleLock={onToggleCardLock}
|
||||
onAssign={onAssignCard}
|
||||
users={users}
|
||||
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
|
||||
inDoneColumn={column.is_done}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</ScrollArea>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => onAddCard(column.id)}
|
||||
mt="xs"
|
||||
fullWidth
|
||||
>
|
||||
Anadir tarjeta
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => onAddCard(column.id)}
|
||||
mt="xs"
|
||||
fullWidth
|
||||
>
|
||||
Anadir tarjeta
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Resize handle (only on board, not sidebar) */}
|
||||
{!isInSidebar && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { MantineProvider, createTheme } from "@mantine/core";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { AuthProvider } from "./auth";
|
||||
import { Root } from "./Root";
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: "blue",
|
||||
@@ -15,7 +16,9 @@ createRoot(document.getElementById("root")!).render(
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<ModalsProvider>
|
||||
<Notifications position="top-right" />
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<Root />
|
||||
</AuthProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface Column {
|
||||
position: number;
|
||||
location: ColumnLocation;
|
||||
width: number;
|
||||
wip_limit: number;
|
||||
is_done: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -19,12 +21,113 @@ export interface Card {
|
||||
color: CardColor;
|
||||
column_id: string;
|
||||
position: number;
|
||||
locked: boolean;
|
||||
assignee_id: string | null;
|
||||
completed_at: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
entered_at: string;
|
||||
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 {
|
||||
columns: Column[];
|
||||
cards: Card[];
|
||||
@@ -39,3 +142,18 @@ export interface HistoryEntry {
|
||||
exited_at: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@fn_library": ["../../../frontend/functions/ui"],
|
||||
"@fn_library/*": ["../../../frontend/functions/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fn_library": path.resolve(__dirname, "../../../frontend/functions/ui"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5180,
|
||||
proxy: {
|
||||
|
||||
+92
-15
@@ -71,12 +71,14 @@ func handleUpdateColumn(db *DB) http.HandlerFunc {
|
||||
Position *int `json:"position"`
|
||||
Location *string `json:"location"`
|
||||
Width *int `json:"width"`
|
||||
WIPLimit *int `json:"wip_limit"`
|
||||
IsDone *bool `json:"is_done"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width}); 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)
|
||||
return
|
||||
}
|
||||
@@ -116,10 +118,11 @@ func handleReorderColumns(db *DB) http.HandlerFunc {
|
||||
func handleCreateCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
ColumnID string `json:"column_id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
@@ -129,7 +132,14 @@ func handleCreateCard(db *DB) http.HandlerFunc {
|
||||
badRequest(w, "column_id and title required")
|
||||
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 {
|
||||
serverError(w, err)
|
||||
return
|
||||
@@ -142,17 +152,38 @@ func handleCreateCard(db *DB) http.HandlerFunc {
|
||||
func handleUpdateCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Requester *string `json:"requester"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
var raw map[string]any
|
||||
if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -188,7 +219,8 @@ func handleMoveCard(db *DB) http.HandlerFunc {
|
||||
badRequest(w, "column_id required")
|
||||
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") {
|
||||
notFound(w, "card not found")
|
||||
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 {
|
||||
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: "POST", Path: "/api/columns", Handler: handleCreateColumn(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: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(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: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
@@ -23,6 +25,7 @@ func main() {
|
||||
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
|
||||
port := flags.Int("port", 8095, "HTTP port")
|
||||
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
||||
initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)")
|
||||
flags.Parse(os.Args[1:])
|
||||
|
||||
db, err := openDB(*dbPath)
|
||||
@@ -31,6 +34,9 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
bootstrapAdmin(db, *initialAdmin)
|
||||
startSessionCleanup(db)
|
||||
|
||||
wd := chatWorkdir(*dbPath)
|
||||
logger := newChatLogger(filepath.Join(wd, "chat.log"))
|
||||
log.Printf("chat tool log: %s", logger.path)
|
||||
@@ -44,9 +50,17 @@ func main() {
|
||||
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(
|
||||
infra.HTTPLoggerMiddleware(os.Stdout),
|
||||
infra.HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}),
|
||||
apiOnlyAuth(authMW),
|
||||
)
|
||||
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 {
|
||||
sub, err := fs.Sub(frontendDist, "frontend/dist")
|
||||
if err != nil {
|
||||
|
||||
+592
@@ -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()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS columns (
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT NOT NULL DEFAULT 'board' CHECK(location IN ('board','sidebar')),
|
||||
width INTEGER NOT NULL DEFAULT 300,
|
||||
wip_limit INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -15,6 +16,7 @@ CREATE TABLE IF NOT EXISTS cards (
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
locked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -27,7 +29,23 @@ CREATE TABLE IF NOT EXISTS card_column_history (
|
||||
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_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_columns_position ON columns(position);
|
||||
CREATE INDEX IF NOT EXISTS idx_lock_history_card ON card_lock_history(card_id);
|
||||
|
||||
@@ -46,6 +46,10 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
|
||||
return toolCardHistory(db, input)
|
||||
case "find_cards":
|
||||
return toolFindCards(db, input)
|
||||
case "list_users":
|
||||
return toolListUsers(db)
|
||||
case "assign_card":
|
||||
return toolAssignCard(db, input)
|
||||
default:
|
||||
return errMsg("unknown tool: " + name)
|
||||
}
|
||||
@@ -55,7 +59,7 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
|
||||
func toolMutates(name string) bool {
|
||||
switch name {
|
||||
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 false
|
||||
@@ -94,6 +98,8 @@ func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult {
|
||||
Name *string `json:"name"`
|
||||
Location *string `json:"location"`
|
||||
Width *int `json:"width"`
|
||||
WIPLimit *int `json:"wip_limit"`
|
||||
IsDone *bool `json:"is_done"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
@@ -101,10 +107,10 @@ func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult {
|
||||
if in.ID == "" {
|
||||
return errMsg("id required")
|
||||
}
|
||||
if in.Name == nil && in.Location == nil && in.Width == nil {
|
||||
return errMsg("at least one of name/location/width required")
|
||||
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/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 okResult(nil)
|
||||
@@ -151,7 +157,7 @@ func toolCreateCard(db *DB, input json.RawMessage) ToolResult {
|
||||
if in.ColumnID == "" || strings.TrimSpace(in.Title) == "" {
|
||||
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 {
|
||||
return errResult(err)
|
||||
}
|
||||
@@ -159,12 +165,57 @@ func toolCreateCard(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 {
|
||||
ID string `json:"id"`
|
||||
Requester *string `json:"requester"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Color *string `json:"color"`
|
||||
ID string `json:"id"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
@@ -172,7 +223,14 @@ func toolUpdateCard(db *DB, input json.RawMessage) ToolResult {
|
||||
if in.ID == "" {
|
||||
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 okResult(nil)
|
||||
@@ -225,7 +283,7 @@ func toolMoveCard(db *DB, input json.RawMessage) ToolResult {
|
||||
ids = append(ids, in.ID)
|
||||
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 okResult(nil)
|
||||
@@ -309,6 +367,7 @@ func validateToolName(name string) error {
|
||||
"delete_column": true, "reorder_columns": true, "create_card": true,
|
||||
"update_card": true, "delete_card": true, "move_card": true,
|
||||
"card_history": true, "find_cards": true,
|
||||
"list_users": true, "assign_card": true,
|
||||
}
|
||||
if !known[name] {
|
||||
return fmt.Errorf("unknown tool: %s", name)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user