Files
kanban/frontend/src/components/CalendarView.tsx
T
egutierrez 7ce227ddea 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>
2026-05-09 03:45:36 +02:00

288 lines
10 KiB
TypeScript

import {
Box,
Center,
Group,
Loader,
Paper,
Popover,
Select,
SimpleGrid,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { MonthPickerInput } from "@mantine/dates";
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 { 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, 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);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
const start = dayjs(month).startOf("month").format("YYYY-MM-DD");
const end = dayjs(month).endOf("month").format("YYYY-MM-DD");
api
.getMetrics({ from: start, to: end, assignee_id: assigneeId || undefined })
.then((m) => {
if (!cancelled) setData(m);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [month, assigneeId]);
const userOptions = useMemo(
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
[users]
);
const dayMap = useMemo(() => {
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, 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, 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, cards]);
// Build month grid (Mon-first).
const grid = useMemo(() => {
const start = dayjs(month).startOf("month");
const end = dayjs(month).endOf("month");
// Day-of-week, ISO Mon=1..Sun=7. We want first cell to be Mon.
const firstDow = (start.day() + 6) % 7; // 0=Mon
const cells: { date: string | null; inMonth: boolean }[] = [];
for (let i = 0; i < firstDow; i++) cells.push({ date: null, inMonth: false });
for (let d = start; !d.isAfter(end); d = d.add(1, "day")) {
cells.push({ date: d.format("YYYY-MM-DD"), inMonth: true });
}
while (cells.length % 7 !== 0) cells.push({ date: null, inMonth: false });
return cells;
}, [month]);
const totalCreated = useMemo(
() => Array.from(dayMap.values()).reduce((s, v) => s + v.created, 0),
[dayMap]
);
const totalDone = useMemo(
() => Array.from(dayMap.values()).reduce((s, v) => s + v.done, 0),
[dayMap]
);
return (
<Box p="md">
<Stack gap="md">
<Group justify="space-between">
<Title order={3}>Calendario</Title>
<Group gap="xs" wrap="nowrap">
<MonthPickerInput
label="Mes"
size="xs"
value={month}
onChange={(v) => v && setMonth(typeof v === "string" ? new Date(v) : v)}
style={{ minWidth: 160 }}
clearable={false}
/>
<Select
label="Asignado"
size="xs"
placeholder="Todos"
value={assigneeId}
onChange={setAssigneeId}
data={userOptions}
clearable
searchable
style={{ minWidth: 180 }}
/>
</Group>
</Group>
<Group gap="md">
<Paper withBorder p="sm" radius="md">
<Group gap={6}>
<IconPlus size={14} color="var(--mantine-color-blue-5)" />
<Text size="sm" fw={600}>
{totalCreated}
</Text>
<Text size="xs" c="dimmed">
creadas
</Text>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group gap={6}>
<IconCheckbox size={14} color="var(--mantine-color-green-5)" />
<Text size="sm" fw={600}>
{totalDone}
</Text>
<Text size="xs" c="dimmed">
hechas
</Text>
</Group>
</Paper>
</Group>
{loading && !data ? (
<Center p="xl">
<Loader />
</Center>
) : (
<Paper withBorder p="md" radius="md">
<SimpleGrid cols={7} spacing={4} mb={4}>
{DAY_LABELS.map((d) => (
<Text key={d} size="xs" c="dimmed" ta="center" fw={600}>
{d}
</Text>
))}
</SimpleGrid>
<SimpleGrid cols={7} spacing={4}>
{grid.map((cell, i) => {
if (!cell.date) {
return <Box key={i} style={{ minHeight: 72 }} />;
}
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}
p={6}
withBorder
radius="sm"
style={{
minHeight: 72,
borderColor: isToday ? "var(--mantine-color-blue-5)" : undefined,
background:
stats.done > 0
? "rgba(81, 207, 102, 0.08)"
: stats.created > 0
? "rgba(34, 139, 230, 0.06)"
: undefined,
}}
>
<Stack gap={2}>
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
{dayNum}
</Text>
{stats.created > 0 && (
<Group gap={3} wrap="nowrap">
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
<Text size="xs" c="blue">
{stats.created}
</Text>
</Group>
)}
{stats.done > 0 && (
<Group gap={3} wrap="nowrap">
<IconCheckbox size={10} color="var(--mantine-color-green-5)" />
<Text size="xs" c="green">
{stats.done}
</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>
);
})}
</SimpleGrid>
</Paper>
)}
</Stack>
</Box>
);
}