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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Board, Card, Column, HistoryEntry } from "./types";
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers || {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ Message: res.statusText }));
|
||||
throw new Error(err.Message || err.message || res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function getBoard(): Promise<Board> {
|
||||
return fetchJSON("/board");
|
||||
}
|
||||
|
||||
export function createColumn(name: string): Promise<Column> {
|
||||
return fetchJSON("/columns", { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
export interface UpdateColumnInput {
|
||||
name?: string;
|
||||
position?: number;
|
||||
location?: "board" | "sidebar";
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
|
||||
return fetchJSON(`/columns/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteColumn(id: string): Promise<void> {
|
||||
return fetchJSON(`/columns/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function reorderColumns(ids: string[]): Promise<void> {
|
||||
return fetchJSON("/columns/reorder", { method: "POST", body: JSON.stringify({ ids }) });
|
||||
}
|
||||
|
||||
export interface CreateCardInput {
|
||||
column_id: string;
|
||||
requester?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function createCard(input: CreateCardInput): Promise<Card> {
|
||||
return fetchJSON("/cards", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export interface UpdateCardInput {
|
||||
requester?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}`, { method: "PATCH", body: JSON.stringify(patch) });
|
||||
}
|
||||
|
||||
export function deleteCard(id: string): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ column_id, ordered_ids }),
|
||||
});
|
||||
}
|
||||
|
||||
export function cardHistory(id: string): Promise<HistoryEntry[]> {
|
||||
return fetchJSON(`/cards/${id}/history`);
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatToolCall {
|
||||
tool: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
role: "assistant";
|
||||
content: string;
|
||||
board_changed: boolean;
|
||||
tool_calls?: ChatToolCall[];
|
||||
}
|
||||
|
||||
export function sendChat(messages: ChatMessage[]): Promise<ChatResponse> {
|
||||
return fetchJSON("/chat", { method: "POST", body: JSON.stringify({ messages }) });
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { FormEvent, KeyboardEvent, useState } from "react";
|
||||
|
||||
export interface CardFormValues {
|
||||
requester: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initial?: Partial<CardFormValues>;
|
||||
submitLabel?: string;
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel }: Props) {
|
||||
const [requester, setRequester] = useState(initial?.requester ?? "");
|
||||
const [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const t = title.trim();
|
||||
if (!t) return;
|
||||
await onSubmit({ requester: requester.trim(), title: t, description });
|
||||
};
|
||||
|
||||
// Enter en TextInput envia el form. Enter en Textarea inserta newline; Ctrl/Cmd+Enter envia.
|
||||
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
const textareaEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Solicitante"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.currentTarget.value)}
|
||||
tabIndex={1}
|
||||
autoComplete="off"
|
||||
data-autofocus
|
||||
onKeyDown={enterSubmit}
|
||||
/>
|
||||
<TextInput
|
||||
label="Tarea"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
tabIndex={2}
|
||||
required
|
||||
autoComplete="off"
|
||||
onKeyDown={enterSubmit}
|
||||
/>
|
||||
<Textarea
|
||||
label="Descripcion"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
tabIndex={3}
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
onKeyDown={textareaEnter}
|
||||
description="Ctrl+Enter para guardar"
|
||||
/>
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<Button variant="subtle" color="gray" tabIndex={5} type="button" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button tabIndex={4} type="submit" disabled={!title.trim()}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconMessageChatbot, IconSend, IconTrash } from "@tabler/icons-react";
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ChatMessage, ChatToolCall, sendChat } from "../api";
|
||||
|
||||
const STORAGE_KEY = "kanban_chat_v1";
|
||||
|
||||
interface StoredMessage extends ChatMessage {
|
||||
ts: number;
|
||||
tool_calls?: ChatToolCall[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onBoardChange: () => void;
|
||||
}
|
||||
|
||||
function loadStored(): StoredMessage[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function ChatPanel({ onBoardChange }: Props) {
|
||||
const [messages, setMessages] = useState<StoredMessage[]>(() => loadStored());
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [messages, loading]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
const userMsg: StoredMessage = { role: "user", content: text, ts: Date.now() };
|
||||
const next = [...messages, userMsg];
|
||||
setMessages(next);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: ChatMessage[] = next.map((m) => ({ role: m.role, content: m.content }));
|
||||
const res = await sendChat(payload);
|
||||
const assistant: StoredMessage = {
|
||||
role: "assistant",
|
||||
content: res.content,
|
||||
ts: Date.now(),
|
||||
tool_calls: res.tool_calls,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistant]);
|
||||
if (res.board_changed) onBoardChange();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: `Error: ${(e as Error).message}`, ts: Date.now() },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setMessages([]);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
<Group justify="space-between" p="xs" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}>
|
||||
<Group gap={6}>
|
||||
<IconMessageChatbot size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
Asistente
|
||||
</Text>
|
||||
</Group>
|
||||
<Tooltip label="Limpiar conversacion" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={clear} disabled={messages.length === 0}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<ScrollArea viewportRef={scrollRef} style={{ flex: 1 }} type="auto" p="xs">
|
||||
<Stack gap="xs">
|
||||
{messages.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="md">
|
||||
Escribe algo. Ejemplos:
|
||||
<br />- "crea columna Backlog"
|
||||
<br />- "anade tarjeta para revisar PR de Lucas en Doing"
|
||||
<br />- "que hay en Doing?"
|
||||
</Text>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<ChatBubble key={i} msg={m} />
|
||||
))}
|
||||
{loading && (
|
||||
<Group gap={6} pl="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Pensando...
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Stack gap={4} p="xs" style={{ borderTop: "1px solid var(--mantine-color-dark-4)" }}>
|
||||
<Group align="flex-end" gap={4} wrap="nowrap">
|
||||
<Textarea
|
||||
placeholder="Pide algo... (Enter envia, Shift+Enter newline)"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={onKey}
|
||||
disabled={loading}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
onClick={send}
|
||||
disabled={!input.trim() || loading}
|
||||
aria-label="Send"
|
||||
>
|
||||
{loading ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBubble({ msg }: { msg: StoredMessage }) {
|
||||
const isUser = msg.role === "user";
|
||||
return (
|
||||
<Paper
|
||||
p="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={isUser ? "blue.9" : "dark.6"}
|
||||
style={{ alignSelf: isUser ? "flex-end" : "flex-start", maxWidth: "92%" }}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{msg.content && (
|
||||
<Box
|
||||
className="kanban-md"
|
||||
style={{ fontSize: 13, lineHeight: 1.45, color: "var(--mantine-color-text)" }}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</Box>
|
||||
)}
|
||||
{msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{msg.tool_calls.map((c, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
size="xs"
|
||||
color={c.ok ? "teal" : "red"}
|
||||
variant="light"
|
||||
title={c.error || ""}
|
||||
>
|
||||
{c.tool}
|
||||
{!c.ok && c.error ? `: ${c.error}` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import { IconColumns3 } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cardHistory } from "../api";
|
||||
import type { Card, HistoryEntry } from "../types";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
}
|
||||
|
||||
export function HistoryModal({ card }: Props) {
|
||||
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cardHistory(card.id).then(setEntries).catch(() => setEntries([]));
|
||||
}, [card.id]);
|
||||
|
||||
if (!entries) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <Text c="dimmed">Sin historial.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tiempo total en cada columna desde que se creo la tarjeta.
|
||||
</Text>
|
||||
<Timeline active={entries.length} bulletSize={22} lineWidth={2}>
|
||||
{entries.map((e) => (
|
||||
<Timeline.Item
|
||||
key={e.id}
|
||||
bullet={<IconColumns3 size={12} />}
|
||||
title={
|
||||
<Group gap={6}>
|
||||
<Text fw={500} size="sm">
|
||||
{e.column_name || e.column_id}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={e.exited_at ? "gray" : "blue"}>
|
||||
{formatDuration(e.duration_ms)}
|
||||
</Badge>
|
||||
{!e.exited_at && (
|
||||
<Badge size="xs" variant="filled" color="blue">
|
||||
actual
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(e.entered_at).toLocaleString()}
|
||||
{e.exited_at && ` -> ${new Date(e.exited_at).toLocaleString()}`}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconClock,
|
||||
IconEdit,
|
||||
IconGripVertical,
|
||||
IconHistory,
|
||||
IconPalette,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, useState } from "react";
|
||||
import type { Card, CardColor } from "../types";
|
||||
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
now: number;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (card: Card) => void;
|
||||
onChangeColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
isOverlay?: boolean;
|
||||
}
|
||||
|
||||
function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHistory, isOverlay }: Props) {
|
||||
const [popOpen, setPopOpen] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: card.id,
|
||||
data: { type: "card", columnId: card.column_id },
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
background: colorBg(card.color),
|
||||
borderColor: colorBorder(card.color),
|
||||
};
|
||||
|
||||
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
|
||||
const liveMs = Math.max(0, now - enteredAt);
|
||||
|
||||
return (
|
||||
<Paper ref={setNodeRef} style={style} withBorder p="xs" shadow={isOverlay ? "lg" : "xs"} radius="md">
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ cursor: "grab" }}
|
||||
aria-label="Drag"
|
||||
>
|
||||
<IconGripVertical size={14} />
|
||||
</ActionIcon>
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
|
||||
{card.title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<Popover opened={popOpen} onChange={setPopOpen} withArrow shadow="md" position="bottom-end">
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => setPopOpen((v) => !v)}
|
||||
aria-label="Color"
|
||||
>
|
||||
<IconPalette size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Group gap={4} maw={200}>
|
||||
{CARD_COLORS.map((c) => (
|
||||
<Tooltip key={c.value} label={c.label} withArrow>
|
||||
<ActionIcon
|
||||
variant={card.color === c.value ? "filled" : "default"}
|
||||
size="md"
|
||||
radius="xl"
|
||||
style={{
|
||||
background: colorSwatch(c.value),
|
||||
borderColor: colorBorder(c.value),
|
||||
}}
|
||||
onClick={() => {
|
||||
onChangeColor(card.id, c.value);
|
||||
setPopOpen(false);
|
||||
}}
|
||||
aria-label={c.label}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => onEdit(card)} aria-label="Edit">
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => onShowHistory(card)}
|
||||
aria-label="History"
|
||||
>
|
||||
<IconHistory size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(card.id)} aria-label="Delete">
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
{card.requester && (
|
||||
<Group gap={4}>
|
||||
<IconUser size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{card.requester}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{card.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{card.description}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
{formatDuration(liveMs)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// memo: re-render solo cuando cambian props relevantes (card, now). Evita rerenders
|
||||
// en cascada cuando otra columna cambia durante drag-over.
|
||||
export const KanbanCard = memo(KanbanCardImpl);
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArchive,
|
||||
IconArchiveOff,
|
||||
IconCheck,
|
||||
IconGripVertical,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
|
||||
import type { Card, CardColor, Column } from "../types";
|
||||
import { KanbanCard } from "./KanbanCard";
|
||||
|
||||
interface Props {
|
||||
column: Column;
|
||||
cards: Card[];
|
||||
now: number;
|
||||
collapsed?: boolean;
|
||||
onAddCard: (columnId: string) => void;
|
||||
onRenameColumn: (id: string, name: string) => void;
|
||||
onResizeColumn: (id: string, width: number) => void;
|
||||
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
|
||||
onDeleteColumn: (id: string) => void;
|
||||
onEditCard: (card: Card) => void;
|
||||
onDeleteCard: (id: string) => void;
|
||||
onChangeCardColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
}
|
||||
|
||||
function KanbanColumnImpl({
|
||||
column,
|
||||
cards,
|
||||
now,
|
||||
collapsed,
|
||||
onAddCard,
|
||||
onRenameColumn,
|
||||
onResizeColumn,
|
||||
onMoveColumnLocation,
|
||||
onDeleteColumn,
|
||||
onEditCard,
|
||||
onDeleteCard,
|
||||
onChangeCardColor,
|
||||
onShowHistory,
|
||||
}: Props) {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState(column.name);
|
||||
const [localWidth, setLocalWidth] = useState<number | null>(null);
|
||||
|
||||
// sync local width when column.width changes from outside (other clients).
|
||||
useEffect(() => {
|
||||
setLocalWidth(null);
|
||||
}, [column.width]);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: `column-${column.id}`,
|
||||
data: { type: "column", columnId: column.id, location: column.location },
|
||||
});
|
||||
|
||||
const effectiveWidth = collapsed ? 220 : localWidth ?? column.width;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
width: effectiveWidth,
|
||||
minWidth: effectiveWidth,
|
||||
maxWidth: effectiveWidth,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
};
|
||||
|
||||
const cardIds = cards.map((c) => c.id);
|
||||
|
||||
const submitRename = () => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && trimmed !== column.name) onRenameColumn(column.id, trimmed);
|
||||
setRenaming(false);
|
||||
};
|
||||
|
||||
// --- resize handle ---
|
||||
const resizingRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
|
||||
const onResizeMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizingRef.current = { startX: e.clientX, startWidth: column.width };
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
const onMove = (ev: globalThis.MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
const dx = ev.clientX - resizingRef.current.startX;
|
||||
const next = Math.min(800, Math.max(200, resizingRef.current.startWidth + dx));
|
||||
setLocalWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
if (resizingRef.current && localWidthRef.current !== null) {
|
||||
onResizeColumn(column.id, localWidthRef.current);
|
||||
}
|
||||
resizingRef.current = null;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
// mirror localWidth into a ref so the mouseup handler always sees the latest value.
|
||||
const localWidthRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
localWidthRef.current = localWidth;
|
||||
}, [localWidth]);
|
||||
|
||||
const isInSidebar = column.location === "sidebar";
|
||||
const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar";
|
||||
const ArchiveIcon = isInSidebar ? IconArchiveOff : IconArchive;
|
||||
|
||||
return (
|
||||
<Paper ref={setNodeRef} style={style} withBorder radius="md" p="sm" bg="dark.7">
|
||||
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ cursor: "grab" }}
|
||||
aria-label="Drag column"
|
||||
>
|
||||
<IconGripVertical size={14} />
|
||||
</ActionIcon>
|
||||
{renaming ? (
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
autoFocus
|
||||
onBlur={submitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitRename();
|
||||
if (e.key === "Escape") {
|
||||
setName(column.name);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
truncate
|
||||
onDoubleClick={() => {
|
||||
setName(column.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
style={{ flex: 1, cursor: "text" }}
|
||||
title="Doble click para renombrar"
|
||||
>
|
||||
{column.name}
|
||||
</Text>
|
||||
)}
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{cards.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
{renaming ? (
|
||||
<>
|
||||
<ActionIcon variant="subtle" color="green" size="sm" onClick={submitRename} aria-label="Save">
|
||||
<IconCheck size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setName(column.name);
|
||||
setRenaming(false);
|
||||
}}
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setName(column.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
aria-label="Rename"
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</ActionIcon>
|
||||
<Tooltip label={archiveLabel} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
||||
aria-label={archiveLabel}
|
||||
>
|
||||
<ArchiveIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => onDeleteColumn(column.id)}
|
||||
aria-label="Delete column"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} type="auto">
|
||||
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
|
||||
{cards.map((c) => (
|
||||
<KanbanCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
now={now}
|
||||
onDelete={onDeleteCard}
|
||||
onEdit={onEditCard}
|
||||
onChangeColor={onChangeCardColor}
|
||||
onShowHistory={onShowHistory}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</ScrollArea>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => onAddCard(column.id)}
|
||||
mt="xs"
|
||||
fullWidth
|
||||
>
|
||||
Anadir tarjeta
|
||||
</Button>
|
||||
|
||||
{/* Resize handle (only on board, not sidebar) */}
|
||||
{!isInSidebar && (
|
||||
<Box
|
||||
onMouseDown={onResizeMouseDown}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: -3,
|
||||
width: 6,
|
||||
height: "100%",
|
||||
cursor: "col-resize",
|
||||
zIndex: 5,
|
||||
}}
|
||||
aria-label="Resize column"
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export const KanbanColumn = memo(KanbanColumnImpl);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CardColor } from "../types";
|
||||
|
||||
export const CARD_COLORS: { value: CardColor; label: string }[] = [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "blue", label: "Azul" },
|
||||
{ value: "teal", label: "Teal" },
|
||||
{ value: "green", label: "Verde" },
|
||||
{ value: "yellow", label: "Amarillo" },
|
||||
{ value: "orange", label: "Naranja" },
|
||||
{ value: "red", label: "Rojo" },
|
||||
{ value: "pink", label: "Rosa" },
|
||||
{ value: "violet", label: "Violeta" },
|
||||
{ value: "indigo", label: "Indigo" },
|
||||
];
|
||||
|
||||
// color-mix mezcla 18% del tono base con dark.6 → suave en dark mode.
|
||||
// Border 30% del tono mas claro con dark.4 para definicion sutil.
|
||||
// Swatch (boton picker) usa tono pleno -7 para que sea visible.
|
||||
export function colorBg(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-6)";
|
||||
return `color-mix(in srgb, var(--mantine-color-${color}-9) 18%, var(--mantine-color-dark-6))`;
|
||||
}
|
||||
|
||||
export function colorBorder(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-4)";
|
||||
return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
|
||||
}
|
||||
|
||||
export function colorSwatch(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-3)";
|
||||
return `var(--mantine-color-${color}-7)`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Escala unidades segun magnitud: m | h Xm | D Xh | S XD | M XS.
|
||||
// <1 minuto cae como "0m" para mantener la unidad mas pequena coherente.
|
||||
const MIN = 60_000;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return "0m";
|
||||
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
|
||||
if (ms < DAY) {
|
||||
const h = Math.floor(ms / HOUR);
|
||||
const m = Math.floor((ms % HOUR) / MIN);
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
if (ms < WEEK) {
|
||||
const d = Math.floor(ms / DAY);
|
||||
const h = Math.floor((ms % DAY) / HOUR);
|
||||
return h === 0 ? `${d}D` : `${d}D ${h}h`;
|
||||
}
|
||||
if (ms < MONTH) {
|
||||
const w = Math.floor(ms / WEEK);
|
||||
const d = Math.floor((ms % WEEK) / DAY);
|
||||
return d === 0 ? `${w}S` : `${w}S ${d}D`;
|
||||
}
|
||||
const m = Math.floor(ms / MONTH);
|
||||
const w = Math.floor((ms % MONTH) / WEEK);
|
||||
return w === 0 ? `${m}M` : `${m}M ${w}S`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import { MantineProvider, createTheme } from "@mantine/core";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: "blue",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<ModalsProvider>
|
||||
<Notifications position="top-right" />
|
||||
<App />
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ColumnLocation = "board" | "sidebar";
|
||||
|
||||
export interface Column {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
location: ColumnLocation;
|
||||
width: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type CardColor = "" | "blue" | "teal" | "green" | "yellow" | "orange" | "red" | "pink" | "violet" | "indigo";
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
requester: string;
|
||||
title: string;
|
||||
description: string;
|
||||
color: CardColor;
|
||||
column_id: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
entered_at: string;
|
||||
time_in_column_ms: number;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
columns: Column[];
|
||||
cards: Card[];
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
card_id: string;
|
||||
column_id: string;
|
||||
column_name: string;
|
||||
entered_at: string;
|
||||
exited_at: string | null;
|
||||
duration_ms: number;
|
||||
}
|
||||
Reference in New Issue
Block a user