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>
);