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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user