9d3ab5f0f3
The random-pick menu entry is meaningless for done columns — cards there are already finished and now get auto-archived after 30 days (issue 0092). Gate the Menu.Item on !column.is_done so the action only appears in active columns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
659 lines
23 KiB
TypeScript
659 lines
23 KiB
TypeScript
import { useSortable } from "@dnd-kit/sortable";
|
|
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Group,
|
|
Menu,
|
|
NumberInput,
|
|
Paper,
|
|
Popover,
|
|
ScrollArea,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import {
|
|
IconArchive,
|
|
IconArchiveOff,
|
|
IconAlertTriangle,
|
|
IconCheck,
|
|
IconCheckbox,
|
|
IconChevronDown,
|
|
IconChevronRight,
|
|
IconClock,
|
|
IconDice5,
|
|
IconDotsVertical,
|
|
IconGripVertical,
|
|
IconPencil,
|
|
IconPlus,
|
|
IconTrash,
|
|
IconX,
|
|
} from "@tabler/icons-react";
|
|
import { memo, MouseEvent, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { Card, CardColor, Column, User } from "../types";
|
|
import { KanbanCard } from "./KanbanCard";
|
|
|
|
type MaxTimeUnit = "minutes" | "hours" | "days" | "weeks" | "months";
|
|
const MAX_TIME_UNIT_MIN: Record<MaxTimeUnit, number> = {
|
|
minutes: 1,
|
|
hours: 60,
|
|
days: 60 * 24,
|
|
weeks: 60 * 24 * 7,
|
|
months: 60 * 24 * 30,
|
|
};
|
|
const MAX_TIME_UNIT_LABEL: Record<MaxTimeUnit, string> = {
|
|
minutes: "minutos",
|
|
hours: "horas",
|
|
days: "dias",
|
|
weeks: "semanas",
|
|
months: "meses",
|
|
};
|
|
const MAX_TIME_UNIT_SELECT_DATA = (Object.keys(MAX_TIME_UNIT_LABEL) as MaxTimeUnit[]).map((u) => ({
|
|
value: u,
|
|
label: MAX_TIME_UNIT_LABEL[u],
|
|
}));
|
|
|
|
interface Props {
|
|
column: Column;
|
|
cards: Card[];
|
|
now: number;
|
|
collapsed?: boolean;
|
|
onAddCard: (columnId: string) => void;
|
|
onRenameColumn: (id: string, name: string) => void;
|
|
onResizeColumn: (id: string, width: number) => void;
|
|
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
|
|
onDeleteColumn: (id: string) => void;
|
|
onSetWIPLimit: (id: string, limit: number) => void;
|
|
onSetMaxTimeMinutes: (id: string, minutes: number) => void;
|
|
onPickRandom: (columnId: string) => void;
|
|
onToggleDone: (id: string, is_done: boolean) => void;
|
|
onEditCard: (card: Card) => void;
|
|
onDeleteCard: (id: string) => void;
|
|
onDuplicateCard: (id: string) => void;
|
|
onChangeCardColor: (id: string, color: CardColor) => void;
|
|
onShowHistory: (card: Card) => void;
|
|
onToggleCardLock: (id: string, locked: boolean) => void;
|
|
onAssignCard: (id: string, assignee_id: string | null) => void;
|
|
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;
|
|
onAddSticker?: (cardId: string, x: number, y: number) => void;
|
|
onRemoveSticker?: (cardId: string, index: number) => void;
|
|
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
|
|
onCommitSticker?: (cardId: string) => void;
|
|
users: User[];
|
|
usersById: Map<string, User>;
|
|
highlightCardId?: string | null;
|
|
}
|
|
|
|
function KanbanColumnImpl({
|
|
column,
|
|
cards,
|
|
now,
|
|
collapsed,
|
|
onAddCard,
|
|
onRenameColumn,
|
|
onResizeColumn,
|
|
onMoveColumnLocation,
|
|
onDeleteColumn,
|
|
onSetWIPLimit,
|
|
onSetMaxTimeMinutes,
|
|
onPickRandom,
|
|
onToggleDone,
|
|
onEditCard,
|
|
onDeleteCard,
|
|
onDuplicateCard,
|
|
onChangeCardColor,
|
|
onShowHistory,
|
|
onToggleCardLock,
|
|
onAssignCard,
|
|
onSetCardDeadline,
|
|
onSetRequester,
|
|
onArchiveCard,
|
|
requesterOptions,
|
|
onOpenCustomCardColor,
|
|
activeSticker,
|
|
onAddSticker,
|
|
onRemoveSticker,
|
|
onMoveSticker,
|
|
onCommitSticker,
|
|
users,
|
|
usersById,
|
|
highlightCardId,
|
|
}: Props) {
|
|
const [renaming, setRenaming] = useState(false);
|
|
const [name, setName] = useState(column.name);
|
|
const [localWidth, setLocalWidth] = useState<number | null>(null);
|
|
const [wipPopOpen, setWipPopOpen] = useState(false);
|
|
const [wipDraft, setWipDraft] = useState<number | string>(column.wip_limit);
|
|
const [maxTimePopOpen, setMaxTimePopOpen] = useState(false);
|
|
// Initial unit picked from current value: largest unit that yields >=1
|
|
const pickInitialUnit = (mins: number): MaxTimeUnit => {
|
|
if (mins <= 0) return "minutes";
|
|
if (mins % 43200 === 0) return "months";
|
|
if (mins % 10080 === 0) return "weeks";
|
|
if (mins % 1440 === 0) return "days";
|
|
if (mins % 60 === 0) return "hours";
|
|
return "minutes";
|
|
};
|
|
const minutesToUnit = (mins: number, u: MaxTimeUnit): number => {
|
|
const div = MAX_TIME_UNIT_MIN[u];
|
|
return mins > 0 ? Math.max(1, Math.round(mins / div)) : 0;
|
|
};
|
|
const [maxTimeUnit, setMaxTimeUnit] = useState<MaxTimeUnit>(() => pickInitialUnit(column.max_time_minutes || 0));
|
|
const [maxTimeDraft, setMaxTimeDraft] = useState<number | string>(() =>
|
|
minutesToUnit(column.max_time_minutes || 0, pickInitialUnit(column.max_time_minutes || 0))
|
|
);
|
|
const [bodyHidden, setBodyHidden] = useState(() => {
|
|
if (!collapsed) return false;
|
|
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
|
|
});
|
|
useEffect(() => {
|
|
if (collapsed) {
|
|
localStorage.setItem(`kanban_col_body_${column.id}`, bodyHidden ? "1" : "0");
|
|
}
|
|
}, [bodyHidden, collapsed, column.id]);
|
|
|
|
const wipLimit = column.wip_limit;
|
|
const overLimit = wipLimit > 0 && cards.length > wipLimit;
|
|
|
|
// sync local width when column.width changes from outside (other clients).
|
|
useEffect(() => {
|
|
setLocalWidth(null);
|
|
}, [column.width]);
|
|
|
|
const sortableData = useMemo(
|
|
() => ({ type: "column" as const, columnId: column.id, location: column.location }),
|
|
[column.id, column.location]
|
|
);
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
id: `column-${column.id}`,
|
|
data: sortableData,
|
|
});
|
|
|
|
const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
|
|
|
|
const style: React.CSSProperties = collapsed
|
|
? {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.4 : 1,
|
|
width: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
position: "relative",
|
|
flex: bodyHidden ? "0 0 auto" : "1 1 auto",
|
|
minHeight: 0,
|
|
}
|
|
: {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.4 : 1,
|
|
width: effectiveWidth,
|
|
minWidth: effectiveWidth,
|
|
maxWidth: effectiveWidth,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "100%",
|
|
position: "relative",
|
|
};
|
|
|
|
const cardIds = cards.map((c) => c.id);
|
|
|
|
const submitRename = () => {
|
|
const trimmed = name.trim();
|
|
if (trimmed && trimmed !== column.name) onRenameColumn(column.id, trimmed);
|
|
setRenaming(false);
|
|
};
|
|
|
|
// --- resize handle ---
|
|
const resizingRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
|
|
const onResizeMouseDown = (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
resizingRef.current = { startX: e.clientX, startWidth: column.width };
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
const onMove = (ev: globalThis.MouseEvent) => {
|
|
if (!resizingRef.current) return;
|
|
const dx = ev.clientX - resizingRef.current.startX;
|
|
const next = Math.min(800, Math.max(200, resizingRef.current.startWidth + dx));
|
|
setLocalWidth(next);
|
|
};
|
|
const onUp = () => {
|
|
if (resizingRef.current && localWidthRef.current !== null) {
|
|
onResizeColumn(column.id, localWidthRef.current);
|
|
}
|
|
resizingRef.current = null;
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
};
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
};
|
|
|
|
// mirror localWidth into a ref so the mouseup handler always sees the latest value.
|
|
const localWidthRef = useRef<number | null>(null);
|
|
useEffect(() => {
|
|
localWidthRef.current = localWidth;
|
|
}, [localWidth]);
|
|
|
|
const isInSidebar = column.location === "sidebar";
|
|
const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar";
|
|
const ArchiveIcon = isInSidebar ? IconArchiveOff : IconArchive;
|
|
|
|
const submitWIP = () => {
|
|
const n = typeof wipDraft === "number" ? wipDraft : parseInt(String(wipDraft), 10);
|
|
const safe = Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
if (safe !== column.wip_limit) onSetWIPLimit(column.id, safe);
|
|
setWipPopOpen(false);
|
|
};
|
|
|
|
const paperBg = overLimit ? "var(--mantine-color-red-9)" : "var(--mantine-color-dark-7)";
|
|
const paperBorderColor = overLimit ? "var(--mantine-color-red-6)" : undefined;
|
|
|
|
return (
|
|
<Paper
|
|
ref={setNodeRef}
|
|
style={{ ...style, background: paperBg, borderColor: paperBorderColor, borderWidth: overLimit ? 2 : 1 }}
|
|
withBorder
|
|
radius="md"
|
|
p="sm"
|
|
data-column-id={column.id}
|
|
data-column-location={column.location}
|
|
>
|
|
<Group justify="space-between" mb="xs" wrap="nowrap">
|
|
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
size="sm"
|
|
{...attributes}
|
|
{...listeners}
|
|
style={{ cursor: "grab" }}
|
|
aria-label="Drag column"
|
|
>
|
|
<IconGripVertical size={14} />
|
|
</ActionIcon>
|
|
{renaming ? (
|
|
<TextInput
|
|
size="xs"
|
|
value={name}
|
|
onChange={(e) => setName(e.currentTarget.value)}
|
|
autoFocus
|
|
onBlur={submitRename}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") submitRename();
|
|
if (e.key === "Escape") {
|
|
setName(column.name);
|
|
setRenaming(false);
|
|
}
|
|
}}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
) : (
|
|
<Text
|
|
fw={600}
|
|
size="sm"
|
|
truncate
|
|
onDoubleClick={() => {
|
|
setName(column.name);
|
|
setRenaming(true);
|
|
}}
|
|
style={{ flex: 1, cursor: "text" }}
|
|
title="Doble click para renombrar"
|
|
>
|
|
{column.name}
|
|
</Text>
|
|
)}
|
|
<Popover
|
|
opened={wipPopOpen}
|
|
onChange={(o) => {
|
|
setWipPopOpen(o);
|
|
if (o) setWipDraft(column.wip_limit);
|
|
}}
|
|
position="bottom"
|
|
withArrow
|
|
shadow="md"
|
|
>
|
|
<Popover.Target>
|
|
<Tooltip
|
|
label={
|
|
wipLimit > 0
|
|
? `WIP ${cards.length}/${wipLimit}${overLimit ? " (excedido)" : ""}`
|
|
: "Click para limitar WIP"
|
|
}
|
|
withArrow
|
|
>
|
|
<Badge
|
|
size="xs"
|
|
variant={overLimit ? "filled" : "light"}
|
|
color={overLimit ? "red" : wipLimit > 0 ? "yellow" : "gray"}
|
|
leftSection={overLimit ? <IconAlertTriangle size={10} /> : null}
|
|
style={{ cursor: "pointer" }}
|
|
onClick={() => setWipPopOpen((v) => !v)}
|
|
>
|
|
{wipLimit > 0 ? `${cards.length}/${wipLimit}` : cards.length}
|
|
</Badge>
|
|
</Tooltip>
|
|
</Popover.Target>
|
|
<Popover.Dropdown p="xs">
|
|
<Stack gap="xs">
|
|
<Text size="xs" c="dimmed">
|
|
Maximo de tarjetas (0 = sin limite)
|
|
</Text>
|
|
<NumberInput
|
|
size="xs"
|
|
value={wipDraft}
|
|
onChange={setWipDraft}
|
|
min={0}
|
|
max={999}
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") submitWIP();
|
|
if (e.key === "Escape") setWipPopOpen(false);
|
|
}}
|
|
/>
|
|
<Group justify="flex-end" gap={4}>
|
|
<Button size="xs" variant="subtle" onClick={() => setWipPopOpen(false)}>
|
|
Cancelar
|
|
</Button>
|
|
<Button size="xs" onClick={submitWIP}>
|
|
Guardar
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
</Group>
|
|
<Group gap={2} wrap="nowrap">
|
|
{renaming ? (
|
|
<>
|
|
<ActionIcon variant="subtle" color="green" size="sm" onClick={submitRename} aria-label="Save">
|
|
<IconCheck size={14} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
size="sm"
|
|
onClick={() => {
|
|
setName(column.name);
|
|
setRenaming(false);
|
|
}}
|
|
aria-label="Cancel"
|
|
>
|
|
<IconX size={14} />
|
|
</ActionIcon>
|
|
</>
|
|
) : (
|
|
<>
|
|
{collapsed && (
|
|
<Tooltip label={bodyHidden ? "Expandir" : "Colapsar"} withArrow>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
size="sm"
|
|
onClick={() => setBodyHidden((v) => !v)}
|
|
aria-label={bodyHidden ? "Expandir columna" : "Colapsar columna"}
|
|
>
|
|
{bodyHidden ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
{column.is_done && (
|
|
<Tooltip label="Columna Done" withArrow>
|
|
<Badge size="xs" color="green" variant="filled" leftSection={<IconCheckbox size={10} />}>
|
|
done
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
<Menu position="bottom-end" shadow="md" withArrow>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones columna">
|
|
<IconDotsVertical size={14} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Label>Columna</Menu.Label>
|
|
<Menu.Item
|
|
leftSection={<IconPencil size={14} />}
|
|
onClick={() => {
|
|
setName(column.name);
|
|
setRenaming(true);
|
|
}}
|
|
>
|
|
Renombrar
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<IconCheckbox size={14} />}
|
|
color={column.is_done ? "yellow" : "green"}
|
|
onClick={() => onToggleDone(column.id, !column.is_done)}
|
|
>
|
|
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
|
|
</Menu.Item>
|
|
<Popover
|
|
opened={maxTimePopOpen}
|
|
onChange={(o) => {
|
|
setMaxTimePopOpen(o);
|
|
if (o) {
|
|
const u = pickInitialUnit(column.max_time_minutes || 0);
|
|
setMaxTimeUnit(u);
|
|
setMaxTimeDraft(minutesToUnit(column.max_time_minutes || 0, u));
|
|
}
|
|
}}
|
|
position="right-start"
|
|
withArrow
|
|
shadow="md"
|
|
withinPortal={false}
|
|
>
|
|
<Popover.Target>
|
|
<Menu.Item
|
|
leftSection={<IconClock size={14} />}
|
|
data-test="column-max-time"
|
|
closeMenuOnClick={false}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setMaxTimePopOpen((v) => !v);
|
|
}}
|
|
>
|
|
Tiempo maximo
|
|
{column.max_time_minutes > 0
|
|
? ` (${(() => {
|
|
const u = pickInitialUnit(column.max_time_minutes);
|
|
return `${minutesToUnit(column.max_time_minutes, u)} ${MAX_TIME_UNIT_LABEL[u]}`;
|
|
})()})`
|
|
: ""}
|
|
</Menu.Item>
|
|
</Popover.Target>
|
|
<Popover.Dropdown
|
|
p="xs"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onDoubleClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Stack gap={6} style={{ minWidth: 240 }}>
|
|
<Text size="xs" c="dimmed">
|
|
Cards que pasen este tiempo se pintaran con borde rojo. 0 = sin limite. Columnas Done no aplican.
|
|
</Text>
|
|
<Group gap={6} wrap="nowrap">
|
|
<NumberInput
|
|
size="xs"
|
|
min={0}
|
|
max={999}
|
|
value={maxTimeDraft}
|
|
onChange={setMaxTimeDraft}
|
|
placeholder="0"
|
|
style={{ width: 90 }}
|
|
data-test="column-max-time-input"
|
|
/>
|
|
<Select
|
|
size="xs"
|
|
value={maxTimeUnit}
|
|
onChange={(v) => v && setMaxTimeUnit(v as MaxTimeUnit)}
|
|
data={MAX_TIME_UNIT_SELECT_DATA}
|
|
style={{ width: 130 }}
|
|
allowDeselect={false}
|
|
data-test="column-max-time-unit"
|
|
/>
|
|
</Group>
|
|
<Group justify="space-between" gap={6}>
|
|
<Tooltip label="Quitar limite" withArrow disabled={!column.max_time_minutes}>
|
|
<ActionIcon
|
|
size="sm"
|
|
variant="subtle"
|
|
color="red"
|
|
disabled={!column.max_time_minutes}
|
|
onClick={() => {
|
|
onSetMaxTimeMinutes(column.id, 0);
|
|
setMaxTimeDraft(0);
|
|
setMaxTimePopOpen(false);
|
|
}}
|
|
>
|
|
<IconTrash size={12} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Button
|
|
size="xs"
|
|
data-test="column-max-time-save"
|
|
onClick={() => {
|
|
const raw =
|
|
typeof maxTimeDraft === "number"
|
|
? maxTimeDraft
|
|
: parseInt(String(maxTimeDraft), 10);
|
|
const n = Number.isFinite(raw) && raw >= 0 ? raw : 0;
|
|
const mins = n * MAX_TIME_UNIT_MIN[maxTimeUnit];
|
|
if (mins !== column.max_time_minutes) {
|
|
onSetMaxTimeMinutes(column.id, mins);
|
|
}
|
|
setMaxTimePopOpen(false);
|
|
}}
|
|
>
|
|
Guardar
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
{!column.is_done && (
|
|
<Menu.Item
|
|
leftSection={<IconDice5 size={14} />}
|
|
data-test="column-random-pick"
|
|
disabled={cards.filter((c) => !c.locked).length === 0}
|
|
onClick={() => onPickRandom(column.id)}
|
|
>
|
|
Seleccionar Aleatorio
|
|
</Menu.Item>
|
|
)}
|
|
<Menu.Item
|
|
leftSection={<ArchiveIcon size={14} />}
|
|
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
|
>
|
|
{archiveLabel}
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<IconTrash size={14} />}
|
|
color="red"
|
|
onClick={() => onDeleteColumn(column.id)}
|
|
>
|
|
Borrar columna
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
|
|
{!(collapsed && bodyHidden) && (
|
|
<>
|
|
<ScrollArea style={{ flex: 1 }} type="auto">
|
|
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
|
|
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
|
|
{cards.map((c) => (
|
|
<KanbanCard
|
|
key={c.id}
|
|
card={c}
|
|
now={now}
|
|
onDelete={onDeleteCard}
|
|
onEdit={onEditCard}
|
|
onDuplicate={onDuplicateCard}
|
|
onChangeColor={onChangeCardColor}
|
|
onShowHistory={onShowHistory}
|
|
onToggleLock={onToggleCardLock}
|
|
onAssign={onAssignCard}
|
|
onSetDeadline={onSetCardDeadline}
|
|
onSetRequester={onSetRequester}
|
|
onArchive={onArchiveCard}
|
|
requesterOptions={requesterOptions}
|
|
onOpenCustomColor={onOpenCustomCardColor}
|
|
users={users}
|
|
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
|
|
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}
|
|
activeSticker={activeSticker}
|
|
onAddSticker={onAddSticker}
|
|
onRemoveSticker={onRemoveSticker}
|
|
onMoveSticker={onMoveSticker}
|
|
onCommitSticker={onCommitSticker}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</SortableContext>
|
|
</ScrollArea>
|
|
|
|
<Button
|
|
variant="subtle"
|
|
color="gray"
|
|
size="xs"
|
|
leftSection={<IconPlus size={14} />}
|
|
onClick={() => onAddCard(column.id)}
|
|
mt="xs"
|
|
fullWidth
|
|
data-test="add-card"
|
|
>
|
|
Anadir tarjeta
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{/* Resize handle (only on board, not sidebar) */}
|
|
{!isInSidebar && (
|
|
<Box
|
|
onMouseDown={onResizeMouseDown}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
right: -3,
|
|
width: 6,
|
|
height: "100%",
|
|
cursor: "col-resize",
|
|
zIndex: 5,
|
|
}}
|
|
aria-label="Resize column"
|
|
/>
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
export const KanbanColumn = memo(KanbanColumnImpl);
|