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; 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(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) => { 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 : ( <> Acciones } onClick={() => { setMenuOpen(false); onEdit(card); }}>Editar {onDuplicate && ( } onClick={() => { setMenuOpen(false); onDuplicate(card.id); }}>Duplicar )} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); setColorPopOpen((v) => !v); }} closeMenuOnClick={false} > Color e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> onChangeColor(card.id, c as CardColor)} onOpenCustom={onOpenCustomColor ? () => onOpenCustomColor(card.id, card.color || "#888888") : undefined} /> } onClick={(e) => { e.preventDefault(); e.stopPropagation(); setAssigneePopOpen((v) => !v); }} closeMenuOnClick={false} > Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."} e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>