c28ae7d3c0
- app.md - backend/handlers.go - backend/main.go - frontend/src/App.tsx - frontend/src/api.ts - frontend/vite.config.ts - backend/mcp_http.go - backend/mcp_tokens.go - backend/mcp_tokens_handlers.go - backend/migrations/016_mcp_tokens.sql - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1806 lines
64 KiB
TypeScript
1806 lines
64 KiB
TypeScript
import {
|
|
CollisionDetection,
|
|
DndContext,
|
|
DragEndEvent,
|
|
DragOverEvent,
|
|
DragOverlay,
|
|
DragStartEvent,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
closestCenter,
|
|
closestCorners,
|
|
pointerWithin,
|
|
rectIntersection,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
arrayMove,
|
|
horizontalListSortingStrategy,
|
|
sortableKeyboardCoordinates,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import {
|
|
ActionIcon,
|
|
AppShell,
|
|
Avatar,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Group,
|
|
Loader,
|
|
Menu,
|
|
MultiSelect,
|
|
Paper,
|
|
Select,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
TextInput,
|
|
Title,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { DatePickerInput } from "@mantine/dates";
|
|
import "@mantine/dates/styles.css";
|
|
import { modals } from "@mantine/modals";
|
|
import { notifications } from "@mantine/notifications";
|
|
import {
|
|
IconArrowBackUp,
|
|
IconCalendar,
|
|
IconChartBar,
|
|
IconCheck,
|
|
IconChevronDown,
|
|
IconChevronRight,
|
|
IconLayoutKanban,
|
|
IconLogout,
|
|
IconPlug,
|
|
IconKey,
|
|
IconMenu2,
|
|
IconMessageChatbot,
|
|
IconMoodSmile,
|
|
IconPlus,
|
|
IconRefresh,
|
|
IconSearch,
|
|
IconTrash,
|
|
IconTrashX,
|
|
IconX,
|
|
} from "@tabler/icons-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import * as api from "./api";
|
|
import { useAuth } from "./auth";
|
|
import { CardForm } from "./components/CardForm";
|
|
import { CardEditPanel } from "./components/CardEditPanel";
|
|
import { ChatPanel } from "./components/ChatPanel";
|
|
import { CalendarView } from "./components/CalendarView";
|
|
import { DailyReportView } from "./components/DailyReport";
|
|
import { Dashboard } from "./components/Dashboard";
|
|
import { HistoryModal } from "./components/HistoryModal";
|
|
import { KanbanCard } from "./components/KanbanCard";
|
|
import { KanbanColumn } from "./components/KanbanColumn";
|
|
import { StickerPicker } from "./components/StickerPicker";
|
|
import { ColorPickerGrid, CustomColorModal } from "./components/ColorPickerGrid";
|
|
import { AVATAR_COLORS } from "./components/colors";
|
|
import { colorBg, colorBorder } from "./components/colors";
|
|
import { NotificationsBell } from "./components/NotificationsBell";
|
|
import { ModulesModal } from "./components/ModulesModal";
|
|
import { MCPTokensModal } from "./components/MCPTokensModal";
|
|
import { useEventStream } from "./hooks/useEventStream";
|
|
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
|
|
|
|
const COL_PREFIX = "column-";
|
|
|
|
// Custom collision detection: prefiere otras columnas como destino al arrastrar
|
|
// columnas; al arrastrar cards prefiere cards/columnas via closestCorners.
|
|
function makeCollisionDetection(activeType: string | undefined): CollisionDetection {
|
|
if (activeType === "column") {
|
|
return (args) => {
|
|
// Solo considerar drops sobre otras columnas (ids con COL_PREFIX).
|
|
const filtered = args.droppableContainers.filter((c) =>
|
|
String(c.id).startsWith(COL_PREFIX)
|
|
);
|
|
const inter = rectIntersection({ ...args, droppableContainers: filtered });
|
|
if (inter.length > 0) return inter;
|
|
return closestCenter({ ...args, droppableContainers: filtered });
|
|
};
|
|
}
|
|
return (args) => {
|
|
const pw = pointerWithin(args);
|
|
if (pw.length > 0) return pw;
|
|
return closestCorners(args);
|
|
};
|
|
}
|
|
|
|
export function App() {
|
|
const auth = useAuth();
|
|
const [board, setBoard] = useState<Board | null>(null);
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [activeCard, setActiveCard] = useState<Card | null>(null);
|
|
const [activeColumnId, setActiveColumnId] = useState<string | null>(null);
|
|
const [activeType, setActiveType] = useState<string | undefined>(undefined);
|
|
const [addingCol, setAddingCol] = useState(false);
|
|
const [colName, setColName] = useState("");
|
|
const [now, setNow] = useState(Date.now());
|
|
const [chatOpen, setChatOpen] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<string>("board");
|
|
const [trash, setTrash] = useState<Card[]>([]);
|
|
const [trashOpen, setTrashOpen] = useState(false);
|
|
const [archive, setArchive] = useState<Card[]>([]);
|
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
|
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
|
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [filterAssigneeId, setFilterAssigneeId] = useState<string | null>(null);
|
|
const [filterRequester, setFilterRequester] = useState<string | null>(null);
|
|
const [filterTags, setFilterTags] = useState<string[]>([]);
|
|
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
|
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
|
|
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
|
|
const [filterDeadlineOnly, setFilterDeadlineOnly] = useState(false);
|
|
const [highlightCardId, setHighlightCardId] = useState<string | null>(null);
|
|
const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
|
|
const [activeSticker, setActiveSticker] = useState<string | null>(null);
|
|
const [avatarColorModalOpen, setAvatarColorModalOpen] = useState(false);
|
|
const [avatarCustomColor, setAvatarCustomColor] = useState("#888888");
|
|
const [cardColorModal, setCardColorModal] = useState<{ cardId: string; color: string } | null>(null);
|
|
const [navOpen, setNavOpen] = useState(false);
|
|
const [navWidth, setNavWidth] = useState<number>(() => {
|
|
const stored = localStorage.getItem("kanban_nav_width");
|
|
const n = stored ? parseInt(stored, 10) : NaN;
|
|
return Number.isFinite(n) && n >= 180 && n <= 600 ? n : 240;
|
|
});
|
|
const navWidthRef = useRef(navWidth);
|
|
useEffect(() => {
|
|
navWidthRef.current = navWidth;
|
|
localStorage.setItem("kanban_nav_width", String(navWidth));
|
|
}, [navWidth]);
|
|
|
|
const onNavResizeMouseDown = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
const startX = e.clientX;
|
|
const startWidth = navWidthRef.current;
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
const onMove = (ev: MouseEvent) => {
|
|
const dx = ev.clientX - startX;
|
|
const next = Math.min(600, Math.max(180, startWidth + dx));
|
|
setNavWidth(next);
|
|
};
|
|
const onUp = () => {
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
};
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
};
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
);
|
|
|
|
// -------- Issue 0091 — drag-aware sidebar dropzone --------
|
|
// While a card or column is being dragged, watch the global pointer.
|
|
// If it dwells inside the 32px left strip for >=400ms, auto-open the sidebar.
|
|
// We listen to mousemove globally because dnd-kit owns the pointer during
|
|
// drag, and the strip itself has pointer-events:none so dnd-kit keeps
|
|
// detecting drop targets underneath.
|
|
const DRAG_EDGE_WIDTH = 32;
|
|
const DRAG_EDGE_HOVER_MS = 400;
|
|
const isDragging = activeCard !== null || activeColumnId !== null;
|
|
const [edgeArmed, setEdgeArmed] = useState(false);
|
|
const navOpenRef = useRef(navOpen);
|
|
useEffect(() => {
|
|
navOpenRef.current = navOpen;
|
|
}, [navOpen]);
|
|
useEffect(() => {
|
|
if (!isDragging) {
|
|
setEdgeArmed(false);
|
|
return;
|
|
}
|
|
let timer: number | null = null;
|
|
let inside = false;
|
|
// Para evitar que un drag iniciado dentro del sidebar abierto dispare un
|
|
// cierre inmediato, exigimos que el puntero haya salido de la franja al
|
|
// menos una vez tras empezar el drag. Asi: abrir = entrar a la franja
|
|
// tras empezar fuera (que ya pasaba); cerrar = salir de la franja y
|
|
// volver a entrar.
|
|
let hasLeftStrip = false;
|
|
const clear = () => {
|
|
if (timer !== null) {
|
|
window.clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
};
|
|
const onMove = (ev: MouseEvent) => {
|
|
const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
|
|
if (nowInside === inside) return;
|
|
inside = nowInside;
|
|
// Brillo visible siempre que el puntero este en la franja y haya drag.
|
|
setEdgeArmed(nowInside);
|
|
if (!nowInside) {
|
|
hasLeftStrip = true;
|
|
clear();
|
|
return;
|
|
}
|
|
// nowInside = true. Para cerrar (navOpen=true) exigimos que el puntero
|
|
// haya salido al menos una vez de la franja desde que empezo el drag;
|
|
// asi un drag que arranca dentro del sidebar abierto no auto-cierra.
|
|
const armable = !navOpenRef.current || hasLeftStrip;
|
|
if (!armable) return;
|
|
clear();
|
|
const willOpen = !navOpenRef.current;
|
|
timer = window.setTimeout(() => {
|
|
setNavOpen(willOpen);
|
|
// Tras toggle, resetea el flag para no encadenar otra accion sin
|
|
// que el usuario salga + vuelva.
|
|
hasLeftStrip = false;
|
|
}, DRAG_EDGE_HOVER_MS);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
return () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
clear();
|
|
setEdgeArmed(false);
|
|
};
|
|
}, [isDragging]);
|
|
|
|
const reload = useCallback(async () => {
|
|
try {
|
|
const b = await api.getBoard();
|
|
setBoard(b);
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}, []);
|
|
|
|
// Coalesce ráfagas de board.invalidated (trailing debounce 300ms) — sin esto
|
|
// cada mutación remota dispara un refetch /api/board completo y la memoria
|
|
// del navegador crece sin techo.
|
|
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const debouncedReload = useCallback(() => {
|
|
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
|
reloadTimerRef.current = setTimeout(() => {
|
|
reloadTimerRef.current = null;
|
|
reload();
|
|
}, 300);
|
|
}, [reload]);
|
|
useEffect(() => {
|
|
return () => {
|
|
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
reload();
|
|
}, [reload]);
|
|
|
|
const reloadUsers = useCallback(async () => {
|
|
try {
|
|
const us = await api.listUsers();
|
|
setUsers(us);
|
|
} catch (e) {
|
|
console.warn("listUsers failed", e);
|
|
}
|
|
}, []);
|
|
|
|
const reloadTrash = useCallback(async () => {
|
|
try {
|
|
const t = await api.listTrash();
|
|
setTrash(t);
|
|
} catch (e) {
|
|
console.warn("listTrash failed", e);
|
|
}
|
|
}, []);
|
|
|
|
const reloadArchive = useCallback(async () => {
|
|
try {
|
|
const a = await api.listArchive();
|
|
setArchive(a);
|
|
} catch (e) {
|
|
console.warn("listArchive failed", e);
|
|
}
|
|
}, []);
|
|
|
|
const reloadTags = useCallback(async () => {
|
|
try {
|
|
const t = await api.listTags();
|
|
setTagOptions(t);
|
|
} catch (e) {
|
|
console.warn("listTags failed", e);
|
|
}
|
|
}, []);
|
|
|
|
const reloadRequesters = useCallback(async () => {
|
|
try {
|
|
const r = await api.listRequesters();
|
|
setRequesterOptions(r);
|
|
} catch (e) {
|
|
console.warn("listRequesters failed", e);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
reloadUsers();
|
|
}, [reloadUsers]);
|
|
|
|
useEffect(() => {
|
|
reloadTrash();
|
|
}, [reloadTrash]);
|
|
|
|
useEffect(() => {
|
|
reloadArchive();
|
|
}, [reloadArchive]);
|
|
|
|
useEffect(() => {
|
|
reloadTags();
|
|
reloadRequesters();
|
|
}, [reloadTags, reloadRequesters]);
|
|
|
|
// Tick de reloj para "tiempo en columna" en cards. Pausamos durante drag
|
|
// porque dispara re-render de TODAS las cards cada segundo y el drag de
|
|
// dnd-kit sufre tirones serios con muchos elementos.
|
|
useEffect(() => {
|
|
if (activeCard || activeColumnId) return;
|
|
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => clearInterval(t);
|
|
}, [activeCard, activeColumnId]);
|
|
|
|
// Notifications state (populated by SSE + initial fetch).
|
|
const [notifs, setNotifs] = useState<Notification[]>([]);
|
|
const [notifUnread, setNotifUnread] = useState(0);
|
|
|
|
// Build version (injected at compile time via -ldflags). Fetched once.
|
|
const [appVersion, setAppVersion] = useState<string>("");
|
|
useEffect(() => {
|
|
api
|
|
.getVersion()
|
|
.then((v) => setAppVersion(v.version))
|
|
.catch(() => setAppVersion(""));
|
|
}, []);
|
|
|
|
const [modulesOpen, setModulesOpen] = useState(false);
|
|
const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
|
|
|
|
const reloadNotifs = useCallback(async () => {
|
|
try {
|
|
const [list, c] = await Promise.all([api.listNotifications(false), api.unreadNotificationCount()]);
|
|
setNotifs(list);
|
|
setNotifUnread(c.count);
|
|
} catch {
|
|
// best-effort; SSE will reconcile
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (auth.user) reloadNotifs();
|
|
}, [auth.user, reloadNotifs]);
|
|
|
|
// Replace 30s polling with SSE. Server pushes board.invalidated on every
|
|
// mutation, message.created on chat traffic and notification.created on
|
|
// per-user notifications. We refetch /api/board on invalidate (cheap +
|
|
// keeps merge logic simple) and patch notification state in-place.
|
|
useEventStream(
|
|
useMemo(
|
|
() => ({
|
|
"board.invalidated": () => {
|
|
debouncedReload();
|
|
},
|
|
"notification.created": (payload: unknown) => {
|
|
const n = payload as Notification;
|
|
if (!n || !n.id) return;
|
|
setNotifs((prev) => (prev.some((x) => x.id === n.id) ? prev : [n, ...prev].slice(0, 100)));
|
|
setNotifUnread((c) => c + 1);
|
|
const who = n.actor_name || "Alguien";
|
|
const card = n.card_seq_num ? `#${n.card_seq_num}` : n.card_title;
|
|
notifications.show({
|
|
autoClose: 4000,
|
|
color: n.kind === "mention" ? "grape" : "blue",
|
|
title: `${who} en ${card}`,
|
|
message: n.snippet,
|
|
});
|
|
},
|
|
"notification.read": (payload: unknown) => {
|
|
const p = payload as { id?: string } | null;
|
|
if (!p?.id) return;
|
|
setNotifs((prev) => prev.map((x) => (x.id === p.id ? { ...x, read_at: new Date().toISOString() } : x)));
|
|
setNotifUnread((c) => Math.max(0, c - 1));
|
|
},
|
|
"notification.read_all": () => {
|
|
setNotifs((prev) => prev.map((x) => (x.read_at ? x : { ...x, read_at: new Date().toISOString() })));
|
|
setNotifUnread(0);
|
|
},
|
|
}),
|
|
[debouncedReload],
|
|
),
|
|
!!auth.user,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!activeSticker) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setActiveSticker(null);
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [activeSticker]);
|
|
|
|
const usersById = useMemo(() => {
|
|
const m = new Map<string, User>();
|
|
for (const u of users) m.set(u.id, u);
|
|
return m;
|
|
}, [users]);
|
|
|
|
const sortedColumns = useMemo(() => {
|
|
if (!board) return [];
|
|
return [...board.columns].sort((a, b) => a.position - b.position);
|
|
}, [board]);
|
|
|
|
const boardColumns = useMemo(() => sortedColumns.filter((c) => c.location !== "sidebar"), [sortedColumns]);
|
|
const sidebarColumns = useMemo(() => sortedColumns.filter((c) => c.location === "sidebar"), [sortedColumns]);
|
|
|
|
const boardSortableIds = useMemo(() => boardColumns.map((c) => `${COL_PREFIX}${c.id}`), [boardColumns]);
|
|
const sidebarSortableIds = useMemo(() => sidebarColumns.map((c) => `${COL_PREFIX}${c.id}`), [sidebarColumns]);
|
|
|
|
const cardMatches = useCallback(
|
|
(c: Card): boolean => {
|
|
const term = searchTerm.trim().toLowerCase();
|
|
if (term) {
|
|
const seqStr = c.seq_num > 0 ? String(c.seq_num) : "";
|
|
const seqPadded = c.seq_num > 0 ? String(c.seq_num).padStart(5, "0") : "";
|
|
const hay = [
|
|
c.title,
|
|
c.description,
|
|
c.requester,
|
|
seqStr,
|
|
seqPadded,
|
|
...(c.tags || []),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
const normalizedTerm = term.replace(/^#/, "").replace(/^0+(?=\d)/, "");
|
|
if (!hay.includes(term) && !(normalizedTerm && hay.includes(normalizedTerm))) return false;
|
|
}
|
|
if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false;
|
|
if (filterUnassigned && c.assignee_id) return false;
|
|
if (filterRequester && c.requester !== filterRequester) return false;
|
|
if (filterTags.length > 0) {
|
|
const cardTags = new Set(c.tags || []);
|
|
for (const t of filterTags) if (!cardTags.has(t)) return false;
|
|
}
|
|
if (filterDeadlineOnly && !c.deadline) return false;
|
|
if (filterDateFrom || filterDateTo) {
|
|
const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity;
|
|
const toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity;
|
|
const created = c.created_at ? new Date(c.created_at).getTime() : NaN;
|
|
const moved = c.entered_at ? new Date(c.entered_at).getTime() : NaN;
|
|
const inRange = (t: number) => !isNaN(t) && t >= fromMs && t <= toMs;
|
|
if (!inRange(created) && !inRange(moved)) return false;
|
|
}
|
|
return true;
|
|
},
|
|
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo, filterDeadlineOnly]
|
|
);
|
|
|
|
const cardsByColumn = useMemo(() => {
|
|
const map = new Map<string, Card[]>();
|
|
if (!board) return map;
|
|
for (const col of board.columns) map.set(col.id, []);
|
|
for (const c of [...board.cards].sort((a, b) => a.position - b.position)) {
|
|
if (!cardMatches(c)) continue;
|
|
const arr = map.get(c.column_id);
|
|
if (arr) arr.push(c);
|
|
}
|
|
return map;
|
|
}, [board, cardMatches]);
|
|
|
|
const filtersActive =
|
|
!!searchTerm.trim() ||
|
|
!!filterAssigneeId ||
|
|
filterUnassigned ||
|
|
!!filterRequester ||
|
|
filterTags.length > 0 ||
|
|
!!filterDateFrom ||
|
|
!!filterDateTo ||
|
|
filterDeadlineOnly;
|
|
|
|
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 activeCard = board.cards.find((c) => c.id === activeId);
|
|
if (activeCard?.locked && activeCard.column_id !== destCol) {
|
|
notifications.show({ color: "yellow", message: "Card bloqueada: no se puede mover entre columnas" });
|
|
reload();
|
|
return;
|
|
}
|
|
const destCards = board.cards
|
|
.filter((c) => c.column_id === destCol)
|
|
.sort((a, b) => a.position - b.position);
|
|
const oldIdx = destCards.findIndex((c) => c.id === activeId);
|
|
|
|
let orderedIds: string[];
|
|
if (isColumnId(overId) || oldIdx === -1) {
|
|
orderedIds = [...destCards.filter((c) => c.id !== activeId).map((c) => c.id), activeId];
|
|
} else {
|
|
const newIdx = destCards.findIndex((c) => c.id === overId);
|
|
orderedIds = arrayMove(destCards.map((c) => c.id), oldIdx, newIdx);
|
|
}
|
|
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
const orderMap = new Map(orderedIds.map((id, i) => [id, i]));
|
|
const cards = prev.cards.map((c) => {
|
|
if (c.column_id === destCol && orderMap.has(c.id)) return { ...c, position: orderMap.get(c.id)! };
|
|
return c;
|
|
});
|
|
return { ...prev, cards };
|
|
});
|
|
|
|
try {
|
|
await api.moveCard(activeId, destCol, orderedIds);
|
|
} catch (err) {
|
|
notifications.show({ color: "red", message: (err as Error).message });
|
|
}
|
|
reload();
|
|
};
|
|
|
|
// --- mutations ---
|
|
|
|
const handleAddColumn = async () => {
|
|
const n = colName.trim();
|
|
if (!n) return;
|
|
try {
|
|
await api.createColumn(n);
|
|
setColName("");
|
|
setAddingCol(false);
|
|
reload();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
};
|
|
|
|
const handleRenameColumn = useCallback(async (id: string, name: string) => {
|
|
try {
|
|
await api.updateColumn(id, { name });
|
|
reload();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleResizeColumn = useCallback(async (id: string, width: number) => {
|
|
try {
|
|
await api.updateColumn(id, { width });
|
|
reload();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleMoveColumnLocation = useCallback(async (id: string, location: ColumnLocation) => {
|
|
try {
|
|
await api.updateColumn(id, { location });
|
|
reload();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleDeleteColumn = useCallback((id: string) => {
|
|
modals.openConfirmModal({
|
|
title: "Eliminar columna",
|
|
children: <Text size="sm">Se borraran todas sus tarjetas. Continuar?</Text>,
|
|
labels: { confirm: "Eliminar", cancel: "Cancelar" },
|
|
confirmProps: { color: "red" },
|
|
onConfirm: async () => {
|
|
try {
|
|
await api.deleteColumn(id);
|
|
reload();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
},
|
|
});
|
|
}, [reload]);
|
|
|
|
const openCreateCard = useCallback((columnId: string) => {
|
|
const id = modals.open({
|
|
title: "Nueva tarjeta",
|
|
size: "md",
|
|
children: (
|
|
<CardForm
|
|
users={users}
|
|
requesterOptions={requesterOptions}
|
|
tagOptions={tagOptions}
|
|
initial={{ requester: "" }}
|
|
submitLabel="Crear"
|
|
onCancel={() => modals.close(id)}
|
|
onSubmit={async (v) => {
|
|
try {
|
|
await api.createCard({
|
|
column_id: columnId,
|
|
requester: v.requester,
|
|
title: v.title,
|
|
description: v.description,
|
|
assignee_id: v.assignee_id,
|
|
tags: v.tags,
|
|
});
|
|
modals.close(id);
|
|
reload();
|
|
reloadTags();
|
|
reloadRequesters();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}}
|
|
/>
|
|
),
|
|
});
|
|
}, [reload, users, auth.user, requesterOptions, tagOptions]);
|
|
|
|
const openEditCard = useCallback((card: Card, options?: { highlightMessageId?: string }) => {
|
|
const id = modals.open({
|
|
title: "Editar tarjeta",
|
|
size: "85%",
|
|
children: (
|
|
<CardEditPanel
|
|
card={card}
|
|
users={users}
|
|
currentUserId={auth.user?.id}
|
|
requesterOptions={requesterOptions}
|
|
tagOptions={tagOptions}
|
|
highlightMessageId={options?.highlightMessageId}
|
|
onCancel={() => modals.close(id)}
|
|
onSubmit={async (v) => {
|
|
try {
|
|
await api.updateCard(card.id, {
|
|
requester: v.requester,
|
|
title: v.title,
|
|
description: v.description,
|
|
assignee_id: v.assignee_id,
|
|
tags: v.tags,
|
|
});
|
|
modals.close(id);
|
|
reload();
|
|
reloadTags();
|
|
reloadRequesters();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}}
|
|
/>
|
|
),
|
|
});
|
|
}, [reload, users, auth.user, requesterOptions, tagOptions]);
|
|
|
|
const handleDuplicateCard = useCallback(async (cardId: string) => {
|
|
try {
|
|
const dup = await api.duplicateCard(cardId);
|
|
await reload();
|
|
notifications.show({ color: "teal", message: `Duplicada: ${dup.title}` });
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleSetRequester = useCallback(async (id: string, requester: string) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, requester } : c)) };
|
|
});
|
|
try {
|
|
await api.updateCard(id, { requester });
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleJumpToCard = useCallback((cardId: string) => {
|
|
setActiveTab("board");
|
|
setHighlightCardId(cardId);
|
|
window.setTimeout(() => setHighlightCardId(null), 3000);
|
|
}, []);
|
|
|
|
const handleOpenDailyReport = useCallback((date: string) => {
|
|
const id = modals.open({
|
|
title: "Reporte diario",
|
|
size: "90%",
|
|
children: (
|
|
<DailyReportView
|
|
date={date}
|
|
onJumpToCard={(cardId) => {
|
|
modals.close(id);
|
|
handleJumpToCard(cardId);
|
|
}}
|
|
/>
|
|
),
|
|
});
|
|
}, [handleJumpToCard]);
|
|
|
|
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, deadline } : c)) };
|
|
});
|
|
try {
|
|
await api.updateCard(id, { deadline });
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, assignee_id } : c)) };
|
|
});
|
|
try {
|
|
await api.updateCard(id, { assignee_id });
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleDeleteCard = useCallback(async (id: string) => {
|
|
try {
|
|
await api.deleteCard(id);
|
|
reload();
|
|
reloadTrash();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}, [reload, reloadTrash]);
|
|
|
|
const handleRestoreCard = useCallback(async (id: string) => {
|
|
try {
|
|
await api.restoreCard(id);
|
|
reload();
|
|
reloadTrash();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}, [reload, reloadTrash]);
|
|
|
|
const handleUnarchiveCard = useCallback(async (id: string) => {
|
|
try {
|
|
await api.unarchiveCard(id);
|
|
reload();
|
|
reloadArchive();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}, [reload, reloadArchive]);
|
|
|
|
const handleArchiveCard = useCallback(async (id: string) => {
|
|
try {
|
|
await api.archiveCard(id);
|
|
reload();
|
|
reloadArchive();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}, [reload, reloadArchive]);
|
|
|
|
const handlePurgeCard = useCallback(async (id: string) => {
|
|
modals.openConfirmModal({
|
|
title: "Borrar permanentemente",
|
|
children: <Text size="sm">Esta accion no se puede deshacer.</Text>,
|
|
labels: { confirm: "Borrar", cancel: "Cancelar" },
|
|
confirmProps: { color: "red" },
|
|
onConfirm: async () => {
|
|
try {
|
|
await api.purgeCard(id);
|
|
reloadTrash();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
},
|
|
});
|
|
}, [reloadTrash]);
|
|
|
|
const handleChangeCardColor = useCallback(async (id: string, color: CardColor) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, color } : c)) };
|
|
});
|
|
try {
|
|
await api.updateCard(id, { color });
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
}
|
|
}, [reload]);
|
|
|
|
const persistStickers = useCallback(async (id: string, stickers: Card["stickers"]) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, stickers } : c)) };
|
|
});
|
|
try {
|
|
await api.updateCardStickers(id, stickers);
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleAddSticker = useCallback((cardId: string, x: number, y: number) => {
|
|
if (!activeSticker) return;
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
const cards = prev.cards.map((c) => {
|
|
if (c.id !== cardId) return c;
|
|
const stickers = [...(c.stickers || []), { emoji: activeSticker, x, y }];
|
|
api.updateCardStickers(cardId, stickers).catch((e) => {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
});
|
|
return { ...c, stickers };
|
|
});
|
|
return { ...prev, cards };
|
|
});
|
|
}, [activeSticker, reload]);
|
|
|
|
const handleRemoveSticker = useCallback((cardId: string, index: number) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
const cards = prev.cards.map((c) => {
|
|
if (c.id !== cardId) return c;
|
|
const stickers = (c.stickers || []).filter((_, i) => i !== index);
|
|
api.updateCardStickers(cardId, stickers).catch((e) => {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
});
|
|
return { ...c, stickers };
|
|
});
|
|
return { ...prev, cards };
|
|
});
|
|
}, [reload]);
|
|
|
|
const handleMoveSticker = useCallback((cardId: string, index: number, x: number, y: number) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
const cards = prev.cards.map((c) => {
|
|
if (c.id !== cardId) return c;
|
|
const stickers = (c.stickers || []).map((s, i) => (i === index ? { ...s, x, y } : s));
|
|
return { ...c, stickers };
|
|
});
|
|
return { ...prev, cards };
|
|
});
|
|
}, []);
|
|
|
|
const handleCommitSticker = useCallback((cardId: string) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
const card = prev.cards.find((c) => c.id === cardId);
|
|
if (card) persistStickers(cardId, card.stickers || []);
|
|
return prev;
|
|
});
|
|
}, [persistStickers]);
|
|
|
|
const handleShowHistory = useCallback((card: Card) => {
|
|
modals.open({
|
|
title: card.title,
|
|
size: "md",
|
|
children: <HistoryModal card={card} columns={board?.columns ?? []} />,
|
|
});
|
|
}, [board?.columns]);
|
|
|
|
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, locked } : c)) };
|
|
});
|
|
try {
|
|
await api.updateCard(id, { locked });
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleSetWIPLimit = useCallback(async (id: string, wip_limit: number) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, wip_limit } : c)) };
|
|
});
|
|
try {
|
|
await api.updateColumn(id, { wip_limit });
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
}
|
|
}, [reload]);
|
|
|
|
// Issue 0090: ruleta de seleccion aleatoria por columna.
|
|
// Recorre las cards visibles (post-filtro) no bloqueadas con highlight
|
|
// acelerado-decelerado y termina con flash verde sobre la ganadora.
|
|
const handlePickRandom = useCallback((columnId: string) => {
|
|
const cards = (cardsByColumn.get(columnId) || []).filter((c) => !c.locked);
|
|
if (cards.length === 0) {
|
|
notifications.show({ color: "yellow", message: "No hay cards disponibles (filtro y bloqueadas excluidas)" });
|
|
return;
|
|
}
|
|
if (cards.length === 1) {
|
|
const el = document.querySelector<HTMLElement>(`[data-card-id="${cards[0].id}"]`);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
el.classList.add("kanban-roulette-winner");
|
|
setTimeout(() => el.classList.remove("kanban-roulette-winner"), 1700);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Decide ganadora con seguridad criptografica.
|
|
const winnerIdx = (() => {
|
|
const buf = new Uint32Array(1);
|
|
crypto.getRandomValues(buf);
|
|
return buf[0] % cards.length;
|
|
})();
|
|
|
|
// Total steps: minimo 2 vueltas completas + offset hasta la ganadora.
|
|
const baseLaps = 2;
|
|
const totalSteps = baseLaps * cards.length + ((winnerIdx - 0 + cards.length) % cards.length);
|
|
|
|
// Decay temporal: empieza rapido (50ms), termina lento (220ms).
|
|
const startMs = 50;
|
|
const endMs = 220;
|
|
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
|
|
|
|
let step = 0;
|
|
const tick = () => {
|
|
const idx = step % cards.length;
|
|
const prevIdx = (idx - 1 + cards.length) % cards.length;
|
|
const prevEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[prevIdx].id}"]`);
|
|
const currEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[idx].id}"]`);
|
|
if (prevEl) prevEl.classList.remove("kanban-roulette-active");
|
|
if (currEl) {
|
|
currEl.classList.add("kanban-roulette-active");
|
|
currEl.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}
|
|
step++;
|
|
if (step > totalSteps) {
|
|
if (currEl) {
|
|
currEl.classList.remove("kanban-roulette-active");
|
|
currEl.classList.add("kanban-roulette-winner");
|
|
setTimeout(() => currEl.classList.remove("kanban-roulette-winner"), 1700);
|
|
}
|
|
return;
|
|
}
|
|
const t = totalSteps > 0 ? step / totalSteps : 1;
|
|
const delay = startMs + (endMs - startMs) * easeOut(t);
|
|
setTimeout(tick, delay);
|
|
};
|
|
tick();
|
|
}, [cardsByColumn]);
|
|
|
|
const handleSetMaxTimeMinutes = useCallback(async (id: string, max_time_minutes: number) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, max_time_minutes } : c)) };
|
|
});
|
|
try {
|
|
await api.updateColumn(id, { max_time_minutes });
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
}
|
|
}, [reload]);
|
|
|
|
const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
|
|
setBoard((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, is_done } : c)) };
|
|
});
|
|
try {
|
|
await api.updateColumn(id, { is_done });
|
|
reload();
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
reload();
|
|
}
|
|
}, [reload]);
|
|
|
|
const headerConfig = useMemo(() => ({ height: 50 }), []);
|
|
const navbarConfig = useMemo(
|
|
() => ({
|
|
width: navWidth,
|
|
breakpoint: "md" as const,
|
|
collapsed: { mobile: !navOpen, desktop: !navOpen },
|
|
}),
|
|
[navWidth, navOpen]
|
|
);
|
|
const asideConfig = useMemo(
|
|
() => ({
|
|
width: 380,
|
|
breakpoint: "md" as const,
|
|
collapsed: { mobile: !chatOpen, desktop: !chatOpen },
|
|
}),
|
|
[chatOpen]
|
|
);
|
|
const appShellStyles = useMemo(
|
|
() => ({ main: { paddingInlineStart: 0, paddingInlineEnd: 0 } }),
|
|
[]
|
|
);
|
|
|
|
if (!board) {
|
|
return (
|
|
<Group justify="center" p="xl">
|
|
<Loader />
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
const dragOverlayCard = activeCard;
|
|
const dragOverlayColumn = activeColumnId ? findColumn(activeColumnId) : null;
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={makeCollisionDetection(activeType)}
|
|
onDragStart={onDragStart}
|
|
onDragOver={onDragOver}
|
|
onDragEnd={onDragEnd}
|
|
>
|
|
{/* Issue 0091 — drag-aware left edge strip; opens sidebar on hover>=400ms */}
|
|
<div
|
|
className={
|
|
"kanban-drag-edge" +
|
|
(isDragging ? " is-active" : "") +
|
|
(edgeArmed ? " is-armed" : "")
|
|
}
|
|
data-test="kanban-drag-edge"
|
|
data-active={isDragging ? "1" : "0"}
|
|
data-armed={edgeArmed ? "1" : "0"}
|
|
aria-hidden="true"
|
|
/>
|
|
<AppShell
|
|
header={headerConfig}
|
|
navbar={navbarConfig}
|
|
aside={asideConfig}
|
|
padding={0}
|
|
styles={appShellStyles}
|
|
>
|
|
<AppShell.Header>
|
|
<Group h="100%" px="md" justify="space-between">
|
|
<Group gap={6}>
|
|
<ActionIcon
|
|
variant={navOpen ? "filled" : "subtle"}
|
|
onClick={() => setNavOpen((v) => !v)}
|
|
aria-label="Toggle sidebar"
|
|
>
|
|
<IconMenu2 size={16} />
|
|
</ActionIcon>
|
|
<IconLayoutKanban size={22} />
|
|
<Title order={4}>Kanban</Title>
|
|
<Tabs value={activeTab} onChange={(v) => v && setActiveTab(v)} variant="pills" ml="md">
|
|
<Tabs.List>
|
|
<Tabs.Tab value="board" leftSection={<IconLayoutKanban size={14} />}>
|
|
Tablero
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="dashboard" leftSection={<IconChartBar size={14} />}>
|
|
Dashboard
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="calendar" leftSection={<IconCalendar size={14} />}>
|
|
Calendario
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
</Tabs>
|
|
</Group>
|
|
<Group gap={4}>
|
|
<ActionIcon variant="subtle" onClick={reload} aria-label="Refresh">
|
|
<IconRefresh size={16} />
|
|
</ActionIcon>
|
|
{auth.user && (
|
|
<NotificationsBell
|
|
unreadCount={notifUnread}
|
|
notifications={notifs}
|
|
onOpenCard={async (cardId, messageId) => {
|
|
// Resolve the card across all possible buckets: live
|
|
// board, refreshed board, archive, trash. Notifications
|
|
// can point at any of them.
|
|
const find = (cs?: Card[]) => cs?.find((c) => c.id === cardId);
|
|
let card = find(board?.cards);
|
|
if (!card) {
|
|
await reload();
|
|
const fresh = await api.getBoard();
|
|
card = find(fresh.cards);
|
|
}
|
|
if (!card) {
|
|
const archived = await api.listArchive();
|
|
card = find(archived);
|
|
}
|
|
if (!card) {
|
|
const trashed = await api.listTrash();
|
|
card = find(trashed);
|
|
}
|
|
if (!card) {
|
|
notifications.show({ color: "red", message: "Card no encontrada" });
|
|
return;
|
|
}
|
|
openEditCard(card, { highlightMessageId: messageId });
|
|
}}
|
|
onChanged={reloadNotifs}
|
|
/>
|
|
)}
|
|
<ActionIcon
|
|
variant={chatOpen ? "filled" : "subtle"}
|
|
onClick={() => setChatOpen((v) => !v)}
|
|
aria-label="Toggle chat"
|
|
>
|
|
<IconMessageChatbot size={16} />
|
|
</ActionIcon>
|
|
{auth.user && (
|
|
<Menu position="bottom-end" shadow="md" withArrow closeOnItemClick={false}>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" aria-label="Usuario">
|
|
<Avatar size={26} radius="xl" color={auth.user.color || "blue"}>
|
|
{(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()}
|
|
</Avatar>
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Label>
|
|
<Group justify="space-between" gap={6} wrap="nowrap">
|
|
<Text size="xs" fw={600} truncate>
|
|
{auth.user.display_name || auth.user.username}
|
|
</Text>
|
|
{appVersion && (
|
|
<Text size="xs" c="dimmed" ff="monospace">v{appVersion}</Text>
|
|
)}
|
|
</Group>
|
|
</Menu.Label>
|
|
<Box p="xs">
|
|
<Text size="xs" c="dimmed" mb={4}>Color del avatar</Text>
|
|
<ColorPickerGrid
|
|
value={auth.user.color || ""}
|
|
onChange={async (c) => {
|
|
try {
|
|
const u = await api.updateMe({ color: c });
|
|
auth.setUser(u);
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}}
|
|
options={AVATAR_COLORS}
|
|
onOpenCustom={() => {
|
|
setAvatarCustomColor(auth.user?.color?.startsWith("#") ? auth.user.color : "#888888");
|
|
setAvatarColorModalOpen(true);
|
|
}}
|
|
/>
|
|
</Box>
|
|
<Menu.Divider />
|
|
{auth.user.is_admin && (
|
|
<Menu.Item
|
|
leftSection={<IconPlug size={14} />}
|
|
onClick={() => setModulesOpen(true)}
|
|
>
|
|
Modulos
|
|
</Menu.Item>
|
|
)}
|
|
<Menu.Item
|
|
leftSection={<IconKey size={14} />}
|
|
onClick={() => setMcpTokensOpen(true)}
|
|
>
|
|
MCP tokens
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<IconLogout size={14} />}
|
|
color="red"
|
|
onClick={() => auth.logout()}
|
|
>
|
|
Cerrar sesion
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
)}
|
|
{auth.user?.is_admin && (
|
|
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
|
|
)}
|
|
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
|
|
</Group>
|
|
</Group>
|
|
</AppShell.Header>
|
|
|
|
<AppShell.Navbar p="xs">
|
|
{/* Drag handle to resize navbar — absolute relative to navbar (which is position:fixed in v9) */}
|
|
<Box
|
|
onMouseDown={onNavResizeMouseDown}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
right: -3,
|
|
width: 6,
|
|
height: "100%",
|
|
cursor: "col-resize",
|
|
zIndex: 10,
|
|
}}
|
|
aria-label="Resize sidebar"
|
|
/>
|
|
<Stack gap="xs" h="100%">
|
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
|
Columnas parqueadas
|
|
</Text>
|
|
<Box style={{ flex: 1, overflowY: "auto" }}>
|
|
<SortableContext items={sidebarSortableIds} strategy={verticalListSortingStrategy}>
|
|
<Stack gap="xs">
|
|
{sidebarColumns.length === 0 && (
|
|
<Text size="xs" c="dimmed">
|
|
Vacio. Mueve columnas aqui con el icono "archivar" en su cabecera.
|
|
</Text>
|
|
)}
|
|
{sidebarColumns.map((col) => (
|
|
<KanbanColumn
|
|
key={col.id}
|
|
column={col}
|
|
cards={cardsByColumn.get(col.id) ?? []}
|
|
now={now}
|
|
collapsed
|
|
onAddCard={openCreateCard}
|
|
onRenameColumn={handleRenameColumn}
|
|
onResizeColumn={handleResizeColumn}
|
|
onMoveColumnLocation={handleMoveColumnLocation}
|
|
onDeleteColumn={handleDeleteColumn}
|
|
onSetWIPLimit={handleSetWIPLimit}
|
|
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
|
onPickRandom={handlePickRandom}
|
|
onToggleDone={handleToggleDone}
|
|
onEditCard={openEditCard}
|
|
onDeleteCard={handleDeleteCard}
|
|
onDuplicateCard={handleDuplicateCard}
|
|
onChangeCardColor={handleChangeCardColor}
|
|
onShowHistory={handleShowHistory}
|
|
onToggleCardLock={handleToggleCardLock}
|
|
onAssignCard={handleAssignCard}
|
|
onSetCardDeadline={handleSetCardDeadline}
|
|
highlightCardId={highlightCardId}
|
|
onSetRequester={handleSetRequester}
|
|
onArchiveCard={handleArchiveCard}
|
|
requesterOptions={requesterOptions}
|
|
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
|
|
activeSticker={activeSticker}
|
|
onAddSticker={handleAddSticker}
|
|
onRemoveSticker={handleRemoveSticker}
|
|
onMoveSticker={handleMoveSticker}
|
|
onCommitSticker={handleCommitSticker}
|
|
users={users}
|
|
usersById={usersById}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</SortableContext>
|
|
</Box>
|
|
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
|
|
<Button
|
|
variant="subtle"
|
|
color="gray"
|
|
size="xs"
|
|
fullWidth
|
|
justify="space-between"
|
|
leftSection={<IconTrash size={14} />}
|
|
rightSection={
|
|
<Group gap={4}>
|
|
<Badge size="xs" variant="light" color={trash.length > 0 ? "red" : "gray"}>
|
|
{trash.length}
|
|
</Badge>
|
|
{trashOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
|
|
</Group>
|
|
}
|
|
onClick={() => setTrashOpen((v) => !v)}
|
|
>
|
|
Papelera
|
|
</Button>
|
|
{trashOpen && (
|
|
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
|
|
{trash.length === 0 && (
|
|
<Text size="xs" c="dimmed" px="xs">
|
|
Vacia.
|
|
</Text>
|
|
)}
|
|
{trash.map((c) => (
|
|
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7">
|
|
<Group justify="space-between" gap={4} wrap="nowrap">
|
|
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
|
|
{c.title}
|
|
</Text>
|
|
<Tooltip label="Restaurar" withArrow>
|
|
<ActionIcon size="xs" variant="subtle" color="green" onClick={() => handleRestoreCard(c.id)}>
|
|
<IconArrowBackUp size={12} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label="Borrar permanentemente" withArrow>
|
|
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => handlePurgeCard(c.id)}>
|
|
<IconTrashX size={12} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
|
|
<Button
|
|
variant="subtle"
|
|
color="gray"
|
|
size="xs"
|
|
fullWidth
|
|
justify="space-between"
|
|
leftSection={<IconCheck size={14} />}
|
|
rightSection={
|
|
<Group gap={4}>
|
|
<Badge size="xs" variant="light" color={archive.length > 0 ? "teal" : "gray"}>
|
|
{archive.length}
|
|
</Badge>
|
|
{archiveOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
|
|
</Group>
|
|
}
|
|
onClick={() => setArchiveOpen((v) => !v)}
|
|
data-test="archive-toggle"
|
|
>
|
|
Hecho (archivo)
|
|
</Button>
|
|
{archiveOpen && (
|
|
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
|
|
{archive.length === 0 && (
|
|
<Text size="xs" c="dimmed" px="xs">
|
|
Sin cards archivadas.
|
|
</Text>
|
|
)}
|
|
{archive.map((c) => (
|
|
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7" data-archived-card-id={c.id}>
|
|
<Group justify="space-between" gap={4} wrap="nowrap">
|
|
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
|
|
{c.title}
|
|
</Text>
|
|
<Tooltip label="Sacar del archivo (volver a Hecho)" withArrow>
|
|
<ActionIcon size="xs" variant="subtle" color="teal" onClick={() => handleUnarchiveCard(c.id)}>
|
|
<IconArrowBackUp size={12} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
</Stack>
|
|
</AppShell.Navbar>
|
|
|
|
<AppShell.Aside>
|
|
<ChatPanel onBoardChange={reload} />
|
|
</AppShell.Aside>
|
|
|
|
<AppShell.Main>
|
|
{activeTab === "dashboard" ? (
|
|
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
|
|
<Dashboard users={users} />
|
|
</Box>
|
|
) : activeTab === "calendar" ? (
|
|
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
|
|
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} onOpenDailyReport={handleOpenDailyReport} />
|
|
</Box>
|
|
) : (
|
|
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
|
<Group gap="xs" p="xs" wrap="wrap" align="end" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}>
|
|
<TextInput
|
|
leftSection={<IconSearch size={14} />}
|
|
placeholder="Buscar (titulo, descripcion, solicitante, tag)"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.currentTarget.value)}
|
|
rightSection={
|
|
searchTerm ? (
|
|
<ActionIcon size="sm" variant="subtle" color="gray" onClick={() => setSearchTerm("")} aria-label="Limpiar">
|
|
<IconX size={14} />
|
|
</ActionIcon>
|
|
) : null
|
|
}
|
|
style={{ minWidth: 280, flex: 1 }}
|
|
size="xs"
|
|
/>
|
|
<Select
|
|
placeholder="Asignado"
|
|
value={filterAssigneeId}
|
|
onChange={setFilterAssigneeId}
|
|
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
|
|
clearable
|
|
searchable
|
|
size="xs"
|
|
style={{ minWidth: 160 }}
|
|
disabled={filterUnassigned}
|
|
/>
|
|
<Checkbox
|
|
size="xs"
|
|
label="Sin asignar"
|
|
checked={filterUnassigned}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.checked;
|
|
setFilterUnassigned(v);
|
|
if (v) setFilterAssigneeId(null);
|
|
}}
|
|
/>
|
|
<Checkbox
|
|
size="xs"
|
|
label="Con deadline"
|
|
checked={filterDeadlineOnly}
|
|
onChange={(e) => setFilterDeadlineOnly(e.currentTarget.checked)}
|
|
/>
|
|
<Select
|
|
placeholder="Solicitante"
|
|
value={filterRequester}
|
|
onChange={setFilterRequester}
|
|
data={requesterOptions}
|
|
clearable
|
|
searchable
|
|
size="xs"
|
|
style={{ minWidth: 160 }}
|
|
/>
|
|
<MultiSelect
|
|
placeholder="Tags"
|
|
value={filterTags}
|
|
onChange={setFilterTags}
|
|
data={tagOptions}
|
|
clearable
|
|
searchable
|
|
size="xs"
|
|
style={{ minWidth: 200 }}
|
|
/>
|
|
<DatePickerInput
|
|
placeholder="Desde"
|
|
value={filterDateFrom}
|
|
onChange={(v) => setFilterDateFrom(v ? new Date(v as unknown as string) : null)}
|
|
clearable
|
|
size="xs"
|
|
style={{ minWidth: 130 }}
|
|
valueFormat="DD/MM/YY"
|
|
/>
|
|
<DatePickerInput
|
|
placeholder="Hasta"
|
|
value={filterDateTo}
|
|
onChange={(v) => setFilterDateTo(v ? new Date(v as unknown as string) : null)}
|
|
clearable
|
|
size="xs"
|
|
style={{ minWidth: 130 }}
|
|
valueFormat="DD/MM/YY"
|
|
/>
|
|
<Group gap={4}>
|
|
<Button size="xs" variant="default" onClick={() => {
|
|
const t = new Date();
|
|
setFilterDateFrom(t);
|
|
setFilterDateTo(t);
|
|
}}>Hoy</Button>
|
|
<Button size="xs" variant="default" onClick={() => {
|
|
const t = new Date();
|
|
const f = new Date();
|
|
f.setDate(f.getDate() - 7);
|
|
setFilterDateFrom(f);
|
|
setFilterDateTo(t);
|
|
}}>7d</Button>
|
|
<Button size="xs" variant="default" onClick={() => {
|
|
const t = new Date();
|
|
const f = new Date();
|
|
f.setDate(f.getDate() - 30);
|
|
setFilterDateFrom(f);
|
|
setFilterDateTo(t);
|
|
}}>30d</Button>
|
|
</Group>
|
|
<StickerPicker
|
|
opened={stickerPickerOpen}
|
|
onClose={() => setStickerPickerOpen(false)}
|
|
onSelect={(emoji) => setActiveSticker(emoji)}
|
|
target={
|
|
<Button
|
|
size="xs"
|
|
variant={activeSticker ? "filled" : "default"}
|
|
color={activeSticker ? "yellow" : undefined}
|
|
leftSection={<IconMoodSmile size={14} />}
|
|
onClick={() => {
|
|
if (!activeSticker) {
|
|
setActiveSticker("😀");
|
|
} else {
|
|
setStickerPickerOpen((v) => !v);
|
|
}
|
|
}}
|
|
>
|
|
{activeSticker ? `Modo sticker: ${activeSticker}` : "Stickers"}
|
|
</Button>
|
|
}
|
|
/>
|
|
{activeSticker && (
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
color="gray"
|
|
leftSection={<IconX size={12} />}
|
|
onClick={() => setActiveSticker(null)}
|
|
>
|
|
ESC
|
|
</Button>
|
|
)}
|
|
{filtersActive && (
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
color="gray"
|
|
leftSection={<IconX size={12} />}
|
|
onClick={() => {
|
|
setSearchTerm("");
|
|
setFilterAssigneeId(null);
|
|
setFilterUnassigned(false);
|
|
setFilterRequester(null);
|
|
setFilterTags([]);
|
|
setFilterDateFrom(null);
|
|
setFilterDateTo(null);
|
|
setFilterDeadlineOnly(false);
|
|
}}
|
|
>
|
|
Limpiar
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
|
|
<Group
|
|
align="stretch"
|
|
wrap="nowrap"
|
|
gap="md"
|
|
p="md"
|
|
style={{ flex: 1, overflowX: "auto", overflowY: "hidden" }}
|
|
>
|
|
{boardColumns.map((col) => (
|
|
<KanbanColumn
|
|
key={col.id}
|
|
column={col}
|
|
cards={cardsByColumn.get(col.id) ?? []}
|
|
now={now}
|
|
onAddCard={openCreateCard}
|
|
onRenameColumn={handleRenameColumn}
|
|
onResizeColumn={handleResizeColumn}
|
|
onMoveColumnLocation={handleMoveColumnLocation}
|
|
onDeleteColumn={handleDeleteColumn}
|
|
onSetWIPLimit={handleSetWIPLimit}
|
|
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
|
onPickRandom={handlePickRandom}
|
|
onToggleDone={handleToggleDone}
|
|
onEditCard={openEditCard}
|
|
onDeleteCard={handleDeleteCard}
|
|
onDuplicateCard={handleDuplicateCard}
|
|
onChangeCardColor={handleChangeCardColor}
|
|
onShowHistory={handleShowHistory}
|
|
onToggleCardLock={handleToggleCardLock}
|
|
onAssignCard={handleAssignCard}
|
|
onSetCardDeadline={handleSetCardDeadline}
|
|
highlightCardId={highlightCardId}
|
|
onSetRequester={handleSetRequester}
|
|
onArchiveCard={handleArchiveCard}
|
|
requesterOptions={requesterOptions}
|
|
activeSticker={activeSticker}
|
|
onAddSticker={handleAddSticker}
|
|
onRemoveSticker={handleRemoveSticker}
|
|
onMoveSticker={handleMoveSticker}
|
|
onCommitSticker={handleCommitSticker}
|
|
users={users}
|
|
usersById={usersById}
|
|
/>
|
|
))}
|
|
|
|
<Box style={{ minWidth: 280, maxWidth: 320 }}>
|
|
{addingCol ? (
|
|
<Stack gap={4}>
|
|
<TextInput
|
|
size="xs"
|
|
placeholder="Nombre de columna..."
|
|
value={colName}
|
|
onChange={(e) => setColName(e.currentTarget.value)}
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleAddColumn();
|
|
if (e.key === "Escape") {
|
|
setAddingCol(false);
|
|
setColName("");
|
|
}
|
|
}}
|
|
/>
|
|
<Group gap={4}>
|
|
<Button size="xs" onClick={handleAddColumn}>
|
|
Anadir
|
|
</Button>
|
|
<ActionIcon variant="subtle" color="gray" onClick={() => setAddingCol(false)}>
|
|
<IconX size={14} />
|
|
</ActionIcon>
|
|
</Group>
|
|
</Stack>
|
|
) : (
|
|
<Button
|
|
variant="light"
|
|
color="gray"
|
|
leftSection={<IconPlus size={14} />}
|
|
onClick={() => setAddingCol(true)}
|
|
>
|
|
Anadir columna
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
</SortableContext>
|
|
</Box>
|
|
)}
|
|
</AppShell.Main>
|
|
|
|
</AppShell>
|
|
|
|
<DragOverlay>
|
|
{dragOverlayCard ? (
|
|
<KanbanCard
|
|
card={dragOverlayCard}
|
|
now={now}
|
|
onDelete={() => {}}
|
|
onEdit={() => {}}
|
|
onChangeColor={() => {}}
|
|
onShowHistory={() => {}}
|
|
onToggleLock={() => {}}
|
|
onAssign={() => {}}
|
|
users={users}
|
|
assignee={dragOverlayCard.assignee_id ? usersById.get(dragOverlayCard.assignee_id) : undefined}
|
|
isOverlay
|
|
/>
|
|
) : dragOverlayColumn ? (
|
|
<Box
|
|
style={{
|
|
width: dragOverlayColumn.location === "sidebar" ? 220 : dragOverlayColumn.width,
|
|
padding: 8,
|
|
background: colorBg(""),
|
|
border: `1px solid ${colorBorder("")}`,
|
|
borderRadius: 8,
|
|
opacity: 0.9,
|
|
}}
|
|
>
|
|
<Text fw={600} size="sm">
|
|
{dragOverlayColumn.name}
|
|
</Text>
|
|
</Box>
|
|
) : null}
|
|
</DragOverlay>
|
|
|
|
|
|
<CustomColorModal
|
|
opened={avatarColorModalOpen}
|
|
onClose={() => setAvatarColorModalOpen(false)}
|
|
value={avatarCustomColor}
|
|
onAccept={async (c) => {
|
|
setAvatarCustomColor(c);
|
|
try {
|
|
const u = await api.updateMe({ color: c });
|
|
auth.setUser(u);
|
|
} catch (e) {
|
|
notifications.show({ color: "red", message: (e as Error).message });
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<CustomColorModal
|
|
opened={!!cardColorModal}
|
|
onClose={() => setCardColorModal(null)}
|
|
value={cardColorModal?.color || "#888888"}
|
|
onAccept={(c) => {
|
|
if (!cardColorModal) return;
|
|
handleChangeCardColor(cardColorModal.cardId, c);
|
|
}}
|
|
/>
|
|
</DndContext>
|
|
);
|
|
}
|