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