chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-05-06 19:04:45 +02:00
commit 94223e68f7
31 changed files with 6837 additions and 0 deletions
+747
View File
@@ -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>
);
}