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, 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 { ChatPanel } from "./components/ChatPanel"; import { CalendarView } from "./components/CalendarView"; 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 { 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 [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 [stickerPickerOpen, setStickerPickerOpen] = useState(false); const [activeSticker, setActiveSticker] = useState(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 }) ); 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 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(() => { reloadTags(); reloadRequesters(); }, [reloadTags, reloadRequesters]); useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []); 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 (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] ); 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; 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 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: "md", 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, requesterOptions, tagOptions]); 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 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: , }); }, []); 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]); 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 ( setNavOpen((v) => !v)} aria-label="Toggle sidebar" > Kanban v && setActiveTab(v)} variant="pills" ml="md"> }> Tablero }> Dashboard }> Calendario setChatOpen((v) => !v)} aria-label="Toggle chat" > {auth.user && ( {(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()} {auth.user.display_name || auth.user.username} } color="red" onClick={() => auth.logout()} > Cerrar sesion )} {/* Drag handle to resize navbar — absolute relative to navbar (which is position:fixed in v9) */} Columnas parqueadas {sidebarColumns.length === 0 && ( Vacio. Mueve columnas aqui con el icono "archivar" en su cabecera. )} {sidebarColumns.map((col) => ( ))} {trashOpen && ( {trash.length === 0 && ( Vacia. )} {trash.map((c) => ( {c.title} handleRestoreCard(c.id)}> handlePurgeCard(c.id)}> ))} )} {activeTab === "dashboard" ? ( ) : activeTab === "calendar" ? ( ) : ( } placeholder="Buscar (titulo, descripcion, solicitante, tag)" value={searchTerm} onChange={(e) => setSearchTerm(e.currentTarget.value)} rightSection={ searchTerm ? ( setSearchTerm("")} aria-label="Limpiar"> ) : null } style={{ minWidth: 280, flex: 1 }} size="xs" /> setFilterDateFrom(v ? new Date(v as unknown as string) : null)} clearable size="xs" style={{ minWidth: 130 }} valueFormat="DD/MM/YY" /> setFilterDateTo(v ? new Date(v as unknown as string) : null)} clearable size="xs" style={{ minWidth: 130 }} valueFormat="DD/MM/YY" /> setStickerPickerOpen(false)} onSelect={(emoji) => setActiveSticker(emoji)} target={ } /> {activeSticker && ( )} {filtersActive && ( )} {boardColumns.map((col) => ( ))} {addingCol ? ( setColName(e.currentTarget.value)} autoFocus onKeyDown={(e) => { if (e.key === "Enter") handleAddColumn(); if (e.key === "Escape") { setAddingCol(false); setColName(""); } }} /> setAddingCol(false)}> ) : ( )} )} {dragOverlayCard ? ( {}} onEdit={() => {}} onChangeColor={() => {}} onShowHistory={() => {}} onToggleLock={() => {}} onAssign={() => {}} users={users} assignee={dragOverlayCard.assignee_id ? usersById.get(dragOverlayCard.assignee_id) : undefined} isOverlay /> ) : dragOverlayColumn ? ( {dragOverlayColumn.name} ) : null} ); }