Files
kanban/frontend/src/App.tsx
T
egutierrez fc7e6a34a7 feat(kanban): reporte diario al click en dia del calendario (issue 0093)
Adds a daily report dashboard accessible by clicking a day number in the
calendar view. Renders inside a full-width modal (90% width).

Backend (new file backend/reports.go):
- Type DailyReport with KPIs, rankings, done_cards list, reopened cards,
  3-bucket stale list (7/14/30d), lead time avg+p50+p95, 24-hour
  movement histogram, deadlines met/missed list, tag distribution and
  archived count.
- DB.DailyReportFor(date, tz) uses Europe/Madrid by default; computes
  [start,end) in local time, converts to UTC and queries:
  * cards.completed_at in range  -> done list
  * card_events kind=created in range -> created counts
  * card_column_history.entered_at in range -> moves + hourly
  * previousColumnWasDone() -> reopened detection
  * card_lock_history overlapping the day -> blocked_ms
  * stale buckets: open history entries on non-done columns aged >=7d
- New route GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid.

Frontend:
- api.ts: DailyReport type + dailyReport(date, tz?) call.
- New component DailyReportView (components/DailyReport.tsx):
  * 6 KPI cards (Hechas, Creadas, Movimientos, Bloqueado, Reabiertas,
    Deadlines on-time %).
  * 4 ranking cards (Top assignees done, Top assignees created,
    Top requesters atendidas, Top requesters aportadas).
  * Done cards table with click-to-jump (links open the card in board).
  * Mantine BarChart with movements per hour.
  * Tag chips, reopened list, deadlines list with late_ms, stale buckets.
- CalendarView wraps the day number in UnstyledButton with data-test
  attribute and forwards onOpenDailyReport.
- App.handleOpenDailyReport opens modals.open size 90% with the view;
  click on a card title closes the modal and jumps to the board with
  highlight (reuses existing handleJumpToCard).

Tests (e2e/daily-report.spec.ts):
- Endpoint shape: kpis, done_cards, hourly_moves[24], stale buckets.
- Calendar day click opens the modal with "Reporte diario" title and
  KPI labels visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00

1655 lines
58 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,
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 type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
const COL_PREFIX = "column-";
// Custom collision detection: prefiere otras columnas como destino al arrastrar
// columnas; al arrastrar cards prefiere cards/columnas via closestCorners.
function makeCollisionDetection(activeType: string | undefined): CollisionDetection {
if (activeType === "column") {
return (args) => {
// Solo considerar drops sobre otras columnas (ids con COL_PREFIX).
const filtered = args.droppableContainers.filter((c) =>
String(c.id).startsWith(COL_PREFIX)
);
const inter = rectIntersection({ ...args, droppableContainers: filtered });
if (inter.length > 0) return inter;
return closestCenter({ ...args, droppableContainers: filtered });
};
}
return (args) => {
const pw = pointerWithin(args);
if (pw.length > 0) return pw;
return closestCorners(args);
};
}
export function App() {
const auth = useAuth();
const [board, setBoard] = useState<Board | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [activeColumnId, setActiveColumnId] = useState<string | null>(null);
const [activeType, setActiveType] = useState<string | undefined>(undefined);
const [addingCol, setAddingCol] = useState(false);
const [colName, setColName] = useState("");
const [now, setNow] = useState(Date.now());
const [chatOpen, setChatOpen] = useState(false);
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [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 });
}
}, []);
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]);
useEffect(() => {
const t = setInterval(() => {
reload();
}, 30000);
return () => clearInterval(t);
}, [reload]);
useEffect(() => {
if (!activeSticker) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActiveSticker(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [activeSticker]);
const usersById = useMemo(() => {
const m = new Map<string, User>();
for (const u of users) m.set(u.id, u);
return m;
}, [users]);
const sortedColumns = useMemo(() => {
if (!board) return [];
return [...board.columns].sort((a, b) => a.position - b.position);
}, [board]);
const boardColumns = useMemo(() => sortedColumns.filter((c) => c.location !== "sidebar"), [sortedColumns]);
const sidebarColumns = useMemo(() => sortedColumns.filter((c) => c.location === "sidebar"), [sortedColumns]);
const boardSortableIds = useMemo(() => boardColumns.map((c) => `${COL_PREFIX}${c.id}`), [boardColumns]);
const sidebarSortableIds = useMemo(() => sidebarColumns.map((c) => `${COL_PREFIX}${c.id}`), [sidebarColumns]);
const cardMatches = useCallback(
(c: Card): boolean => {
const term = searchTerm.trim().toLowerCase();
if (term) {
const hay = [
c.title,
c.description,
c.requester,
...(c.tags || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!hay.includes(term)) return false;
}
if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false;
if (filterUnassigned && c.assignee_id) return false;
if (filterRequester && c.requester !== filterRequester) return false;
if (filterTags.length > 0) {
const cardTags = new Set(c.tags || []);
for (const t of filterTags) if (!cardTags.has(t)) return false;
}
if (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) => {
const id = modals.open({
title: "Editar tarjeta",
size: "85%",
children: (
<CardEditPanel
card={card}
users={users}
currentUserId={auth.user?.id}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
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>
<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>{auth.user.display_name || auth.user.username}</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 />
<Menu.Item
leftSection={<IconLogout size={14} />}
color="red"
onClick={() => auth.logout()}
>
Cerrar sesion
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p="xs">
{/* Drag handle to resize navbar — absolute relative to navbar (which is position:fixed in v9) */}
<Box
onMouseDown={onNavResizeMouseDown}
style={{
position: "absolute",
top: 0,
right: -3,
width: 6,
height: "100%",
cursor: "col-resize",
zIndex: 10,
}}
aria-label="Resize sidebar"
/>
<Stack gap="xs" h="100%">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Columnas parqueadas
</Text>
<Box style={{ flex: 1, overflowY: "auto" }}>
<SortableContext items={sidebarSortableIds} strategy={verticalListSortingStrategy}>
<Stack gap="xs">
{sidebarColumns.length === 0 && (
<Text size="xs" c="dimmed">
Vacio. Mueve columnas aqui con el icono "archivar" en su cabecera.
</Text>
)}
{sidebarColumns.map((col) => (
<KanbanColumn
key={col.id}
column={col}
cards={cardsByColumn.get(col.id) ?? []}
now={now}
collapsed
onAddCard={openCreateCard}
onRenameColumn={handleRenameColumn}
onResizeColumn={handleResizeColumn}
onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
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>
);
}