9b503f0555
Adds an archive layer separate from the trash. Cards in is_done columns that have been there for more than 30 days are auto-archived on the next board load (throttled to once every 30 minutes). Archived cards leave the board but stay in the DB and are listed in a new sidebar drawer "Hecho (archivo)" below the existing Papelera, with a one-click restore. Schema (migration 012_card_archived.sql): - ALTER TABLE cards ADD COLUMN archived_at TEXT; - NULL = active, ISO timestamp = archived. Independent from deleted_at. Backend: - Card.ArchivedAt + JSON; ListCardsWithTime filters archived_at IS NULL. - New methods: ArchiveCard, UnarchiveCard, ListArchivedCards, AutoArchiveDoneOlderThan. - New endpoints: GET /api/archive, POST /api/cards/:id/archive, POST /api/cards/:id/unarchive. - handleGetBoard invokes maybeAutoArchive (atomic throttle, 30 min sweep, 30 day cutoff). Errors logged but never block the board response. Frontend: - Card type + api.ts add the new field and helpers. - App.tsx state for archive list, reload, archive/unarchive handlers. - New sidebar drawer with toggle, count badge, restore button. - KanbanCard gains an "Archivar" menu item (gated on isDone + onArchive prop) for manual archiving of any done card. Tests: - Playwright e2e/archive.spec.ts: manual archive via menu, drawer toggle, unarchive. Picks a done card via /api/board introspection so it stays stable regardless of board state. - Auto-archive of >30d cards: not under e2e (real time travel needed); covered by code review of the SQL query and the throttle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
621 lines
24 KiB
TypeScript
621 lines
24 KiB
TypeScript
import { useSortable } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import {
|
|
ActionIcon,
|
|
Autocomplete,
|
|
Avatar,
|
|
Badge,
|
|
Group,
|
|
Menu,
|
|
Paper,
|
|
Popover,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import {
|
|
IconArchive,
|
|
IconCalendarDue,
|
|
IconCheck,
|
|
IconClock,
|
|
IconCopy,
|
|
IconDotsVertical,
|
|
IconEdit,
|
|
IconGripVertical,
|
|
IconHistory,
|
|
IconHourglass,
|
|
IconLock,
|
|
IconLockOpen,
|
|
IconPalette,
|
|
IconUserSquare,
|
|
IconTrash,
|
|
IconUserCircle,
|
|
} from "@tabler/icons-react";
|
|
import { DatePickerInput } from "@mantine/dates";
|
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { Card, CardColor, User } from "../types";
|
|
import { colorBg, colorBorder, tagColor } from "./colors";
|
|
import { ColorPickerGrid } from "./ColorPickerGrid";
|
|
import { formatDateTimeShort, formatDuration } from "./format";
|
|
|
|
interface Props {
|
|
card: Card;
|
|
now: number;
|
|
onDelete: (id: string) => void;
|
|
onEdit: (card: Card) => void;
|
|
onDuplicate?: (id: string) => void;
|
|
onChangeColor: (id: string, color: CardColor) => void;
|
|
onShowHistory: (card: Card) => void;
|
|
onToggleLock: (id: string, locked: boolean) => void;
|
|
onAssign: (id: string, assignee_id: string | null) => void;
|
|
onSetDeadline?: (id: string, deadline: string | null) => void;
|
|
onSetRequester?: (id: string, requester: string) => void;
|
|
onArchive?: (id: string) => void;
|
|
requesterOptions?: string[];
|
|
onOpenCustomColor?: (cardId: string, current: string) => void;
|
|
activeSticker?: string | null;
|
|
onAddSticker?: (cardId: string, x: number, y: number) => void;
|
|
onRemoveSticker?: (cardId: string, index: number) => void;
|
|
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
|
|
onCommitSticker?: (cardId: string) => void;
|
|
users: User[];
|
|
assignee?: User;
|
|
inDoneColumn?: boolean;
|
|
columnOverdue?: boolean;
|
|
isOverlay?: boolean;
|
|
highlight?: boolean;
|
|
}
|
|
|
|
// PERF debug helpers (gated): cuentan renders por capa durante drag.
|
|
function _probeRender() {
|
|
const w = window as unknown as { _cardRenderProbe?: boolean; _cardRenderCount?: number };
|
|
if (w._cardRenderProbe) w._cardRenderCount = (w._cardRenderCount || 0) + 1;
|
|
}
|
|
function _probeBodyRender() {
|
|
const w = window as unknown as { _cardRenderProbe?: boolean; _cardBodyRenderCount?: number };
|
|
if (w._cardRenderProbe) w._cardBodyRenderCount = (w._cardBodyRenderCount || 0) + 1;
|
|
}
|
|
|
|
// KanbanCardBody — contiene Stack + sticker overlay + states locales (popovers,
|
|
// requesterDraft). Memoizado para que dnd-kit re-render del wrapper exterior
|
|
// (provocado por useSortable cada pointermove) NO rebote a este tree.
|
|
interface CardBodyProps {
|
|
card: Card;
|
|
isDone: boolean;
|
|
isOverlay?: boolean;
|
|
highlight?: boolean;
|
|
activeSticker?: string | null;
|
|
cardElRef: React.MutableRefObject<HTMLElement | null>;
|
|
now: number;
|
|
users: User[];
|
|
assignee?: User;
|
|
requesterOptions?: string[];
|
|
menuOpen: boolean;
|
|
setMenuOpen: (v: boolean | ((p: boolean) => boolean)) => void;
|
|
onDelete: (id: string) => void;
|
|
onEdit: (card: Card) => void;
|
|
onDuplicate?: (id: string) => void;
|
|
onChangeColor: (id: string, color: CardColor) => void;
|
|
onShowHistory: (card: Card) => void;
|
|
onToggleLock: (id: string, locked: boolean) => void;
|
|
onAssign: (id: string, assignee_id: string | null) => void;
|
|
onSetDeadline?: (id: string, deadline: string | null) => void;
|
|
onSetRequester?: (id: string, requester: string) => void;
|
|
onArchive?: (id: string) => void;
|
|
onOpenCustomColor?: (cardId: string, current: string) => void;
|
|
onRemoveSticker?: (cardId: string, index: number) => void;
|
|
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
|
|
onCommitSticker?: (cardId: string) => void;
|
|
}
|
|
|
|
const KanbanCardBody = memo(function KanbanCardBody({
|
|
card,
|
|
isDone,
|
|
isOverlay,
|
|
activeSticker,
|
|
cardElRef,
|
|
now,
|
|
users,
|
|
assignee,
|
|
requesterOptions,
|
|
menuOpen,
|
|
setMenuOpen,
|
|
onDelete,
|
|
onEdit,
|
|
onDuplicate,
|
|
onChangeColor,
|
|
onShowHistory,
|
|
onToggleLock,
|
|
onAssign,
|
|
onSetDeadline,
|
|
onSetRequester,
|
|
onArchive,
|
|
onOpenCustomColor,
|
|
onRemoveSticker,
|
|
onMoveSticker,
|
|
onCommitSticker,
|
|
}: CardBodyProps) {
|
|
_probeBodyRender();
|
|
const stickerMode = !!activeSticker;
|
|
const [colorPopOpen, setColorPopOpen] = useState(false);
|
|
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
|
|
const [requesterPopOpen, setRequesterPopOpen] = useState(false);
|
|
const [deadlinePopOpen, setDeadlinePopOpen] = useState(false);
|
|
const [requesterDraft, setRequesterDraft] = useState(card.requester || "");
|
|
const draggingStickerRef = useRef<number | null>(null);
|
|
|
|
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
|
|
const liveMs = Math.max(0, now - enteredAt);
|
|
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
|
|
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
|
|
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
|
|
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
|
|
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
|
|
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
|
|
let dlColor: string = "blue";
|
|
let dlVariant: "light" | "filled" = "light";
|
|
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
|
|
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
|
|
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
|
|
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
|
|
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
|
|
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
|
|
const completedAt = card.completed_at ? new Date(card.completed_at).getTime() : 0;
|
|
const totalDoneMs = isDone && createdAt && completedAt ? Math.max(0, completedAt - createdAt) : 0;
|
|
|
|
const startStickerDrag = (index: number) => (e: React.PointerEvent<HTMLSpanElement>) => {
|
|
if (!stickerMode || isOverlay || !onMoveSticker) return;
|
|
if (e.button !== 0) return;
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const rect = cardElRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
draggingStickerRef.current = index;
|
|
const target = e.currentTarget;
|
|
target.setPointerCapture(e.pointerId);
|
|
const onMove = (ev: PointerEvent) => {
|
|
const idx = draggingStickerRef.current;
|
|
if (idx === null) return;
|
|
const x = (ev.clientX - rect.left) / rect.width;
|
|
const y = (ev.clientY - rect.top) / rect.height;
|
|
onMoveSticker(card.id, idx, Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y)));
|
|
};
|
|
const onUp = (ev: PointerEvent) => {
|
|
target.releasePointerCapture?.(ev.pointerId);
|
|
target.removeEventListener("pointermove", onMove);
|
|
target.removeEventListener("pointerup", onUp);
|
|
target.removeEventListener("pointercancel", onUp);
|
|
draggingStickerRef.current = null;
|
|
onCommitSticker?.(card.id);
|
|
};
|
|
target.addEventListener("pointermove", onMove);
|
|
target.addEventListener("pointerup", onUp);
|
|
target.addEventListener("pointercancel", onUp);
|
|
};
|
|
|
|
const onStickerContextMenu = (index: number) => (e: React.MouseEvent) => {
|
|
if (!stickerMode || isOverlay) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onRemoveSticker?.(card.id, index);
|
|
};
|
|
|
|
const menuItems = !menuOpen ? null : (
|
|
<>
|
|
<Menu.Label>Acciones</Menu.Label>
|
|
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => { setMenuOpen(false); onEdit(card); }}>Editar</Menu.Item>
|
|
{onDuplicate && (
|
|
<Menu.Item leftSection={<IconCopy size={14} />} onClick={() => { setMenuOpen(false); onDuplicate(card.id); }}>Duplicar</Menu.Item>
|
|
)}
|
|
<Popover opened={colorPopOpen} onChange={setColorPopOpen} position="right-start" withArrow shadow="md">
|
|
<Popover.Target>
|
|
<Menu.Item
|
|
leftSection={<IconPalette size={14} />}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setColorPopOpen((v) => !v); }}
|
|
closeMenuOnClick={false}
|
|
>
|
|
Color
|
|
</Menu.Item>
|
|
</Popover.Target>
|
|
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
<ColorPickerGrid
|
|
value={card.color}
|
|
onChange={(c) => onChangeColor(card.id, c as CardColor)}
|
|
onOpenCustom={onOpenCustomColor ? () => onOpenCustomColor(card.id, card.color || "#888888") : undefined}
|
|
/>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
<Popover opened={assigneePopOpen} onChange={setAssigneePopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
|
|
<Popover.Target>
|
|
<Menu.Item
|
|
leftSection={<IconUserCircle size={14} />}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setAssigneePopOpen((v) => !v); }}
|
|
closeMenuOnClick={false}
|
|
>
|
|
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
|
|
</Menu.Item>
|
|
</Popover.Target>
|
|
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
<Select
|
|
placeholder="Sin asignar"
|
|
value={card.assignee_id ?? null}
|
|
onChange={(v) => { onAssign(card.id, v); setAssigneePopOpen(false); setMenuOpen(false); }}
|
|
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
|
|
clearable
|
|
searchable
|
|
autoFocus
|
|
comboboxProps={{ withinPortal: false }}
|
|
/>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
<Popover opened={requesterPopOpen} onChange={setRequesterPopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
|
|
<Popover.Target>
|
|
<Menu.Item
|
|
leftSection={<IconUserSquare size={14} />}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setRequesterDraft(card.requester || ""); setRequesterPopOpen((v) => !v); }}
|
|
closeMenuOnClick={false}
|
|
>
|
|
Solicitante {card.requester ? `(${card.requester})` : "..."}
|
|
</Menu.Item>
|
|
</Popover.Target>
|
|
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
<Autocomplete
|
|
placeholder="Sin solicitante"
|
|
value={requesterDraft}
|
|
onChange={setRequesterDraft}
|
|
data={requesterOptions || []}
|
|
autoFocus
|
|
comboboxProps={{ withinPortal: false }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
onSetRequester?.(card.id, requesterDraft.trim());
|
|
setRequesterPopOpen(false);
|
|
setMenuOpen(false);
|
|
} else if (e.key === "Escape") {
|
|
setRequesterPopOpen(false);
|
|
}
|
|
}}
|
|
onOptionSubmit={(v) => { setRequesterDraft(v); onSetRequester?.(card.id, v); setRequesterPopOpen(false); setMenuOpen(false); }}
|
|
/>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
<Menu.Item
|
|
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
|
|
color={card.locked ? "yellow" : undefined}
|
|
onClick={() => { setMenuOpen(false); onToggleLock(card.id, !card.locked); }}
|
|
>
|
|
{card.locked ? "Desbloquear" : "Bloquear"}
|
|
</Menu.Item>
|
|
<Menu.Item leftSection={<IconHistory size={14} />} onClick={() => { setMenuOpen(false); onShowHistory(card); }}>Historial</Menu.Item>
|
|
{onSetDeadline && (
|
|
<Popover opened={deadlinePopOpen} onChange={setDeadlinePopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
|
|
<Popover.Target>
|
|
<Menu.Item
|
|
leftSection={<IconCalendarDue size={14} />}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setDeadlinePopOpen((v) => !v); }}
|
|
closeMenuOnClick={false}
|
|
>
|
|
{card.deadline ? `Deadline (${card.deadline.slice(0, 10)})` : "Deadline..."}
|
|
</Menu.Item>
|
|
</Popover.Target>
|
|
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
<DatePickerInput
|
|
value={card.deadline ? card.deadline.slice(0, 10) : null}
|
|
onChange={(v) => {
|
|
const s = v ? (typeof v === "string" ? v.slice(0, 10) : new Date(v as unknown as string).toISOString().slice(0, 10)) : null;
|
|
onSetDeadline(card.id, s ? `${s}T23:59:59Z` : null);
|
|
setDeadlinePopOpen(false);
|
|
setMenuOpen(false);
|
|
}}
|
|
clearable
|
|
valueFormat="DD/MM/YYYY"
|
|
size="xs"
|
|
placeholder="Elegir fecha"
|
|
popoverProps={{ withinPortal: false }}
|
|
/>
|
|
{card.deadline && (
|
|
<Tooltip label="Quitar deadline" withArrow>
|
|
<ActionIcon size="sm" variant="subtle" color="red" mt={6} onClick={() => { onSetDeadline(card.id, null); setDeadlinePopOpen(false); setMenuOpen(false); }}>
|
|
<IconTrash size={12} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
)}
|
|
{isDone && onArchive && (
|
|
<Menu.Item
|
|
leftSection={<IconArchive size={14} />}
|
|
color="teal"
|
|
onClick={() => { setMenuOpen(false); onArchive(card.id); }}
|
|
>
|
|
Archivar
|
|
</Menu.Item>
|
|
)}
|
|
<Menu.Divider />
|
|
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => { setMenuOpen(false); onDelete(card.id); }}>Borrar</Menu.Item>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Stack gap={6} style={{ position: "relative", zIndex: 1, pointerEvents: stickerMode ? "none" : undefined }}>
|
|
<Group justify="space-between" gap={4} wrap="nowrap" align="flex-start">
|
|
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }} align="flex-start">
|
|
<IconGripVertical size={14} color="var(--mantine-color-dark-2)" style={{ flexShrink: 0, marginTop: 4 }} />
|
|
{card.locked && (
|
|
<Tooltip label="Bloqueada" withArrow>
|
|
<IconLock size={14} color="var(--mantine-color-yellow-6)" style={{ flexShrink: 0, marginTop: 4 }} />
|
|
</Tooltip>
|
|
)}
|
|
<Text
|
|
size="sm"
|
|
fw={500}
|
|
style={{ flex: 1, wordBreak: "break-word", whiteSpace: "normal", textDecoration: isDone ? "line-through" : "none", opacity: isDone ? 0.7 : 1 }}
|
|
>
|
|
{card.title}
|
|
</Text>
|
|
</Group>
|
|
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" style={{ flexShrink: 0 }} onPointerDown={(e) => e.stopPropagation()}>
|
|
<IconDotsVertical size={14} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
|
|
{menuItems}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Group>
|
|
{(card.requester || assignee) && (
|
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
{card.requester && (
|
|
<>
|
|
<Avatar size={18} radius="xs" color={tagColor(card.requester)} style={{ flexShrink: 0 }}>
|
|
{card.requester.slice(0, 2).toUpperCase()}
|
|
</Avatar>
|
|
<Text size="xs" c="dimmed" truncate>{card.requester}</Text>
|
|
</>
|
|
)}
|
|
{card.requester && assignee && (
|
|
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>-</Text>
|
|
)}
|
|
{assignee && (
|
|
<>
|
|
<Avatar size={18} radius="xl" color={assignee.color || "blue"} style={{ flexShrink: 0 }}>
|
|
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
|
|
</Avatar>
|
|
<Text size="xs" c="dimmed" truncate>{assignee.display_name || assignee.username}</Text>
|
|
</>
|
|
)}
|
|
</Group>
|
|
)}
|
|
{card.description && (
|
|
<Text size="xs" c="dimmed" lineClamp={3}>{card.description}</Text>
|
|
)}
|
|
{card.tags && card.tags.length > 0 && (
|
|
<Group gap={4} wrap="wrap">
|
|
{card.tags.map((t) => (
|
|
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">{t}</Badge>
|
|
))}
|
|
</Group>
|
|
)}
|
|
<Group gap={4} wrap="wrap">
|
|
{card.locked && (
|
|
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>{formatDuration(lockedMs)}</Badge>
|
|
)}
|
|
{!card.locked && isDone && card.completed_at ? (
|
|
<>
|
|
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>{formatDateTimeShort(card.completed_at)}</Badge>
|
|
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>Total: {formatDuration(totalDoneMs)}</Badge>
|
|
{card.total_locked_ms > 0 && (
|
|
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>{formatDuration(card.total_locked_ms)}</Badge>
|
|
)}
|
|
</>
|
|
) : !card.locked ? (
|
|
card.deadline ? (
|
|
<Tooltip label={`Vence: ${formatDateTimeShort(card.deadline)}`} withArrow>
|
|
<Badge size="xs" variant={dlVariant} color={dlColor} leftSection={<IconHourglass size={10} />}>
|
|
{overdue ? `-${formatDuration(-deadlineRemainingMs)}` : formatDuration(deadlineRemainingMs)}
|
|
</Badge>
|
|
</Tooltip>
|
|
) : (
|
|
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>{formatDuration(liveMs)}</Badge>
|
|
)
|
|
) : null}
|
|
</Group>
|
|
{card.seq_num > 0 && (
|
|
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>#{String(card.seq_num).padStart(5, "0")}</Text>
|
|
)}
|
|
</Stack>
|
|
{card.stickers && card.stickers.length > 0 && (
|
|
<div
|
|
data-sticker-overlay
|
|
style={{ position: "absolute", inset: 0, pointerEvents: "none", overflow: "hidden", borderRadius: "inherit", zIndex: 0 }}
|
|
>
|
|
{card.stickers.map((s, i) => (
|
|
<span
|
|
key={i}
|
|
onPointerDown={startStickerDrag(i)}
|
|
onContextMenu={onStickerContextMenu(i)}
|
|
title={stickerMode ? "Arrastra para mover. Click derecho para borrar." : ""}
|
|
style={{
|
|
position: "absolute",
|
|
left: `${s.x * 100}%`,
|
|
top: `${s.y * 100}%`,
|
|
transform: "translate(-50%, -50%)",
|
|
fontSize: 48,
|
|
lineHeight: 1,
|
|
opacity: 1,
|
|
userSelect: "none",
|
|
cursor: stickerMode && !isOverlay ? "grab" : "default",
|
|
pointerEvents: stickerMode && !isOverlay ? "auto" : "none",
|
|
touchAction: "none",
|
|
}}
|
|
>
|
|
{s.emoji}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
});
|
|
|
|
function KanbanCardImpl({
|
|
card,
|
|
now,
|
|
onDelete,
|
|
onEdit,
|
|
onDuplicate,
|
|
onChangeColor,
|
|
onShowHistory,
|
|
onToggleLock,
|
|
onAssign,
|
|
onSetDeadline,
|
|
onSetRequester,
|
|
onArchive,
|
|
requesterOptions,
|
|
onOpenCustomColor,
|
|
activeSticker,
|
|
onAddSticker,
|
|
onRemoveSticker,
|
|
onMoveSticker,
|
|
onCommitSticker,
|
|
users,
|
|
assignee,
|
|
inDoneColumn,
|
|
columnOverdue,
|
|
isOverlay,
|
|
highlight,
|
|
}: Props) {
|
|
_probeRender();
|
|
const isDone = inDoneColumn || !!card.completed_at;
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const cardElRef = useRef<HTMLElement | null>(null);
|
|
const stickerMode = !!activeSticker;
|
|
// Memo: useSortable es sensible a la identidad del objeto `data`. Si lo
|
|
// re-creamos cada render, el setNodeRef interno se vuelve inestable y
|
|
// dispara loops por useMergedRef de Mantine (Paper). Issue: maximum
|
|
// update depth visto durante drag.
|
|
const sortableData = useMemo(
|
|
() => ({ type: "card" as const, columnId: card.column_id, locked: card.locked }),
|
|
[card.column_id, card.locked]
|
|
);
|
|
// Perf: disable layout animations. dnd-kit's default animates the slide of
|
|
// non-dragged items into their new sort position via an FLIP-like loop that
|
|
// re-runs useSortable on every pointermove for ALL cards in the
|
|
// SortableContext. With dozens of cards that drops frames hard (p95>=80ms).
|
|
// Disabling animations keeps the visual shift driven only by the active
|
|
// card's transform.
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
id: card.id,
|
|
data: sortableData,
|
|
disabled: stickerMode,
|
|
animateLayoutChanges: () => false,
|
|
});
|
|
|
|
const setCardRef = useCallback((el: HTMLElement | null) => {
|
|
cardElRef.current = el;
|
|
setNodeRef(el);
|
|
}, [setNodeRef]);
|
|
|
|
useEffect(() => {
|
|
if (highlight && cardElRef.current) {
|
|
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}
|
|
}, [highlight]);
|
|
|
|
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!stickerMode || !onAddSticker || isOverlay) return;
|
|
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) / rect.width;
|
|
const y = (e.clientY - rect.top) / rect.height;
|
|
onAddSticker(card.id, Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y)));
|
|
};
|
|
|
|
const borderColorPicked = highlight
|
|
? "var(--mantine-color-blue-5)"
|
|
: columnOverdue
|
|
? "var(--mantine-color-red-6)"
|
|
: card.locked
|
|
? "var(--mantine-color-yellow-6)"
|
|
: colorBorder(card.color);
|
|
const style: React.CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.4 : 1,
|
|
background: colorBg(card.color),
|
|
borderColor: borderColorPicked,
|
|
borderWidth: highlight || card.locked || columnOverdue ? 2 : 1,
|
|
boxShadow: highlight
|
|
? "0 0 0 3px var(--mantine-color-blue-4)"
|
|
: columnOverdue
|
|
? "0 0 0 2px var(--mantine-color-red-3)"
|
|
: undefined,
|
|
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
|
|
};
|
|
|
|
const onContextMenu = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setMenuOpen(true);
|
|
};
|
|
|
|
|
|
return (
|
|
<Paper
|
|
ref={setCardRef}
|
|
style={{ ...style, position: "relative", cursor: stickerMode ? "copy" : "grab", touchAction: "none" }}
|
|
withBorder
|
|
p="xs"
|
|
shadow={isOverlay ? "lg" : "xs"}
|
|
radius="md"
|
|
data-card-id={card.id}
|
|
data-column-overdue={columnOverdue ? "true" : "false"}
|
|
data-locked={card.locked ? "true" : "false"}
|
|
onContextMenu={onContextMenu}
|
|
onClick={onCardClickAddSticker}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
onEdit(card);
|
|
}}
|
|
{...attributes}
|
|
{...(stickerMode ? {} : listeners)}
|
|
>
|
|
<KanbanCardBody
|
|
card={card}
|
|
isDone={isDone}
|
|
isOverlay={isOverlay}
|
|
highlight={highlight}
|
|
activeSticker={activeSticker}
|
|
cardElRef={cardElRef}
|
|
now={now}
|
|
users={users}
|
|
assignee={assignee}
|
|
requesterOptions={requesterOptions}
|
|
menuOpen={menuOpen}
|
|
setMenuOpen={setMenuOpen}
|
|
onDelete={onDelete}
|
|
onEdit={onEdit}
|
|
onDuplicate={onDuplicate}
|
|
onChangeColor={onChangeColor}
|
|
onShowHistory={onShowHistory}
|
|
onToggleLock={onToggleLock}
|
|
onAssign={onAssign}
|
|
onSetDeadline={onSetDeadline}
|
|
onSetRequester={onSetRequester}
|
|
onArchive={onArchive}
|
|
onOpenCustomColor={onOpenCustomColor}
|
|
onRemoveSticker={onRemoveSticker}
|
|
onMoveSticker={onMoveSticker}
|
|
onCommitSticker={onCommitSticker}
|
|
/>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
export const KanbanCard = memo(KanbanCardImpl);
|