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:
2026-05-14 16:11:59 +02:00
parent c4caff85be
commit 9b503f0555
12 changed files with 1529 additions and 1191 deletions
+93 -3
View File
@@ -47,6 +47,7 @@ type Card struct {
AssigneeID *string `json:"assignee_id"`
CompletedAt *string `json:"completed_at"`
DeletedAt *string `json:"deleted_at"`
ArchivedAt *string `json:"archived_at"`
Tags []string `json:"tags"`
Stickers []Sticker `json:"stickers"`
Deadline *string `json:"deadline"`
@@ -448,7 +449,7 @@ func (db *DB) ReorderColumns(ids []string) error {
func (db *DB) ListCardsWithTime() ([]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.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,
COALESCE((
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
LEFT JOIN card_lock_history l
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
`, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
@@ -474,12 +475,13 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
var assignee sql.NullString
var completed sql.NullString
var deleted sql.NullString
var archived 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.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
}
c.Stickers = parseStickers(stickersJSON)
@@ -504,6 +506,10 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
s := deleted.String
c.DeletedAt = &s
}
if archived.Valid && archived.String != "" {
s := archived.String
c.ArchivedAt = &s
}
c.Tags = parseTags(tagsJSON)
if entered.Valid {
c.EnteredAt = entered.String
@@ -827,6 +833,90 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
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,
// 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).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<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-Bb2Ri4SN.js"></script>
<script type="module" crossorigin src="/assets/index-Cdqq92Kx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
+72
View File
@@ -1,14 +1,46 @@
package main
import (
"log"
"net/http"
"strings"
"sync/atomic"
"time"
"fn-registry/functions/infra"
)
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) {
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: [...] }
func handleGetBoard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
maybeAutoArchive(db)
cols, err := db.ListColumns()
if err != nil {
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
func handlePurgeCard(db *DB) http.HandlerFunc {
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/trash", Handler: handleListTrash(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: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
{Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)},
+5
View File
@@ -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;
+57
View File
@@ -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
View File
@@ -50,6 +50,7 @@ import {
IconArrowBackUp,
IconCalendar,
IconChartBar,
IconCheck,
IconChevronDown,
IconChevronRight,
IconLayoutKanban,
@@ -118,6 +119,8 @@ export function App() {
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [archive, setArchive] = useState<Card[]>([]);
const [archiveOpen, setArchiveOpen] = useState(false);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
@@ -209,16 +212,17 @@ export function App() {
const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
if (nowInside === inside) return;
inside = nowInside;
// El brillo visible solo cuando "armable": fuera-y-luego-dentro (open
// siempre) o dentro con sidebar cerrado (open trigger).
const armable = nowInside && (!navOpenRef.current || hasLeftStrip);
setEdgeArmed(armable);
// Brillo visible siempre que el puntero este en la franja y haya drag.
setEdgeArmed(nowInside);
if (!nowInside) {
hasLeftStrip = true;
clear();
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;
clear();
const willOpen = !navOpenRef.current;
@@ -227,7 +231,6 @@ export function App() {
// Tras toggle, resetea el flag para no encadenar otra accion sin
// que el usuario salga + vuelva.
hasLeftStrip = false;
setEdgeArmed(false);
}, DRAG_EDGE_HOVER_MS);
};
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 () => {
try {
const t = await api.listTags();
@@ -295,6 +307,10 @@ export function App() {
reloadTrash();
}, [reloadTrash]);
useEffect(() => {
reloadArchive();
}, [reloadArchive]);
useEffect(() => {
reloadTags();
reloadRequesters();
@@ -750,6 +766,26 @@ export function App() {
}
}, [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) => {
modals.openConfirmModal({
title: "Borrar permanentemente",
@@ -1165,6 +1201,7 @@ export function App() {
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
onArchiveCard={handleArchiveCard}
requesterOptions={requesterOptions}
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
activeSticker={activeSticker}
@@ -1228,6 +1265,51 @@ export function App() {
</Stack>
)}
</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>
</AppShell.Navbar>
@@ -1435,6 +1517,7 @@ export function App() {
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
onArchiveCard={handleArchiveCard}
requesterOptions={requesterOptions}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
+12
View File
@@ -107,6 +107,18 @@ export function purgeCard(id: string): Promise<void> {
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> {
return fetchJSON(`/cards/${id}/move`, {
method: "POST",
+15
View File
@@ -15,6 +15,7 @@ import {
Tooltip,
} from "@mantine/core";
import {
IconArchive,
IconCalendarDue,
IconCheck,
IconClock,
@@ -50,6 +51,7 @@ interface Props {
onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchive?: (id: string) => void;
requesterOptions?: string[];
onOpenCustomColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
@@ -100,6 +102,7 @@ interface CardBodyProps {
onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchive?: (id: string) => void;
onOpenCustomColor?: (cardId: string, current: string) => void;
onRemoveSticker?: (cardId: string, index: number) => void;
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
@@ -127,6 +130,7 @@ const KanbanCardBody = memo(function KanbanCardBody({
onAssign,
onSetDeadline,
onSetRequester,
onArchive,
onOpenCustomColor,
onRemoveSticker,
onMoveSticker,
@@ -321,6 +325,15 @@ const KanbanCardBody = memo(function KanbanCardBody({
</Popover.Dropdown>
</Popover>
)}
{isDone && onArchive && (
<Menu.Item
leftSection={<IconArchive size={14} />}
color="teal"
onClick={() => { setMenuOpen(false); onArchive(card.id); }}
>
Archivar
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => { setMenuOpen(false); onDelete(card.id); }}>Borrar</Menu.Item>
</>
@@ -463,6 +476,7 @@ function KanbanCardImpl({
onAssign,
onSetDeadline,
onSetRequester,
onArchive,
requesterOptions,
onOpenCustomColor,
activeSticker,
@@ -593,6 +607,7 @@ function KanbanCardImpl({
onAssign={onAssign}
onSetDeadline={onSetDeadline}
onSetRequester={onSetRequester}
onArchive={onArchive}
onOpenCustomColor={onOpenCustomColor}
onRemoveSticker={onRemoveSticker}
onMoveSticker={onMoveSticker}
+3
View File
@@ -82,6 +82,7 @@ interface Props {
onAssignCard: (id: string, assignee_id: string | null) => void;
onSetCardDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchiveCard?: (id: string) => void;
requesterOptions?: string[];
onOpenCustomCardColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
@@ -117,6 +118,7 @@ function KanbanColumnImpl({
onAssignCard,
onSetCardDeadline,
onSetRequester,
onArchiveCard,
requesterOptions,
onOpenCustomCardColor,
activeSticker,
@@ -593,6 +595,7 @@ function KanbanColumnImpl({
onAssign={onAssignCard}
onSetDeadline={onSetCardDeadline}
onSetRequester={onSetRequester}
onArchive={onArchiveCard}
requesterOptions={requesterOptions}
onOpenCustomColor={onOpenCustomCardColor}
users={users}
+1
View File
@@ -34,6 +34,7 @@ export interface Card {
assignee_id: string | null;
completed_at: string | null;
deleted_at: string | null;
archived_at: string | null;
tags: string[];
stickers: Sticker[];
deadline: string | null;