feat(kanban): deadlines en cards (context menu, badges, calendario, history)
- migration 009 + columna deadline TEXT en cards - backend: CardPatch.HasDeadline, eventos deadline_set/deadline_cleared - KanbanCard: menu derecho con DatePicker, badge countdown con colores por ratio (azul>=50%, amarillo<50%, rojo<10%, red.9 overdue) - App.tsx: filtro "Con deadline", handleSetCardDeadline optimista, jump-to-card + highlight - CalendarView: popover por dia con seq_num + titulo, click navega a card en tablero - HistoryModal: render eventos deadline_set/deadline_cleared - .gitignore: *.log Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -15,3 +15,6 @@ frontend/tsconfig.tsbuildinfo
|
||||
|
||||
# Local files
|
||||
local_files/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -6,7 +6,10 @@ description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas
|
||||
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
|
||||
uses_functions:
|
||||
- random_hex_id_go_core
|
||||
- parse_date_or_default_go_core
|
||||
- sqlite_open_go_infra
|
||||
- sqlite_apply_migrations_go_infra
|
||||
- sqlite_column_exists_go_infra
|
||||
- spa_handler_go_infra
|
||||
- http_router_go_infra
|
||||
- http_serve_go_infra
|
||||
@@ -17,13 +20,26 @@ uses_functions:
|
||||
- http_error_response_go_infra
|
||||
- http_parse_body_go_infra
|
||||
- http_session_cookie_middleware_go_infra
|
||||
- http_session_token_extract_go_infra
|
||||
- http_session_cookie_set_go_infra
|
||||
- http_session_cookie_clear_go_infra
|
||||
- password_hash_go_infra
|
||||
- password_verify_go_infra
|
||||
- session_create_go_infra
|
||||
- session_cleanup_go_infra
|
||||
uses_types: []
|
||||
- percentile_int64_go_datascience
|
||||
- duration_stats_go_datascience
|
||||
- format_duration_ts_core
|
||||
- format_datetime_short_ts_core
|
||||
- string_hash_palette_ts_core
|
||||
- color_bg_ts_ui
|
||||
- color_border_ts_ui
|
||||
- color_swatch_ts_ui
|
||||
- fetch_json_ts_infra
|
||||
uses_types:
|
||||
- DurationStats_go_datascience
|
||||
framework: "net/http + vite + react + mantine + dnd-kit"
|
||||
entry_point: "main.go"
|
||||
entry_point: "backend/main.go"
|
||||
dir_path: "apps/kanban"
|
||||
---
|
||||
|
||||
|
||||
@@ -18,36 +18,15 @@ 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),
|
||||
})
|
||||
infra.SessionCookieSet(w, cookieName, token, expiresAt)
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
infra.SessionCookieClear(w, cookieName)
|
||||
}
|
||||
|
||||
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 ""
|
||||
return infra.SessionTokenExtract(r, cookieName)
|
||||
}
|
||||
|
||||
// POST /api/auth/register {username, password, display_name?}
|
||||
@@ -130,6 +109,36 @@ func handleMe(db *DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/me { color? }
|
||||
func handlePatchMe(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
|
||||
}
|
||||
var body struct {
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if body.Color != nil {
|
||||
if err := db.UpdateUserColor(uid, *body.Color); err != nil {
|
||||
serverError(w, err)
|
||||
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) {
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -36,6 +35,7 @@ type Sticker struct {
|
||||
|
||||
type Card struct {
|
||||
ID string `json:"id"`
|
||||
SeqNum int `json:"seq_num"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
@@ -48,11 +48,13 @@ type Card struct {
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
Tags []string `json:"tags"`
|
||||
Stickers []Sticker `json:"stickers"`
|
||||
Deadline *string `json:"deadline"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
TimeInColumn int64 `json:"time_in_column_ms"`
|
||||
LockedAt *string `json:"locked_at"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
TimeInColumn int64 `json:"time_in_column_ms"`
|
||||
LockedAt *string `json:"locked_at"`
|
||||
TotalLockedMs int64 `json:"total_locked_ms"`
|
||||
}
|
||||
|
||||
type HistoryEntry struct {
|
||||
@@ -63,6 +65,7 @@ type HistoryEntry struct {
|
||||
EnteredAt string `json:"entered_at"`
|
||||
ExitedAt *string `json:"exited_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
}
|
||||
|
||||
type LockPeriod struct {
|
||||
@@ -71,15 +74,26 @@ type LockPeriod struct {
|
||||
LockedAt string `json:"locked_at"`
|
||||
UnlockedAt *string `json:"unlocked_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
}
|
||||
|
||||
type CardHistoryResponse struct {
|
||||
ColumnHistory []HistoryEntry `json:"column_history"`
|
||||
LockPeriods []LockPeriod `json:"lock_periods"`
|
||||
Events []CardEvent `json:"events"`
|
||||
TotalLockedMs int64 `json:"total_locked_ms"`
|
||||
CurrentlyLock bool `json:"currently_locked"`
|
||||
}
|
||||
|
||||
type CardEvent struct {
|
||||
ID string `json:"id"`
|
||||
CardID string `json:"card_id"`
|
||||
Kind string `json:"kind"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
Payload string `json:"payload"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type DB struct{ conn *sql.DB }
|
||||
|
||||
func openDB(path string) (*DB, error) {
|
||||
@@ -87,7 +101,7 @@ func openDB(path string) (*DB, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyMigrations(conn); err != nil {
|
||||
if err := infra.ApplyMigrations(conn, migrationsFS, "migrations/*.sql"); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
@@ -100,60 +114,6 @@ func openDB(path string) (*DB, error) {
|
||||
return &DB{conn: conn}, nil
|
||||
}
|
||||
|
||||
func applyMigrations(conn *sql.DB) error {
|
||||
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
b, err := migrationsFS.ReadFile(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range splitSQLStatements(string(b)) {
|
||||
s := strings.TrimSpace(stmt)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Exec(s); err != nil {
|
||||
if isIdempotentMigrationError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSQLStatements(s string) []string {
|
||||
out := []string{}
|
||||
cur := strings.Builder{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
trim := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trim, "--") || trim == "" {
|
||||
continue
|
||||
}
|
||||
cur.WriteString(line)
|
||||
cur.WriteString("\n")
|
||||
if strings.HasSuffix(trim, ";") {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isIdempotentMigrationError(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "duplicate column") ||
|
||||
strings.Contains(msg, "already exists")
|
||||
}
|
||||
|
||||
// ensureColumns adds columns missing from older schemas without dropping data.
|
||||
// SQLite ALTER TABLE ADD COLUMN supports NOT NULL with literal DEFAULT but not CHECK,
|
||||
// so location's CHECK is enforced in Go (UpdateColumn) when the column is added later.
|
||||
@@ -171,11 +131,12 @@ func ensureColumns(conn *sql.DB) error {
|
||||
{"cards", "deleted_at", "TEXT"},
|
||||
{"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"},
|
||||
{"cards", "stickers", "TEXT NOT NULL DEFAULT '[]'"},
|
||||
{"cards", "deadline", "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)
|
||||
exists, err := infra.ColumnExists(conn, s.table, s.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -192,28 +153,6 @@ func ensureColumns(conn *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func columnExists(conn *sql.DB, table, name string) (bool, error) {
|
||||
rows, err := conn.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName, ctype string
|
||||
var notnull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &colName, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if colName == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) Close() error { return db.conn.Close() }
|
||||
|
||||
func newID() string {
|
||||
@@ -351,6 +290,18 @@ func nullableActor(actorID string) any {
|
||||
return actorID
|
||||
}
|
||||
|
||||
// insertEvent registra un evento timeline de la card. tx puede ser nil para usar conn.
|
||||
func insertCardEvent(execer interface {
|
||||
Exec(string, ...any) (sql.Result, error)
|
||||
}, cardID, kind, actorID string, payload any) error {
|
||||
pj, _ := json.Marshal(payload)
|
||||
_, err := execer.Exec(
|
||||
`INSERT INTO card_events (id, card_id, kind, actor_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
newID(), cardID, kind, nullableActor(actorID), string(pj), nowRFC3339(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Columns ---
|
||||
|
||||
func (db *DB) ListColumns() ([]Column, error) {
|
||||
@@ -486,8 +437,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.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.created_at, c.updated_at,
|
||||
h.entered_at, l.locked_at
|
||||
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
|
||||
h.entered_at, l.locked_at,
|
||||
COALESCE((
|
||||
SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
|
||||
FROM card_lock_history WHERE card_id = c.id
|
||||
), 0) AS total_locked_ms
|
||||
FROM cards c
|
||||
LEFT JOIN card_column_history h
|
||||
ON h.card_id = c.id AND h.exited_at IS NULL
|
||||
@@ -495,7 +450,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
ON l.card_id = c.id AND l.unlocked_at IS NULL
|
||||
WHERE c.deleted_at IS NULL
|
||||
ORDER BY c.column_id, c.position, c.created_at
|
||||
`)
|
||||
`, time.Now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -510,12 +465,17 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
var deleted sql.NullString
|
||||
var tagsJSON string
|
||||
var stickersJSON string
|
||||
var deadline sql.NullString
|
||||
var lockedAt 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, &tagsJSON, &stickersJSON, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Stickers = parseStickers(stickersJSON)
|
||||
if deadline.Valid && deadline.String != "" {
|
||||
s := deadline.String
|
||||
c.Deadline = &s
|
||||
}
|
||||
if lockedAt.Valid && lockedAt.String != "" {
|
||||
s := lockedAt.String
|
||||
c.LockedAt = &s
|
||||
@@ -555,20 +515,28 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
|
||||
pos = int(maxPos.Int64) + 1
|
||||
}
|
||||
now := nowRFC3339()
|
||||
c := Card{
|
||||
ID: newID(), Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
|
||||
Tags: []string{},
|
||||
Stickers: []Sticker{},
|
||||
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
|
||||
}
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var maxSeq sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seqNum := 1
|
||||
if maxSeq.Valid {
|
||||
seqNum = int(maxSeq.Int64) + 1
|
||||
}
|
||||
c := Card{
|
||||
ID: newID(), SeqNum: seqNum, Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
|
||||
Tags: []string{},
|
||||
Stickers: []Sticker{},
|
||||
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO cards (id, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, encodeTags(c.Tags), c.CreatedAt, c.UpdatedAt,
|
||||
`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, encodeTags(c.Tags), c.CreatedAt, c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -589,6 +557,9 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
|
||||
}
|
||||
c.CompletedAt = &now
|
||||
}
|
||||
if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": title, "column_id": columnID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -604,6 +575,8 @@ type CardPatch struct {
|
||||
AssigneeID *string // empty string clears assignment
|
||||
HasAssignee bool // distinguishes "set to null" from "not provided"
|
||||
Tags *[]string
|
||||
Deadline *string // empty string clears deadline
|
||||
HasDeadline bool // distinguishes "set to null" from "not provided"
|
||||
}
|
||||
|
||||
func (db *DB) UpdateCard(id string, patch CardPatch) error {
|
||||
@@ -617,40 +590,102 @@ func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) er
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if patch.Requester != nil {
|
||||
var oldReq string
|
||||
_ = tx.QueryRow(`SELECT requester FROM cards WHERE id=?`, id).Scan(&oldReq)
|
||||
if _, err := tx.Exec(`UPDATE cards SET requester=?, updated_at=? WHERE id=?`, *patch.Requester, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldReq != *patch.Requester {
|
||||
if err := insertCardEvent(tx, id, "requester_changed", actorID, map[string]any{"old": oldReq, "new": *patch.Requester}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Title != nil {
|
||||
var oldTitle string
|
||||
_ = tx.QueryRow(`SELECT title FROM cards WHERE id=?`, id).Scan(&oldTitle)
|
||||
if _, err := tx.Exec(`UPDATE cards SET title=?, updated_at=? WHERE id=?`, *patch.Title, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldTitle != *patch.Title {
|
||||
if err := insertCardEvent(tx, id, "title_changed", actorID, map[string]any{"old": oldTitle, "new": *patch.Title}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Description != nil {
|
||||
var oldDesc string
|
||||
_ = tx.QueryRow(`SELECT description FROM cards WHERE id=?`, id).Scan(&oldDesc)
|
||||
if _, err := tx.Exec(`UPDATE cards SET description=?, updated_at=? WHERE id=?`, *patch.Description, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldDesc != *patch.Description {
|
||||
if err := insertCardEvent(tx, id, "description_changed", actorID, map[string]any{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Color != nil {
|
||||
if _, err := tx.Exec(`UPDATE cards SET color=?, updated_at=? WHERE id=?`, *patch.Color, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertCardEvent(tx, id, "color_changed", actorID, map[string]any{"color": *patch.Color}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.HasAssignee {
|
||||
var oldAssignee sql.NullString
|
||||
_ = tx.QueryRow(`SELECT assignee_id FROM cards WHERE id=?`, id).Scan(&oldAssignee)
|
||||
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
|
||||
}
|
||||
if oldAssignee.Valid && oldAssignee.String != "" {
|
||||
if err := insertCardEvent(tx, id, "unassigned", actorID, map[string]any{"prev": oldAssignee.String}); 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 oldAssignee.String != *patch.AssigneeID {
|
||||
if err := insertCardEvent(tx, id, "assigned", actorID, map[string]any{"assignee_id": *patch.AssigneeID}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Tags != nil {
|
||||
if _, err := tx.Exec(`UPDATE cards SET tags=?, updated_at=? WHERE id=?`, encodeTags(*patch.Tags), nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertCardEvent(tx, id, "tags_changed", actorID, map[string]any{"tags": *patch.Tags}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.HasDeadline {
|
||||
var oldDeadline sql.NullString
|
||||
_ = tx.QueryRow(`SELECT deadline FROM cards WHERE id=?`, id).Scan(&oldDeadline)
|
||||
if patch.Deadline == nil || *patch.Deadline == "" {
|
||||
if _, err := tx.Exec(`UPDATE cards SET deadline=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldDeadline.Valid && oldDeadline.String != "" {
|
||||
if err := insertCardEvent(tx, id, "deadline_cleared", actorID, map[string]any{"prev": oldDeadline.String}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(`UPDATE cards SET deadline=?, updated_at=? WHERE id=?`, *patch.Deadline, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldDeadline.String != *patch.Deadline {
|
||||
if err := insertCardEvent(tx, id, "deadline_set", actorID, map[string]any{"deadline": *patch.Deadline}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Locked != nil {
|
||||
var current int
|
||||
@@ -688,14 +723,42 @@ func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) er
|
||||
|
||||
// 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
|
||||
return db.DeleteCardWithActor(id, "")
|
||||
}
|
||||
|
||||
func (db *DB) DeleteCardWithActor(id, actorID string) error {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertCardEvent(tx, id, "deleted", actorID, map[string]any{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// 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
|
||||
return db.RestoreCardWithActor(id, "")
|
||||
}
|
||||
|
||||
func (db *DB) RestoreCardWithActor(id, actorID string) error {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertCardEvent(tx, id, "restored", actorID, map[string]any{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// PurgeCard permanently removes the card from the DB.
|
||||
@@ -707,7 +770,7 @@ func (db *DB) PurgeCard(id string) error {
|
||||
// ListDeletedCards returns cards in the trash, newest first.
|
||||
func (db *DB) ListDeletedCards() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.created_at, c.updated_at
|
||||
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
|
||||
FROM cards c
|
||||
WHERE c.deleted_at IS NOT NULL
|
||||
ORDER BY c.deleted_at DESC
|
||||
@@ -724,11 +787,16 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
|
||||
var deleted sql.NullString
|
||||
var tagsJSON string
|
||||
var stickersJSON string
|
||||
var deadline 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, &tagsJSON, &stickersJSON, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Stickers = parseStickers(stickersJSON)
|
||||
if deadline.Valid && deadline.String != "" {
|
||||
s := deadline.String
|
||||
c.Deadline = &s
|
||||
}
|
||||
c.Locked = locked != 0
|
||||
if assignee.Valid && assignee.String != "" {
|
||||
s := assignee.String
|
||||
@@ -846,7 +914,7 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string, actorID
|
||||
|
||||
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
|
||||
SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at, h.actor_id
|
||||
FROM card_column_history h
|
||||
LEFT JOIN columns c ON c.id = h.column_id
|
||||
WHERE h.card_id=?
|
||||
@@ -861,9 +929,14 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
|
||||
for rows.Next() {
|
||||
var h HistoryEntry
|
||||
var exited sql.NullString
|
||||
if err := rows.Scan(&h.ID, &h.CardID, &h.ColumnID, &h.ColumnName, &h.EnteredAt, &exited); err != nil {
|
||||
var actor sql.NullString
|
||||
if err := rows.Scan(&h.ID, &h.CardID, &h.ColumnID, &h.ColumnName, &h.EnteredAt, &exited, &actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actor.Valid && actor.String != "" {
|
||||
s := actor.String
|
||||
h.ActorID = &s
|
||||
}
|
||||
entered, err := time.Parse(time.RFC3339Nano, h.EnteredAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -883,7 +956,7 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
|
||||
}
|
||||
|
||||
lockRows, err := db.conn.Query(`
|
||||
SELECT id, card_id, locked_at, unlocked_at
|
||||
SELECT id, card_id, locked_at, unlocked_at, actor_id
|
||||
FROM card_lock_history
|
||||
WHERE card_id=?
|
||||
ORDER BY locked_at
|
||||
@@ -898,9 +971,14 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
|
||||
for lockRows.Next() {
|
||||
var lp LockPeriod
|
||||
var unlocked sql.NullString
|
||||
if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked); err != nil {
|
||||
var actor sql.NullString
|
||||
if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked, &actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actor.Valid && actor.String != "" {
|
||||
s := actor.String
|
||||
lp.ActorID = &s
|
||||
}
|
||||
start, err := time.Parse(time.RFC3339Nano, lp.LockedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -921,9 +999,34 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
evRows, err := db.conn.Query(`
|
||||
SELECT id, card_id, kind, actor_id, payload, created_at
|
||||
FROM card_events
|
||||
WHERE card_id=?
|
||||
ORDER BY created_at
|
||||
`, cardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer evRows.Close()
|
||||
events := []CardEvent{}
|
||||
for evRows.Next() {
|
||||
var e CardEvent
|
||||
var actor sql.NullString
|
||||
if err := evRows.Scan(&e.ID, &e.CardID, &e.Kind, &actor, &e.Payload, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actor.Valid && actor.String != "" {
|
||||
s := actor.String
|
||||
e.ActorID = &s
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
|
||||
return &CardHistoryResponse{
|
||||
ColumnHistory: cols,
|
||||
LockPeriods: locks,
|
||||
Events: events,
|
||||
TotalLockedMs: totalLocked,
|
||||
CurrentlyLock: currently,
|
||||
}, nil
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<script type="module" crossorigin src="/assets/index-BKxzRoLi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,4 +46,4 @@ require (
|
||||
nhooyr.io/websocket v1.8.17 // indirect
|
||||
)
|
||||
|
||||
replace fn-registry => ../..
|
||||
replace fn-registry => ../../..
|
||||
@@ -190,6 +190,15 @@ func handleUpdateCard(db *DB) http.HandlerFunc {
|
||||
patch.AssigneeID = &s
|
||||
}
|
||||
}
|
||||
if v, present := raw["deadline"]; present {
|
||||
patch.HasDeadline = true
|
||||
if v == nil {
|
||||
empty := ""
|
||||
patch.Deadline = &empty
|
||||
} else if s, ok := v.(string); ok {
|
||||
patch.Deadline = &s
|
||||
}
|
||||
}
|
||||
if v, present := raw["tags"]; present {
|
||||
tags := []string{}
|
||||
if arr, ok := v.([]any); ok {
|
||||
@@ -233,7 +242,8 @@ func handleUpdateCardStickers(db *DB) http.HandlerFunc {
|
||||
func handleDeleteCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := db.DeleteCard(id); err != nil {
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
if err := db.DeleteCardWithActor(id, actor); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -299,7 +309,8 @@ func handleListTrash(db *DB) http.HandlerFunc {
|
||||
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 {
|
||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||
if err := db.RestoreCardWithActor(id, actor); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -325,6 +336,7 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
|
||||
{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: "PATCH", Path: "/api/me", Handler: handlePatchMe(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)},
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
//go:embed all:dist
|
||||
var frontendDist embed.FS
|
||||
|
||||
func main() {
|
||||
@@ -132,7 +132,7 @@ func startSessionCleanup(db *DB) {
|
||||
}
|
||||
|
||||
func frontendHandler() http.Handler {
|
||||
sub, err := fs.Sub(frontendDist, "frontend/dist")
|
||||
sub, err := fs.Sub(frontendDist, "dist")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -3,11 +3,16 @@ package main
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/core"
|
||||
"fn-registry/functions/datascience"
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
type DurationStats = datascience.DurationStats
|
||||
|
||||
type Metrics struct {
|
||||
Range DateRange `json:"range"`
|
||||
Totals Totals `json:"totals"`
|
||||
@@ -58,14 +63,6 @@ type DailyCount struct {
|
||||
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"`
|
||||
@@ -95,63 +92,16 @@ type MovementStat struct {
|
||||
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),
|
||||
}
|
||||
return datascience.DurationStatsFrom(durations)
|
||||
}
|
||||
|
||||
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
|
||||
return core.ParseDateOrDefault(s, dflt, false)
|
||||
}
|
||||
|
||||
func parseEndDateOrDefault(s string, dflt time.Time) time.Time {
|
||||
if s == "" {
|
||||
return dflt
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||
return t.Add(24*time.Hour - time.Nanosecond)
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t
|
||||
}
|
||||
return dflt
|
||||
return core.ParseDateOrDefault(s, dflt, true)
|
||||
}
|
||||
|
||||
// GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=...
|
||||
@@ -162,8 +112,17 @@ func handleMetrics(db *DB) http.HandlerFunc {
|
||||
to := parseEndDateOrDefault(r.URL.Query().Get("to"), now)
|
||||
assignee := r.URL.Query().Get("assignee_id")
|
||||
requester := r.URL.Query().Get("requester")
|
||||
tagsRaw := r.URL.Query().Get("tags")
|
||||
var tags []string
|
||||
if tagsRaw != "" {
|
||||
for _, t := range strings.Split(tagsRaw, ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m, err := computeMetrics(db, from, to, assignee, requester)
|
||||
m, err := computeMetrics(db, from, to, assignee, requester, tags)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
@@ -172,7 +131,7 @@ func handleMetrics(db *DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Metrics, error) {
|
||||
func computeMetrics(db *DB, from, to time.Time, assignee, requester string, tags []string) (*Metrics, error) {
|
||||
fromStr := from.Format(time.RFC3339Nano)
|
||||
toStr := to.Format(time.RFC3339Nano)
|
||||
|
||||
@@ -198,6 +157,10 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
|
||||
cardWhere += " AND requester=?"
|
||||
args = append(args, requester)
|
||||
}
|
||||
for _, t := range tags {
|
||||
cardWhere += " AND tags LIKE ?"
|
||||
args = append(args, `%"`+t+`"%`)
|
||||
}
|
||||
|
||||
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM cards `+cardWhere, args...).Scan(&m.Totals.Cards); err != nil {
|
||||
return nil, err
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Color del avatar del usuario (Mantine color name o '#rrggbb' personalizado).
|
||||
ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Eventos cronologicos por card. Complementa column_history (moves) y lock_history (locks).
|
||||
-- Captura: created, assigned, unassigned, title_changed, description_changed, color_changed, tags_changed.
|
||||
CREATE TABLE IF NOT EXISTS card_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
actor_id TEXT,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_events_card ON card_events(card_id, created_at);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- ID secuencial humano por card. Distinto del id hex (PK interna).
|
||||
-- Backfill por orden de creacion.
|
||||
ALTER TABLE cards ADD COLUMN seq_num INTEGER NOT NULL DEFAULT 0;
|
||||
UPDATE cards SET seq_num = (
|
||||
SELECT COUNT(*) FROM cards c2 WHERE c2.created_at <= cards.created_at
|
||||
) WHERE seq_num = 0;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_cards_seq_num ON cards(seq_num) WHERE seq_num > 0;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Deadline opcional por card. Fecha RFC3339 (precision dia o instante).
|
||||
-- NULL = sin deadline (default). El frontend muestra countdown hasta la fecha.
|
||||
ALTER TABLE cards ADD COLUMN deadline TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_deadline ON cards(deadline) WHERE deadline IS NOT NULL;
|
||||
@@ -13,6 +13,7 @@ type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Color string `json:"color"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -51,8 +52,8 @@ func (db *DB) CreateUser(username, password, displayName string) (*User, error)
|
||||
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)
|
||||
`SELECT id, username, display_name, color, created_at FROM users WHERE id=?`, id,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errUserNotFound
|
||||
}
|
||||
@@ -67,8 +68,8 @@ func (db *DB) GetUserByUsername(username string) (*User, string, error) {
|
||||
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)
|
||||
`SELECT id, username, display_name, color, created_at, password_hash FROM users WHERE username=?`, username,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt, &hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", errUserNotFound
|
||||
}
|
||||
@@ -79,7 +80,7 @@ func (db *DB) GetUserByUsername(username string) (*User, string, error) {
|
||||
}
|
||||
|
||||
func (db *DB) ListUsers() ([]User, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, username, display_name, created_at FROM users ORDER BY username`)
|
||||
rows, err := db.conn.Query(`SELECT id, username, display_name, color, created_at FROM users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -87,7 +88,7 @@ func (db *DB) ListUsers() ([]User, error) {
|
||||
out := []User{}
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
@@ -117,6 +118,11 @@ func (db *DB) CountUsers() (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateUserColor(id, color string) error {
|
||||
_, err := db.conn.Exec(`UPDATE users SET color=? WHERE id=?`, color, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteSessionByToken(token string) error {
|
||||
_, err := db.conn.Exec(`DELETE FROM sessions WHERE token=?`, token)
|
||||
return err
|
||||
@@ -1,69 +0,0 @@
|
||||
{"ts":"2026-05-06T22:48:54.982377303Z","tool":"delete_card","input":{"id":"1cdfc05e20c51430"},"ok":true}
|
||||
{"ts":"2026-05-06T22:48:54.982541766Z","tool":"delete_card","input":{"id":"0d4b8afab5344cbd"},"ok":true}
|
||||
{"ts":"2026-05-06T22:48:54.982583432Z","tool":"delete_card","input":{"id":"88551589d2f7abd0"},"ok":true}
|
||||
{"ts":"2026-05-08T11:05:19.870107956Z","tool":"create_column","input":{"name":"HACIENDO 🚧"},"ok":true,"result_summary":"column a5f7f05963bbf3ed name=\"HACIENDO 🚧\""}
|
||||
{"ts":"2026-05-08T11:05:19.879303459Z","tool":"create_column","input":{"name":"PNDNT FEEDBACK ▶️"},"ok":true,"result_summary":"column 61e44ab592ce223a name=\"PNDNT FEEDBACK ▶️\""}
|
||||
{"ts":"2026-05-08T11:05:19.879427883Z","tool":"create_column","input":{"name":"HECHO ✅"},"ok":true,"result_summary":"column 06ac391eb6d8ce8b name=\"HECHO ✅\""}
|
||||
{"ts":"2026-05-08T11:05:19.879530269Z","tool":"create_column","input":{"name":"IDEAS 💡"},"ok":true,"result_summary":"column 63974019466e3f1d name=\"IDEAS 💡\""}
|
||||
{"ts":"2026-05-08T11:05:19.879639469Z","tool":"create_column","input":{"name":"DEUDA TÉCNICA 🔄"},"ok":true,"result_summary":"column 635506c9aaac540a name=\"DEUDA TÉCNICA 🔄\""}
|
||||
{"ts":"2026-05-08T11:05:40.973634884Z","tool":"update_column","input":{"id":"06ac391eb6d8ce8b","is_done":true},"ok":true}
|
||||
{"ts":"2026-05-08T11:05:40.974205892Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Simon","title":"MCP","locked":true},"ok":true,"result_summary":"card f6efaa13146787dd title=\"MCP\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.974526775Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Alvaro Calvo","title":"Footprint: Arreglar centros"},"ok":true,"result_summary":"card cbc358b5c0cac316 title=\"Footprint: Arreglar centros\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.974963613Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Sofia","title":"Añadir precaweb + centros web a informe de venta v1.2 — Cuadrar con datos de Diego 😰"},"ok":true,"result_summary":"card 6a8f39dc0e8e7218 title=\"Añadir precaweb + centros web a informe de venta v1.2 — Cuadrar con datos de Diego 😰\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.975384857Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Alberto Frias","title":"Mejorar el informe de ventas (Nat)","description":"Todos los detalles menos rehacer indicadores. Preguntar a Andrés."},"ok":true,"result_summary":"card 5d44483861cbdda3 title=\"Mejorar el informe de ventas (Nat)\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.975700632Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"MariClaire","title":"Media de ticket medio por día, semana y hora (Enma)"},"ok":true,"result_summary":"card eed928c34ccb85a2 title=\"Media de ticket medio por día, semana y hora (Enma)\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.975969784Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Data","title":"Visualizaciones de dashboards (Alfon)"},"ok":true,"result_summary":"card f960cc196dd2ab0a title=\"Visualizaciones de dashboards (Alfon)\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:05:40.976229693Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Emilio","title":"Conversión OTRS centros de glass"},"ok":true,"result_summary":"card c3c867025281c088 title=\"Conversión OTRS centros de glass\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.976519357Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Alberto Frias","title":"Informe de Car"},"ok":true,"result_summary":"card 66ae0108656a731e title=\"Informe de Car\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.976869903Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Marta","title":"Lean n3, n2, n1: Dashboard"},"ok":true,"result_summary":"card bdd86aa84645b3f3 title=\"Lean n3, n2, n1: Dashboard\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.977235162Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Santiago","title":"% de callcenter sobre total (Alfon)"},"ok":true,"result_summary":"card 69615eb998a5705d title=\"% de callcenter sobre total (Alfon)\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.977586792Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Alvaro Calvo","title":"Tasaciones de Galicia"},"ok":true,"result_summary":"card 81e756341403a4d7 title=\"Tasaciones de Galicia\" col=61e44ab592ce223a"}
|
||||
{"ts":"2026-05-08T11:05:40.977901945Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Javi","title":"Añadir usuarios"},"ok":true,"result_summary":"card 1e13d5da79a9bae2 title=\"Añadir usuarios\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.978247494Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Pilar RRHH","title":"DNIs de trabajadores"},"ok":true,"result_summary":"card 56a4b2b4ac5e8251 title=\"DNIs de trabajadores\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.978587684Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Angel","title":"Dashboard de servicios"},"ok":true,"result_summary":"card 6017f8cb1d6c4d8c title=\"Dashboard de servicios\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.978902225Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Fer","title":"Venta de neu por día desde CallCenter y precio medio para obtener facturación real"},"ok":true,"result_summary":"card b1e820b29afa5cdf title=\"Venta de neu por día desde CallCenter y precio medio para obtener facturación real\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.979238803Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"MariClaire","title":"Tiempo de empleados por hora"},"ok":true,"result_summary":"card 2a67ec283a40dd1a title=\"Tiempo de empleados por hora\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.979544021Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Simon","title":"Promoción Ceat en mano de obra"},"ok":true,"result_summary":"card 1eba435104d4391a title=\"Promoción Ceat en mano de obra\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.979853172Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Data","title":"Preparar informe de transformación"},"ok":true,"result_summary":"card 47dc1a64d4811539 title=\"Preparar informe de transformación\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.980121612Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Andres","title":"Facturación Marcajes 2026"},"ok":true,"result_summary":"card 18fa5511fb0c8095 title=\"Facturación Marcajes 2026\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.980594215Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Paco","title":"Permisos Metabase (Enma)"},"ok":true,"result_summary":"card 442714f56f74b1f0 title=\"Permisos Metabase (Enma)\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.981228033Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","title":"Herramienta sencilla alternativa a Jira"},"ok":true,"result_summary":"card 2268e2cd44a587fb title=\"Herramienta sencilla alternativa a Jira\" col=06ac391eb6d8ce8b"}
|
||||
{"ts":"2026-05-08T11:05:40.981582925Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Reinventar informe de CAR"},"ok":true,"result_summary":"card 7043c7f97b2e9c43 title=\"Reinventar informe de CAR\" col=635506c9aaac540a"}
|
||||
{"ts":"2026-05-08T11:05:40.981924049Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Limpiar tablas con datos erróneos"},"ok":true,"result_summary":"card e6863961ad8648f9 title=\"Limpiar tablas con datos erróneos\" col=635506c9aaac540a"}
|
||||
{"ts":"2026-05-08T11:05:40.982232277Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Unificar DATACLAW"},"ok":true,"result_summary":"card 3662ba02fdae93bf title=\"Unificar DATACLAW\" col=635506c9aaac540a"}
|
||||
{"ts":"2026-05-08T11:05:40.982524229Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Unificar la ontología"},"ok":true,"result_summary":"card 47237e4a0c55fcff title=\"Unificar la ontología\" col=635506c9aaac540a"}
|
||||
{"ts":"2026-05-08T11:05:54.670085952Z","tool":"update_card","input":{"id":"f6efaa13146787dd","locked":true},"ok":true}
|
||||
{"ts":"2026-05-08T11:05:54.670203761Z","tool":"update_column","input":{"id":"63974019466e3f1d","location":"sidebar"},"ok":true}
|
||||
{"ts":"2026-05-08T11:06:09.679110703Z","tool":"update_column","input":{"id":"635506c9aaac540a","location":"sidebar"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.355764942Z","tool":"update_card","input":{"id":"1e13d5da79a9bae2","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.355959344Z","tool":"update_card","input":{"id":"2a67ec283a40dd1a","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.35612212Z","tool":"update_card","input":{"id":"47dc1a64d4811539","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356239725Z","tool":"update_card","input":{"id":"442714f56f74b1f0","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356354693Z","tool":"update_card","input":{"id":"2268e2cd44a587fb","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356462581Z","tool":"update_card","input":{"id":"f6efaa13146787dd","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356572287Z","tool":"update_card","input":{"id":"6a8f39dc0e8e7218","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356699579Z","tool":"update_card","input":{"id":"eed928c34ccb85a2","color":"blue"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356824315Z","tool":"update_card","input":{"id":"56a4b2b4ac5e8251","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.356935364Z","tool":"update_card","input":{"id":"6017f8cb1d6c4d8c","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357046515Z","tool":"update_card","input":{"id":"1eba435104d4391a","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357157261Z","tool":"update_card","input":{"id":"18fa5511fb0c8095","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.35726429Z","tool":"update_card","input":{"id":"81e756341403a4d7","color":"green"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357372562Z","tool":"update_card","input":{"id":"b1e820b29afa5cdf","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357496076Z","tool":"update_card","input":{"id":"c3c867025281c088","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357605751Z","tool":"update_card","input":{"id":"66ae0108656a731e","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.357748295Z","tool":"update_card","input":{"id":"69615eb998a5705d","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.35784585Z","tool":"update_card","input":{"id":"b83087eb4162fdac","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:16:12.358008545Z","tool":"update_card","input":{"id":"f960cc196dd2ab0a","color":"orange"},"ok":true}
|
||||
{"ts":"2026-05-08T11:23:48.494485056Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Enmanuel","title":"Dashboard con totales — líneas TPV/Quote/OTR/Invoice por vendedor, diag y mecánico","description":"Analizar tabla y sacar totales de forma sencilla.\nhttps://reports.autingo.es/question/9754-lineas-tpv-quote-otr-invoice-actores-vendedor-diag-mecanico-producto?con_mecanico=No\u0026fecha=\u0026con_diagnosticador=No\u0026producto_nav_id=","color":"orange"},"ok":true,"result_summary":"card a33c10a6600db235 title=\"Dashboard con totales — líneas TPV/Quote/OTR/Invoice por vendedor, diag y mecánico\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:23:59.15054959Z","tool":"list_users","input":{},"ok":true,"result_summary":"[{\"id\":\"039c97acf1869393\",\"username\":\"amassaguer\",\"display_name\":\"alfon\",\"created_at\":\"2026-05-08T11:03:27.358308764Z\"},{\"id\":\"6a75edc6e99d8405\",\"username\":\"egutierrez\",\"display_name\":\"Enmaa\",\"created..."}
|
||||
{"ts":"2026-05-08T11:24:05.428419675Z","tool":"assign_card","input":{"id":"a33c10a6600db235","assignee_id":"039c97acf1869393"},"ok":true}
|
||||
{"ts":"2026-05-08T11:28:42.163127804Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Danny Sanchez","title":"MB: estado y número de OTRs por presupuesto","description":"","assignee_id":"9e91db261084d529"},"ok":true,"result_summary":"card 11d55b6752f10bdd title=\"MB: estado y número de OTRs por presupuesto\" col=a5f7f05963bbf3ed"}
|
||||
{"ts":"2026-05-08T11:28:50.498149425Z","tool":"assign_card","input":{"id":"11d55b6752f10bdd","assignee_id":"9e91db261084d529"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.549537256Z","tool":"update_card","input":{"id":"b1e820b29afa5cdf","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.54980371Z","tool":"update_card","input":{"id":"c3c867025281c088","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.550312442Z","tool":"update_card","input":{"id":"66ae0108656a731e","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.550427194Z","tool":"update_card","input":{"id":"69615eb998a5705d","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.550564181Z","tool":"update_card","input":{"id":"b83087eb4162fdac","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T11:50:31.550752616Z","tool":"update_card","input":{"id":"f960cc196dd2ab0a","color":"pink"},"ok":true}
|
||||
{"ts":"2026-05-08T12:46:10.901190181Z","tool":"move_card","input":{"id":"1e13d5da79a9bae2","column_id":"06ac391eb6d8ce8b","ordered_ids":["1e13d5da79a9bae2","2a67ec283a40dd1a","47dc1a64d4811539","442714f56f74b1f0","2268e2cd44a587fb","56a4b2b4ac5e8251","6017f8cb1d6c4d8c","1eba435104d4391a","18fa5511fb0c8095","c3c867025281c088","b1e820b29afa5cdf"]},"ok":true}
|
||||
{"ts":"2026-05-08T13:00:55.650201794Z","tool":"create_card","input":{"column_id":"63974019466e3f1d","title":"Mezclar dashboard de fichajes con productividad","description":"https://reports.autingo.es/dashboard/994?centro=\u0026dni=\u0026fecha=thisday\u0026provincia=\u0026tipo=\u0026usuario="},"ok":true,"result_summary":"card acf64523865f23d0 title=\"Mezclar dashboard de fichajes con productividad\" col=63974019466e3f1d"}
|
||||
@@ -0,0 +1,575 @@
|
||||
// Tests del color picker (Modal personalizado dentro de Menu/Popover de Mantine).
|
||||
// Reproduce el bug: click en el circulo "Color personalizado" abre Modal pero
|
||||
// se cierra inmediatamente. Comprueba que el Modal permanezca visible >300ms.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/browser"
|
||||
)
|
||||
|
||||
// loginAndGetCookie registra (idempotente) y hace login. Retorna valor de la cookie kanban_session.
|
||||
func loginAndGetCookie(t *testing.T, baseURL, user, pass string) string {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"username":%q,"password":%q,"display_name":%q}`, user, pass, user)
|
||||
// Registro: 200 OK la primera vez, error si ya existe (ignorable).
|
||||
_, _ = http.Post(baseURL+"/api/auth/register", "application/json", strings.NewReader(body))
|
||||
|
||||
loginBody := fmt.Sprintf(`{"username":%q,"password":%q}`, user, pass)
|
||||
resp, err := http.Post(baseURL+"/api/auth/login", "application/json", strings.NewReader(loginBody))
|
||||
if err != nil {
|
||||
t.Fatalf("login http: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
buf := new(bytes.Buffer)
|
||||
buf.ReadFrom(resp.Body)
|
||||
t.Fatalf("login status %d: %s", resp.StatusCode, buf.String())
|
||||
}
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == "kanban_session" {
|
||||
return ck.Value
|
||||
}
|
||||
}
|
||||
t.Fatalf("login no devolvio cookie kanban_session")
|
||||
return ""
|
||||
}
|
||||
|
||||
// ensureBoardSeed crea una columna y card si la BD esta vacia. Usa la cookie autenticada.
|
||||
func ensureBoardSeed(t *testing.T, baseURL, cookie string) {
|
||||
t.Helper()
|
||||
client := &http.Client{}
|
||||
mk := func(method, url string, body string) *http.Response {
|
||||
req, _ := http.NewRequest(method, baseURL+url, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: "kanban_session", Value: cookie})
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, url, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
// Lee board.
|
||||
resp := mk("GET", "/api/board", "")
|
||||
defer resp.Body.Close()
|
||||
var board struct {
|
||||
Columns []map[string]any `json:"columns"`
|
||||
Cards []map[string]any `json:"cards"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&board)
|
||||
|
||||
var colID string
|
||||
if len(board.Columns) == 0 {
|
||||
r := mk("POST", "/api/columns", `{"name":"e2e"}`)
|
||||
var c map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&c)
|
||||
r.Body.Close()
|
||||
colID = c["id"].(string)
|
||||
} else {
|
||||
colID = board.Columns[0]["id"].(string)
|
||||
}
|
||||
if len(board.Cards) == 0 {
|
||||
r := mk("POST", "/api/cards", fmt.Sprintf(`{"column_id":%q,"title":"e2e card"}`, colID))
|
||||
r.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// authedSetup hace login + inyecta cookie en el browser CDP.
|
||||
func authedSetup(t *testing.T) (*ctx, string) {
|
||||
t.Helper()
|
||||
c := setup(t)
|
||||
user := envOr("KANBAN_USER", "e2etest")
|
||||
pass := envOr("KANBAN_PASS", "e2etest")
|
||||
cookie := loginAndGetCookie(t, c.baseURL, user, pass)
|
||||
ensureBoardSeed(t, c.baseURL, cookie)
|
||||
|
||||
// Navegar a la home primero para que el browser tenga el dominio en su jar.
|
||||
c.navigate("/")
|
||||
host := strings.TrimPrefix(strings.TrimPrefix(c.baseURL, "http://"), "https://")
|
||||
host = strings.SplitN(host, ":", 2)[0]
|
||||
if err := browser.CdpSetCookie(c.conn, "kanban_session", cookie, host, "/", true); err != nil {
|
||||
t.Fatalf("set_cookie: %v", err)
|
||||
}
|
||||
c.navigate("/")
|
||||
return c, cookie
|
||||
}
|
||||
|
||||
func TestColorPicker_AvatarMenu_ModalStaysOpen(t *testing.T) {
|
||||
c, _ := authedSetup(t)
|
||||
|
||||
// Esperar avatar (header).
|
||||
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
|
||||
c.screenshot("debug_no_avatar")
|
||||
t.Fatalf("avatar no aparecio (login fallo?): %v", err)
|
||||
}
|
||||
if err := browser.CdpClick(c.conn, "[aria-label='Usuario']"); err != nil {
|
||||
t.Fatalf("click avatar: %v", err)
|
||||
}
|
||||
// Esperar el grid de colores.
|
||||
if err := browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second); err != nil {
|
||||
c.screenshot("debug_no_picker_grid")
|
||||
t.Fatalf("picker grid no visible: %v", err)
|
||||
}
|
||||
c.screenshot("avatar_menu_open")
|
||||
|
||||
// Click "+".
|
||||
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
|
||||
t.Fatalf("click +: %v", err)
|
||||
}
|
||||
// Esperar 350ms — si el bug persiste, el modal habra desaparecido.
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
c.screenshot("avatar_after_plus_click")
|
||||
|
||||
// Mantine Modal renderiza con role="dialog". Comprobar visible.
|
||||
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
t.Logf("dialogs en DOM tras 350ms: %s", val)
|
||||
if strings.Contains(val, "0") {
|
||||
// Bug confirmado: modal cerro.
|
||||
t.Errorf("BUG: Modal del color picker se cerro inmediatamente (avatar menu)")
|
||||
}
|
||||
|
||||
// Comprobar header del modal.
|
||||
val = c.eval(`(() => { const m = document.querySelector('[role="dialog"] .mantine-Modal-title'); return m ? m.textContent : 'NULL'; })()`)
|
||||
t.Logf("modal title: %s", val)
|
||||
}
|
||||
|
||||
func TestColorPicker_AvatarModal_ClicksInsideKeepOpen(t *testing.T) {
|
||||
c, _ := authedSetup(t)
|
||||
|
||||
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
|
||||
t.Fatalf("avatar: %v", err)
|
||||
}
|
||||
if err := browser.CdpClick(c.conn, "[aria-label='Usuario']"); err != nil {
|
||||
t.Fatalf("click avatar: %v", err)
|
||||
}
|
||||
if err := browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second); err != nil {
|
||||
t.Fatalf("picker grid: %v", err)
|
||||
}
|
||||
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
|
||||
t.Fatalf("click +: %v", err)
|
||||
}
|
||||
if err := browser.CdpWaitElement(c.conn, "[role='dialog']", 2*time.Second); err != nil {
|
||||
t.Fatalf("modal: %v", err)
|
||||
}
|
||||
|
||||
// Click 1: input hex
|
||||
if err := browser.CdpClick(c.conn, "[role='dialog'] input"); err != nil {
|
||||
t.Fatalf("click input: %v", err)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
if strings.Contains(val, "0") {
|
||||
c.screenshot("modal_closed_after_input_click")
|
||||
t.Errorf("BUG: modal cerro tras click en input hex (dialogs=%s)", val)
|
||||
}
|
||||
|
||||
// Click 2: zona saturation del ColorPicker
|
||||
if err := browser.CdpClick(c.conn, "[role='dialog'] .mantine-ColorPicker-saturation"); err != nil {
|
||||
t.Logf("click saturation no fue posible: %v", err)
|
||||
} else {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
if strings.Contains(val, "0") {
|
||||
c.screenshot("modal_closed_after_saturation")
|
||||
t.Errorf("BUG: modal cerro tras click en saturation (dialogs=%s)", val)
|
||||
}
|
||||
}
|
||||
|
||||
// Click 3: swatch
|
||||
val = c.eval(`(() => { const s = document.querySelector('[role="dialog"] .mantine-ColorPicker-swatch'); if (!s) return 'NO_SWATCH'; s.click(); return 'OK'; })()`)
|
||||
t.Logf("swatch click: %s", val)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
if strings.Contains(val, "0") {
|
||||
c.screenshot("modal_closed_after_swatch")
|
||||
t.Errorf("BUG: modal cerro tras click en swatch (dialogs=%s)", val)
|
||||
}
|
||||
|
||||
// Click 4: titulo del modal (zona muerta)
|
||||
val = c.eval(`(() => { const t = document.querySelector('.mantine-Modal-title'); if (!t) return 'NO_TITLE'; t.click(); return 'OK'; })()`)
|
||||
t.Logf("title click: %s", val)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
if strings.Contains(val, "0") {
|
||||
c.screenshot("modal_closed_after_title")
|
||||
t.Errorf("BUG: modal cerro tras click en title (dialogs=%s)", val)
|
||||
}
|
||||
|
||||
c.screenshot("modal_after_all_clicks")
|
||||
}
|
||||
|
||||
// Simula drag desde dentro del ColorPicker hasta fuera del modal,
|
||||
// que es el patron de uso humano cuando arrastra el saturation.
|
||||
func TestColorPicker_DragInsideThenOutside_StaysOpen(t *testing.T) {
|
||||
c, _ := authedSetup(t)
|
||||
|
||||
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
|
||||
t.Fatalf("avatar: %v", err)
|
||||
}
|
||||
if err := browser.CdpClick(c.conn, "[aria-label='Usuario']"); err != nil {
|
||||
t.Fatalf("click avatar: %v", err)
|
||||
}
|
||||
if err := browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second); err != nil {
|
||||
t.Fatalf("picker grid: %v", err)
|
||||
}
|
||||
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
|
||||
t.Fatalf("click +: %v", err)
|
||||
}
|
||||
if err := browser.CdpWaitElement(c.conn, "[role='dialog'] .mantine-ColorPicker-saturation", 3*time.Second); err != nil {
|
||||
t.Fatalf("saturation no aparecio: %v", err)
|
||||
}
|
||||
|
||||
// Despachar drag manual via JS: pointerdown sat, pointermove out, pointerup out.
|
||||
out := c.eval(`(() => {
|
||||
const sat = document.querySelector('[role="dialog"] .mantine-ColorPicker-saturation');
|
||||
if (!sat) return 'NO_SAT';
|
||||
const r = sat.getBoundingClientRect();
|
||||
const startX = r.left + r.width / 2;
|
||||
const startY = r.top + r.height / 2;
|
||||
const endX = r.left - 200; // fuera del modal por la izquierda
|
||||
const endY = r.top - 200; // fuera por arriba
|
||||
const fire = (target, type, x, y) => {
|
||||
const ev = new PointerEvent(type, {
|
||||
bubbles: true, cancelable: true, composed: true,
|
||||
clientX: x, clientY: y, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1,
|
||||
});
|
||||
target.dispatchEvent(ev);
|
||||
const m = new MouseEvent(type === 'pointerdown' ? 'mousedown' : type === 'pointerup' ? 'mouseup' : 'mousemove', {
|
||||
bubbles: true, cancelable: true, view: window,
|
||||
clientX: x, clientY: y, button: 0, buttons: type === 'pointerup' ? 0 : 1,
|
||||
});
|
||||
target.dispatchEvent(m);
|
||||
};
|
||||
fire(sat, 'pointerdown', startX, startY);
|
||||
// Mover en pasos
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const x = startX + (endX - startX) * i / 10;
|
||||
const y = startY + (endY - startY) * i / 10;
|
||||
const elAt = document.elementFromPoint(x, y) || document;
|
||||
fire(elAt, 'pointermove', x, y);
|
||||
}
|
||||
const finalEl = document.elementFromPoint(endX, endY) || document;
|
||||
fire(finalEl, 'pointerup', endX, endY);
|
||||
return 'OK';
|
||||
})()`)
|
||||
t.Logf("drag result: %s", out)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
t.Logf("dialogs tras drag fuera: %s", val)
|
||||
c.screenshot("modal_after_drag_outside")
|
||||
if strings.Contains(val, "0") {
|
||||
t.Errorf("BUG: modal cerro tras drag desde saturation hasta fuera del modal")
|
||||
}
|
||||
|
||||
// Click en una zona vacia del modal (no input, no buttons).
|
||||
val = c.eval(`(() => {
|
||||
const dlg = document.querySelector('[role="dialog"]');
|
||||
if (!dlg) return 'NO_DLG';
|
||||
const r = dlg.getBoundingClientRect();
|
||||
// Click en el header del modal (margen superior).
|
||||
const x = r.left + 10;
|
||||
const y = r.top + 10;
|
||||
const target = document.elementFromPoint(x, y);
|
||||
if (!target) return 'NO_TARGET';
|
||||
const ev = new MouseEvent('click', { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y });
|
||||
target.dispatchEvent(ev);
|
||||
return 'OK target=' + target.tagName + '.' + target.className;
|
||||
})()`)
|
||||
t.Logf("click header zone: %s", val)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
t.Logf("dialogs tras click header: %s", val)
|
||||
if strings.Contains(val, "0") {
|
||||
c.screenshot("modal_closed_after_header_click")
|
||||
t.Errorf("BUG: modal cerro tras click en header del modal")
|
||||
}
|
||||
}
|
||||
|
||||
func jsonQuote(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Click en cada region clickeable del modal — verifica que ninguna cierre.
|
||||
func TestColorPicker_AllRegionsKeepModalOpen(t *testing.T) {
|
||||
c, _ := authedSetup(t)
|
||||
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
|
||||
t.Fatalf("avatar: %v", err)
|
||||
}
|
||||
browser.CdpClick(c.conn, "[aria-label='Usuario']")
|
||||
browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second)
|
||||
browser.CdpClick(c.conn, "[aria-label='Color personalizado']")
|
||||
browser.CdpWaitElement(c.conn, "[role='dialog']", 3*time.Second)
|
||||
|
||||
regions := []struct {
|
||||
name string
|
||||
selector string
|
||||
}{
|
||||
{"body padding", "[role='dialog'] .mantine-Modal-body"},
|
||||
{"saturation", "[role='dialog'] .mantine-ColorPicker-saturation"},
|
||||
{"hue slider", "[role='dialog'] .mantine-ColorPicker-slider"},
|
||||
{"swatch 0", "[role='dialog'] .mantine-ColorPicker-swatch"},
|
||||
{"hex input", "[role='dialog'] input"},
|
||||
{"hex label", "[role='dialog'] .mantine-TextInput-label"},
|
||||
{"stack gap", "[role='dialog'] .mantine-Stack-root"},
|
||||
}
|
||||
for _, r := range regions {
|
||||
v := c.eval(`(() => {
|
||||
const el = document.querySelector(` + jsonQuote(r.selector) + `);
|
||||
if (!el) return 'NO_EL';
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
const target = document.elementFromPoint(x, y);
|
||||
if (!target) return 'NO_TARGET';
|
||||
['pointerdown','mousedown','pointerup','mouseup','click'].forEach(type => {
|
||||
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
|
||||
const ev = new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, view: window });
|
||||
target.dispatchEvent(ev);
|
||||
});
|
||||
return 'OK ' + target.tagName + '.' + (target.className || '').slice(0, 40);
|
||||
})()`)
|
||||
t.Logf("region %s: %s", r.name, v)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
if strings.Contains(count, "0") {
|
||||
c.screenshot("modal_closed_at_" + strings.ReplaceAll(r.name, " ", "_"))
|
||||
t.Errorf("BUG: modal cerro tras click en region %q", r.name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verifica que clicar el ColorPicker en zonas de uso real (dragging del saturation,
|
||||
// click en el slider de hue, click en swatches) NO cierre el modal.
|
||||
// Sleep extra de 600ms tras cada accion para esperar transiciones Mantine.
|
||||
func TestColorPicker_RealisticInteractions(t *testing.T) {
|
||||
c, _ := authedSetup(t)
|
||||
browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second)
|
||||
browser.CdpClick(c.conn, "[aria-label='Usuario']")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second)
|
||||
browser.CdpClick(c.conn, "[aria-label='Color personalizado']")
|
||||
browser.CdpWaitElement(c.conn, "[role='dialog'] .mantine-ColorPicker-saturation", 3*time.Second)
|
||||
time.Sleep(500 * time.Millisecond) // animacion modal entrada
|
||||
|
||||
// 1. Drag DENTRO del saturation (movimientos cortos, sin salir)
|
||||
out := c.eval(`(() => {
|
||||
const sat = document.querySelector('[role="dialog"] .mantine-ColorPicker-saturation');
|
||||
if (!sat) return 'NO_SAT';
|
||||
const r = sat.getBoundingClientRect();
|
||||
const mid = (axis) => axis === 'x' ? r.left + r.width / 2 : r.top + r.height / 2;
|
||||
const fire = (target, type, x, y) => {
|
||||
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
|
||||
target.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, buttons: type === 'pointerup' || type === 'mouseup' ? 0 : 1, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
};
|
||||
fire(sat, 'pointerdown', mid('x'), mid('y'));
|
||||
fire(sat, 'mousedown', mid('x'), mid('y'));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = r.left + (r.width * (0.3 + i * 0.1));
|
||||
const y = r.top + (r.height * (0.3 + i * 0.1));
|
||||
fire(sat, 'pointermove', x, y);
|
||||
fire(sat, 'mousemove', x, y);
|
||||
}
|
||||
fire(sat, 'pointerup', mid('x'), mid('y'));
|
||||
fire(sat, 'mouseup', mid('x'), mid('y'));
|
||||
return 'OK';
|
||||
})()`)
|
||||
t.Logf("drag interno saturation: %s", out)
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
c.screenshot("after_drag_internal")
|
||||
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
t.Logf("dialogs tras drag interno (600ms): %s", val)
|
||||
if strings.Contains(val, "0") {
|
||||
t.Errorf("BUG: modal cerro tras drag interno de saturation")
|
||||
}
|
||||
|
||||
// 2. Verificar que Mantine NO añade close button (X) — debe estar deshabilitado.
|
||||
closeBtn := c.eval(`document.querySelectorAll('[role="dialog"] .mantine-Modal-close').length`)
|
||||
t.Logf("close buttons en modal: %s", closeBtn)
|
||||
if !strings.Contains(closeBtn, "0") {
|
||||
t.Errorf("BUG: modal tiene close button (X). Click accidental cierra. Usar withCloseButton={false}")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: abre modal avatar y devuelve ctx con modal listo.
|
||||
func openAvatarColorModal(t *testing.T) *ctx {
|
||||
t.Helper()
|
||||
c, _ := authedSetup(t)
|
||||
browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second)
|
||||
browser.CdpClick(c.conn, "[aria-label='Usuario']")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second)
|
||||
browser.CdpClick(c.conn, "[aria-label='Color personalizado']")
|
||||
browser.CdpWaitElement(c.conn, "[role='dialog']", 3*time.Second)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return c
|
||||
}
|
||||
|
||||
// Comportamiento deseado: clicks DENTRO del modal NO cierran. Tests granulares
|
||||
// con sleep generoso para esperar animaciones de Mantine.
|
||||
func TestColorPicker_InsideClicks_DoNotClose(t *testing.T) {
|
||||
c := openAvatarColorModal(t)
|
||||
|
||||
regions := []struct {
|
||||
name string
|
||||
selector string
|
||||
}{
|
||||
{"hex_input", "[role='dialog'] input"},
|
||||
{"hex_label", "[role='dialog'] .mantine-TextInput-label"},
|
||||
{"saturation_center", "[role='dialog'] .mantine-ColorPicker-saturation"},
|
||||
{"hue_slider", "[role='dialog'] .mantine-ColorPicker-slider"},
|
||||
{"swatch_first", "[role='dialog'] .mantine-ColorPicker-swatch"},
|
||||
{"body", "[role='dialog'] .mantine-Modal-body"},
|
||||
{"stack", "[role='dialog'] .mantine-Stack-root"},
|
||||
{"title", "[role='dialog'] .mantine-Modal-title"},
|
||||
{"header", "[role='dialog'] .mantine-Modal-header"},
|
||||
{"content", "[role='dialog']"},
|
||||
}
|
||||
for _, r := range regions {
|
||||
t.Run(r.name, func(t *testing.T) {
|
||||
res := c.eval(`(() => {
|
||||
const el = document.querySelector(` + jsonQuote(r.selector) + `);
|
||||
if (!el) return 'NO_EL';
|
||||
const rc = el.getBoundingClientRect();
|
||||
const x = rc.left + Math.min(rc.width / 2, 30);
|
||||
const y = rc.top + Math.min(rc.height / 2, 12);
|
||||
const target = document.elementFromPoint(x, y);
|
||||
if (!target) return 'NO_TARGET';
|
||||
['pointerdown','mousedown','pointerup','mouseup','click'].forEach(type => {
|
||||
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
|
||||
target.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, buttons: type.includes('up') ? 0 : 1, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
});
|
||||
return 'OK ' + target.tagName;
|
||||
})()`)
|
||||
t.Logf("region %s: %s", r.name, res)
|
||||
time.Sleep(500 * time.Millisecond) // esperar animaciones
|
||||
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
if strings.Contains(count, "0") {
|
||||
c.screenshot("inside_closed_" + r.name)
|
||||
t.Errorf("BUG: modal cerro tras click en %q", r.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Click en overlay (zona oscura fuera del panel del modal) DEBE cerrar.
|
||||
func TestColorPicker_OverlayClick_Closes(t *testing.T) {
|
||||
c := openAvatarColorModal(t)
|
||||
res := c.eval(`(() => {
|
||||
const overlay = document.querySelector('.mantine-Overlay-root, .mantine-Modal-overlay');
|
||||
if (!overlay) return 'NO_OVERLAY';
|
||||
const rc = overlay.getBoundingClientRect();
|
||||
// click en esquina superior izquierda del overlay (lejos del modal centrado)
|
||||
const x = rc.left + 10;
|
||||
const y = rc.top + 10;
|
||||
const target = document.elementFromPoint(x, y);
|
||||
if (!target) return 'NO_TARGET';
|
||||
['pointerdown','mousedown','pointerup','mouseup','click'].forEach(type => {
|
||||
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
|
||||
target.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
});
|
||||
return 'OK ' + target.tagName + '.' + (target.className || '').slice(0,30);
|
||||
})()`)
|
||||
t.Logf("overlay click: %s", res)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
if !strings.Contains(count, "0") {
|
||||
c.screenshot("overlay_did_not_close")
|
||||
t.Errorf("BUG: modal NO cerro tras click en overlay (esperado: cierra)")
|
||||
}
|
||||
}
|
||||
|
||||
// Boton Cancelar DEBE cerrar.
|
||||
func TestColorPicker_CancelButton_Closes(t *testing.T) {
|
||||
c := openAvatarColorModal(t)
|
||||
res := c.eval(`(() => { const b = [...document.querySelectorAll('[role="dialog"] button')].find(x => x.textContent.trim() === 'Cancelar'); if (!b) return 'NO_BTN'; b.click(); return 'OK'; })()`)
|
||||
t.Logf("cancelar: %s", res)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
if !strings.Contains(count, "0") {
|
||||
t.Errorf("BUG: modal NO cerro tras Cancelar")
|
||||
}
|
||||
}
|
||||
|
||||
// Boton Aceptar DEBE cerrar.
|
||||
func TestColorPicker_AcceptButton_Closes(t *testing.T) {
|
||||
c := openAvatarColorModal(t)
|
||||
res := c.eval(`(() => { const b = [...document.querySelectorAll('[role="dialog"] button')].find(x => x.textContent.trim() === 'Aceptar'); if (!b) return 'NO_BTN'; b.click(); return 'OK'; })()`)
|
||||
t.Logf("aceptar: %s", res)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
if !strings.Contains(count, "0") {
|
||||
t.Errorf("BUG: modal NO cerro tras Aceptar")
|
||||
}
|
||||
}
|
||||
|
||||
// Modal NO debe tener close button (X) — clicks accidentales cierran.
|
||||
func TestColorPicker_NoXCloseButton(t *testing.T) {
|
||||
c := openAvatarColorModal(t)
|
||||
count := c.eval(`document.querySelectorAll('[role="dialog"] .mantine-Modal-close').length`)
|
||||
t.Logf("X buttons: %s", count)
|
||||
if !strings.Contains(count, "0") {
|
||||
t.Errorf("BUG: modal tiene close button (X). Quitar withCloseButton={false}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorPicker_CardMenu_ModalStaysOpen(t *testing.T) {
|
||||
c, _ := authedSetup(t)
|
||||
|
||||
// Esperar al menos una card.
|
||||
if err := browser.CdpWaitElement(c.conn, "[aria-label='Acciones']", 8*time.Second); err != nil {
|
||||
c.screenshot("debug_no_card")
|
||||
t.Fatalf("card menu trigger no visible: %v", err)
|
||||
}
|
||||
if err := browser.CdpClick(c.conn, "[aria-label='Acciones']"); err != nil {
|
||||
t.Fatalf("click card menu: %v", err)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Click submenu Color.
|
||||
val := c.eval(`(() => { const items = [...document.querySelectorAll('.mantine-Menu-item')]; const t = items.find(i => i.textContent.trim() === 'Color'); if (!t) return 'NOT_FOUND'; t.click(); return 'OK'; })()`)
|
||||
if !strings.Contains(val, "OK") {
|
||||
c.screenshot("debug_no_color_item")
|
||||
t.Fatalf("item Color no encontrado en menu: %s", val)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
c.screenshot("card_color_popover_open")
|
||||
|
||||
// Inyectar capturador de logs ahora (despues de la nav, antes del click).
|
||||
c.eval(`(() => { window.__logs = []; const orig = console.log; console.log = function(...a) { window.__logs.push(a.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' ')); orig.apply(console, a); }; })()`)
|
||||
|
||||
// Diagnostico DOM: cuantos "+" hay y donde estan?
|
||||
plus := c.eval(`document.querySelectorAll('[aria-label="Color personalizado"]').length`)
|
||||
t.Logf("'+' en DOM: %s", plus)
|
||||
plusVisible := c.eval(`(() => { const el = document.querySelector('[aria-label="Color personalizado"]'); if (!el) return 'NO_EL'; const r = el.getBoundingClientRect(); return JSON.stringify({x: r.x, y: r.y, w: r.width, h: r.height}); })()`)
|
||||
t.Logf("'+' rect: %s", plusVisible)
|
||||
|
||||
// Click "+" custom.
|
||||
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
|
||||
t.Fatalf("click + en card popover: %v", err)
|
||||
}
|
||||
// Sondear cada 50ms hasta 800ms para ver si el modal aparece y luego desaparece.
|
||||
for i := 0; i < 16; i++ {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
v := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
t.Logf("[%dms] dialogs=%s", (i+1)*50, v)
|
||||
}
|
||||
c.screenshot("card_after_plus_click")
|
||||
|
||||
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
|
||||
t.Logf("dialogs final (card): %s", val)
|
||||
logs := c.eval(`JSON.stringify(window.__logs || [])`)
|
||||
t.Logf("console logs: %s", logs)
|
||||
if strings.Contains(val, "0") {
|
||||
t.Errorf("BUG: Modal del color picker se cerro inmediatamente (card menu)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module kanban-e2e
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require fn-registry v0.0.0-00010101000000-000000000000
|
||||
|
||||
replace fn-registry => ../../..
|
||||
@@ -0,0 +1,178 @@
|
||||
// Tests e2e contra kanban server (puerto 8095) usando funciones del registry.
|
||||
// Requiere kanban backend corriendo + Chrome accesible (WSL2 o Linux).
|
||||
//
|
||||
// Ejecucion:
|
||||
// cd e2e && go test -v -tags fts5 ./...
|
||||
// o: BASE_URL=http://localhost:5180 go test -v ./... (modo dev con Vite)
|
||||
//
|
||||
// Variables de entorno:
|
||||
// BASE_URL — default http://localhost:8095
|
||||
// KANBAN_USER — default e2e
|
||||
// KANBAN_PASS — default e2etest
|
||||
// HEADLESS — "1" para headless. Default "1"
|
||||
//
|
||||
// Reusa funciones del registry: chrome_launch, cdp_*. NO duplica logica.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/browser"
|
||||
)
|
||||
|
||||
const cdpPort = 9335
|
||||
|
||||
type ctx struct {
|
||||
t *testing.T
|
||||
conn *browser.CDPConn
|
||||
chromePID int
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func envOr(k, dflt string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return dflt
|
||||
}
|
||||
|
||||
func setup(t *testing.T) *ctx {
|
||||
t.Helper()
|
||||
baseURL := envOr("BASE_URL", "http://localhost:8095")
|
||||
|
||||
// Verificar que el backend responde antes de lanzar Chrome.
|
||||
resp, err := http.Get(baseURL + "/api/board")
|
||||
if err != nil {
|
||||
t.Skipf("backend no accesible en %s: %v", baseURL, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
headless := envOr("HEADLESS", "1") == "1"
|
||||
pid, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{
|
||||
Port: cdpPort,
|
||||
UserDataDir: "/tmp/kanban-e2e-profile",
|
||||
Headless: headless,
|
||||
})
|
||||
if err != nil {
|
||||
t.Skipf("chrome_launch fallo: %v", err)
|
||||
}
|
||||
|
||||
conn, err := browser.CdpConnect(cdpPort)
|
||||
if err != nil {
|
||||
_ = browser.CdpClose(nil, pid)
|
||||
t.Fatalf("cdp_connect fallo: %v", err)
|
||||
}
|
||||
|
||||
c := &ctx{t: t, conn: conn, chromePID: pid, baseURL: baseURL}
|
||||
t.Cleanup(func() { _ = browser.CdpClose(c.conn, c.chromePID) })
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ctx) navigate(path string) {
|
||||
c.t.Helper()
|
||||
if err := browser.CdpNavigate(c.conn, c.baseURL+path); err != nil {
|
||||
c.t.Fatalf("navigate %s: %v", path, err)
|
||||
}
|
||||
if err := browser.CdpWaitLoad(c.conn, 10*time.Second); err != nil {
|
||||
c.t.Fatalf("wait_load %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ctx) screenshot(name string) {
|
||||
c.t.Helper()
|
||||
dir := "screenshots"
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
out := fmt.Sprintf("%s/%s.png", dir, name)
|
||||
if err := browser.CdpScreenshot(c.conn, out, browser.CdpScreenshotOpts{Format: "png"}); err != nil {
|
||||
c.t.Logf("screenshot fallo (%s): %v", name, err)
|
||||
return
|
||||
}
|
||||
c.t.Logf("screenshot: %s", out)
|
||||
}
|
||||
|
||||
func (c *ctx) eval(expr string) string {
|
||||
c.t.Helper()
|
||||
out, err := browser.CdpEvaluate(c.conn, expr)
|
||||
if err != nil {
|
||||
c.t.Fatalf("eval (%s): %v", expr, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestE2E_HomeLoads(t *testing.T) {
|
||||
c := setup(t)
|
||||
c.navigate("/")
|
||||
|
||||
// Login form o board (segun haya sesion previa). Busca cualquier rasgo visible.
|
||||
if err := browser.CdpWaitElement(c.conn, "body", 5*time.Second); err != nil {
|
||||
t.Fatalf("body no aparecio: %v", err)
|
||||
}
|
||||
html, err := browser.CdpGetHTML(c.conn)
|
||||
if err != nil {
|
||||
t.Fatalf("get_html: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(html), "kanban") &&
|
||||
!strings.Contains(strings.ToLower(html), "iniciar") &&
|
||||
!strings.Contains(strings.ToLower(html), "login") {
|
||||
t.Errorf("home no contiene rastros esperados (kanban/login). HTML[:200]=%s", html[:min(len(html), 200)])
|
||||
}
|
||||
c.screenshot("01_home")
|
||||
}
|
||||
|
||||
func TestE2E_ApiBoardResponds(t *testing.T) {
|
||||
baseURL := envOr("BASE_URL", "http://localhost:8095")
|
||||
resp, err := http.Get(baseURL + "/api/board")
|
||||
if err != nil {
|
||||
t.Skipf("backend no accesible: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// 401 (sin sesion) o 200 (sesion activa) — ambos validos.
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 401 {
|
||||
t.Errorf("/api/board status inesperado: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_FlagsEndpoint_DoesNotExist(t *testing.T) {
|
||||
// Smoke: endpoint /api/me devuelve 401 sin auth (no 5xx).
|
||||
baseURL := envOr("BASE_URL", "http://localhost:8095")
|
||||
resp, err := http.Get(baseURL + "/api/me")
|
||||
if err != nil {
|
||||
t.Skipf("backend no accesible: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Errorf("/api/me devolvio 5xx: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_FrontendBundleHasNoConsoleErrors(t *testing.T) {
|
||||
c := setup(t)
|
||||
c.navigate("/")
|
||||
if err := browser.CdpWaitElement(c.conn, "body", 5*time.Second); err != nil {
|
||||
t.Fatalf("body: %v", err)
|
||||
}
|
||||
// Comprueba que no hay errores graves en el DOM.
|
||||
val := c.eval(`document.querySelectorAll('script[src*="error"]').length`)
|
||||
if !strings.Contains(val, "0") {
|
||||
t.Errorf("scripts de error detectados: %s", val)
|
||||
}
|
||||
// Verifica que el bundle se cargo (algun script de assets).
|
||||
val = c.eval(`document.querySelectorAll('script[src*="/assets/"]').length`)
|
||||
if strings.Contains(val, "0") {
|
||||
t.Errorf("bundle no cargado: %s", val)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -75,6 +75,8 @@ import { HistoryModal } from "./components/HistoryModal";
|
||||
import { KanbanCard } from "./components/KanbanCard";
|
||||
import { KanbanColumn } from "./components/KanbanColumn";
|
||||
import { StickerPicker } from "./components/StickerPicker";
|
||||
import { ColorPickerGrid, CustomColorModal } from "./components/ColorPickerGrid";
|
||||
import { AVATAR_COLORS } from "./components/colors";
|
||||
import { colorBg, colorBorder } from "./components/colors";
|
||||
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
|
||||
|
||||
@@ -124,8 +126,13 @@ export function App() {
|
||||
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
||||
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
|
||||
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
|
||||
const [filterDeadlineOnly, setFilterDeadlineOnly] = useState(false);
|
||||
const [highlightCardId, setHighlightCardId] = useState<string | null>(null);
|
||||
const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
|
||||
const [activeSticker, setActiveSticker] = useState<string | null>(null);
|
||||
const [avatarColorModalOpen, setAvatarColorModalOpen] = useState(false);
|
||||
const [avatarCustomColor, setAvatarCustomColor] = useState("#888888");
|
||||
const [cardColorModal, setCardColorModal] = useState<{ cardId: string; color: string } | null>(null);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [navWidth, setNavWidth] = useState<number>(() => {
|
||||
const stored = localStorage.getItem("kanban_nav_width");
|
||||
@@ -279,6 +286,7 @@ export function App() {
|
||||
const cardTags = new Set(c.tags || []);
|
||||
for (const t of filterTags) if (!cardTags.has(t)) return false;
|
||||
}
|
||||
if (filterDeadlineOnly && !c.deadline) return false;
|
||||
if (filterDateFrom || filterDateTo) {
|
||||
const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity;
|
||||
const toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity;
|
||||
@@ -289,7 +297,7 @@ export function App() {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo]
|
||||
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo, filterDeadlineOnly]
|
||||
);
|
||||
|
||||
const cardsByColumn = useMemo(() => {
|
||||
@@ -311,7 +319,8 @@ export function App() {
|
||||
!!filterRequester ||
|
||||
filterTags.length > 0 ||
|
||||
!!filterDateFrom ||
|
||||
!!filterDateTo;
|
||||
!!filterDateTo ||
|
||||
filterDeadlineOnly;
|
||||
|
||||
const findCard = (id: string): Card | undefined => board?.cards.find((c) => c.id === id);
|
||||
const findColumn = (id: string): Column | undefined => board?.columns.find((c) => c.id === id);
|
||||
@@ -594,6 +603,38 @@ export function App() {
|
||||
});
|
||||
}, [reload, users, requesterOptions, tagOptions]);
|
||||
|
||||
const handleSetRequester = useCallback(async (id: string, requester: string) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, requester } : c)) };
|
||||
});
|
||||
try {
|
||||
await api.updateCard(id, { requester });
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleJumpToCard = useCallback((cardId: string) => {
|
||||
setActiveTab("board");
|
||||
setHighlightCardId(cardId);
|
||||
window.setTimeout(() => setHighlightCardId(null), 3000);
|
||||
}, []);
|
||||
|
||||
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, deadline } : c)) };
|
||||
});
|
||||
try {
|
||||
await api.updateCard(id, { deadline });
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -858,16 +899,36 @@ export function App() {
|
||||
<IconMessageChatbot size={16} />
|
||||
</ActionIcon>
|
||||
{auth.user && (
|
||||
<Menu position="bottom-end" shadow="md" withArrow>
|
||||
<Menu position="bottom-end" shadow="md" withArrow closeOnItemClick={false}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" aria-label="Usuario">
|
||||
<Avatar size={26} radius="xl" color="blue">
|
||||
<Avatar size={26} radius="xl" color={auth.user.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>
|
||||
<Box p="xs">
|
||||
<Text size="xs" c="dimmed" mb={4}>Color del avatar</Text>
|
||||
<ColorPickerGrid
|
||||
value={auth.user.color || ""}
|
||||
onChange={async (c) => {
|
||||
try {
|
||||
const u = await api.updateMe({ color: c });
|
||||
auth.setUser(u);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}}
|
||||
options={AVATAR_COLORS}
|
||||
onOpenCustom={() => {
|
||||
setAvatarCustomColor(auth.user?.color?.startsWith("#") ? auth.user.color : "#888888");
|
||||
setAvatarColorModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconLogout size={14} />}
|
||||
color="red"
|
||||
@@ -929,6 +990,11 @@ export function App() {
|
||||
onShowHistory={handleShowHistory}
|
||||
onToggleCardLock={handleToggleCardLock}
|
||||
onAssignCard={handleAssignCard}
|
||||
onSetCardDeadline={handleSetCardDeadline}
|
||||
highlightCardId={highlightCardId}
|
||||
onSetRequester={handleSetRequester}
|
||||
requesterOptions={requesterOptions}
|
||||
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
|
||||
activeSticker={activeSticker}
|
||||
onAddSticker={handleAddSticker}
|
||||
onRemoveSticker={handleRemoveSticker}
|
||||
@@ -1004,7 +1070,7 @@ export function App() {
|
||||
</Box>
|
||||
) : activeTab === "calendar" ? (
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
|
||||
<CalendarView users={users} />
|
||||
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
@@ -1045,6 +1111,12 @@ export function App() {
|
||||
if (v) setFilterAssigneeId(null);
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label="Con deadline"
|
||||
checked={filterDeadlineOnly}
|
||||
onChange={(e) => setFilterDeadlineOnly(e.currentTarget.checked)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Solicitante"
|
||||
value={filterRequester}
|
||||
@@ -1151,6 +1223,7 @@ export function App() {
|
||||
setFilterTags([]);
|
||||
setFilterDateFrom(null);
|
||||
setFilterDateTo(null);
|
||||
setFilterDeadlineOnly(false);
|
||||
}}
|
||||
>
|
||||
Limpiar
|
||||
@@ -1184,6 +1257,10 @@ export function App() {
|
||||
onShowHistory={handleShowHistory}
|
||||
onToggleCardLock={handleToggleCardLock}
|
||||
onAssignCard={handleAssignCard}
|
||||
onSetCardDeadline={handleSetCardDeadline}
|
||||
highlightCardId={highlightCardId}
|
||||
onSetRequester={handleSetRequester}
|
||||
requesterOptions={requesterOptions}
|
||||
activeSticker={activeSticker}
|
||||
onAddSticker={handleAddSticker}
|
||||
onRemoveSticker={handleRemoveSticker}
|
||||
@@ -1271,6 +1348,32 @@ export function App() {
|
||||
</Box>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
||||
|
||||
<CustomColorModal
|
||||
opened={avatarColorModalOpen}
|
||||
onClose={() => setAvatarColorModalOpen(false)}
|
||||
value={avatarCustomColor}
|
||||
onAccept={async (c) => {
|
||||
setAvatarCustomColor(c);
|
||||
try {
|
||||
const u = await api.updateMe({ color: c });
|
||||
auth.setUser(u);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<CustomColorModal
|
||||
opened={!!cardColorModal}
|
||||
onClose={() => setCardColorModal(null)}
|
||||
value={cardColorModal?.color || "#888888"}
|
||||
onAccept={(c) => {
|
||||
if (!cardColorModal) return;
|
||||
handleChangeCardColor(cardColorModal.cardId, c);
|
||||
}}
|
||||
/>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,27 +8,14 @@ import type {
|
||||
Sticker,
|
||||
User,
|
||||
} from "./types";
|
||||
import { fetchJSON as registryFetchJSON, HTTPError } from "@fn_library/infra/fetch_json";
|
||||
|
||||
export { HTTPError };
|
||||
|
||||
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 HTTPError(res.status, err.Message || err.message || res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return registryFetchJSON<T>(path, init, BASE);
|
||||
}
|
||||
|
||||
export function getBoard(): Promise<Board> {
|
||||
@@ -84,6 +71,7 @@ export interface UpdateCardInput {
|
||||
locked?: boolean;
|
||||
assignee_id?: string | null;
|
||||
tags?: string[];
|
||||
deadline?: string | null;
|
||||
}
|
||||
|
||||
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
|
||||
@@ -168,6 +156,10 @@ export function getMe(): Promise<User> {
|
||||
return fetchJSON("/me");
|
||||
}
|
||||
|
||||
export function updateMe(patch: { color?: string }): Promise<User> {
|
||||
return fetchJSON("/me", { method: "PATCH", body: JSON.stringify(patch) });
|
||||
}
|
||||
|
||||
export function listUsers(): Promise<User[]> {
|
||||
return fetchJSON("/users");
|
||||
}
|
||||
@@ -186,6 +178,7 @@ export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||
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);
|
||||
if (f.tags && f.tags.length > 0) qs.set("tags", f.tags.join(","));
|
||||
const q = qs.toString();
|
||||
return fetchJSON(`/metrics${q ? `?${q}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ interface AuthCtx {
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, password: string, displayName: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
setUser: (u: User | null) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AuthCtx | null>(null);
|
||||
@@ -45,7 +46,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return <Ctx.Provider value={{ user, loading, login, register, logout }}>{children}</Ctx.Provider>;
|
||||
return <Ctx.Provider value={{ user, loading, login, register, logout, setUser }}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthCtx {
|
||||
|
||||
@@ -4,26 +4,31 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Popover,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { MonthPickerInput } from "@mantine/dates";
|
||||
import { IconCheckbox, IconPlus } from "@tabler/icons-react";
|
||||
import { IconCheckbox, IconHourglass, 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";
|
||||
import type { Card, Metrics, User } from "../types";
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
cards: Card[];
|
||||
onJumpToCard?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
|
||||
|
||||
export function CalendarView({ users }: Props) {
|
||||
export function CalendarView({ users, cards, onJumpToCard }: Props) {
|
||||
const [openDate, setOpenDate] = useState<string | null>(null);
|
||||
const [month, setMonth] = useState<Date>(new Date());
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
||||
const [data, setData] = useState<Metrics | null>(null);
|
||||
@@ -53,20 +58,27 @@ export function CalendarView({ users }: Props) {
|
||||
);
|
||||
|
||||
const dayMap = useMemo(() => {
|
||||
const m = new Map<string, { created: number; done: number }>();
|
||||
const m = new Map<string, { created: number; done: number; deadlines: Card[] }>();
|
||||
if (!data) return m;
|
||||
for (const d of data.created_daily) {
|
||||
const cur = m.get(d.date) ?? { created: 0, done: 0 };
|
||||
const cur = m.get(d.date) ?? { created: 0, done: 0, deadlines: [] };
|
||||
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 };
|
||||
const cur = m.get(d.date) ?? { created: 0, done: 0, deadlines: [] };
|
||||
cur.done = d.count;
|
||||
m.set(d.date, cur);
|
||||
}
|
||||
for (const c of cards) {
|
||||
if (!c.deadline || c.deleted_at) continue;
|
||||
const date = c.deadline.slice(0, 10);
|
||||
const cur = m.get(date) ?? { created: 0, done: 0, deadlines: [] };
|
||||
cur.deadlines.push(c);
|
||||
m.set(date, cur);
|
||||
}
|
||||
return m;
|
||||
}, [data]);
|
||||
}, [data, cards]);
|
||||
|
||||
// Build month grid (Mon-first).
|
||||
const grid = useMemo(() => {
|
||||
@@ -163,9 +175,12 @@ export function CalendarView({ users }: Props) {
|
||||
if (!cell.date) {
|
||||
return <Box key={i} style={{ minHeight: 72 }} />;
|
||||
}
|
||||
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0 };
|
||||
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0, deadlines: [] as Card[] };
|
||||
const dayNum = parseInt(cell.date.slice(8, 10), 10);
|
||||
const isToday = cell.date === dayjs().format("YYYY-MM-DD");
|
||||
const todayMs = dayjs().startOf("day").valueOf();
|
||||
const cellMs = dayjs(cell.date).startOf("day").valueOf();
|
||||
const overdueDay = cellMs < todayMs;
|
||||
return (
|
||||
<Paper
|
||||
key={i}
|
||||
@@ -203,6 +218,62 @@ export function CalendarView({ users }: Props) {
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{stats.deadlines.length > 0 && (
|
||||
<Popover
|
||||
opened={openDate === cell.date}
|
||||
onChange={(o) => setOpenDate(o ? cell.date : null)}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
width={280}
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => setOpenDate(openDate === cell.date ? null : cell.date)}
|
||||
style={{ textAlign: "left" }}
|
||||
>
|
||||
<Stack gap={1}>
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconHourglass size={10} color={overdueDay ? "var(--mantine-color-red-5)" : "var(--mantine-color-orange-5)"} />
|
||||
<Text size="xs" c={overdueDay ? "red" : "orange"} fw={700} td="underline">
|
||||
{stats.deadlines.length} deadline{stats.deadlines.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={6}>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" fw={600} mb={2}>
|
||||
Vencen el {dayjs(cell.date).format("DD/MM/YYYY")}
|
||||
</Text>
|
||||
{stats.deadlines.map((c) => (
|
||||
<UnstyledButton
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setOpenDate(null);
|
||||
onJumpToCard?.(c.id);
|
||||
}}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
borderRadius: 4,
|
||||
background: "var(--mantine-color-dark-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
#{String(c.seq_num).padStart(5, "0")}
|
||||
</Text>
|
||||
<Text size="xs" lineClamp={1} title={c.title}>
|
||||
{c.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Box, Button, ColorPicker, Group, Modal, Stack, TextInput, Tooltip } from "@mantine/core";
|
||||
import { IconPalette } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CARD_COLORS, colorBorder, colorSwatch } from "./colors";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
options?: { value: string; label: string }[];
|
||||
// Si se da, el "+" delega en el padre (recomendado dentro de Menu/Popover).
|
||||
// Sin esto, ColorPickerGrid abre Modal interno (puede colisionar con cierres del padre).
|
||||
onOpenCustom?: () => void;
|
||||
}
|
||||
|
||||
const SWATCH = 26;
|
||||
|
||||
export function ColorPickerGrid({ value, onChange, options = CARD_COLORS, onOpenCustom }: Props) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [custom, setCustom] = useState(value && value.startsWith("#") ? value : "#888888");
|
||||
const isCustomActive = !!value && value.startsWith("#") && !options.some((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap={6} maw={280}>
|
||||
{options.map((c) => {
|
||||
const selected = value === c.value;
|
||||
return (
|
||||
<Tooltip key={c.value || "default"} label={c.label} withArrow>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={(e) => { e.stopPropagation(); onChange(c.value); }}
|
||||
aria-label={c.label}
|
||||
style={{
|
||||
width: SWATCH,
|
||||
height: SWATCH,
|
||||
borderRadius: "50%",
|
||||
background: colorSwatch(c.value),
|
||||
border: `2px solid ${selected ? "var(--mantine-color-white)" : colorBorder(c.value)}`,
|
||||
boxShadow: selected ? "0 0 0 2px var(--mantine-color-blue-5)" : undefined,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
transition: "transform .1s",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<Tooltip label="Color personalizado" withArrow>
|
||||
<Box
|
||||
role="button"
|
||||
onMouseDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onOpenCustom) onOpenCustom();
|
||||
else setPickerOpen(true);
|
||||
}}
|
||||
aria-label="Color personalizado"
|
||||
style={{
|
||||
width: SWATCH,
|
||||
height: SWATCH,
|
||||
borderRadius: "50%",
|
||||
background: isCustomActive ? custom : "transparent",
|
||||
border: `2px dashed ${isCustomActive ? custom : "var(--mantine-color-gray-5)"}`,
|
||||
boxShadow: isCustomActive ? "0 0 0 2px var(--mantine-color-blue-5)" : undefined,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<IconPalette size={14} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{!onOpenCustom && (
|
||||
<CustomColorModal
|
||||
opened={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
value={custom}
|
||||
onAccept={(v) => { setCustom(v); onChange(v); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
value: string;
|
||||
// Disparado solo cuando el usuario pulsa "Aceptar". Mientras arrastra el picker
|
||||
// el cambio queda local — no fuga al resto de la app.
|
||||
onAccept: (v: string) => void;
|
||||
}
|
||||
|
||||
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
export function CustomColorModal({ opened, onClose, value, onAccept }: ModalProps) {
|
||||
const [local, setLocal] = useState(value || "#888888");
|
||||
const [hexInput, setHexInput] = useState(value || "#888888");
|
||||
|
||||
// Reset state cuando abre con un value nuevo (cada vez que se abre).
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
const v = value && HEX_RE.test(value) ? value : "#888888";
|
||||
setLocal(v);
|
||||
setHexInput(v);
|
||||
}
|
||||
}, [opened, value]);
|
||||
|
||||
const onHexChange = (v: string) => {
|
||||
let s = v.trim();
|
||||
if (s && !s.startsWith("#")) s = "#" + s;
|
||||
setHexInput(s);
|
||||
if (HEX_RE.test(s)) setLocal(s);
|
||||
};
|
||||
const onPickerChange = (v: string) => {
|
||||
setLocal(v);
|
||||
setHexInput(v);
|
||||
};
|
||||
const accept = () => { onAccept(local); onClose(); };
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Color personalizado"
|
||||
size="auto"
|
||||
centered
|
||||
withinPortal
|
||||
zIndex={2000}
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
trapFocus={false}
|
||||
withCloseButton={false}
|
||||
>
|
||||
<Stack
|
||||
gap="sm"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ColorPicker
|
||||
value={local}
|
||||
onChange={onPickerChange}
|
||||
format="hex"
|
||||
swatches={["#1c7ed6", "#15aabf", "#12b886", "#37b24d", "#82c91e", "#fab005", "#fd7e14", "#fa5252", "#e64980", "#be4bdb", "#7950f2", "#4c6ef5", "#868e96", "#212529"]}
|
||||
fullWidth
|
||||
/>
|
||||
<Group align="end" gap="xs">
|
||||
<TextInput
|
||||
label="Hex"
|
||||
value={hexInput}
|
||||
onChange={(e) => onHexChange(e.currentTarget.value)}
|
||||
error={hexInput && !HEX_RE.test(hexInput) ? "Hex invalido" : undefined}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
placeholder="#rrggbb"
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 4,
|
||||
background: HEX_RE.test(hexInput) ? hexInput : "transparent",
|
||||
border: "1px solid var(--mantine-color-dark-4)",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" size="xs" onClick={onClose}>Cancelar</Button>
|
||||
<Button size="xs" onClick={accept} disabled={!HEX_RE.test(local)}>Aceptar</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
@@ -89,10 +90,16 @@ export function Dashboard({ users }: Props) {
|
||||
const [to, setTo] = useState<Date | null>(() => new Date());
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
||||
const [requester, setRequester] = useState<string | null>(null);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
||||
const [data, setData] = useState<Metrics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.listTags().then(setTagOptions).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
@@ -102,6 +109,7 @@ export function Dashboard({ users }: Props) {
|
||||
to: fmtDate(to),
|
||||
assignee_id: assigneeId || undefined,
|
||||
requester: requester || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
})
|
||||
.then((m) => {
|
||||
if (cancelled) return;
|
||||
@@ -119,7 +127,7 @@ export function Dashboard({ users }: Props) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [from, to, assigneeId, requester]);
|
||||
}, [from, to, assigneeId, requester, tags]);
|
||||
|
||||
const userOptions = useMemo(
|
||||
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
|
||||
@@ -240,6 +248,17 @@ export function Dashboard({ users }: Props) {
|
||||
searchable
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Tags"
|
||||
size="xs"
|
||||
placeholder="Todas"
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
data={tagOptions}
|
||||
clearable
|
||||
searchable
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -1,25 +1,115 @@
|
||||
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, CardHistoryResponse } from "../types";
|
||||
import {
|
||||
IconArrowsHorizontal,
|
||||
IconCalendarDue,
|
||||
IconCalendarOff,
|
||||
IconColumns3,
|
||||
IconEdit,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPalette,
|
||||
IconPlus,
|
||||
IconTag,
|
||||
IconUser,
|
||||
IconUserMinus,
|
||||
IconUserPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cardHistory, listUsers } from "../api";
|
||||
import type { Card, CardEvent, CardHistoryResponse, User } from "../types";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
}
|
||||
|
||||
interface UnifiedEvent {
|
||||
id: string;
|
||||
ts: string;
|
||||
kind: string;
|
||||
actorID: string | null;
|
||||
detail: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function parsePayload(p: string): Record<string, unknown> {
|
||||
try { return JSON.parse(p); } catch { return {}; }
|
||||
}
|
||||
|
||||
function eventToUnified(e: CardEvent): UnifiedEvent {
|
||||
const p = parsePayload(e.payload);
|
||||
switch (e.kind) {
|
||||
case "created":
|
||||
return { id: e.id, ts: e.created_at, kind: "Creada", actorID: e.actor_id, detail: String(p.title || ""), icon: <IconPlus size={12} />, color: "green" };
|
||||
case "title_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Titulo", actorID: e.actor_id, detail: `"${p.old}" → "${p.new}"`, icon: <IconEdit size={12} />, color: "blue" };
|
||||
case "requester_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Solicitante", actorID: e.actor_id, detail: `"${p.old || "(vacio)"}" → "${p.new || "(vacio)"}"`, icon: <IconEdit size={12} />, color: "orange" };
|
||||
case "description_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Descripcion", actorID: e.actor_id, detail: "edicion", icon: <IconEdit size={12} />, color: "blue" };
|
||||
case "color_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Color", actorID: e.actor_id, detail: String(p.color || ""), icon: <IconPalette size={12} />, color: "violet" };
|
||||
case "tags_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Tags", actorID: e.actor_id, detail: Array.isArray(p.tags) ? (p.tags as string[]).join(", ") || "(sin tags)" : "", icon: <IconTag size={12} />, color: "grape" };
|
||||
case "assigned":
|
||||
return { id: e.id, ts: e.created_at, kind: "Asignada", actorID: e.actor_id, detail: String(p.assignee_id || ""), icon: <IconUserPlus size={12} />, color: "teal" };
|
||||
case "unassigned":
|
||||
return { id: e.id, ts: e.created_at, kind: "Sin asignar", actorID: e.actor_id, detail: "", icon: <IconUserMinus size={12} />, color: "gray" };
|
||||
case "deadline_set": {
|
||||
const d = String(p.deadline || "");
|
||||
return { id: e.id, ts: e.created_at, kind: "Deadline", actorID: e.actor_id, detail: d ? d.slice(0, 10) : "", icon: <IconCalendarDue size={12} />, color: "orange" };
|
||||
}
|
||||
case "deadline_cleared":
|
||||
return { id: e.id, ts: e.created_at, kind: "Deadline quitado", actorID: e.actor_id, detail: p.prev ? String(p.prev).slice(0, 10) : "", icon: <IconCalendarOff size={12} />, color: "gray" };
|
||||
default:
|
||||
return { id: e.id, ts: e.created_at, kind: e.kind, actorID: e.actor_id, detail: e.payload, icon: <IconEdit size={12} />, color: "gray" };
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoryModal({ card }: Props) {
|
||||
const [data, setData] = useState<CardHistoryResponse | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
cardHistory(card.id)
|
||||
.then(setData)
|
||||
.catch(() =>
|
||||
setData({ column_history: [], lock_periods: [], total_locked_ms: 0, currently_locked: false })
|
||||
setData({ column_history: [], lock_periods: [], events: [], total_locked_ms: 0, currently_locked: false })
|
||||
);
|
||||
listUsers().then(setUsers).catch(() => {});
|
||||
}, [card.id]);
|
||||
|
||||
const userById = useMemo(() => {
|
||||
const m = new Map<string, User>();
|
||||
for (const u of users) m.set(u.id, u);
|
||||
return m;
|
||||
}, [users]);
|
||||
|
||||
const unified = useMemo(() => {
|
||||
if (!data) return [] as UnifiedEvent[];
|
||||
const out: UnifiedEvent[] = [];
|
||||
for (const e of data.events || []) out.push(eventToUnified(e));
|
||||
for (const h of data.column_history || []) {
|
||||
out.push({
|
||||
id: "h_in_" + h.id,
|
||||
ts: h.entered_at,
|
||||
kind: "Mueve a columna",
|
||||
actorID: h.actor_id,
|
||||
detail: h.column_name || h.column_id,
|
||||
icon: <IconArrowsHorizontal size={12} />,
|
||||
color: "blue",
|
||||
});
|
||||
}
|
||||
for (const p of data.lock_periods || []) {
|
||||
out.push({ id: "lk_" + p.id, ts: p.locked_at, kind: "Bloqueada", actorID: p.actor_id, detail: "", icon: <IconLock size={12} />, color: "yellow" });
|
||||
if (p.unlocked_at) {
|
||||
out.push({ id: "lku_" + p.id, ts: p.unlocked_at, kind: "Desbloqueada", actorID: p.actor_id, detail: formatDuration(p.duration_ms), icon: <IconLockOpen size={12} />, color: "yellow" });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||
}, [data]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
@@ -28,42 +118,45 @@ export function HistoryModal({ card }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const { column_history, lock_periods, total_locked_ms, currently_locked } = data;
|
||||
const { column_history, total_locked_ms, currently_locked } = data;
|
||||
|
||||
if (column_history.length === 0 && lock_periods.length === 0) {
|
||||
if (unified.length === 0) {
|
||||
return <Text c="dimmed">Sin historial.</Text>;
|
||||
}
|
||||
|
||||
const userLabel = (id: string | null): string => {
|
||||
if (!id) return "";
|
||||
const u = userById.get(id);
|
||||
if (!u) return id;
|
||||
return u.display_name || u.username;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tiempo total en cada columna desde que se creo la tarjeta.
|
||||
</Text>
|
||||
<Timeline active={column_history.length} bulletSize={22} lineWidth={2}>
|
||||
{column_history.map((e) => (
|
||||
<Text size="sm" c="dimmed">Linea de tiempo completa de la tarjeta.</Text>
|
||||
<Timeline active={unified.length} bulletSize={22} lineWidth={2}>
|
||||
{unified.map((e) => (
|
||||
<Timeline.Item
|
||||
key={e.id}
|
||||
bullet={<IconColumns3 size={12} />}
|
||||
bullet={e.icon}
|
||||
color={e.color}
|
||||
title={
|
||||
<Group gap={6}>
|
||||
<Text fw={500} size="sm">
|
||||
{e.column_name || e.column_id}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={e.exited_at ? "gray" : "blue"}>
|
||||
{formatDuration(e.duration_ms)}
|
||||
</Badge>
|
||||
{!e.exited_at && (
|
||||
<Badge size="xs" variant="filled" color="blue">
|
||||
actual
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text fw={500} size="sm">{e.kind}</Text>
|
||||
{e.actorID && (
|
||||
<Badge size="xs" variant="light" color="cyan" leftSection={<IconUser size={10} />}>
|
||||
{userLabel(e.actorID)}
|
||||
</Badge>
|
||||
)}
|
||||
{e.detail && (
|
||||
<Badge size="xs" variant="outline" color={e.color}>
|
||||
{e.detail}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(e.entered_at).toLocaleString()}
|
||||
{e.exited_at && ` -> ${new Date(e.exited_at).toLocaleString()}`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">{new Date(e.ts).toLocaleString()}</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
@@ -71,55 +164,15 @@ export function HistoryModal({ card }: Props) {
|
||||
<Divider />
|
||||
|
||||
<Group gap={6} align="center">
|
||||
<IconColumns3 size={14} />
|
||||
<Text fw={500} size="sm">Columnas visitadas</Text>
|
||||
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
|
||||
<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>
|
||||
)}
|
||||
{currently_locked && <Badge size="xs" variant="filled" color="yellow">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,6 +2,7 @@ import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ActionIcon,
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Badge,
|
||||
Group,
|
||||
@@ -14,22 +15,26 @@ import {
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconCalendarDue,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconGripVertical,
|
||||
IconHistory,
|
||||
IconHourglass,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPalette,
|
||||
IconUserSquare,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Card, CardColor, User } from "../types";
|
||||
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
|
||||
import { colorBg, colorBorder, tagColor } from "./colors";
|
||||
import { ColorPickerGrid } from "./ColorPickerGrid";
|
||||
import { formatDateTimeShort, formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
@@ -41,6 +46,10 @@ interface Props {
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleLock: (id: string, locked: boolean) => void;
|
||||
onAssign: (id: string, assignee_id: string | null) => void;
|
||||
onSetDeadline?: (id: string, deadline: string | null) => void;
|
||||
onSetRequester?: (id: string, requester: string) => void;
|
||||
requesterOptions?: string[];
|
||||
onOpenCustomColor?: (cardId: string, current: string) => void;
|
||||
activeSticker?: string | null;
|
||||
onAddSticker?: (cardId: string, x: number, y: number) => void;
|
||||
onRemoveSticker?: (cardId: string, index: number) => void;
|
||||
@@ -50,6 +59,7 @@ interface Props {
|
||||
assignee?: User;
|
||||
inDoneColumn?: boolean;
|
||||
isOverlay?: boolean;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
function KanbanCardImpl({
|
||||
@@ -61,6 +71,10 @@ function KanbanCardImpl({
|
||||
onShowHistory,
|
||||
onToggleLock,
|
||||
onAssign,
|
||||
onSetDeadline,
|
||||
onSetRequester,
|
||||
requesterOptions,
|
||||
onOpenCustomColor,
|
||||
activeSticker,
|
||||
onAddSticker,
|
||||
onRemoveSticker,
|
||||
@@ -70,10 +84,14 @@ function KanbanCardImpl({
|
||||
assignee,
|
||||
inDoneColumn,
|
||||
isOverlay,
|
||||
highlight,
|
||||
}: Props) {
|
||||
const isDone = inDoneColumn || !!card.completed_at;
|
||||
const [colorPopOpen, setColorPopOpen] = useState(false);
|
||||
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
|
||||
const [requesterPopOpen, setRequesterPopOpen] = useState(false);
|
||||
const [deadlinePopOpen, setDeadlinePopOpen] = useState(false);
|
||||
const [requesterDraft, setRequesterDraft] = useState(card.requester || "");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const cardElRef = useRef<HTMLElement | null>(null);
|
||||
const draggingStickerRef = useRef<number | null>(null);
|
||||
@@ -89,6 +107,12 @@ function KanbanCardImpl({
|
||||
setNodeRef(el);
|
||||
}, [setNodeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlight && cardElRef.current) {
|
||||
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [highlight]);
|
||||
|
||||
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!stickerMode || !onAddSticker || isOverlay) return;
|
||||
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
|
||||
@@ -140,13 +164,25 @@ function KanbanCardImpl({
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
background: colorBg(card.color),
|
||||
borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
|
||||
borderWidth: card.locked ? 2 : 1,
|
||||
borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
|
||||
borderWidth: highlight || card.locked ? 2 : 1,
|
||||
boxShadow: highlight ? "0 0 0 3px var(--mantine-color-blue-4)" : undefined,
|
||||
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
|
||||
};
|
||||
|
||||
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
|
||||
const liveMs = Math.max(0, now - enteredAt);
|
||||
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
|
||||
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
|
||||
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
|
||||
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
|
||||
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
|
||||
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
|
||||
let dlColor: string = "blue";
|
||||
let dlVariant: "light" | "filled" = "light";
|
||||
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
|
||||
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
|
||||
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
|
||||
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
|
||||
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
|
||||
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
|
||||
@@ -190,28 +226,12 @@ function KanbanCardImpl({
|
||||
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 p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<ColorPickerGrid
|
||||
value={card.color}
|
||||
onChange={(c) => onChangeColor(card.id, c as CardColor)}
|
||||
onOpenCustom={onOpenCustomColor ? () => onOpenCustomColor(card.id, card.color || "#888888") : undefined}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Popover
|
||||
@@ -235,7 +255,7 @@ function KanbanCardImpl({
|
||||
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
|
||||
</Menu.Item>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
placeholder="Sin asignar"
|
||||
value={card.assignee_id ?? null}
|
||||
@@ -252,6 +272,55 @@ function KanbanCardImpl({
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Popover
|
||||
opened={requesterPopOpen}
|
||||
onChange={setRequesterPopOpen}
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
leftSection={<IconUserSquare size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRequesterDraft(card.requester || "");
|
||||
setRequesterPopOpen((v) => !v);
|
||||
}}
|
||||
closeMenuOnClick={false}
|
||||
>
|
||||
Solicitante {card.requester ? `(${card.requester})` : "..."}
|
||||
</Menu.Item>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Autocomplete
|
||||
placeholder="Sin solicitante"
|
||||
value={requesterDraft}
|
||||
onChange={setRequesterDraft}
|
||||
data={requesterOptions || []}
|
||||
autoFocus
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSetRequester?.(card.id, requesterDraft.trim());
|
||||
setRequesterPopOpen(false);
|
||||
setMenuOpen(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setRequesterPopOpen(false);
|
||||
}
|
||||
}}
|
||||
onOptionSubmit={(v) => {
|
||||
setRequesterDraft(v);
|
||||
onSetRequester?.(card.id, v);
|
||||
setRequesterPopOpen(false);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Menu.Item
|
||||
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
|
||||
color={card.locked ? "yellow" : undefined}
|
||||
@@ -271,6 +340,63 @@ function KanbanCardImpl({
|
||||
>
|
||||
Historial
|
||||
</Menu.Item>
|
||||
{onSetDeadline && (
|
||||
<Popover
|
||||
opened={deadlinePopOpen}
|
||||
onChange={setDeadlinePopOpen}
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
leftSection={<IconCalendarDue size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDeadlinePopOpen((v) => !v);
|
||||
}}
|
||||
closeMenuOnClick={false}
|
||||
>
|
||||
{card.deadline ? `Deadline (${card.deadline.slice(0, 10)})` : "Deadline..."}
|
||||
</Menu.Item>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<DatePickerInput
|
||||
value={card.deadline ? card.deadline.slice(0, 10) : null}
|
||||
onChange={(v) => {
|
||||
const s = v ? (typeof v === "string" ? v.slice(0, 10) : new Date(v as unknown as string).toISOString().slice(0, 10)) : null;
|
||||
onSetDeadline(card.id, s ? `${s}T23:59:59Z` : null);
|
||||
setDeadlinePopOpen(false);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
clearable
|
||||
valueFormat="DD/MM/YYYY"
|
||||
size="xs"
|
||||
placeholder="Elegir fecha"
|
||||
popoverProps={{ withinPortal: false }}
|
||||
/>
|
||||
{card.deadline && (
|
||||
<Tooltip label="Quitar deadline" withArrow>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
mt={6}
|
||||
onClick={() => {
|
||||
onSetDeadline(card.id, null);
|
||||
setDeadlinePopOpen(false);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={14} />}
|
||||
@@ -346,25 +472,39 @@ function KanbanCardImpl({
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>{menuItems}</Menu.Dropdown>
|
||||
<Menu.Dropdown
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
{menuItems}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
{card.requester && (
|
||||
<Group gap={4}>
|
||||
<IconUser size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{card.requester}
|
||||
</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>
|
||||
{(card.requester || assignee) && (
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{card.requester && (
|
||||
<>
|
||||
<Avatar size={18} radius="xs" color={tagColor(card.requester)} style={{ flexShrink: 0 }}>
|
||||
{card.requester.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Text size="xs" c="dimmed" truncate>{card.requester}</Text>
|
||||
</>
|
||||
)}
|
||||
{card.requester && assignee && (
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>-</Text>
|
||||
)}
|
||||
{assignee && (
|
||||
<>
|
||||
<Avatar size={18} radius="xl" color={assignee.color || "blue"} style={{ flexShrink: 0 }}>
|
||||
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{assignee.display_name || assignee.username}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{card.description && (
|
||||
@@ -375,18 +515,19 @@ function KanbanCardImpl({
|
||||
{card.tags && card.tags.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{card.tags.map((t) => (
|
||||
<Badge key={t} size="xs" variant="outline" color="violet" radius="sm">
|
||||
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Group gap={4} wrap="wrap">
|
||||
{card.locked ? (
|
||||
{card.locked && (
|
||||
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
|
||||
{formatDuration(lockedMs)}
|
||||
</Badge>
|
||||
) : isDone && card.completed_at ? (
|
||||
)}
|
||||
{!card.locked && isDone && card.completed_at ? (
|
||||
<>
|
||||
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
{formatDateTimeShort(card.completed_at)}
|
||||
@@ -394,13 +535,36 @@ function KanbanCardImpl({
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
Total: {formatDuration(totalDoneMs)}
|
||||
</Badge>
|
||||
{card.total_locked_ms > 0 && (
|
||||
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
|
||||
{formatDuration(card.total_locked_ms)}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
{formatDuration(liveMs)}
|
||||
</Badge>
|
||||
)}
|
||||
) : !card.locked ? (
|
||||
card.deadline ? (
|
||||
<Tooltip label={`Vence: ${formatDateTimeShort(card.deadline)}`} withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={dlVariant}
|
||||
color={dlColor}
|
||||
leftSection={<IconHourglass size={10} />}
|
||||
>
|
||||
{overdue ? `-${formatDuration(-deadlineRemainingMs)}` : formatDuration(deadlineRemainingMs)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
{formatDuration(liveMs)}
|
||||
</Badge>
|
||||
)
|
||||
) : null}
|
||||
</Group>
|
||||
{card.seq_num > 0 && (
|
||||
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>
|
||||
#{String(card.seq_num).padStart(5, "0")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{card.stickers && card.stickers.length > 0 && (
|
||||
<div
|
||||
|
||||
@@ -54,6 +54,10 @@ interface Props {
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleCardLock: (id: string, locked: boolean) => void;
|
||||
onAssignCard: (id: string, assignee_id: string | null) => void;
|
||||
onSetCardDeadline?: (id: string, deadline: string | null) => void;
|
||||
onSetRequester?: (id: string, requester: string) => void;
|
||||
requesterOptions?: string[];
|
||||
onOpenCustomCardColor?: (cardId: string, current: string) => void;
|
||||
activeSticker?: string | null;
|
||||
onAddSticker?: (cardId: string, x: number, y: number) => void;
|
||||
onRemoveSticker?: (cardId: string, index: number) => void;
|
||||
@@ -61,6 +65,7 @@ interface Props {
|
||||
onCommitSticker?: (cardId: string) => void;
|
||||
users: User[];
|
||||
usersById: Map<string, User>;
|
||||
highlightCardId?: string | null;
|
||||
}
|
||||
|
||||
function KanbanColumnImpl({
|
||||
@@ -81,6 +86,10 @@ function KanbanColumnImpl({
|
||||
onShowHistory,
|
||||
onToggleCardLock,
|
||||
onAssignCard,
|
||||
onSetCardDeadline,
|
||||
onSetRequester,
|
||||
requesterOptions,
|
||||
onOpenCustomCardColor,
|
||||
activeSticker,
|
||||
onAddSticker,
|
||||
onRemoveSticker,
|
||||
@@ -88,6 +97,7 @@ function KanbanColumnImpl({
|
||||
onCommitSticker,
|
||||
users,
|
||||
usersById,
|
||||
highlightCardId,
|
||||
}: Props) {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState(column.name);
|
||||
@@ -415,9 +425,14 @@ function KanbanColumnImpl({
|
||||
onShowHistory={onShowHistory}
|
||||
onToggleLock={onToggleCardLock}
|
||||
onAssign={onAssignCard}
|
||||
onSetDeadline={onSetCardDeadline}
|
||||
onSetRequester={onSetRequester}
|
||||
requesterOptions={requesterOptions}
|
||||
onOpenCustomColor={onOpenCustomCardColor}
|
||||
users={users}
|
||||
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
|
||||
inDoneColumn={column.is_done}
|
||||
highlight={highlightCardId === c.id}
|
||||
activeSticker={activeSticker}
|
||||
onAddSticker={onAddSticker}
|
||||
onRemoveSticker={onRemoveSticker}
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
import type { CardColor } from "../types";
|
||||
import { stringHashPalette } from "@fn_library/core/string_hash_palette";
|
||||
|
||||
export { colorBg } from "@fn_library/ui/color_bg";
|
||||
export { colorBorder } from "@fn_library/ui/color_border";
|
||||
export { colorSwatch } from "@fn_library/ui/color_swatch";
|
||||
|
||||
// 22 colores fijos (default + 21 distintos). El ColorPickerGrid añade un 23º circulo "+"
|
||||
// que abre el ColorPicker libre para hex personalizado.
|
||||
export const CARD_COLORS: { value: CardColor; label: string }[] = [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "blue", label: "Azul" },
|
||||
{ value: "cyan", label: "Cian" },
|
||||
{ value: "teal", label: "Teal" },
|
||||
{ value: "green", label: "Verde" },
|
||||
{ value: "lime", label: "Lima" },
|
||||
{ value: "yellow", label: "Amarillo" },
|
||||
{ value: "orange", label: "Naranja" },
|
||||
{ value: "red", label: "Rojo" },
|
||||
{ value: "pink", label: "Rosa" },
|
||||
{ value: "grape", label: "Uva" },
|
||||
{ value: "violet", label: "Violeta" },
|
||||
{ value: "indigo", label: "Indigo" },
|
||||
{ value: "gray", label: "Gris" },
|
||||
{ value: "#0ea5e9", label: "Sky" },
|
||||
{ value: "#14b8a6", label: "Esmeralda" },
|
||||
{ value: "#84cc16", label: "Lima fluor" },
|
||||
{ value: "#ec4899", label: "Magenta" },
|
||||
{ value: "#a855f7", label: "Lavanda" },
|
||||
{ value: "#f97316", label: "Mandarina" },
|
||||
{ value: "#dc2626", label: "Rubi" },
|
||||
{ value: "#0891b2", label: "Petroleo" },
|
||||
{ value: "#fde047", label: "Limon" },
|
||||
{ value: "#10b981", label: "Menta" },
|
||||
{ value: "#fb7185", label: "Coral" },
|
||||
{ value: "#6366f1", label: "Iris" },
|
||||
{ value: "#94a3b8", label: "Pizarra" },
|
||||
];
|
||||
|
||||
// color-mix mezcla 18% del tono base con dark.6 → suave en dark mode.
|
||||
// Border 30% del tono mas claro con dark.4 para definicion sutil.
|
||||
// Swatch (boton picker) usa tono pleno -7 para que sea visible.
|
||||
export function colorBg(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-6)";
|
||||
return `color-mix(in srgb, var(--mantine-color-${color}-9) 18%, var(--mantine-color-dark-6))`;
|
||||
}
|
||||
export const AVATAR_COLORS = CARD_COLORS;
|
||||
|
||||
export function colorBorder(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-4)";
|
||||
return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
|
||||
}
|
||||
const TAG_PALETTE = ["blue", "cyan", "teal", "green", "lime", "yellow", "orange", "red", "pink", "grape", "violet", "indigo"];
|
||||
|
||||
export function colorSwatch(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-3)";
|
||||
return `var(--mantine-color-${color}-7)`;
|
||||
export function tagColor(tag: string): string {
|
||||
return stringHashPalette(tag, TAG_PALETTE);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,2 @@
|
||||
// Escala unidades segun magnitud: m | h Xm | D Xh | S XD | M XS.
|
||||
// <1 minuto cae como "0m" para mantener la unidad mas pequena coherente.
|
||||
const MIN = 60_000;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return "0m";
|
||||
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
|
||||
if (ms < DAY) {
|
||||
const h = Math.floor(ms / HOUR);
|
||||
const m = Math.floor((ms % HOUR) / MIN);
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
if (ms < WEEK) {
|
||||
const d = Math.floor(ms / DAY);
|
||||
const h = Math.floor((ms % DAY) / HOUR);
|
||||
return h === 0 ? `${d}D` : `${d}D ${h}h`;
|
||||
}
|
||||
if (ms < MONTH) {
|
||||
const w = Math.floor(ms / WEEK);
|
||||
const d = Math.floor((ms % WEEK) / DAY);
|
||||
return d === 0 ? `${w}S` : `${w}S ${d}D`;
|
||||
}
|
||||
const m = Math.floor(ms / MONTH);
|
||||
const w = Math.floor((ms % MONTH) / WEEK);
|
||||
return w === 0 ? `${m}M` : `${m}M ${w}S`;
|
||||
}
|
||||
|
||||
export function formatDateTimeShort(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const yy = String(d.getFullYear()).slice(-2);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${dd}/${mm}/${yy} ${hh}:${mi}`;
|
||||
}
|
||||
export { formatDuration } from "@fn_library/core/format_duration";
|
||||
export { formatDateTimeShort } from "@fn_library/core/format_datetime_short";
|
||||
|
||||
@@ -11,7 +11,8 @@ export interface Column {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type CardColor = "" | "blue" | "teal" | "green" | "yellow" | "orange" | "red" | "pink" | "violet" | "indigo";
|
||||
// "" | mantine color name | "#rrggbb"
|
||||
export type CardColor = string;
|
||||
|
||||
export interface Sticker {
|
||||
emoji: string;
|
||||
@@ -21,6 +22,7 @@ export interface Sticker {
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
seq_num: number;
|
||||
requester: string;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -33,17 +35,20 @@ export interface Card {
|
||||
deleted_at: string | null;
|
||||
tags: string[];
|
||||
stickers: Sticker[];
|
||||
deadline: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
entered_at: string;
|
||||
time_in_column_ms: number;
|
||||
locked_at: string | null;
|
||||
total_locked_ms: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
color: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -139,6 +144,7 @@ export interface MetricsFilter {
|
||||
to?: string;
|
||||
assignee_id?: string;
|
||||
requester?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
@@ -154,6 +160,7 @@ export interface HistoryEntry {
|
||||
entered_at: string;
|
||||
exited_at: string | null;
|
||||
duration_ms: number;
|
||||
actor_id: string | null;
|
||||
}
|
||||
|
||||
export interface LockPeriod {
|
||||
@@ -162,11 +169,22 @@ export interface LockPeriod {
|
||||
locked_at: string;
|
||||
unlocked_at: string | null;
|
||||
duration_ms: number;
|
||||
actor_id: string | null;
|
||||
}
|
||||
|
||||
export interface CardEvent {
|
||||
id: string;
|
||||
card_id: string;
|
||||
kind: string;
|
||||
actor_id: string | null;
|
||||
payload: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CardHistoryResponse {
|
||||
column_history: HistoryEntry[];
|
||||
lock_periods: LockPeriod[];
|
||||
events: CardEvent[];
|
||||
total_locked_ms: number;
|
||||
currently_locked: boolean;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@fn_library": ["../../../frontend/functions/ui"],
|
||||
"@fn_library/*": ["../../../frontend/functions/ui/*"]
|
||||
"@fn_library": ["../../../frontend/functions"],
|
||||
"@fn_library/*": ["../../../frontend/functions/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fn_library": path.resolve(__dirname, "../../../frontend/functions/ui"),
|
||||
"@fn_library": path.resolve(__dirname, "../../../frontend/functions"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
outDir: "../backend/dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Lanza backend Go (puerto 8095) + frontend Vite dev (puerto 5180) en paralelo.
|
||||
# Vite hace proxy /api -> 8095, asi que abrir http://localhost:5180
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACK_DIR="$ROOT/backend"
|
||||
FRONT_DIR="$ROOT/frontend"
|
||||
|
||||
PORT_BACK="${PORT_BACK:-8095}"
|
||||
PORT_FRONT="${PORT_FRONT:-5180}"
|
||||
@@ -22,25 +22,25 @@ cleanup() {
|
||||
trap cleanup INT TERM EXIT
|
||||
|
||||
# 1. Build backend si no existe o si los .go/.sql son mas nuevos que el binario
|
||||
if [[ ! -x ./kanban ]] || [[ -n "$(find . -maxdepth 3 \( -name '*.go' -o -name '*.sql' \) -newer ./kanban 2>/dev/null)" ]]; then
|
||||
if [[ ! -x "$BACK_DIR/kanban" ]] || [[ -n "$(find "$BACK_DIR" -maxdepth 3 \( -name '*.go' -o -name '*.sql' \) -newer "$BACK_DIR/kanban" 2>/dev/null)" ]]; then
|
||||
echo ">>> Building backend..."
|
||||
CGO_ENABLED=1 go build -tags fts5 -o kanban .
|
||||
(cd "$BACK_DIR" && CGO_ENABLED=1 go build -tags fts5 -o kanban .)
|
||||
fi
|
||||
|
||||
# 2. Asegurar deps frontend
|
||||
if [[ ! -d frontend/node_modules ]]; then
|
||||
if [[ ! -d "$FRONT_DIR/node_modules" ]]; then
|
||||
echo ">>> Installing frontend deps..."
|
||||
(cd frontend && pnpm install)
|
||||
(cd "$FRONT_DIR" && pnpm install)
|
||||
fi
|
||||
|
||||
# 3. Lanzar backend
|
||||
echo ">>> Backend http://localhost:$PORT_BACK (db=$DB_PATH)"
|
||||
./kanban --port "$PORT_BACK" --db "$DB_PATH" &
|
||||
(cd "$BACK_DIR" && ./kanban --port "$PORT_BACK" --db "$DB_PATH") &
|
||||
BACK_PID=$!
|
||||
|
||||
# 4. Lanzar frontend (Vite con HMR + proxy a backend)
|
||||
echo ">>> Frontend http://localhost:$PORT_FRONT (HMR)"
|
||||
(cd frontend && pnpm dev --port "$PORT_FRONT" --strictPort) &
|
||||
(cd "$FRONT_DIR" && pnpm dev --port "$PORT_FRONT" --strictPort) &
|
||||
FRONT_PID=$!
|
||||
|
||||
echo ""
|
||||
|
||||