7ce227ddea
- 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>
288 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|