feat(kanban): archive automatico para cards Done +30 dias (issue 0092)
Adds an archive layer separate from the trash. Cards in is_done columns that have been there for more than 30 days are auto-archived on the next board load (throttled to once every 30 minutes). Archived cards leave the board but stay in the DB and are listed in a new sidebar drawer "Hecho (archivo)" below the existing Papelera, with a one-click restore. Schema (migration 012_card_archived.sql): - ALTER TABLE cards ADD COLUMN archived_at TEXT; - NULL = active, ISO timestamp = archived. Independent from deleted_at. Backend: - Card.ArchivedAt + JSON; ListCardsWithTime filters archived_at IS NULL. - New methods: ArchiveCard, UnarchiveCard, ListArchivedCards, AutoArchiveDoneOlderThan. - New endpoints: GET /api/archive, POST /api/cards/:id/archive, POST /api/cards/:id/unarchive. - handleGetBoard invokes maybeAutoArchive (atomic throttle, 30 min sweep, 30 day cutoff). Errors logged but never block the board response. Frontend: - Card type + api.ts add the new field and helpers. - App.tsx state for archive list, reload, archive/unarchive handlers. - New sidebar drawer with toggle, count badge, restore button. - KanbanCard gains an "Archivar" menu item (gated on isDone + onArchive prop) for manual archiving of any done card. Tests: - Playwright e2e/archive.spec.ts: manual archive via menu, drawer toggle, unarchive. Picks a done card via /api/board introspection so it stays stable regardless of board state. - Auto-archive of >30d cards: not under e2e (real time travel needed); covered by code review of the SQL query and the throttle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+93
-3
@@ -47,6 +47,7 @@ type Card struct {
|
|||||||
AssigneeID *string `json:"assignee_id"`
|
AssigneeID *string `json:"assignee_id"`
|
||||||
CompletedAt *string `json:"completed_at"`
|
CompletedAt *string `json:"completed_at"`
|
||||||
DeletedAt *string `json:"deleted_at"`
|
DeletedAt *string `json:"deleted_at"`
|
||||||
|
ArchivedAt *string `json:"archived_at"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Stickers []Sticker `json:"stickers"`
|
Stickers []Sticker `json:"stickers"`
|
||||||
Deadline *string `json:"deadline"`
|
Deadline *string `json:"deadline"`
|
||||||
@@ -448,7 +449,7 @@ func (db *DB) ReorderColumns(ids []string) error {
|
|||||||
|
|
||||||
func (db *DB) ListCardsWithTime() ([]Card, error) {
|
func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||||
rows, err := db.conn.Query(`
|
rows, err := db.conn.Query(`
|
||||||
SELECT c.id, c.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,
|
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.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
|
||||||
h.entered_at, l.locked_at,
|
h.entered_at, l.locked_at,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
|
SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
|
||||||
@@ -459,7 +460,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
|||||||
ON h.card_id = c.id AND h.exited_at IS NULL
|
ON h.card_id = c.id AND h.exited_at IS NULL
|
||||||
LEFT JOIN card_lock_history l
|
LEFT JOIN card_lock_history l
|
||||||
ON l.card_id = c.id AND l.unlocked_at IS NULL
|
ON l.card_id = c.id AND l.unlocked_at IS NULL
|
||||||
WHERE c.deleted_at IS NULL
|
WHERE c.deleted_at IS NULL AND c.archived_at IS NULL
|
||||||
ORDER BY c.column_id, c.position, c.created_at
|
ORDER BY c.column_id, c.position, c.created_at
|
||||||
`, time.Now().UTC().Format(time.RFC3339Nano))
|
`, time.Now().UTC().Format(time.RFC3339Nano))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -474,12 +475,13 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
|||||||
var assignee sql.NullString
|
var assignee sql.NullString
|
||||||
var completed sql.NullString
|
var completed sql.NullString
|
||||||
var deleted sql.NullString
|
var deleted sql.NullString
|
||||||
|
var archived sql.NullString
|
||||||
var tagsJSON string
|
var tagsJSON string
|
||||||
var stickersJSON string
|
var stickersJSON string
|
||||||
var deadline sql.NullString
|
var deadline sql.NullString
|
||||||
var lockedAt sql.NullString
|
var lockedAt sql.NullString
|
||||||
var locked int
|
var locked int
|
||||||
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 {
|
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.Stickers = parseStickers(stickersJSON)
|
c.Stickers = parseStickers(stickersJSON)
|
||||||
@@ -504,6 +506,10 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
|||||||
s := deleted.String
|
s := deleted.String
|
||||||
c.DeletedAt = &s
|
c.DeletedAt = &s
|
||||||
}
|
}
|
||||||
|
if archived.Valid && archived.String != "" {
|
||||||
|
s := archived.String
|
||||||
|
c.ArchivedAt = &s
|
||||||
|
}
|
||||||
c.Tags = parseTags(tagsJSON)
|
c.Tags = parseTags(tagsJSON)
|
||||||
if entered.Valid {
|
if entered.Valid {
|
||||||
c.EnteredAt = entered.String
|
c.EnteredAt = entered.String
|
||||||
@@ -827,6 +833,90 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArchiveCard moves a card to the archive (out of the board, retrievable).
|
||||||
|
// Used both manually and by AutoArchiveDoneOlderThan.
|
||||||
|
func (db *DB) ArchiveCard(id string) error {
|
||||||
|
now := nowRFC3339()
|
||||||
|
_, err := db.conn.Exec(`UPDATE cards SET archived_at=?, updated_at=? WHERE id=? AND archived_at IS NULL AND deleted_at IS NULL`, now, now, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnarchiveCard pulls a card out of the archive back into its column.
|
||||||
|
func (db *DB) UnarchiveCard(id string) error {
|
||||||
|
now := nowRFC3339()
|
||||||
|
_, err := db.conn.Exec(`UPDATE cards SET archived_at=NULL, updated_at=? WHERE id=? AND archived_at IS NOT NULL`, now, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoArchiveDoneOlderThan archives every card whose column is is_done=1 AND
|
||||||
|
// whose entered_at in that column is older than `older`. Idempotent: cards
|
||||||
|
// already archived or deleted are skipped. Returns the count affected.
|
||||||
|
func (db *DB) AutoArchiveDoneOlderThan(older time.Duration) (int64, error) {
|
||||||
|
cutoff := time.Now().UTC().Add(-older).Format(time.RFC3339Nano)
|
||||||
|
now := nowRFC3339()
|
||||||
|
res, err := db.conn.Exec(`
|
||||||
|
UPDATE cards SET archived_at=?, updated_at=?
|
||||||
|
WHERE archived_at IS NULL
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND column_id IN (SELECT id FROM columns WHERE is_done=1)
|
||||||
|
AND id IN (
|
||||||
|
SELECT card_id FROM card_column_history
|
||||||
|
WHERE exited_at IS NULL AND entered_at < ?
|
||||||
|
)
|
||||||
|
`, now, now, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListArchivedCards returns cards in the archive, newest first.
|
||||||
|
func (db *DB) ListArchivedCards() ([]Card, error) {
|
||||||
|
rows, err := db.conn.Query(`
|
||||||
|
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.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
|
||||||
|
FROM cards c
|
||||||
|
WHERE c.archived_at IS NOT NULL AND c.deleted_at IS NULL
|
||||||
|
ORDER BY c.archived_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Card{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Card
|
||||||
|
var assignee, completed, deleted, archived sql.NullString
|
||||||
|
var tagsJSON, stickersJSON string
|
||||||
|
var deadline sql.NullString
|
||||||
|
var locked int
|
||||||
|
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &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
|
||||||
|
c.AssigneeID = &s
|
||||||
|
}
|
||||||
|
if completed.Valid && completed.String != "" {
|
||||||
|
s := completed.String
|
||||||
|
c.CompletedAt = &s
|
||||||
|
}
|
||||||
|
if archived.Valid && archived.String != "" {
|
||||||
|
s := archived.String
|
||||||
|
c.ArchivedAt = &s
|
||||||
|
}
|
||||||
|
c.Tags = parseTags(tagsJSON)
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// MoveCard updates the card's column and/or position. If the column changes,
|
// MoveCard updates the card's column and/or position. If the column changes,
|
||||||
// the open history entry is closed and a new one is opened.
|
// the open history entry is closed and a new one is opened.
|
||||||
// orderedIDs is the new order of cards in the destination column (including this card).
|
// orderedIDs is the new order of cards in the destination column (including this card).
|
||||||
|
|||||||
-1181
File diff suppressed because one or more lines are too long
+1181
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<title>Kanban</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Bb2Ri4SN.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cdqq92Kx.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,14 +1,46 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fn-registry/functions/infra"
|
"fn-registry/functions/infra"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxBodyBytes = 1 << 20 // 1 MiB
|
const maxBodyBytes = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
|
// Auto-archive: cards en columnas Done con >30 dias se mueven al cajon.
|
||||||
|
// Issue 0092. Lo dispara handleGetBoard de forma "lazy" pero solo cada
|
||||||
|
// archiveSweepEvery minutos para no martillear el UPDATE.
|
||||||
|
const (
|
||||||
|
archiveAfter = 30 * 24 * time.Hour
|
||||||
|
archiveSweepEvery = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastArchiveSweepNs atomic.Int64
|
||||||
|
|
||||||
|
func maybeAutoArchive(db *DB) {
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
last := lastArchiveSweepNs.Load()
|
||||||
|
if last != 0 && time.Duration(now-last) < archiveSweepEvery {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !lastArchiveSweepNs.CompareAndSwap(last, now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := db.AutoArchiveDoneOlderThan(archiveAfter)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("auto-archive failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("auto-archive moved %d done card(s) older than %s", n, archiveAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func badRequest(w http.ResponseWriter, msg string) {
|
func badRequest(w http.ResponseWriter, msg string) {
|
||||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg})
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg})
|
||||||
}
|
}
|
||||||
@@ -24,6 +56,7 @@ func serverError(w http.ResponseWriter, err error) {
|
|||||||
// GET /api/board → { columns: [...], cards: [...] }
|
// GET /api/board → { columns: [...], cards: [...] }
|
||||||
func handleGetBoard(db *DB) http.HandlerFunc {
|
func handleGetBoard(db *DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
maybeAutoArchive(db)
|
||||||
cols, err := db.ListColumns()
|
cols, err := db.ListColumns()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
@@ -404,6 +437,42 @@ func handleRestoreCard(db *DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/archive
|
||||||
|
func handleListArchive(db *DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cards, err := db.ListArchivedCards()
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
infra.HTTPJSONResponse(w, http.StatusOK, cards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/cards/{id}/archive
|
||||||
|
func handleArchiveCard(db *DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if err := db.ArchiveCard(id); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/cards/{id}/unarchive
|
||||||
|
func handleUnarchiveCard(db *DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if err := db.UnarchiveCard(id); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/cards/{id}/purge
|
// DELETE /api/cards/{id}/purge
|
||||||
func handlePurgeCard(db *DB) http.HandlerFunc {
|
func handlePurgeCard(db *DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -442,6 +511,9 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
|
|||||||
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
|
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
|
||||||
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
|
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
|
||||||
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
|
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
|
||||||
|
{Method: "GET", Path: "/api/archive", Handler: handleListArchive(db)},
|
||||||
|
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)},
|
||||||
|
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db)},
|
||||||
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
|
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
|
||||||
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
|
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
|
||||||
{Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)},
|
{Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)},
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Issue 0092: archivo automatico para cards en columnas Done con +30 dias.
|
||||||
|
-- archived_at NULL = card activa. archived_at = timestamp ISO = card en cajon.
|
||||||
|
-- Independiente de deleted_at (papelera): una card puede estar archived sin
|
||||||
|
-- haber sido borrada. Restaurar = vuelve a su columna original sin deletear.
|
||||||
|
ALTER TABLE cards ADD COLUMN archived_at TEXT;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
|
||||||
|
|
||||||
|
const USER = process.env.KANBAN_USER || "e2e_user";
|
||||||
|
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue 0092: cards en columnas DONE con >30 dias se mueven al cajon "Hecho".
|
||||||
|
* Test cubre: archivar via menu manual, listar archivo, des-archivar.
|
||||||
|
*/
|
||||||
|
test.describe("kanban archive (issue 0092)", () => {
|
||||||
|
test("archiva una done card via menu y la des-archiva desde el cajon", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||||
|
|
||||||
|
// Pick a card from a done column (queried directly from the API).
|
||||||
|
const board = await page.request.get("/api/board").then((r) => r.json());
|
||||||
|
const doneCol = (board.columns as Array<{ id: string; is_done: boolean }>).find((c) => c.is_done);
|
||||||
|
if (!doneCol) test.skip(true, "no done column in board");
|
||||||
|
const cardInDone = (board.cards as Array<{ id: string; column_id: string }>).find(
|
||||||
|
(c) => c.column_id === doneCol!.id
|
||||||
|
);
|
||||||
|
if (!cardInDone) test.skip(true, "no card in a done column");
|
||||||
|
const targetId = cardInDone!.id;
|
||||||
|
|
||||||
|
const cardSel = `[data-card-id="${targetId}"]`;
|
||||||
|
const card = page.locator(cardSel).first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Open the per-card menu. Use dispatchEvent so we ignore viewport scroll constraints.
|
||||||
|
await card.locator('button[aria-label="Acciones"]').dispatchEvent("click");
|
||||||
|
const archiveItem = page.getByRole("menuitem", { name: /Archivar/i }).first();
|
||||||
|
await expect(archiveItem).toBeVisible();
|
||||||
|
await archiveItem.click();
|
||||||
|
|
||||||
|
// Card disappears from board.
|
||||||
|
await expect(card).toHaveCount(0, { timeout: 5000 });
|
||||||
|
|
||||||
|
// Archive drawer toggle visible + opens.
|
||||||
|
const archiveToggle = page.locator('[data-test="archive-toggle"]');
|
||||||
|
await archiveToggle.scrollIntoViewIfNeeded();
|
||||||
|
await archiveToggle.dispatchEvent("click");
|
||||||
|
|
||||||
|
// Archived row appears in the drawer.
|
||||||
|
const archivedRow = page.locator(`[data-archived-card-id="${targetId}"]`);
|
||||||
|
await expect(archivedRow).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Restore from archive (force click — sidebar can be scrollable / off-viewport).
|
||||||
|
await archivedRow.locator("button").first().dispatchEvent("click");
|
||||||
|
|
||||||
|
// Back on board.
|
||||||
|
await expect(page.locator(cardSel).first()).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// No longer in archive.
|
||||||
|
await expect(archivedRow).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+89
-6
@@ -50,6 +50,7 @@ import {
|
|||||||
IconArrowBackUp,
|
IconArrowBackUp,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
|
IconCheck,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconLayoutKanban,
|
IconLayoutKanban,
|
||||||
@@ -118,6 +119,8 @@ export function App() {
|
|||||||
const [activeTab, setActiveTab] = useState<string>("board");
|
const [activeTab, setActiveTab] = useState<string>("board");
|
||||||
const [trash, setTrash] = useState<Card[]>([]);
|
const [trash, setTrash] = useState<Card[]>([]);
|
||||||
const [trashOpen, setTrashOpen] = useState(false);
|
const [trashOpen, setTrashOpen] = useState(false);
|
||||||
|
const [archive, setArchive] = useState<Card[]>([]);
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
||||||
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
|
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -209,16 +212,17 @@ export function App() {
|
|||||||
const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
|
const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
|
||||||
if (nowInside === inside) return;
|
if (nowInside === inside) return;
|
||||||
inside = nowInside;
|
inside = nowInside;
|
||||||
// El brillo visible solo cuando "armable": fuera-y-luego-dentro (open
|
// Brillo visible siempre que el puntero este en la franja y haya drag.
|
||||||
// siempre) o dentro con sidebar cerrado (open trigger).
|
setEdgeArmed(nowInside);
|
||||||
const armable = nowInside && (!navOpenRef.current || hasLeftStrip);
|
|
||||||
setEdgeArmed(armable);
|
|
||||||
if (!nowInside) {
|
if (!nowInside) {
|
||||||
hasLeftStrip = true;
|
hasLeftStrip = true;
|
||||||
clear();
|
clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// nowInside = true
|
// nowInside = true. Para cerrar (navOpen=true) exigimos que el puntero
|
||||||
|
// haya salido al menos una vez de la franja desde que empezo el drag;
|
||||||
|
// asi un drag que arranca dentro del sidebar abierto no auto-cierra.
|
||||||
|
const armable = !navOpenRef.current || hasLeftStrip;
|
||||||
if (!armable) return;
|
if (!armable) return;
|
||||||
clear();
|
clear();
|
||||||
const willOpen = !navOpenRef.current;
|
const willOpen = !navOpenRef.current;
|
||||||
@@ -227,7 +231,6 @@ export function App() {
|
|||||||
// Tras toggle, resetea el flag para no encadenar otra accion sin
|
// Tras toggle, resetea el flag para no encadenar otra accion sin
|
||||||
// que el usuario salga + vuelva.
|
// que el usuario salga + vuelva.
|
||||||
hasLeftStrip = false;
|
hasLeftStrip = false;
|
||||||
setEdgeArmed(false);
|
|
||||||
}, DRAG_EDGE_HOVER_MS);
|
}, DRAG_EDGE_HOVER_MS);
|
||||||
};
|
};
|
||||||
document.addEventListener("mousemove", onMove);
|
document.addEventListener("mousemove", onMove);
|
||||||
@@ -269,6 +272,15 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const reloadArchive = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const a = await api.listArchive();
|
||||||
|
setArchive(a);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("listArchive failed", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const reloadTags = useCallback(async () => {
|
const reloadTags = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const t = await api.listTags();
|
const t = await api.listTags();
|
||||||
@@ -295,6 +307,10 @@ export function App() {
|
|||||||
reloadTrash();
|
reloadTrash();
|
||||||
}, [reloadTrash]);
|
}, [reloadTrash]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadArchive();
|
||||||
|
}, [reloadArchive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reloadTags();
|
reloadTags();
|
||||||
reloadRequesters();
|
reloadRequesters();
|
||||||
@@ -750,6 +766,26 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [reload, reloadTrash]);
|
}, [reload, reloadTrash]);
|
||||||
|
|
||||||
|
const handleUnarchiveCard = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.unarchiveCard(id);
|
||||||
|
reload();
|
||||||
|
reloadArchive();
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: (e as Error).message });
|
||||||
|
}
|
||||||
|
}, [reload, reloadArchive]);
|
||||||
|
|
||||||
|
const handleArchiveCard = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.archiveCard(id);
|
||||||
|
reload();
|
||||||
|
reloadArchive();
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: (e as Error).message });
|
||||||
|
}
|
||||||
|
}, [reload, reloadArchive]);
|
||||||
|
|
||||||
const handlePurgeCard = useCallback(async (id: string) => {
|
const handlePurgeCard = useCallback(async (id: string) => {
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: "Borrar permanentemente",
|
title: "Borrar permanentemente",
|
||||||
@@ -1165,6 +1201,7 @@ export function App() {
|
|||||||
onSetCardDeadline={handleSetCardDeadline}
|
onSetCardDeadline={handleSetCardDeadline}
|
||||||
highlightCardId={highlightCardId}
|
highlightCardId={highlightCardId}
|
||||||
onSetRequester={handleSetRequester}
|
onSetRequester={handleSetRequester}
|
||||||
|
onArchiveCard={handleArchiveCard}
|
||||||
requesterOptions={requesterOptions}
|
requesterOptions={requesterOptions}
|
||||||
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
|
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
|
||||||
activeSticker={activeSticker}
|
activeSticker={activeSticker}
|
||||||
@@ -1228,6 +1265,51 @@ export function App() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="xs"
|
||||||
|
fullWidth
|
||||||
|
justify="space-between"
|
||||||
|
leftSection={<IconCheck size={14} />}
|
||||||
|
rightSection={
|
||||||
|
<Group gap={4}>
|
||||||
|
<Badge size="xs" variant="light" color={archive.length > 0 ? "teal" : "gray"}>
|
||||||
|
{archive.length}
|
||||||
|
</Badge>
|
||||||
|
{archiveOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
onClick={() => setArchiveOpen((v) => !v)}
|
||||||
|
data-test="archive-toggle"
|
||||||
|
>
|
||||||
|
Hecho (archivo)
|
||||||
|
</Button>
|
||||||
|
{archiveOpen && (
|
||||||
|
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
|
||||||
|
{archive.length === 0 && (
|
||||||
|
<Text size="xs" c="dimmed" px="xs">
|
||||||
|
Sin cards archivadas.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{archive.map((c) => (
|
||||||
|
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7" data-archived-card-id={c.id}>
|
||||||
|
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||||
|
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
|
||||||
|
{c.title}
|
||||||
|
</Text>
|
||||||
|
<Tooltip label="Sacar del archivo (volver a Hecho)" withArrow>
|
||||||
|
<ActionIcon size="xs" variant="subtle" color="teal" onClick={() => handleUnarchiveCard(c.id)}>
|
||||||
|
<IconArrowBackUp size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
@@ -1435,6 +1517,7 @@ export function App() {
|
|||||||
onSetCardDeadline={handleSetCardDeadline}
|
onSetCardDeadline={handleSetCardDeadline}
|
||||||
highlightCardId={highlightCardId}
|
highlightCardId={highlightCardId}
|
||||||
onSetRequester={handleSetRequester}
|
onSetRequester={handleSetRequester}
|
||||||
|
onArchiveCard={handleArchiveCard}
|
||||||
requesterOptions={requesterOptions}
|
requesterOptions={requesterOptions}
|
||||||
activeSticker={activeSticker}
|
activeSticker={activeSticker}
|
||||||
onAddSticker={handleAddSticker}
|
onAddSticker={handleAddSticker}
|
||||||
|
|||||||
@@ -107,6 +107,18 @@ export function purgeCard(id: string): Promise<void> {
|
|||||||
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
|
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listArchive(): Promise<Card[]> {
|
||||||
|
return fetchJSON("/archive");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function archiveCard(id: string): Promise<void> {
|
||||||
|
return fetchJSON(`/cards/${id}/archive`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unarchiveCard(id: string): Promise<void> {
|
||||||
|
return fetchJSON(`/cards/${id}/unarchive`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
|
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
|
||||||
return fetchJSON(`/cards/${id}/move`, {
|
return fetchJSON(`/cards/${id}/move`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconArchive,
|
||||||
IconCalendarDue,
|
IconCalendarDue,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconClock,
|
IconClock,
|
||||||
@@ -50,6 +51,7 @@ interface Props {
|
|||||||
onAssign: (id: string, assignee_id: string | null) => void;
|
onAssign: (id: string, assignee_id: string | null) => void;
|
||||||
onSetDeadline?: (id: string, deadline: string | null) => void;
|
onSetDeadline?: (id: string, deadline: string | null) => void;
|
||||||
onSetRequester?: (id: string, requester: string) => void;
|
onSetRequester?: (id: string, requester: string) => void;
|
||||||
|
onArchive?: (id: string) => void;
|
||||||
requesterOptions?: string[];
|
requesterOptions?: string[];
|
||||||
onOpenCustomColor?: (cardId: string, current: string) => void;
|
onOpenCustomColor?: (cardId: string, current: string) => void;
|
||||||
activeSticker?: string | null;
|
activeSticker?: string | null;
|
||||||
@@ -100,6 +102,7 @@ interface CardBodyProps {
|
|||||||
onAssign: (id: string, assignee_id: string | null) => void;
|
onAssign: (id: string, assignee_id: string | null) => void;
|
||||||
onSetDeadline?: (id: string, deadline: string | null) => void;
|
onSetDeadline?: (id: string, deadline: string | null) => void;
|
||||||
onSetRequester?: (id: string, requester: string) => void;
|
onSetRequester?: (id: string, requester: string) => void;
|
||||||
|
onArchive?: (id: string) => void;
|
||||||
onOpenCustomColor?: (cardId: string, current: string) => void;
|
onOpenCustomColor?: (cardId: string, current: string) => void;
|
||||||
onRemoveSticker?: (cardId: string, index: number) => void;
|
onRemoveSticker?: (cardId: string, index: number) => void;
|
||||||
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
|
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
|
||||||
@@ -127,6 +130,7 @@ const KanbanCardBody = memo(function KanbanCardBody({
|
|||||||
onAssign,
|
onAssign,
|
||||||
onSetDeadline,
|
onSetDeadline,
|
||||||
onSetRequester,
|
onSetRequester,
|
||||||
|
onArchive,
|
||||||
onOpenCustomColor,
|
onOpenCustomColor,
|
||||||
onRemoveSticker,
|
onRemoveSticker,
|
||||||
onMoveSticker,
|
onMoveSticker,
|
||||||
@@ -321,6 +325,15 @@ const KanbanCardBody = memo(function KanbanCardBody({
|
|||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
{isDone && onArchive && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconArchive size={14} />}
|
||||||
|
color="teal"
|
||||||
|
onClick={() => { setMenuOpen(false); onArchive(card.id); }}
|
||||||
|
>
|
||||||
|
Archivar
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => { setMenuOpen(false); onDelete(card.id); }}>Borrar</Menu.Item>
|
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => { setMenuOpen(false); onDelete(card.id); }}>Borrar</Menu.Item>
|
||||||
</>
|
</>
|
||||||
@@ -463,6 +476,7 @@ function KanbanCardImpl({
|
|||||||
onAssign,
|
onAssign,
|
||||||
onSetDeadline,
|
onSetDeadline,
|
||||||
onSetRequester,
|
onSetRequester,
|
||||||
|
onArchive,
|
||||||
requesterOptions,
|
requesterOptions,
|
||||||
onOpenCustomColor,
|
onOpenCustomColor,
|
||||||
activeSticker,
|
activeSticker,
|
||||||
@@ -593,6 +607,7 @@ function KanbanCardImpl({
|
|||||||
onAssign={onAssign}
|
onAssign={onAssign}
|
||||||
onSetDeadline={onSetDeadline}
|
onSetDeadline={onSetDeadline}
|
||||||
onSetRequester={onSetRequester}
|
onSetRequester={onSetRequester}
|
||||||
|
onArchive={onArchive}
|
||||||
onOpenCustomColor={onOpenCustomColor}
|
onOpenCustomColor={onOpenCustomColor}
|
||||||
onRemoveSticker={onRemoveSticker}
|
onRemoveSticker={onRemoveSticker}
|
||||||
onMoveSticker={onMoveSticker}
|
onMoveSticker={onMoveSticker}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ interface Props {
|
|||||||
onAssignCard: (id: string, assignee_id: string | null) => void;
|
onAssignCard: (id: string, assignee_id: string | null) => void;
|
||||||
onSetCardDeadline?: (id: string, deadline: string | null) => void;
|
onSetCardDeadline?: (id: string, deadline: string | null) => void;
|
||||||
onSetRequester?: (id: string, requester: string) => void;
|
onSetRequester?: (id: string, requester: string) => void;
|
||||||
|
onArchiveCard?: (id: string) => void;
|
||||||
requesterOptions?: string[];
|
requesterOptions?: string[];
|
||||||
onOpenCustomCardColor?: (cardId: string, current: string) => void;
|
onOpenCustomCardColor?: (cardId: string, current: string) => void;
|
||||||
activeSticker?: string | null;
|
activeSticker?: string | null;
|
||||||
@@ -117,6 +118,7 @@ function KanbanColumnImpl({
|
|||||||
onAssignCard,
|
onAssignCard,
|
||||||
onSetCardDeadline,
|
onSetCardDeadline,
|
||||||
onSetRequester,
|
onSetRequester,
|
||||||
|
onArchiveCard,
|
||||||
requesterOptions,
|
requesterOptions,
|
||||||
onOpenCustomCardColor,
|
onOpenCustomCardColor,
|
||||||
activeSticker,
|
activeSticker,
|
||||||
@@ -593,6 +595,7 @@ function KanbanColumnImpl({
|
|||||||
onAssign={onAssignCard}
|
onAssign={onAssignCard}
|
||||||
onSetDeadline={onSetCardDeadline}
|
onSetDeadline={onSetCardDeadline}
|
||||||
onSetRequester={onSetRequester}
|
onSetRequester={onSetRequester}
|
||||||
|
onArchive={onArchiveCard}
|
||||||
requesterOptions={requesterOptions}
|
requesterOptions={requesterOptions}
|
||||||
onOpenCustomColor={onOpenCustomCardColor}
|
onOpenCustomColor={onOpenCustomCardColor}
|
||||||
users={users}
|
users={users}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface Card {
|
|||||||
assignee_id: string | null;
|
assignee_id: string | null;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
|
archived_at: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
stickers: Sticker[];
|
stickers: Sticker[];
|
||||||
deadline: string | null;
|
deadline: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user