chore: sync from fn-registry agent
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
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,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconColumnInsertRight,
|
||||
IconLayoutKanban,
|
||||
IconMenu2,
|
||||
IconMessageChatbot,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as api from "./api";
|
||||
import { CardForm } from "./components/CardForm";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { HistoryModal } from "./components/HistoryModal";
|
||||
import { KanbanCard } from "./components/KanbanCard";
|
||||
import { KanbanColumn } from "./components/KanbanColumn";
|
||||
import { colorBg, colorBorder } from "./components/colors";
|
||||
import type { Board, Card, CardColor, Column, ColumnLocation } from "./types";
|
||||
|
||||
const COL_PREFIX = "column-";
|
||||
|
||||
function AddColumnDialog({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (name: string) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const submit = () => {
|
||||
const n = name.trim();
|
||||
if (n) onSubmit(n);
|
||||
};
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Nombre"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="subtle" color="gray" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!name.trim()}>
|
||||
Crear
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 [board, setBoard] = useState<Board | null>(null);
|
||||
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 [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]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
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 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)) {
|
||||
const arr = map.get(c.column_id);
|
||||
if (arr) arr.push(c);
|
||||
}
|
||||
return map;
|
||||
}, [board]);
|
||||
|
||||
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 openAddColumnModal = useCallback(() => {
|
||||
const id = modals.open({
|
||||
title: "Nueva columna",
|
||||
size: "sm",
|
||||
children: <AddColumnDialog onSubmit={async (name) => {
|
||||
try {
|
||||
await api.createColumn(name);
|
||||
modals.close(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}} onCancel={() => modals.close(id)} />,
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
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
|
||||
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,
|
||||
});
|
||||
modals.close(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
const openEditCard = useCallback((card: Card) => {
|
||||
const id = modals.open({
|
||||
title: "Editar tarjeta",
|
||||
size: "md",
|
||||
children: (
|
||||
<CardForm
|
||||
initial={{ requester: card.requester, title: card.title, description: card.description }}
|
||||
submitLabel="Guardar"
|
||||
onCancel={() => modals.close(id)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
await api.updateCard(card.id, {
|
||||
requester: v.requester,
|
||||
title: v.title,
|
||||
description: v.description,
|
||||
});
|
||||
modals.close(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
const handleDeleteCard = useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.deleteCard(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
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 handleShowHistory = useCallback((card: Card) => {
|
||||
modals.open({
|
||||
title: card.title,
|
||||
size: "md",
|
||||
children: <HistoryModal card={card} />,
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const dragOverlayCard = activeCard;
|
||||
const dragOverlayColumn = activeColumnId ? findColumn(activeColumnId) : null;
|
||||
|
||||
// Memo configs — objetos inline causan re-emit del <style> inline de Mantine
|
||||
// cada vez que `now` (tick 1s) o cualquier otro state actualice.
|
||||
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 } }),
|
||||
[]
|
||||
);
|
||||
|
||||
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>
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
<Tooltip label="Nueva columna" withArrow>
|
||||
<ActionIcon variant="subtle" onClick={openAddColumnModal} aria-label="Add column">
|
||||
<IconColumnInsertRight size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<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>
|
||||
</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}
|
||||
onEditCard={openEditCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onChangeCardColor={handleChangeCardColor}
|
||||
onShowHistory={handleShowHistory}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Aside>
|
||||
<ChatPanel onBoardChange={reload} />
|
||||
</AppShell.Aside>
|
||||
|
||||
<AppShell.Main>
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden" }}>
|
||||
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
|
||||
<Group
|
||||
align="stretch"
|
||||
wrap="nowrap"
|
||||
gap="md"
|
||||
p="md"
|
||||
style={{ height: "100%", overflowX: "auto" }}
|
||||
>
|
||||
{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}
|
||||
onEditCard={openEditCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onChangeCardColor={handleChangeCardColor}
|
||||
onShowHistory={handleShowHistory}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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={() => {}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user