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 = { minutes: 1, hours: 60, days: 60 * 24, weeks: 60 * 24 * 7, months: 60 * 24 * 30, }; const MAX_TIME_UNIT_LABEL: Record = { 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; 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(null); const [wipPopOpen, setWipPopOpen] = useState(false); const [wipDraft, setWipDraft] = useState(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(() => pickInitialUnit(column.max_time_minutes || 0)); const [maxTimeDraft, setMaxTimeDraft] = useState(() => 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(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 ( {renaming ? ( 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 }} /> ) : ( { setName(column.name); setRenaming(true); }} style={{ flex: 1, cursor: "text" }} title="Doble click para renombrar" > {column.name} )} { setWipPopOpen(o); if (o) setWipDraft(column.wip_limit); }} position="bottom" withArrow shadow="md" > 0 ? `WIP ${cards.length}/${wipLimit}${overLimit ? " (excedido)" : ""}` : "Click para limitar WIP" } withArrow > 0 ? "yellow" : "gray"} leftSection={overLimit ? : null} style={{ cursor: "pointer" }} onClick={() => setWipPopOpen((v) => !v)} > {wipLimit > 0 ? `${cards.length}/${wipLimit}` : cards.length} Maximo de tarjetas (0 = sin limite) { if (e.key === "Enter") submitWIP(); if (e.key === "Escape") setWipPopOpen(false); }} /> {renaming ? ( <> { setName(column.name); setRenaming(false); }} aria-label="Cancel" > ) : ( <> {collapsed && ( setBodyHidden((v) => !v)} aria-label={bodyHidden ? "Expandir columna" : "Colapsar columna"} > {bodyHidden ? : } )} {column.is_done && ( }> done )} Columna } onClick={() => { setName(column.name); setRenaming(true); }} > Renombrar } color={column.is_done ? "yellow" : "green"} onClick={() => onToggleDone(column.id, !column.is_done)} > {column.is_done ? "Quitar marca Done" : "Marcar como Done"} { 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} > } 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]}`; })()})` : ""} e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} > Cards que pasen este tiempo se pintaran con borde rojo. 0 = sin limite. Columnas Done no aplican.