feat(kanban): deadlines en cards (context menu, badges, calendario, history)

- migration 009 + columna deadline TEXT en cards
- backend: CardPatch.HasDeadline, eventos deadline_set/deadline_cleared
- KanbanCard: menu derecho con DatePicker, badge countdown con colores por ratio (azul>=50%, amarillo<50%, rojo<10%, red.9 overdue)
- App.tsx: filtro "Con deadline", handleSetCardDeadline optimista, jump-to-card + highlight
- CalendarView: popover por dia con seq_num + titulo, click navega a card en tablero
- HistoryModal: render eventos deadline_set/deadline_cleared
- .gitignore: *.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 03:45:36 +02:00
parent 5ba0254e57
commit 7ce227ddea
54 changed files with 3066 additions and 496 deletions
+217 -53
View File
@@ -2,6 +2,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ActionIcon,
Autocomplete,
Avatar,
Badge,
Group,
@@ -14,22 +15,26 @@ import {
Tooltip,
} from "@mantine/core";
import {
IconCalendarDue,
IconCheck,
IconClock,
IconDotsVertical,
IconEdit,
IconGripVertical,
IconHistory,
IconHourglass,
IconLock,
IconLockOpen,
IconPalette,
IconUserSquare,
IconTrash,
IconUser,
IconUserCircle,
} from "@tabler/icons-react";
import { memo, useCallback, useRef, useState } from "react";
import { DatePickerInput } from "@mantine/dates";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { Card, CardColor, User } from "../types";
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
import { colorBg, colorBorder, tagColor } from "./colors";
import { ColorPickerGrid } from "./ColorPickerGrid";
import { formatDateTimeShort, formatDuration } from "./format";
interface Props {
@@ -41,6 +46,10 @@ interface Props {
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;
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;
@@ -50,6 +59,7 @@ interface Props {
assignee?: User;
inDoneColumn?: boolean;
isOverlay?: boolean;
highlight?: boolean;
}
function KanbanCardImpl({
@@ -61,6 +71,10 @@ function KanbanCardImpl({
onShowHistory,
onToggleLock,
onAssign,
onSetDeadline,
onSetRequester,
requesterOptions,
onOpenCustomColor,
activeSticker,
onAddSticker,
onRemoveSticker,
@@ -70,10 +84,14 @@ function KanbanCardImpl({
assignee,
inDoneColumn,
isOverlay,
highlight,
}: Props) {
const isDone = inDoneColumn || !!card.completed_at;
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 [menuOpen, setMenuOpen] = useState(false);
const cardElRef = useRef<HTMLElement | null>(null);
const draggingStickerRef = useRef<number | null>(null);
@@ -89,6 +107,12 @@ function KanbanCardImpl({
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;
@@ -140,13 +164,25 @@ function KanbanCardImpl({
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: card.locked ? 2 : 1,
borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: highlight || card.locked ? 2 : 1,
boxShadow: highlight ? "0 0 0 3px var(--mantine-color-blue-4)" : undefined,
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
};
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;
@@ -190,28 +226,12 @@ function KanbanCardImpl({
Color
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group gap={4} maw={200}>
{CARD_COLORS.map((c) => (
<Tooltip key={c.value} label={c.label} withArrow>
<ActionIcon
variant={card.color === c.value ? "filled" : "default"}
size="md"
radius="xl"
style={{
background: colorSwatch(c.value),
borderColor: colorBorder(c.value),
}}
onClick={() => {
onChangeColor(card.id, c.value);
setColorPopOpen(false);
setMenuOpen(false);
}}
aria-label={c.label}
/>
</Tooltip>
))}
</Group>
<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
@@ -235,7 +255,7 @@ function KanbanCardImpl({
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs">
<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}
@@ -252,6 +272,55 @@ function KanbanCardImpl({
/>
</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}
@@ -271,6 +340,63 @@ function KanbanCardImpl({
>
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>
)}
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={14} />}
@@ -346,25 +472,39 @@ function KanbanCardImpl({
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>{menuItems}</Menu.Dropdown>
<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 && (
<Group gap={4}>
<IconUser size={12} />
<Text size="xs" c="dimmed">
{card.requester}
</Text>
</Group>
)}
{assignee && (
<Group gap={6} wrap="nowrap">
<Avatar size={18} radius="xl" color="blue">
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
</Avatar>
<Text size="xs" c="dimmed">
{assignee.display_name || assignee.username}
</Text>
{(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 && (
@@ -375,18 +515,19 @@ function KanbanCardImpl({
{card.tags && card.tags.length > 0 && (
<Group gap={4} wrap="wrap">
{card.tags.map((t) => (
<Badge key={t} size="xs" variant="outline" color="violet" radius="sm">
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
{t}
</Badge>
))}
</Group>
)}
<Group gap={4} wrap="wrap">
{card.locked ? (
{card.locked && (
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
{formatDuration(lockedMs)}
</Badge>
) : isDone && card.completed_at ? (
)}
{!card.locked && isDone && card.completed_at ? (
<>
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>
{formatDateTimeShort(card.completed_at)}
@@ -394,13 +535,36 @@ function KanbanCardImpl({
<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>
)}
</>
) : (
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
{formatDuration(liveMs)}
</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