656516f219
- backend: Sticker type, idempotent stickers column, PUT /api/cards/:id/stickers, 4 tests - frontend: emoji-mart picker, toolbar button + ESC, draggable overlay with right-click delete, % coords for resize survival - dashboard: null guards on metrics arrays Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1265 lines
43 KiB
TypeScript
1265 lines
43 KiB
TypeScript
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<Board | null>(null);
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [activeCard, setActiveCard] = useState<Card | null>(null);
|
|
const [activeColumnId, setActiveColumnId] = useState<string | null>(null);
|
|
const [activeType, setActiveType] = useState<string | undefined>(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<string>("board");
|
|
const [trash, setTrash] = useState<Card[]>([]);
|
|
const [trashOpen, setTrashOpen] = useState(false);
|
|
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
|
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [filterAssigneeId, setFilterAssigneeId] = useState<string | null>(null);
|
|
const [filterRequester, setFilterRequester] = useState<string | null>(null);
|
|
const [filterTags, setFilterTags] = useState<string[]>([]);
|
|
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
|
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
|
|
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
|
|
const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
|
|
const [activeSticker, setActiveSticker] = useState<string | null>(null);
|
|
const [navOpen, setNavOpen] = useState(false);
|
|
const [navWidth, setNavWidth] = useState<number>(() => {
|
|
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<string, User>();
|
|
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<string, Card[]>();
|
|
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: <Text size="sm">Se borraran todas sus tarjetas. Continuar?</Text>,
|
|
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: (
|
|
<CardForm
|
|
users={users}
|
|
requesterOptions={requesterOptions}
|
|
tagOptions={tagOptions}
|
|
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
|
|
submitLabel="Crear"
|
|
onCancel={() => 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: (
|
|
<CardForm
|
|
users={users}
|
|
requesterOptions={requesterOptions}
|
|
tagOptions={tagOptions}
|
|
initial={{
|
|
requester: card.requester,
|
|
title: card.title,
|
|
description: card.description,
|
|
assignee_id: card.assignee_id,
|
|
tags: card.tags || [],
|
|
}}
|
|
submitLabel="Guardar"
|
|
onCancel={() => 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: <Text size="sm">Esta accion no se puede deshacer.</Text>,
|
|
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: <HistoryModal card={card} />,
|
|
});
|
|
}, []);
|
|
|
|
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 (
|
|
<Group justify="center" p="xl">
|
|
<Loader />
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
const dragOverlayCard = activeCard;
|
|
const dragOverlayColumn = activeColumnId ? findColumn(activeColumnId) : null;
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={makeCollisionDetection(activeType)}
|
|
onDragStart={onDragStart}
|
|
onDragOver={onDragOver}
|
|
onDragEnd={onDragEnd}
|
|
>
|
|
<AppShell
|
|
header={headerConfig}
|
|
navbar={navbarConfig}
|
|
aside={asideConfig}
|
|
padding={0}
|
|
styles={appShellStyles}
|
|
>
|
|
<AppShell.Header>
|
|
<Group h="100%" px="md" justify="space-between">
|
|
<Group gap={6}>
|
|
<ActionIcon
|
|
variant={navOpen ? "filled" : "subtle"}
|
|
onClick={() => setNavOpen((v) => !v)}
|
|
aria-label="Toggle sidebar"
|
|
>
|
|
<IconMenu2 size={16} />
|
|
</ActionIcon>
|
|
<IconLayoutKanban size={22} />
|
|
<Title order={4}>Kanban</Title>
|
|
<Tabs value={activeTab} onChange={(v) => v && setActiveTab(v)} variant="pills" ml="md">
|
|
<Tabs.List>
|
|
<Tabs.Tab value="board" leftSection={<IconLayoutKanban size={14} />}>
|
|
Tablero
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="dashboard" leftSection={<IconChartBar size={14} />}>
|
|
Dashboard
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="calendar" leftSection={<IconCalendar size={14} />}>
|
|
Calendario
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
</Tabs>
|
|
</Group>
|
|
<Group gap={4}>
|
|
<ActionIcon variant="subtle" onClick={reload} aria-label="Refresh">
|
|
<IconRefresh size={16} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
variant={chatOpen ? "filled" : "subtle"}
|
|
onClick={() => setChatOpen((v) => !v)}
|
|
aria-label="Toggle chat"
|
|
>
|
|
<IconMessageChatbot size={16} />
|
|
</ActionIcon>
|
|
{auth.user && (
|
|
<Menu position="bottom-end" shadow="md" withArrow>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" aria-label="Usuario">
|
|
<Avatar size={26} radius="xl" color="blue">
|
|
{(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()}
|
|
</Avatar>
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Label>{auth.user.display_name || auth.user.username}</Menu.Label>
|
|
<Menu.Item
|
|
leftSection={<IconLogout size={14} />}
|
|
color="red"
|
|
onClick={() => auth.logout()}
|
|
>
|
|
Cerrar sesion
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
</AppShell.Header>
|
|
|
|
<AppShell.Navbar p="xs">
|
|
{/* Drag handle to resize navbar — absolute relative to navbar (which is position:fixed in v9) */}
|
|
<Box
|
|
onMouseDown={onNavResizeMouseDown}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
right: -3,
|
|
width: 6,
|
|
height: "100%",
|
|
cursor: "col-resize",
|
|
zIndex: 10,
|
|
}}
|
|
aria-label="Resize sidebar"
|
|
/>
|
|
<Stack gap="xs" h="100%">
|
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
|
Columnas parqueadas
|
|
</Text>
|
|
<Box style={{ flex: 1, overflowY: "auto" }}>
|
|
<SortableContext items={sidebarSortableIds} strategy={verticalListSortingStrategy}>
|
|
<Stack gap="xs">
|
|
{sidebarColumns.length === 0 && (
|
|
<Text size="xs" c="dimmed">
|
|
Vacio. Mueve columnas aqui con el icono "archivar" en su cabecera.
|
|
</Text>
|
|
)}
|
|
{sidebarColumns.map((col) => (
|
|
<KanbanColumn
|
|
key={col.id}
|
|
column={col}
|
|
cards={cardsByColumn.get(col.id) ?? []}
|
|
now={now}
|
|
collapsed
|
|
onAddCard={openCreateCard}
|
|
onRenameColumn={handleRenameColumn}
|
|
onResizeColumn={handleResizeColumn}
|
|
onMoveColumnLocation={handleMoveColumnLocation}
|
|
onDeleteColumn={handleDeleteColumn}
|
|
onSetWIPLimit={handleSetWIPLimit}
|
|
onToggleDone={handleToggleDone}
|
|
onEditCard={openEditCard}
|
|
onDeleteCard={handleDeleteCard}
|
|
onChangeCardColor={handleChangeCardColor}
|
|
onShowHistory={handleShowHistory}
|
|
onToggleCardLock={handleToggleCardLock}
|
|
onAssignCard={handleAssignCard}
|
|
activeSticker={activeSticker}
|
|
onAddSticker={handleAddSticker}
|
|
onRemoveSticker={handleRemoveSticker}
|
|
onMoveSticker={handleMoveSticker}
|
|
onCommitSticker={handleCommitSticker}
|
|
users={users}
|
|
usersById={usersById}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</SortableContext>
|
|
</Box>
|
|
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
|
|
<Button
|
|
variant="subtle"
|
|
color="gray"
|
|
size="xs"
|
|
fullWidth
|
|
justify="space-between"
|
|
leftSection={<IconTrash size={14} />}
|
|
rightSection={
|
|
<Group gap={4}>
|
|
<Badge size="xs" variant="light" color={trash.length > 0 ? "red" : "gray"}>
|
|
{trash.length}
|
|
</Badge>
|
|
{trashOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
|
|
</Group>
|
|
}
|
|
onClick={() => setTrashOpen((v) => !v)}
|
|
>
|
|
Papelera
|
|
</Button>
|
|
{trashOpen && (
|
|
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
|
|
{trash.length === 0 && (
|
|
<Text size="xs" c="dimmed" px="xs">
|
|
Vacia.
|
|
</Text>
|
|
)}
|
|
{trash.map((c) => (
|
|
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7">
|
|
<Group justify="space-between" gap={4} wrap="nowrap">
|
|
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
|
|
{c.title}
|
|
</Text>
|
|
<Tooltip label="Restaurar" withArrow>
|
|
<ActionIcon size="xs" variant="subtle" color="green" onClick={() => handleRestoreCard(c.id)}>
|
|
<IconArrowBackUp size={12} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label="Borrar permanentemente" withArrow>
|
|
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => handlePurgeCard(c.id)}>
|
|
<IconTrashX size={12} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
</Stack>
|
|
</AppShell.Navbar>
|
|
|
|
<AppShell.Aside>
|
|
<ChatPanel onBoardChange={reload} />
|
|
</AppShell.Aside>
|
|
|
|
<AppShell.Main>
|
|
{activeTab === "dashboard" ? (
|
|
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
|
|
<Dashboard users={users} />
|
|
</Box>
|
|
) : activeTab === "calendar" ? (
|
|
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
|
|
<CalendarView users={users} />
|
|
</Box>
|
|
) : (
|
|
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
|
<Group gap="xs" p="xs" wrap="wrap" align="end" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}>
|
|
<TextInput
|
|
leftSection={<IconSearch size={14} />}
|
|
placeholder="Buscar (titulo, descripcion, solicitante, tag)"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.currentTarget.value)}
|
|
rightSection={
|
|
searchTerm ? (
|
|
<ActionIcon size="sm" variant="subtle" color="gray" onClick={() => setSearchTerm("")} aria-label="Limpiar">
|
|
<IconX size={14} />
|
|
</ActionIcon>
|
|
) : null
|
|
}
|
|
style={{ minWidth: 280, flex: 1 }}
|
|
size="xs"
|
|
/>
|
|
<Select
|
|
placeholder="Asignado"
|
|
value={filterAssigneeId}
|
|
onChange={setFilterAssigneeId}
|
|
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
|
|
clearable
|
|
searchable
|
|
size="xs"
|
|
style={{ minWidth: 160 }}
|
|
disabled={filterUnassigned}
|
|
/>
|
|
<Checkbox
|
|
size="xs"
|
|
label="Sin asignar"
|
|
checked={filterUnassigned}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.checked;
|
|
setFilterUnassigned(v);
|
|
if (v) setFilterAssigneeId(null);
|
|
}}
|
|
/>
|
|
<Select
|
|
placeholder="Solicitante"
|
|
value={filterRequester}
|
|
onChange={setFilterRequester}
|
|
data={requesterOptions}
|
|
clearable
|
|
searchable
|
|
size="xs"
|
|
style={{ minWidth: 160 }}
|
|
/>
|
|
<MultiSelect
|
|
placeholder="Tags"
|
|
value={filterTags}
|
|
onChange={setFilterTags}
|
|
data={tagOptions}
|
|
clearable
|
|
searchable
|
|
size="xs"
|
|
style={{ minWidth: 200 }}
|
|
/>
|
|
<DatePickerInput
|
|
placeholder="Desde"
|
|
value={filterDateFrom}
|
|
onChange={(v) => setFilterDateFrom(v ? new Date(v as unknown as string) : null)}
|
|
clearable
|
|
size="xs"
|
|
style={{ minWidth: 130 }}
|
|
valueFormat="DD/MM/YY"
|
|
/>
|
|
<DatePickerInput
|
|
placeholder="Hasta"
|
|
value={filterDateTo}
|
|
onChange={(v) => setFilterDateTo(v ? new Date(v as unknown as string) : null)}
|
|
clearable
|
|
size="xs"
|
|
style={{ minWidth: 130 }}
|
|
valueFormat="DD/MM/YY"
|
|
/>
|
|
<Group gap={4}>
|
|
<Button size="xs" variant="default" onClick={() => {
|
|
const t = new Date();
|
|
setFilterDateFrom(t);
|
|
setFilterDateTo(t);
|
|
}}>Hoy</Button>
|
|
<Button size="xs" variant="default" onClick={() => {
|
|
const t = new Date();
|
|
const f = new Date();
|
|
f.setDate(f.getDate() - 7);
|
|
setFilterDateFrom(f);
|
|
setFilterDateTo(t);
|
|
}}>7d</Button>
|
|
<Button size="xs" variant="default" onClick={() => {
|
|
const t = new Date();
|
|
const f = new Date();
|
|
f.setDate(f.getDate() - 30);
|
|
setFilterDateFrom(f);
|
|
setFilterDateTo(t);
|
|
}}>30d</Button>
|
|
</Group>
|
|
<StickerPicker
|
|
opened={stickerPickerOpen}
|
|
onClose={() => setStickerPickerOpen(false)}
|
|
onSelect={(emoji) => setActiveSticker(emoji)}
|
|
target={
|
|
<Button
|
|
size="xs"
|
|
variant={activeSticker ? "filled" : "default"}
|
|
color={activeSticker ? "yellow" : undefined}
|
|
leftSection={<IconMoodSmile size={14} />}
|
|
onClick={() => setStickerPickerOpen((v) => !v)}
|
|
>
|
|
{activeSticker ? `Modo sticker: ${activeSticker}` : "Stickers"}
|
|
</Button>
|
|
}
|
|
/>
|
|
{activeSticker && (
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
color="gray"
|
|
leftSection={<IconX size={12} />}
|
|
onClick={() => setActiveSticker(null)}
|
|
>
|
|
ESC
|
|
</Button>
|
|
)}
|
|
{filtersActive && (
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
color="gray"
|
|
leftSection={<IconX size={12} />}
|
|
onClick={() => {
|
|
setSearchTerm("");
|
|
setFilterAssigneeId(null);
|
|
setFilterUnassigned(false);
|
|
setFilterRequester(null);
|
|
setFilterTags([]);
|
|
setFilterDateFrom(null);
|
|
setFilterDateTo(null);
|
|
}}
|
|
>
|
|
Limpiar
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
|
|
<Group
|
|
align="stretch"
|
|
wrap="nowrap"
|
|
gap="md"
|
|
p="md"
|
|
style={{ flex: 1, overflowX: "auto", overflowY: "hidden" }}
|
|
>
|
|
{boardColumns.map((col) => (
|
|
<KanbanColumn
|
|
key={col.id}
|
|
column={col}
|
|
cards={cardsByColumn.get(col.id) ?? []}
|
|
now={now}
|
|
onAddCard={openCreateCard}
|
|
onRenameColumn={handleRenameColumn}
|
|
onResizeColumn={handleResizeColumn}
|
|
onMoveColumnLocation={handleMoveColumnLocation}
|
|
onDeleteColumn={handleDeleteColumn}
|
|
onSetWIPLimit={handleSetWIPLimit}
|
|
onToggleDone={handleToggleDone}
|
|
onEditCard={openEditCard}
|
|
onDeleteCard={handleDeleteCard}
|
|
onChangeCardColor={handleChangeCardColor}
|
|
onShowHistory={handleShowHistory}
|
|
onToggleCardLock={handleToggleCardLock}
|
|
onAssignCard={handleAssignCard}
|
|
activeSticker={activeSticker}
|
|
onAddSticker={handleAddSticker}
|
|
onRemoveSticker={handleRemoveSticker}
|
|
onMoveSticker={handleMoveSticker}
|
|
onCommitSticker={handleCommitSticker}
|
|
users={users}
|
|
usersById={usersById}
|
|
/>
|
|
))}
|
|
|
|
<Box style={{ minWidth: 280, maxWidth: 320 }}>
|
|
{addingCol ? (
|
|
<Stack gap={4}>
|
|
<TextInput
|
|
size="xs"
|
|
placeholder="Nombre de columna..."
|
|
value={colName}
|
|
onChange={(e) => setColName(e.currentTarget.value)}
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleAddColumn();
|
|
if (e.key === "Escape") {
|
|
setAddingCol(false);
|
|
setColName("");
|
|
}
|
|
}}
|
|
/>
|
|
<Group gap={4}>
|
|
<Button size="xs" onClick={handleAddColumn}>
|
|
Anadir
|
|
</Button>
|
|
<ActionIcon variant="subtle" color="gray" onClick={() => setAddingCol(false)}>
|
|
<IconX size={14} />
|
|
</ActionIcon>
|
|
</Group>
|
|
</Stack>
|
|
) : (
|
|
<Button
|
|
variant="light"
|
|
color="gray"
|
|
leftSection={<IconPlus size={14} />}
|
|
onClick={() => setAddingCol(true)}
|
|
>
|
|
Anadir columna
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
</SortableContext>
|
|
</Box>
|
|
)}
|
|
</AppShell.Main>
|
|
|
|
</AppShell>
|
|
|
|
<DragOverlay>
|
|
{dragOverlayCard ? (
|
|
<KanbanCard
|
|
card={dragOverlayCard}
|
|
now={now}
|
|
onDelete={() => {}}
|
|
onEdit={() => {}}
|
|
onChangeColor={() => {}}
|
|
onShowHistory={() => {}}
|
|
onToggleLock={() => {}}
|
|
onAssign={() => {}}
|
|
users={users}
|
|
assignee={dragOverlayCard.assignee_id ? usersById.get(dragOverlayCard.assignee_id) : undefined}
|
|
isOverlay
|
|
/>
|
|
) : dragOverlayColumn ? (
|
|
<Box
|
|
style={{
|
|
width: dragOverlayColumn.location === "sidebar" ? 220 : dragOverlayColumn.width,
|
|
padding: 8,
|
|
background: colorBg(""),
|
|
border: `1px solid ${colorBorder("")}`,
|
|
borderRadius: 8,
|
|
opacity: 0.9,
|
|
}}
|
|
>
|
|
<Text fw={600} size="sm">
|
|
{dragOverlayColumn.name}
|
|
</Text>
|
|
</Box>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
}
|