import { CollisionDetection, DndContext, DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, KeyboardSensor, PointerSensor, closestCenter, closestCorners, pointerWithin, rectIntersection, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, arrayMove, horizontalListSortingStrategy, sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { ActionIcon, AppShell, Avatar, Badge, Box, Button, Checkbox, Group, Loader, Menu, MultiSelect, Paper, Select, Stack, Tabs, Text, TextInput, Title, Tooltip, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import "@mantine/dates/styles.css"; import { modals } from "@mantine/modals"; import { notifications } from "@mantine/notifications"; import { IconArrowBackUp, IconCalendar, IconChartBar, IconCheck, IconChevronDown, IconChevronRight, IconLayoutKanban, IconLogout, IconMenu2, IconMessageChatbot, IconMoodSmile, IconPlus, IconRefresh, IconSearch, IconTrash, IconTrashX, IconX, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as api from "./api"; import { useAuth } from "./auth"; import { CardForm } from "./components/CardForm"; import { CardEditPanel } from "./components/CardEditPanel"; import { ChatPanel } from "./components/ChatPanel"; import { CalendarView } from "./components/CalendarView"; import { DailyReportView } from "./components/DailyReport"; import { Dashboard } from "./components/Dashboard"; import { HistoryModal } from "./components/HistoryModal"; import { KanbanCard } from "./components/KanbanCard"; import { KanbanColumn } from "./components/KanbanColumn"; import { StickerPicker } from "./components/StickerPicker"; import { ColorPickerGrid, CustomColorModal } from "./components/ColorPickerGrid"; import { AVATAR_COLORS } from "./components/colors"; import { colorBg, colorBorder } from "./components/colors"; import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types"; const COL_PREFIX = "column-"; // Custom collision detection: prefiere otras columnas como destino al arrastrar // columnas; al arrastrar cards prefiere cards/columnas via closestCorners. function makeCollisionDetection(activeType: string | undefined): CollisionDetection { if (activeType === "column") { return (args) => { // Solo considerar drops sobre otras columnas (ids con COL_PREFIX). const filtered = args.droppableContainers.filter((c) => String(c.id).startsWith(COL_PREFIX) ); const inter = rectIntersection({ ...args, droppableContainers: filtered }); if (inter.length > 0) return inter; return closestCenter({ ...args, droppableContainers: filtered }); }; } return (args) => { const pw = pointerWithin(args); if (pw.length > 0) return pw; return closestCorners(args); }; } export function App() { const auth = useAuth(); const [board, setBoard] = useState(null); const [users, setUsers] = useState([]); const [activeCard, setActiveCard] = useState(null); const [activeColumnId, setActiveColumnId] = useState(null); const [activeType, setActiveType] = useState(undefined); const [addingCol, setAddingCol] = useState(false); const [colName, setColName] = useState(""); const [now, setNow] = useState(Date.now()); const [chatOpen, setChatOpen] = useState(false); const [activeTab, setActiveTab] = useState("board"); const [trash, setTrash] = useState([]); const [trashOpen, setTrashOpen] = useState(false); const [archive, setArchive] = useState([]); const [archiveOpen, setArchiveOpen] = useState(false); const [tagOptions, setTagOptions] = useState([]); const [requesterOptions, setRequesterOptions] = useState([]); const [searchTerm, setSearchTerm] = useState(""); const [filterAssigneeId, setFilterAssigneeId] = useState(null); const [filterRequester, setFilterRequester] = useState(null); const [filterTags, setFilterTags] = useState([]); const [filterUnassigned, setFilterUnassigned] = useState(false); const [filterDateFrom, setFilterDateFrom] = useState(null); const [filterDateTo, setFilterDateTo] = useState(null); const [filterDeadlineOnly, setFilterDeadlineOnly] = useState(false); const [highlightCardId, setHighlightCardId] = useState(null); const [stickerPickerOpen, setStickerPickerOpen] = useState(false); const [activeSticker, setActiveSticker] = useState(null); const [avatarColorModalOpen, setAvatarColorModalOpen] = useState(false); const [avatarCustomColor, setAvatarCustomColor] = useState("#888888"); const [cardColorModal, setCardColorModal] = useState<{ cardId: string; color: string } | null>(null); const [navOpen, setNavOpen] = useState(false); const [navWidth, setNavWidth] = useState(() => { const stored = localStorage.getItem("kanban_nav_width"); const n = stored ? parseInt(stored, 10) : NaN; return Number.isFinite(n) && n >= 180 && n <= 600 ? n : 240; }); const navWidthRef = useRef(navWidth); useEffect(() => { navWidthRef.current = navWidth; localStorage.setItem("kanban_nav_width", String(navWidth)); }, [navWidth]); const onNavResizeMouseDown = (e: React.MouseEvent) => { e.preventDefault(); const startX = e.clientX; const startWidth = navWidthRef.current; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; const onMove = (ev: MouseEvent) => { const dx = ev.clientX - startX; const next = Math.min(600, Math.max(180, startWidth + dx)); setNavWidth(next); }; const onUp = () => { document.body.style.cursor = ""; document.body.style.userSelect = ""; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }; const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ); // -------- Issue 0091 — drag-aware sidebar dropzone -------- // While a card or column is being dragged, watch the global pointer. // If it dwells inside the 32px left strip for >=400ms, auto-open the sidebar. // We listen to mousemove globally because dnd-kit owns the pointer during // drag, and the strip itself has pointer-events:none so dnd-kit keeps // detecting drop targets underneath. const DRAG_EDGE_WIDTH = 32; const DRAG_EDGE_HOVER_MS = 400; const isDragging = activeCard !== null || activeColumnId !== null; const [edgeArmed, setEdgeArmed] = useState(false); const navOpenRef = useRef(navOpen); useEffect(() => { navOpenRef.current = navOpen; }, [navOpen]); useEffect(() => { if (!isDragging) { setEdgeArmed(false); return; } let timer: number | null = null; let inside = false; // Para evitar que un drag iniciado dentro del sidebar abierto dispare un // cierre inmediato, exigimos que el puntero haya salido de la franja al // menos una vez tras empezar el drag. Asi: abrir = entrar a la franja // tras empezar fuera (que ya pasaba); cerrar = salir de la franja y // volver a entrar. let hasLeftStrip = false; const clear = () => { if (timer !== null) { window.clearTimeout(timer); timer = null; } }; const onMove = (ev: MouseEvent) => { const nowInside = ev.clientX <= DRAG_EDGE_WIDTH; if (nowInside === inside) return; inside = nowInside; // Brillo visible siempre que el puntero este en la franja y haya drag. setEdgeArmed(nowInside); if (!nowInside) { hasLeftStrip = true; clear(); return; } // 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; timer = window.setTimeout(() => { setNavOpen(willOpen); // Tras toggle, resetea el flag para no encadenar otra accion sin // que el usuario salga + vuelva. hasLeftStrip = false; }, DRAG_EDGE_HOVER_MS); }; document.addEventListener("mousemove", onMove); return () => { document.removeEventListener("mousemove", onMove); clear(); setEdgeArmed(false); }; }, [isDragging]); const reload = useCallback(async () => { try { const b = await api.getBoard(); setBoard(b); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }, []); useEffect(() => { reload(); }, [reload]); const reloadUsers = useCallback(async () => { try { const us = await api.listUsers(); setUsers(us); } catch (e) { console.warn("listUsers failed", e); } }, []); const reloadTrash = useCallback(async () => { try { const t = await api.listTrash(); setTrash(t); } catch (e) { console.warn("listTrash failed", e); } }, []); 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(); setTagOptions(t); } catch (e) { console.warn("listTags failed", e); } }, []); const reloadRequesters = useCallback(async () => { try { const r = await api.listRequesters(); setRequesterOptions(r); } catch (e) { console.warn("listRequesters failed", e); } }, []); useEffect(() => { reloadUsers(); }, [reloadUsers]); useEffect(() => { reloadTrash(); }, [reloadTrash]); useEffect(() => { reloadArchive(); }, [reloadArchive]); useEffect(() => { reloadTags(); reloadRequesters(); }, [reloadTags, reloadRequesters]); // Tick de reloj para "tiempo en columna" en cards. Pausamos durante drag // porque dispara re-render de TODAS las cards cada segundo y el drag de // dnd-kit sufre tirones serios con muchos elementos. useEffect(() => { if (activeCard || activeColumnId) return; const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, [activeCard, activeColumnId]); useEffect(() => { const t = setInterval(() => { reload(); }, 30000); return () => clearInterval(t); }, [reload]); useEffect(() => { if (!activeSticker) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setActiveSticker(null); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [activeSticker]); const usersById = useMemo(() => { const m = new Map(); for (const u of users) m.set(u.id, u); return m; }, [users]); const sortedColumns = useMemo(() => { if (!board) return []; return [...board.columns].sort((a, b) => a.position - b.position); }, [board]); const boardColumns = useMemo(() => sortedColumns.filter((c) => c.location !== "sidebar"), [sortedColumns]); const sidebarColumns = useMemo(() => sortedColumns.filter((c) => c.location === "sidebar"), [sortedColumns]); const boardSortableIds = useMemo(() => boardColumns.map((c) => `${COL_PREFIX}${c.id}`), [boardColumns]); const sidebarSortableIds = useMemo(() => sidebarColumns.map((c) => `${COL_PREFIX}${c.id}`), [sidebarColumns]); const cardMatches = useCallback( (c: Card): boolean => { const term = searchTerm.trim().toLowerCase(); if (term) { const hay = [ c.title, c.description, c.requester, ...(c.tags || []), ] .filter(Boolean) .join(" ") .toLowerCase(); if (!hay.includes(term)) return false; } if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false; if (filterUnassigned && c.assignee_id) return false; if (filterRequester && c.requester !== filterRequester) return false; if (filterTags.length > 0) { const cardTags = new Set(c.tags || []); for (const t of filterTags) if (!cardTags.has(t)) return false; } if (filterDeadlineOnly && !c.deadline) return false; if (filterDateFrom || filterDateTo) { const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity; const toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity; const created = c.created_at ? new Date(c.created_at).getTime() : NaN; const moved = c.entered_at ? new Date(c.entered_at).getTime() : NaN; const inRange = (t: number) => !isNaN(t) && t >= fromMs && t <= toMs; if (!inRange(created) && !inRange(moved)) return false; } return true; }, [searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo, filterDeadlineOnly] ); const cardsByColumn = useMemo(() => { const map = new Map(); if (!board) return map; for (const col of board.columns) map.set(col.id, []); for (const c of [...board.cards].sort((a, b) => a.position - b.position)) { if (!cardMatches(c)) continue; const arr = map.get(c.column_id); if (arr) arr.push(c); } return map; }, [board, cardMatches]); const filtersActive = !!searchTerm.trim() || !!filterAssigneeId || filterUnassigned || !!filterRequester || filterTags.length > 0 || !!filterDateFrom || !!filterDateTo || filterDeadlineOnly; const findCard = (id: string): Card | undefined => board?.cards.find((c) => c.id === id); const findColumn = (id: string): Column | undefined => board?.columns.find((c) => c.id === id); const findColumnIdOfCard = (id: string): string | undefined => findCard(id)?.column_id; const isColumnId = (id: string) => id.startsWith(COL_PREFIX); const stripColumnPrefix = (id: string) => id.slice(COL_PREFIX.length); const resolveColumnId = (overId: string): string | undefined => { if (!board) return undefined; if (isColumnId(overId)) return stripColumnPrefix(overId); return findColumnIdOfCard(overId); }; // --- DnD handlers --- const onDragStart = (e: DragStartEvent) => { const id = e.active.id as string; const type = e.active.data.current?.type as string | undefined; setActiveType(type); if (type === "column") { setActiveColumnId(stripColumnPrefix(id)); return; } const c = findCard(id); if (c) setActiveCard(c); }; const onDragOver = (e: DragOverEvent) => { if (!board) return; if (e.active.data.current?.type !== "card") return; const activeId = e.active.id as string; const overId = e.over?.id as string | undefined; if (!overId) return; const fromCol = findColumnIdOfCard(activeId); const toCol = resolveColumnId(overId); if (!fromCol || !toCol || fromCol === toCol) return; setBoard((prev) => { if (!prev) return prev; const cards = prev.cards.map((c) => (c.id === activeId ? { ...c, column_id: toCol } : c)); return { ...prev, cards }; }); }; const onDragEnd = async (e: DragEndEvent) => { const type = e.active.data.current?.type as string | undefined; const activeId = e.active.id as string; const overId = e.over?.id as string | undefined; setActiveCard(null); setActiveColumnId(null); setActiveType(undefined); if (!board || !overId) return; if (type === "column") { if (!isColumnId(overId)) return; const activeColId = stripColumnPrefix(activeId); const overColId = stripColumnPrefix(overId); if (activeColId === overColId) return; const activeCol = findColumn(activeColId); const overCol = findColumn(overColId); if (!activeCol || !overCol) return; // Determine destination location: same as the column it was dropped on. const destLocation: ColumnLocation = overCol.location; const destSiblings = sortedColumns.filter((c) => c.location === destLocation); const destIds = destSiblings.map((c) => c.id); const oldIdx = destIds.indexOf(activeColId); const newIdx = destIds.indexOf(overColId); let reordered: string[]; if (oldIdx === -1) { // Coming from another location: append at overCol position. const insertAt = newIdx === -1 ? destIds.length : newIdx; reordered = [...destIds.slice(0, insertAt), activeColId, ...destIds.slice(insertAt)]; } else { if (oldIdx === newIdx) return; reordered = arrayMove(destIds, oldIdx, newIdx); } // Optimistic update. setBoard((prev) => { if (!prev) return prev; const posMap = new Map(reordered.map((id, i) => [id, i])); const columns = prev.columns.map((c) => { if (c.id === activeColId) return { ...c, location: destLocation, position: posMap.get(c.id) ?? c.position }; if (posMap.has(c.id)) return { ...c, position: posMap.get(c.id)! }; return c; }); return { ...prev, columns }; }); try { if (activeCol.location !== destLocation) { await api.updateColumn(activeColId, { location: destLocation }); } await api.reorderColumns(reordered); } catch (err) { notifications.show({ color: "red", message: (err as Error).message }); } reload(); return; } // Card drag const destCol = resolveColumnId(overId); if (!destCol) return; const activeCard = board.cards.find((c) => c.id === activeId); if (activeCard?.locked && activeCard.column_id !== destCol) { notifications.show({ color: "yellow", message: "Card bloqueada: no se puede mover entre columnas" }); reload(); return; } const destCards = board.cards .filter((c) => c.column_id === destCol) .sort((a, b) => a.position - b.position); const oldIdx = destCards.findIndex((c) => c.id === activeId); let orderedIds: string[]; if (isColumnId(overId) || oldIdx === -1) { orderedIds = [...destCards.filter((c) => c.id !== activeId).map((c) => c.id), activeId]; } else { const newIdx = destCards.findIndex((c) => c.id === overId); orderedIds = arrayMove(destCards.map((c) => c.id), oldIdx, newIdx); } setBoard((prev) => { if (!prev) return prev; const orderMap = new Map(orderedIds.map((id, i) => [id, i])); const cards = prev.cards.map((c) => { if (c.column_id === destCol && orderMap.has(c.id)) return { ...c, position: orderMap.get(c.id)! }; return c; }); return { ...prev, cards }; }); try { await api.moveCard(activeId, destCol, orderedIds); } catch (err) { notifications.show({ color: "red", message: (err as Error).message }); } reload(); }; // --- mutations --- const handleAddColumn = async () => { const n = colName.trim(); if (!n) return; try { await api.createColumn(n); setColName(""); setAddingCol(false); reload(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }; const handleRenameColumn = useCallback(async (id: string, name: string) => { try { await api.updateColumn(id, { name }); reload(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }, [reload]); const handleResizeColumn = useCallback(async (id: string, width: number) => { try { await api.updateColumn(id, { width }); reload(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }, [reload]); const handleMoveColumnLocation = useCallback(async (id: string, location: ColumnLocation) => { try { await api.updateColumn(id, { location }); reload(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }, [reload]); const handleDeleteColumn = useCallback((id: string) => { modals.openConfirmModal({ title: "Eliminar columna", children: Se borraran todas sus tarjetas. Continuar?, labels: { confirm: "Eliminar", cancel: "Cancelar" }, confirmProps: { color: "red" }, onConfirm: async () => { try { await api.deleteColumn(id); reload(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }, }); }, [reload]); const openCreateCard = useCallback((columnId: string) => { const id = modals.open({ title: "Nueva tarjeta", size: "md", children: ( modals.close(id)} onSubmit={async (v) => { try { await api.createCard({ column_id: columnId, requester: v.requester, title: v.title, description: v.description, assignee_id: v.assignee_id, tags: v.tags, }); modals.close(id); reload(); reloadTags(); reloadRequesters(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }} /> ), }); }, [reload, users, auth.user, requesterOptions, tagOptions]); const openEditCard = useCallback((card: Card) => { const id = modals.open({ title: "Editar tarjeta", size: "85%", children: ( modals.close(id)} onSubmit={async (v) => { try { await api.updateCard(card.id, { requester: v.requester, title: v.title, description: v.description, assignee_id: v.assignee_id, tags: v.tags, }); modals.close(id); reload(); reloadTags(); reloadRequesters(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }} /> ), }); }, [reload, users, auth.user, requesterOptions, tagOptions]); const handleDuplicateCard = useCallback(async (cardId: string) => { try { const dup = await api.duplicateCard(cardId); await reload(); notifications.show({ color: "teal", message: `Duplicada: ${dup.title}` }); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }, [reload]); const handleSetRequester = useCallback(async (id: string, requester: string) => { setBoard((prev) => { if (!prev) return prev; return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, requester } : c)) }; }); try { await api.updateCard(id, { requester }); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); reload(); } }, [reload]); const handleJumpToCard = useCallback((cardId: string) => { setActiveTab("board"); setHighlightCardId(cardId); window.setTimeout(() => setHighlightCardId(null), 3000); }, []); const handleOpenDailyReport = useCallback((date: string) => { const id = modals.open({ title: "Reporte diario", size: "90%", children: ( { modals.close(id); handleJumpToCard(cardId); }} /> ), }); }, [handleJumpToCard]); const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => { setBoard((prev) => { if (!prev) return prev; return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, deadline } : c)) }; }); try { await api.updateCard(id, { deadline }); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); reload(); } }, [reload]); const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => { setBoard((prev) => { if (!prev) return prev; return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, assignee_id } : c)) }; }); try { await api.updateCard(id, { assignee_id }); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); reload(); } }, [reload]); const handleDeleteCard = useCallback(async (id: string) => { try { await api.deleteCard(id); reload(); reloadTrash(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }, [reload, reloadTrash]); const handleRestoreCard = useCallback(async (id: string) => { try { await api.restoreCard(id); reload(); reloadTrash(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }, [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", children: Esta accion no se puede deshacer., labels: { confirm: "Borrar", cancel: "Cancelar" }, confirmProps: { color: "red" }, onConfirm: async () => { try { await api.purgeCard(id); reloadTrash(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); } }, }); }, [reloadTrash]); const handleChangeCardColor = useCallback(async (id: string, color: CardColor) => { setBoard((prev) => { if (!prev) return prev; return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, color } : c)) }; }); try { await api.updateCard(id, { color }); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); reload(); } }, [reload]); const persistStickers = useCallback(async (id: string, stickers: Card["stickers"]) => { setBoard((prev) => { if (!prev) return prev; return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, stickers } : c)) }; }); try { await api.updateCardStickers(id, stickers); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); reload(); } }, [reload]); const handleAddSticker = useCallback((cardId: string, x: number, y: number) => { if (!activeSticker) return; setBoard((prev) => { if (!prev) return prev; const cards = prev.cards.map((c) => { if (c.id !== cardId) return c; const stickers = [...(c.stickers || []), { emoji: activeSticker, x, y }]; api.updateCardStickers(cardId, stickers).catch((e) => { notifications.show({ color: "red", message: (e as Error).message }); reload(); }); return { ...c, stickers }; }); return { ...prev, cards }; }); }, [activeSticker, reload]); const handleRemoveSticker = useCallback((cardId: string, index: number) => { setBoard((prev) => { if (!prev) return prev; const cards = prev.cards.map((c) => { if (c.id !== cardId) return c; const stickers = (c.stickers || []).filter((_, i) => i !== index); api.updateCardStickers(cardId, stickers).catch((e) => { notifications.show({ color: "red", message: (e as Error).message }); reload(); }); return { ...c, stickers }; }); return { ...prev, cards }; }); }, [reload]); const handleMoveSticker = useCallback((cardId: string, index: number, x: number, y: number) => { setBoard((prev) => { if (!prev) return prev; const cards = prev.cards.map((c) => { if (c.id !== cardId) return c; const stickers = (c.stickers || []).map((s, i) => (i === index ? { ...s, x, y } : s)); return { ...c, stickers }; }); return { ...prev, cards }; }); }, []); const handleCommitSticker = useCallback((cardId: string) => { setBoard((prev) => { if (!prev) return prev; const card = prev.cards.find((c) => c.id === cardId); if (card) persistStickers(cardId, card.stickers || []); return prev; }); }, [persistStickers]); const handleShowHistory = useCallback((card: Card) => { modals.open({ title: card.title, size: "md", children: , }); }, [board?.columns]); const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => { setBoard((prev) => { if (!prev) return prev; return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, locked } : c)) }; }); try { await api.updateCard(id, { locked }); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); reload(); } }, [reload]); const handleSetWIPLimit = useCallback(async (id: string, wip_limit: number) => { setBoard((prev) => { if (!prev) return prev; return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, wip_limit } : c)) }; }); try { await api.updateColumn(id, { wip_limit }); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); reload(); } }, [reload]); // Issue 0090: ruleta de seleccion aleatoria por columna. // Recorre las cards visibles (post-filtro) no bloqueadas con highlight // acelerado-decelerado y termina con flash verde sobre la ganadora. const handlePickRandom = useCallback((columnId: string) => { const cards = (cardsByColumn.get(columnId) || []).filter((c) => !c.locked); if (cards.length === 0) { notifications.show({ color: "yellow", message: "No hay cards disponibles (filtro y bloqueadas excluidas)" }); return; } if (cards.length === 1) { const el = document.querySelector(`[data-card-id="${cards[0].id}"]`); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); el.classList.add("kanban-roulette-winner"); setTimeout(() => el.classList.remove("kanban-roulette-winner"), 1700); } return; } // Decide ganadora con seguridad criptografica. const winnerIdx = (() => { const buf = new Uint32Array(1); crypto.getRandomValues(buf); return buf[0] % cards.length; })(); // Total steps: minimo 2 vueltas completas + offset hasta la ganadora. const baseLaps = 2; const totalSteps = baseLaps * cards.length + ((winnerIdx - 0 + cards.length) % cards.length); // Decay temporal: empieza rapido (50ms), termina lento (220ms). const startMs = 50; const endMs = 220; const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); let step = 0; const tick = () => { const idx = step % cards.length; const prevIdx = (idx - 1 + cards.length) % cards.length; const prevEl = document.querySelector(`[data-card-id="${cards[prevIdx].id}"]`); const currEl = document.querySelector(`[data-card-id="${cards[idx].id}"]`); if (prevEl) prevEl.classList.remove("kanban-roulette-active"); if (currEl) { currEl.classList.add("kanban-roulette-active"); currEl.scrollIntoView({ behavior: "smooth", block: "center" }); } step++; if (step > totalSteps) { if (currEl) { currEl.classList.remove("kanban-roulette-active"); currEl.classList.add("kanban-roulette-winner"); setTimeout(() => currEl.classList.remove("kanban-roulette-winner"), 1700); } return; } const t = totalSteps > 0 ? step / totalSteps : 1; const delay = startMs + (endMs - startMs) * easeOut(t); setTimeout(tick, delay); }; tick(); }, [cardsByColumn]); 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) => { setBoard((prev) => { if (!prev) return prev; return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, is_done } : c)) }; }); try { await api.updateColumn(id, { is_done }); reload(); } catch (e) { notifications.show({ color: "red", message: (e as Error).message }); reload(); } }, [reload]); const headerConfig = useMemo(() => ({ height: 50 }), []); const navbarConfig = useMemo( () => ({ width: navWidth, breakpoint: "md" as const, collapsed: { mobile: !navOpen, desktop: !navOpen }, }), [navWidth, navOpen] ); const asideConfig = useMemo( () => ({ width: 380, breakpoint: "md" as const, collapsed: { mobile: !chatOpen, desktop: !chatOpen }, }), [chatOpen] ); const appShellStyles = useMemo( () => ({ main: { paddingInlineStart: 0, paddingInlineEnd: 0 } }), [] ); if (!board) { return ( ); } const dragOverlayCard = activeCard; const dragOverlayColumn = activeColumnId ? findColumn(activeColumnId) : null; return ( {/* Issue 0091 — drag-aware left edge strip; opens sidebar on hover>=400ms */}