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
+79 -8
View File
@@ -4,26 +4,31 @@ import {
Group,
Loader,
Paper,
Popover,
Select,
SimpleGrid,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { MonthPickerInput } from "@mantine/dates";
import { IconCheckbox, IconPlus } from "@tabler/icons-react";
import { IconCheckbox, IconHourglass, IconPlus } from "@tabler/icons-react";
import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { Metrics, User } from "../types";
import type { Card, Metrics, User } from "../types";
interface Props {
users: User[];
cards: Card[];
onJumpToCard?: (cardId: string) => void;
}
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
export function CalendarView({ users }: Props) {
export function CalendarView({ users, cards, onJumpToCard }: Props) {
const [openDate, setOpenDate] = useState<string | null>(null);
const [month, setMonth] = useState<Date>(new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [data, setData] = useState<Metrics | null>(null);
@@ -53,20 +58,27 @@ export function CalendarView({ users }: Props) {
);
const dayMap = useMemo(() => {
const m = new Map<string, { created: number; done: number }>();
const m = new Map<string, { created: number; done: number; deadlines: Card[] }>();
if (!data) return m;
for (const d of data.created_daily) {
const cur = m.get(d.date) ?? { created: 0, done: 0 };
const cur = m.get(d.date) ?? { created: 0, done: 0, deadlines: [] };
cur.created = d.count;
m.set(d.date, cur);
}
for (const d of data.throughput_daily) {
const cur = m.get(d.date) ?? { created: 0, done: 0 };
const cur = m.get(d.date) ?? { created: 0, done: 0, deadlines: [] };
cur.done = d.count;
m.set(d.date, cur);
}
for (const c of cards) {
if (!c.deadline || c.deleted_at) continue;
const date = c.deadline.slice(0, 10);
const cur = m.get(date) ?? { created: 0, done: 0, deadlines: [] };
cur.deadlines.push(c);
m.set(date, cur);
}
return m;
}, [data]);
}, [data, cards]);
// Build month grid (Mon-first).
const grid = useMemo(() => {
@@ -163,9 +175,12 @@ export function CalendarView({ users }: Props) {
if (!cell.date) {
return <Box key={i} style={{ minHeight: 72 }} />;
}
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0 };
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0, deadlines: [] as Card[] };
const dayNum = parseInt(cell.date.slice(8, 10), 10);
const isToday = cell.date === dayjs().format("YYYY-MM-DD");
const todayMs = dayjs().startOf("day").valueOf();
const cellMs = dayjs(cell.date).startOf("day").valueOf();
const overdueDay = cellMs < todayMs;
return (
<Paper
key={i}
@@ -203,6 +218,62 @@ export function CalendarView({ users }: Props) {
</Text>
</Group>
)}
{stats.deadlines.length > 0 && (
<Popover
opened={openDate === cell.date}
onChange={(o) => setOpenDate(o ? cell.date : null)}
position="bottom"
withArrow
shadow="md"
width={280}
>
<Popover.Target>
<UnstyledButton
onClick={() => setOpenDate(openDate === cell.date ? null : cell.date)}
style={{ textAlign: "left" }}
>
<Stack gap={1}>
<Group gap={3} wrap="nowrap">
<IconHourglass size={10} color={overdueDay ? "var(--mantine-color-red-5)" : "var(--mantine-color-orange-5)"} />
<Text size="xs" c={overdueDay ? "red" : "orange"} fw={700} td="underline">
{stats.deadlines.length} deadline{stats.deadlines.length === 1 ? "" : "s"}
</Text>
</Group>
</Stack>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p={6}>
<Stack gap={2}>
<Text size="xs" c="dimmed" fw={600} mb={2}>
Vencen el {dayjs(cell.date).format("DD/MM/YYYY")}
</Text>
{stats.deadlines.map((c) => (
<UnstyledButton
key={c.id}
onClick={() => {
setOpenDate(null);
onJumpToCard?.(c.id);
}}
style={{
padding: "4px 6px",
borderRadius: 4,
background: "var(--mantine-color-dark-6)",
}}
>
<Group gap={6} wrap="nowrap">
<Text size="xs" c="dimmed" ff="monospace">
#{String(c.seq_num).padStart(5, "0")}
</Text>
<Text size="xs" lineClamp={1} title={c.title}>
{c.title}
</Text>
</Group>
</UnstyledButton>
))}
</Stack>
</Popover.Dropdown>
</Popover>
)}
</Stack>
</Paper>
);
+180
View File
@@ -0,0 +1,180 @@
import { Box, Button, ColorPicker, Group, Modal, Stack, TextInput, Tooltip } from "@mantine/core";
import { IconPalette } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { CARD_COLORS, colorBorder, colorSwatch } from "./colors";
interface Props {
value: string;
onChange: (color: string) => void;
options?: { value: string; label: string }[];
// Si se da, el "+" delega en el padre (recomendado dentro de Menu/Popover).
// Sin esto, ColorPickerGrid abre Modal interno (puede colisionar con cierres del padre).
onOpenCustom?: () => void;
}
const SWATCH = 26;
export function ColorPickerGrid({ value, onChange, options = CARD_COLORS, onOpenCustom }: Props) {
const [pickerOpen, setPickerOpen] = useState(false);
const [custom, setCustom] = useState(value && value.startsWith("#") ? value : "#888888");
const isCustomActive = !!value && value.startsWith("#") && !options.some((o) => o.value === value);
return (
<>
<Group gap={6} maw={280}>
{options.map((c) => {
const selected = value === c.value;
return (
<Tooltip key={c.value || "default"} label={c.label} withArrow>
<Box
role="button"
onClick={(e) => { e.stopPropagation(); onChange(c.value); }}
aria-label={c.label}
style={{
width: SWATCH,
height: SWATCH,
borderRadius: "50%",
background: colorSwatch(c.value),
border: `2px solid ${selected ? "var(--mantine-color-white)" : colorBorder(c.value)}`,
boxShadow: selected ? "0 0 0 2px var(--mantine-color-blue-5)" : undefined,
cursor: "pointer",
flexShrink: 0,
transition: "transform .1s",
}}
/>
</Tooltip>
);
})}
<Tooltip label="Color personalizado" withArrow>
<Box
role="button"
onMouseDown={(e) => { e.stopPropagation(); }}
onClick={(e) => {
e.stopPropagation();
if (onOpenCustom) onOpenCustom();
else setPickerOpen(true);
}}
aria-label="Color personalizado"
style={{
width: SWATCH,
height: SWATCH,
borderRadius: "50%",
background: isCustomActive ? custom : "transparent",
border: `2px dashed ${isCustomActive ? custom : "var(--mantine-color-gray-5)"}`,
boxShadow: isCustomActive ? "0 0 0 2px var(--mantine-color-blue-5)" : undefined,
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--mantine-color-gray-3)",
}}
>
<IconPalette size={14} />
</Box>
</Tooltip>
</Group>
{!onOpenCustom && (
<CustomColorModal
opened={pickerOpen}
onClose={() => setPickerOpen(false)}
value={custom}
onAccept={(v) => { setCustom(v); onChange(v); }}
/>
)}
</>
);
}
interface ModalProps {
opened: boolean;
onClose: () => void;
value: string;
// Disparado solo cuando el usuario pulsa "Aceptar". Mientras arrastra el picker
// el cambio queda local — no fuga al resto de la app.
onAccept: (v: string) => void;
}
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
export function CustomColorModal({ opened, onClose, value, onAccept }: ModalProps) {
const [local, setLocal] = useState(value || "#888888");
const [hexInput, setHexInput] = useState(value || "#888888");
// Reset state cuando abre con un value nuevo (cada vez que se abre).
useEffect(() => {
if (opened) {
const v = value && HEX_RE.test(value) ? value : "#888888";
setLocal(v);
setHexInput(v);
}
}, [opened, value]);
const onHexChange = (v: string) => {
let s = v.trim();
if (s && !s.startsWith("#")) s = "#" + s;
setHexInput(s);
if (HEX_RE.test(s)) setLocal(s);
};
const onPickerChange = (v: string) => {
setLocal(v);
setHexInput(v);
};
const accept = () => { onAccept(local); onClose(); };
return (
<Modal
opened={opened}
onClose={onClose}
title="Color personalizado"
size="auto"
centered
withinPortal
zIndex={2000}
closeOnClickOutside
closeOnEscape={false}
trapFocus={false}
withCloseButton={false}
>
<Stack
gap="sm"
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<ColorPicker
value={local}
onChange={onPickerChange}
format="hex"
swatches={["#1c7ed6", "#15aabf", "#12b886", "#37b24d", "#82c91e", "#fab005", "#fd7e14", "#fa5252", "#e64980", "#be4bdb", "#7950f2", "#4c6ef5", "#868e96", "#212529"]}
fullWidth
/>
<Group align="end" gap="xs">
<TextInput
label="Hex"
value={hexInput}
onChange={(e) => onHexChange(e.currentTarget.value)}
error={hexInput && !HEX_RE.test(hexInput) ? "Hex invalido" : undefined}
size="xs"
style={{ flex: 1 }}
placeholder="#rrggbb"
/>
<Box
style={{
width: 32,
height: 32,
borderRadius: 4,
background: HEX_RE.test(hexInput) ? hexInput : "transparent",
border: "1px solid var(--mantine-color-dark-4)",
}}
/>
</Group>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={onClose}>Cancelar</Button>
<Button size="xs" onClick={accept} disabled={!HEX_RE.test(local)}>Aceptar</Button>
</Group>
</Stack>
</Modal>
);
}
+20 -1
View File
@@ -17,6 +17,7 @@ import {
Grid,
Group,
Loader,
MultiSelect,
Paper,
Select,
SimpleGrid,
@@ -89,10 +90,16 @@ export function Dashboard({ users }: Props) {
const [to, setTo] = useState<Date | null>(() => new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [requester, setRequester] = useState<string | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [data, setData] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(false);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
useEffect(() => {
api.listTags().then(setTagOptions).catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -102,6 +109,7 @@ export function Dashboard({ users }: Props) {
to: fmtDate(to),
assignee_id: assigneeId || undefined,
requester: requester || undefined,
tags: tags.length > 0 ? tags : undefined,
})
.then((m) => {
if (cancelled) return;
@@ -119,7 +127,7 @@ export function Dashboard({ users }: Props) {
return () => {
cancelled = true;
};
}, [from, to, assigneeId, requester]);
}, [from, to, assigneeId, requester, tags]);
const userOptions = useMemo(
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
@@ -240,6 +248,17 @@ export function Dashboard({ users }: Props) {
searchable
style={{ minWidth: 160 }}
/>
<MultiSelect
label="Tags"
size="xs"
placeholder="Todas"
value={tags}
onChange={setTags}
data={tagOptions}
clearable
searchable
style={{ minWidth: 200 }}
/>
</Group>
</Group>
+124 -71
View File
@@ -1,25 +1,115 @@
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { IconColumns3, IconLock } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { cardHistory } from "../api";
import type { Card, CardHistoryResponse } from "../types";
import {
IconArrowsHorizontal,
IconCalendarDue,
IconCalendarOff,
IconColumns3,
IconEdit,
IconLock,
IconLockOpen,
IconPalette,
IconPlus,
IconTag,
IconUser,
IconUserMinus,
IconUserPlus,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { cardHistory, listUsers } from "../api";
import type { Card, CardEvent, CardHistoryResponse, User } from "../types";
import { formatDuration } from "./format";
interface Props {
card: Card;
}
interface UnifiedEvent {
id: string;
ts: string;
kind: string;
actorID: string | null;
detail: string;
icon: React.ReactNode;
color: string;
}
function parsePayload(p: string): Record<string, unknown> {
try { return JSON.parse(p); } catch { return {}; }
}
function eventToUnified(e: CardEvent): UnifiedEvent {
const p = parsePayload(e.payload);
switch (e.kind) {
case "created":
return { id: e.id, ts: e.created_at, kind: "Creada", actorID: e.actor_id, detail: String(p.title || ""), icon: <IconPlus size={12} />, color: "green" };
case "title_changed":
return { id: e.id, ts: e.created_at, kind: "Titulo", actorID: e.actor_id, detail: `"${p.old}" → "${p.new}"`, icon: <IconEdit size={12} />, color: "blue" };
case "requester_changed":
return { id: e.id, ts: e.created_at, kind: "Solicitante", actorID: e.actor_id, detail: `"${p.old || "(vacio)"}" → "${p.new || "(vacio)"}"`, icon: <IconEdit size={12} />, color: "orange" };
case "description_changed":
return { id: e.id, ts: e.created_at, kind: "Descripcion", actorID: e.actor_id, detail: "edicion", icon: <IconEdit size={12} />, color: "blue" };
case "color_changed":
return { id: e.id, ts: e.created_at, kind: "Color", actorID: e.actor_id, detail: String(p.color || ""), icon: <IconPalette size={12} />, color: "violet" };
case "tags_changed":
return { id: e.id, ts: e.created_at, kind: "Tags", actorID: e.actor_id, detail: Array.isArray(p.tags) ? (p.tags as string[]).join(", ") || "(sin tags)" : "", icon: <IconTag size={12} />, color: "grape" };
case "assigned":
return { id: e.id, ts: e.created_at, kind: "Asignada", actorID: e.actor_id, detail: String(p.assignee_id || ""), icon: <IconUserPlus size={12} />, color: "teal" };
case "unassigned":
return { id: e.id, ts: e.created_at, kind: "Sin asignar", actorID: e.actor_id, detail: "", icon: <IconUserMinus size={12} />, color: "gray" };
case "deadline_set": {
const d = String(p.deadline || "");
return { id: e.id, ts: e.created_at, kind: "Deadline", actorID: e.actor_id, detail: d ? d.slice(0, 10) : "", icon: <IconCalendarDue size={12} />, color: "orange" };
}
case "deadline_cleared":
return { id: e.id, ts: e.created_at, kind: "Deadline quitado", actorID: e.actor_id, detail: p.prev ? String(p.prev).slice(0, 10) : "", icon: <IconCalendarOff size={12} />, color: "gray" };
default:
return { id: e.id, ts: e.created_at, kind: e.kind, actorID: e.actor_id, detail: e.payload, icon: <IconEdit size={12} />, color: "gray" };
}
}
export function HistoryModal({ card }: Props) {
const [data, setData] = useState<CardHistoryResponse | null>(null);
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
cardHistory(card.id)
.then(setData)
.catch(() =>
setData({ column_history: [], lock_periods: [], total_locked_ms: 0, currently_locked: false })
setData({ column_history: [], lock_periods: [], events: [], total_locked_ms: 0, currently_locked: false })
);
listUsers().then(setUsers).catch(() => {});
}, [card.id]);
const userById = useMemo(() => {
const m = new Map<string, User>();
for (const u of users) m.set(u.id, u);
return m;
}, [users]);
const unified = useMemo(() => {
if (!data) return [] as UnifiedEvent[];
const out: UnifiedEvent[] = [];
for (const e of data.events || []) out.push(eventToUnified(e));
for (const h of data.column_history || []) {
out.push({
id: "h_in_" + h.id,
ts: h.entered_at,
kind: "Mueve a columna",
actorID: h.actor_id,
detail: h.column_name || h.column_id,
icon: <IconArrowsHorizontal size={12} />,
color: "blue",
});
}
for (const p of data.lock_periods || []) {
out.push({ id: "lk_" + p.id, ts: p.locked_at, kind: "Bloqueada", actorID: p.actor_id, detail: "", icon: <IconLock size={12} />, color: "yellow" });
if (p.unlocked_at) {
out.push({ id: "lku_" + p.id, ts: p.unlocked_at, kind: "Desbloqueada", actorID: p.actor_id, detail: formatDuration(p.duration_ms), icon: <IconLockOpen size={12} />, color: "yellow" });
}
}
return out.sort((a, b) => a.ts.localeCompare(b.ts));
}, [data]);
if (!data) {
return (
<Group justify="center" p="xl">
@@ -28,42 +118,45 @@ export function HistoryModal({ card }: Props) {
);
}
const { column_history, lock_periods, total_locked_ms, currently_locked } = data;
const { column_history, total_locked_ms, currently_locked } = data;
if (column_history.length === 0 && lock_periods.length === 0) {
if (unified.length === 0) {
return <Text c="dimmed">Sin historial.</Text>;
}
const userLabel = (id: string | null): string => {
if (!id) return "";
const u = userById.get(id);
if (!u) return id;
return u.display_name || u.username;
};
return (
<Stack gap="md">
<Text size="sm" c="dimmed">
Tiempo total en cada columna desde que se creo la tarjeta.
</Text>
<Timeline active={column_history.length} bulletSize={22} lineWidth={2}>
{column_history.map((e) => (
<Text size="sm" c="dimmed">Linea de tiempo completa de la tarjeta.</Text>
<Timeline active={unified.length} bulletSize={22} lineWidth={2}>
{unified.map((e) => (
<Timeline.Item
key={e.id}
bullet={<IconColumns3 size={12} />}
bullet={e.icon}
color={e.color}
title={
<Group gap={6}>
<Text fw={500} size="sm">
{e.column_name || e.column_id}
</Text>
<Badge size="xs" variant="light" color={e.exited_at ? "gray" : "blue"}>
{formatDuration(e.duration_ms)}
</Badge>
{!e.exited_at && (
<Badge size="xs" variant="filled" color="blue">
actual
<Group gap={6} wrap="wrap">
<Text fw={500} size="sm">{e.kind}</Text>
{e.actorID && (
<Badge size="xs" variant="light" color="cyan" leftSection={<IconUser size={10} />}>
{userLabel(e.actorID)}
</Badge>
)}
{e.detail && (
<Badge size="xs" variant="outline" color={e.color}>
{e.detail}
</Badge>
)}
</Group>
}
>
<Text size="xs" c="dimmed">
{new Date(e.entered_at).toLocaleString()}
{e.exited_at && ` -> ${new Date(e.exited_at).toLocaleString()}`}
</Text>
<Text size="xs" c="dimmed">{new Date(e.ts).toLocaleString()}</Text>
</Timeline.Item>
))}
</Timeline>
@@ -71,55 +164,15 @@ export function HistoryModal({ card }: Props) {
<Divider />
<Group gap={6} align="center">
<IconColumns3 size={14} />
<Text fw={500} size="sm">Columnas visitadas</Text>
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
<Text fw={500} size="sm">
Tiempo bloqueada
</Text>
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
{formatDuration(total_locked_ms)}
</Badge>
{currently_locked && (
<Badge size="xs" variant="filled" color="yellow">
actualmente bloqueada
</Badge>
)}
{currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
</Group>
{lock_periods.length === 0 ? (
<Text size="xs" c="dimmed">
Nunca ha sido bloqueada.
</Text>
) : (
<Timeline active={lock_periods.length} bulletSize={22} lineWidth={2}>
{lock_periods.map((p) => (
<Timeline.Item
key={p.id}
bullet={<IconLock size={12} />}
title={
<Group gap={6}>
<Badge
size="xs"
variant="light"
color={p.unlocked_at ? "gray" : "yellow"}
>
{formatDuration(p.duration_ms)}
</Badge>
{!p.unlocked_at && (
<Badge size="xs" variant="filled" color="yellow">
en curso
</Badge>
)}
</Group>
}
>
<Text size="xs" c="dimmed">
{new Date(p.locked_at).toLocaleString()}
{p.unlocked_at && ` -> ${new Date(p.unlocked_at).toLocaleString()}`}
</Text>
</Timeline.Item>
))}
</Timeline>
)}
</Stack>
);
}
+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
+15
View File
@@ -54,6 +54,10 @@ interface Props {
onShowHistory: (card: Card) => void;
onToggleCardLock: (id: string, locked: boolean) => void;
onAssignCard: (id: string, assignee_id: string | null) => void;
onSetCardDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
requesterOptions?: string[];
onOpenCustomCardColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
onAddSticker?: (cardId: string, x: number, y: number) => void;
onRemoveSticker?: (cardId: string, index: number) => void;
@@ -61,6 +65,7 @@ interface Props {
onCommitSticker?: (cardId: string) => void;
users: User[];
usersById: Map<string, User>;
highlightCardId?: string | null;
}
function KanbanColumnImpl({
@@ -81,6 +86,10 @@ function KanbanColumnImpl({
onShowHistory,
onToggleCardLock,
onAssignCard,
onSetCardDeadline,
onSetRequester,
requesterOptions,
onOpenCustomCardColor,
activeSticker,
onAddSticker,
onRemoveSticker,
@@ -88,6 +97,7 @@ function KanbanColumnImpl({
onCommitSticker,
users,
usersById,
highlightCardId,
}: Props) {
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(column.name);
@@ -415,9 +425,14 @@ function KanbanColumnImpl({
onShowHistory={onShowHistory}
onToggleLock={onToggleCardLock}
onAssign={onAssignCard}
onSetDeadline={onSetCardDeadline}
onSetRequester={onSetRequester}
requesterOptions={requesterOptions}
onOpenCustomColor={onOpenCustomCardColor}
users={users}
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
inDoneColumn={column.is_done}
highlight={highlightCardId === c.id}
activeSticker={activeSticker}
onAddSticker={onAddSticker}
onRemoveSticker={onRemoveSticker}
+28 -14
View File
@@ -1,32 +1,46 @@
import type { CardColor } from "../types";
import { stringHashPalette } from "@fn_library/core/string_hash_palette";
export { colorBg } from "@fn_library/ui/color_bg";
export { colorBorder } from "@fn_library/ui/color_border";
export { colorSwatch } from "@fn_library/ui/color_swatch";
// 22 colores fijos (default + 21 distintos). El ColorPickerGrid añade un 23º circulo "+"
// que abre el ColorPicker libre para hex personalizado.
export const CARD_COLORS: { value: CardColor; label: string }[] = [
{ value: "", label: "Default" },
{ value: "blue", label: "Azul" },
{ value: "cyan", label: "Cian" },
{ value: "teal", label: "Teal" },
{ value: "green", label: "Verde" },
{ value: "lime", label: "Lima" },
{ value: "yellow", label: "Amarillo" },
{ value: "orange", label: "Naranja" },
{ value: "red", label: "Rojo" },
{ value: "pink", label: "Rosa" },
{ value: "grape", label: "Uva" },
{ value: "violet", label: "Violeta" },
{ value: "indigo", label: "Indigo" },
{ value: "gray", label: "Gris" },
{ value: "#0ea5e9", label: "Sky" },
{ value: "#14b8a6", label: "Esmeralda" },
{ value: "#84cc16", label: "Lima fluor" },
{ value: "#ec4899", label: "Magenta" },
{ value: "#a855f7", label: "Lavanda" },
{ value: "#f97316", label: "Mandarina" },
{ value: "#dc2626", label: "Rubi" },
{ value: "#0891b2", label: "Petroleo" },
{ value: "#fde047", label: "Limon" },
{ value: "#10b981", label: "Menta" },
{ value: "#fb7185", label: "Coral" },
{ value: "#6366f1", label: "Iris" },
{ value: "#94a3b8", label: "Pizarra" },
];
// color-mix mezcla 18% del tono base con dark.6 → suave en dark mode.
// Border 30% del tono mas claro con dark.4 para definicion sutil.
// Swatch (boton picker) usa tono pleno -7 para que sea visible.
export function colorBg(color: CardColor): string {
if (color === "") return "var(--mantine-color-dark-6)";
return `color-mix(in srgb, var(--mantine-color-${color}-9) 18%, var(--mantine-color-dark-6))`;
}
export const AVATAR_COLORS = CARD_COLORS;
export function colorBorder(color: CardColor): string {
if (color === "") return "var(--mantine-color-dark-4)";
return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
}
const TAG_PALETTE = ["blue", "cyan", "teal", "green", "lime", "yellow", "orange", "red", "pink", "grape", "violet", "indigo"];
export function colorSwatch(color: CardColor): string {
if (color === "") return "var(--mantine-color-dark-3)";
return `var(--mantine-color-${color}-7)`;
export function tagColor(tag: string): string {
return stringHashPalette(tag, TAG_PALETTE);
}
+2 -41
View File
@@ -1,41 +1,2 @@
// Escala unidades segun magnitud: m | h Xm | D Xh | S XD | M XS.
// <1 minuto cae como "0m" para mantener la unidad mas pequena coherente.
const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "0m";
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
if (ms < DAY) {
const h = Math.floor(ms / HOUR);
const m = Math.floor((ms % HOUR) / MIN);
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}
if (ms < WEEK) {
const d = Math.floor(ms / DAY);
const h = Math.floor((ms % DAY) / HOUR);
return h === 0 ? `${d}D` : `${d}D ${h}h`;
}
if (ms < MONTH) {
const w = Math.floor(ms / WEEK);
const d = Math.floor((ms % WEEK) / DAY);
return d === 0 ? `${w}S` : `${w}S ${d}D`;
}
const m = Math.floor(ms / MONTH);
const w = Math.floor((ms % MONTH) / WEEK);
return w === 0 ? `${m}M` : `${m}M ${w}S`;
}
export function formatDateTimeShort(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yy = String(d.getFullYear()).slice(-2);
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yy} ${hh}:${mi}`;
}
export { formatDuration } from "@fn_library/core/format_duration";
export { formatDateTimeShort } from "@fn_library/core/format_datetime_short";