feat(kanban): tiempo maximo por columna con borde rojo (issue 0089)
Adds column-level max time limit. Cards whose time_in_column_ms exceeds the limit show a red border + red halo. Columns marked as Done never trigger the visual regardless of the limit (per spec). Backend: - Migration 011_column_max_time.sql adds columns.max_time_minutes INTEGER NOT NULL DEFAULT 0 (0 = no limit). Aditiva, idempotente. - Column struct + ColumnPatch + UpdateColumn handle the new field; negatives clamp to 0; listing query includes it. - handleUpdateColumn (PATCH /api/columns/:id) accepts max_time_minutes in the JSON body. Frontend: - Column TS interface + UpdateColumnInput updated. - KanbanColumn context menu: new entry "Tiempo maximo" using window.prompt for low-friction config; shows current value when >0. - KanbanCard receives columnOverdue prop calculated from the column state and card.time_in_column_ms; renders red border (var --mantine-color-red-6) with 2px width + 2px red halo when overdue. - data-card-id, data-column-overdue, data-locked attributes on the card paper element so e2e tests / scripts can query state. Tests: TestColumnMaxTimeMinutes_Defaults + _Update verify the schema default, the clamp on negative input, and that updating max_time leaves other fields untouched. Visual regression of the red border kept out of automated e2e because it requires either clock control or real cards aged > N minutes; will be verified manually after merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 0089: tiempo maximo por columna.
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *DB {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.db")
|
||||||
|
db, err := openDB(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("openDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Close()
|
||||||
|
_ = os.Remove(path)
|
||||||
|
})
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColumnMaxTimeMinutes_Defaults(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
c, err := db.CreateColumn("col1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateColumn: %v", err)
|
||||||
|
}
|
||||||
|
if c.MaxTimeMinutes != 0 {
|
||||||
|
t.Fatalf("new column max_time_minutes = %d, want 0", c.MaxTimeMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
cols, err := db.ListColumns()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListColumns: %v", err)
|
||||||
|
}
|
||||||
|
if len(cols) == 0 || cols[0].MaxTimeMinutes != 0 {
|
||||||
|
t.Fatalf("listed col max_time_minutes = %d, want 0", cols[0].MaxTimeMinutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColumnMaxTimeMinutes_Update(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
c, _ := db.CreateColumn("c")
|
||||||
|
v := 30
|
||||||
|
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &v}); err != nil {
|
||||||
|
t.Fatalf("UpdateColumn set 30: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cols, _ := db.ListColumns()
|
||||||
|
if cols[0].MaxTimeMinutes != 30 {
|
||||||
|
t.Fatalf("after set max=30 got %d", cols[0].MaxTimeMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative clamps to 0.
|
||||||
|
neg := -5
|
||||||
|
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &neg}); err != nil {
|
||||||
|
t.Fatalf("UpdateColumn neg: %v", err)
|
||||||
|
}
|
||||||
|
cols, _ = db.ListColumns()
|
||||||
|
if cols[0].MaxTimeMinutes != 0 {
|
||||||
|
t.Fatalf("negative should clamp to 0, got %d", cols[0].MaxTimeMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other fields untouched.
|
||||||
|
w := 555
|
||||||
|
if err := db.UpdateColumn(c.ID, ColumnPatch{Width: &w}); err != nil {
|
||||||
|
t.Fatalf("UpdateColumn width: %v", err)
|
||||||
|
}
|
||||||
|
cols, _ = db.ListColumns()
|
||||||
|
if cols[0].MaxTimeMinutes != 0 {
|
||||||
|
t.Fatalf("max_time should still be 0 after width update, got %d", cols[0].MaxTimeMinutes)
|
||||||
|
}
|
||||||
|
if cols[0].Width != 555 {
|
||||||
|
t.Fatalf("width = %d, want 555", cols[0].Width)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-16
@@ -17,14 +17,15 @@ import (
|
|||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
type Column struct {
|
type Column struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Position int `json:"position"`
|
Position int `json:"position"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
WIPLimit int `json:"wip_limit"`
|
WIPLimit int `json:"wip_limit"`
|
||||||
IsDone bool `json:"is_done"`
|
IsDone bool `json:"is_done"`
|
||||||
CreatedAt string `json:"created_at"`
|
MaxTimeMinutes int `json:"max_time_minutes"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Sticker struct {
|
type Sticker struct {
|
||||||
@@ -305,7 +306,7 @@ func insertCardEvent(execer interface {
|
|||||||
// --- Columns ---
|
// --- Columns ---
|
||||||
|
|
||||||
func (db *DB) ListColumns() ([]Column, error) {
|
func (db *DB) ListColumns() ([]Column, error) {
|
||||||
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, created_at FROM columns ORDER BY position, created_at`)
|
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, max_time_minutes, created_at FROM columns ORDER BY position, created_at`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -314,7 +315,7 @@ func (db *DB) ListColumns() ([]Column, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c Column
|
var c Column
|
||||||
var isDone int
|
var isDone int
|
||||||
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.CreatedAt); err != nil {
|
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.MaxTimeMinutes, &c.CreatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.IsDone = isDone != 0
|
c.IsDone = isDone != 0
|
||||||
@@ -344,12 +345,13 @@ func (db *DB) CreateColumn(name string) (*Column, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ColumnPatch struct {
|
type ColumnPatch struct {
|
||||||
Name *string
|
Name *string
|
||||||
Position *int
|
Position *int
|
||||||
Location *string
|
Location *string
|
||||||
Width *int
|
Width *int
|
||||||
WIPLimit *int
|
WIPLimit *int
|
||||||
IsDone *bool
|
IsDone *bool
|
||||||
|
MaxTimeMinutes *int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
||||||
@@ -411,6 +413,15 @@ func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if patch.MaxTimeMinutes != nil {
|
||||||
|
m := *patch.MaxTimeMinutes
|
||||||
|
if m < 0 {
|
||||||
|
m = 0
|
||||||
|
}
|
||||||
|
if _, err := db.conn.Exec(`UPDATE columns SET max_time_minutes=? WHERE id=?`, m, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1176
File diff suppressed because one or more lines are too long
+1176
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-CUPtTPZl.js"></script>
|
<script type="module" crossorigin src="/assets/index-D1wc-P9j.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+8
-7
@@ -67,18 +67,19 @@ func handleUpdateColumn(db *DB) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
var body struct {
|
var body struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Position *int `json:"position"`
|
Position *int `json:"position"`
|
||||||
Location *string `json:"location"`
|
Location *string `json:"location"`
|
||||||
Width *int `json:"width"`
|
Width *int `json:"width"`
|
||||||
WIPLimit *int `json:"wip_limit"`
|
WIPLimit *int `json:"wip_limit"`
|
||||||
IsDone *bool `json:"is_done"`
|
IsDone *bool `json:"is_done"`
|
||||||
|
MaxTimeMinutes *int `json:"max_time_minutes"`
|
||||||
}
|
}
|
||||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||||
badRequest(w, err.Error())
|
badRequest(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil {
|
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone, MaxTimeMinutes: body.MaxTimeMinutes}); err != nil {
|
||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Issue 0089: tiempo maximo por columna.
|
||||||
|
-- NULL/0 = sin limite. >0 = minutos antes de marcar como vencida la card.
|
||||||
|
-- Cards en columnas con is_done=1 nunca se marcan como vencidas.
|
||||||
|
ALTER TABLE columns ADD COLUMN max_time_minutes INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -811,6 +811,19 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
|
const handleSetMaxTimeMinutes = useCallback(async (id: string, max_time_minutes: number) => {
|
||||||
|
setBoard((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, max_time_minutes } : c)) };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await api.updateColumn(id, { max_time_minutes });
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: (e as Error).message });
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
|
const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
|
||||||
setBoard((prev) => {
|
setBoard((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
@@ -995,6 +1008,7 @@ export function App() {
|
|||||||
onMoveColumnLocation={handleMoveColumnLocation}
|
onMoveColumnLocation={handleMoveColumnLocation}
|
||||||
onDeleteColumn={handleDeleteColumn}
|
onDeleteColumn={handleDeleteColumn}
|
||||||
onSetWIPLimit={handleSetWIPLimit}
|
onSetWIPLimit={handleSetWIPLimit}
|
||||||
|
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
||||||
onToggleDone={handleToggleDone}
|
onToggleDone={handleToggleDone}
|
||||||
onEditCard={openEditCard}
|
onEditCard={openEditCard}
|
||||||
onDeleteCard={handleDeleteCard}
|
onDeleteCard={handleDeleteCard}
|
||||||
@@ -1263,6 +1277,7 @@ export function App() {
|
|||||||
onMoveColumnLocation={handleMoveColumnLocation}
|
onMoveColumnLocation={handleMoveColumnLocation}
|
||||||
onDeleteColumn={handleDeleteColumn}
|
onDeleteColumn={handleDeleteColumn}
|
||||||
onSetWIPLimit={handleSetWIPLimit}
|
onSetWIPLimit={handleSetWIPLimit}
|
||||||
|
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
||||||
onToggleDone={handleToggleDone}
|
onToggleDone={handleToggleDone}
|
||||||
onEditCard={openEditCard}
|
onEditCard={openEditCard}
|
||||||
onDeleteCard={handleDeleteCard}
|
onDeleteCard={handleDeleteCard}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface UpdateColumnInput {
|
|||||||
width?: number;
|
width?: number;
|
||||||
wip_limit?: number;
|
wip_limit?: number;
|
||||||
is_done?: boolean;
|
is_done?: boolean;
|
||||||
|
max_time_minutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
|
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ interface Props {
|
|||||||
users: User[];
|
users: User[];
|
||||||
assignee?: User;
|
assignee?: User;
|
||||||
inDoneColumn?: boolean;
|
inDoneColumn?: boolean;
|
||||||
|
columnOverdue?: boolean;
|
||||||
isOverlay?: boolean;
|
isOverlay?: boolean;
|
||||||
highlight?: boolean;
|
highlight?: boolean;
|
||||||
}
|
}
|
||||||
@@ -86,6 +87,7 @@ function KanbanCardImpl({
|
|||||||
users,
|
users,
|
||||||
assignee,
|
assignee,
|
||||||
inDoneColumn,
|
inDoneColumn,
|
||||||
|
columnOverdue,
|
||||||
isOverlay,
|
isOverlay,
|
||||||
highlight,
|
highlight,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -162,14 +164,25 @@ function KanbanCardImpl({
|
|||||||
onRemoveSticker?.(card.id, index);
|
onRemoveSticker?.(card.id, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const borderColorPicked = highlight
|
||||||
|
? "var(--mantine-color-blue-5)"
|
||||||
|
: columnOverdue
|
||||||
|
? "var(--mantine-color-red-6)"
|
||||||
|
: card.locked
|
||||||
|
? "var(--mantine-color-yellow-6)"
|
||||||
|
: colorBorder(card.color);
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.4 : 1,
|
opacity: isDragging ? 0.4 : 1,
|
||||||
background: colorBg(card.color),
|
background: colorBg(card.color),
|
||||||
borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
|
borderColor: borderColorPicked,
|
||||||
borderWidth: highlight || card.locked ? 2 : 1,
|
borderWidth: highlight || card.locked || columnOverdue ? 2 : 1,
|
||||||
boxShadow: highlight ? "0 0 0 3px var(--mantine-color-blue-4)" : undefined,
|
boxShadow: highlight
|
||||||
|
? "0 0 0 3px var(--mantine-color-blue-4)"
|
||||||
|
: columnOverdue
|
||||||
|
? "0 0 0 2px var(--mantine-color-red-3)"
|
||||||
|
: undefined,
|
||||||
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
|
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -433,6 +446,9 @@ function KanbanCardImpl({
|
|||||||
p="xs"
|
p="xs"
|
||||||
shadow={isOverlay ? "lg" : "xs"}
|
shadow={isOverlay ? "lg" : "xs"}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
data-card-id={card.id}
|
||||||
|
data-column-overdue={columnOverdue ? "true" : "false"}
|
||||||
|
data-locked={card.locked ? "true" : "false"}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
onClick={onCardClickAddSticker}
|
onClick={onCardClickAddSticker}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
IconCheckbox,
|
IconCheckbox,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
|
IconClock,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconGripVertical,
|
IconGripVertical,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
@@ -47,6 +48,7 @@ interface Props {
|
|||||||
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
|
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
|
||||||
onDeleteColumn: (id: string) => void;
|
onDeleteColumn: (id: string) => void;
|
||||||
onSetWIPLimit: (id: string, limit: number) => void;
|
onSetWIPLimit: (id: string, limit: number) => void;
|
||||||
|
onSetMaxTimeMinutes: (id: string, minutes: number) => void;
|
||||||
onToggleDone: (id: string, is_done: boolean) => void;
|
onToggleDone: (id: string, is_done: boolean) => void;
|
||||||
onEditCard: (card: Card) => void;
|
onEditCard: (card: Card) => void;
|
||||||
onDeleteCard: (id: string) => void;
|
onDeleteCard: (id: string) => void;
|
||||||
@@ -80,6 +82,7 @@ function KanbanColumnImpl({
|
|||||||
onMoveColumnLocation,
|
onMoveColumnLocation,
|
||||||
onDeleteColumn,
|
onDeleteColumn,
|
||||||
onSetWIPLimit,
|
onSetWIPLimit,
|
||||||
|
onSetMaxTimeMinutes,
|
||||||
onToggleDone,
|
onToggleDone,
|
||||||
onEditCard,
|
onEditCard,
|
||||||
onDeleteCard,
|
onDeleteCard,
|
||||||
@@ -390,6 +393,24 @@ function KanbanColumnImpl({
|
|||||||
>
|
>
|
||||||
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
|
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconClock size={14} />}
|
||||||
|
data-test="column-max-time"
|
||||||
|
onClick={() => {
|
||||||
|
const current = column.max_time_minutes || 0;
|
||||||
|
const raw = window.prompt(
|
||||||
|
"Tiempo maximo en minutos (0 = sin limite). Cards que pasen este tiempo en la columna mostraran borde rojo. Columnas Done no aplican.",
|
||||||
|
String(current)
|
||||||
|
);
|
||||||
|
if (raw === null) return;
|
||||||
|
const v = parseInt(raw.trim(), 10);
|
||||||
|
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||||
|
if (safe !== current) onSetMaxTimeMinutes(column.id, safe);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tiempo maximo
|
||||||
|
{column.max_time_minutes > 0 ? ` (${column.max_time_minutes} min)` : ""}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<ArchiveIcon size={14} />}
|
leftSection={<ArchiveIcon size={14} />}
|
||||||
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
||||||
@@ -435,6 +456,11 @@ function KanbanColumnImpl({
|
|||||||
users={users}
|
users={users}
|
||||||
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
|
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
|
||||||
inDoneColumn={column.is_done}
|
inDoneColumn={column.is_done}
|
||||||
|
columnOverdue={
|
||||||
|
!column.is_done &&
|
||||||
|
column.max_time_minutes > 0 &&
|
||||||
|
c.time_in_column_ms > column.max_time_minutes * 60_000
|
||||||
|
}
|
||||||
highlight={highlightCardId === c.id}
|
highlight={highlightCardId === c.id}
|
||||||
activeSticker={activeSticker}
|
activeSticker={activeSticker}
|
||||||
onAddSticker={onAddSticker}
|
onAddSticker={onAddSticker}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Column {
|
|||||||
width: number;
|
width: number;
|
||||||
wip_limit: number;
|
wip_limit: number;
|
||||||
is_done: boolean;
|
is_done: boolean;
|
||||||
|
max_time_minutes: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user