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
+108 -5
View File
@@ -75,6 +75,8 @@ import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard";
import { KanbanColumn } from "./components/KanbanColumn";
import { StickerPicker } from "./components/StickerPicker";
import { ColorPickerGrid, CustomColorModal } from "./components/ColorPickerGrid";
import { AVATAR_COLORS } from "./components/colors";
import { colorBg, colorBorder } from "./components/colors";
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
@@ -124,8 +126,13 @@ export function App() {
const [filterUnassigned, setFilterUnassigned] = useState(false);
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
const [filterDeadlineOnly, setFilterDeadlineOnly] = useState(false);
const [highlightCardId, setHighlightCardId] = useState<string | null>(null);
const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
const [activeSticker, setActiveSticker] = useState<string | null>(null);
const [avatarColorModalOpen, setAvatarColorModalOpen] = useState(false);
const [avatarCustomColor, setAvatarCustomColor] = useState("#888888");
const [cardColorModal, setCardColorModal] = useState<{ cardId: string; color: string } | null>(null);
const [navOpen, setNavOpen] = useState(false);
const [navWidth, setNavWidth] = useState<number>(() => {
const stored = localStorage.getItem("kanban_nav_width");
@@ -279,6 +286,7 @@ export function App() {
const cardTags = new Set(c.tags || []);
for (const t of filterTags) if (!cardTags.has(t)) return false;
}
if (filterDeadlineOnly && !c.deadline) return false;
if (filterDateFrom || filterDateTo) {
const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity;
const toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity;
@@ -289,7 +297,7 @@ export function App() {
}
return true;
},
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo]
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo, filterDeadlineOnly]
);
const cardsByColumn = useMemo(() => {
@@ -311,7 +319,8 @@ export function App() {
!!filterRequester ||
filterTags.length > 0 ||
!!filterDateFrom ||
!!filterDateTo;
!!filterDateTo ||
filterDeadlineOnly;
const findCard = (id: string): Card | undefined => board?.cards.find((c) => c.id === id);
const findColumn = (id: string): Column | undefined => board?.columns.find((c) => c.id === id);
@@ -594,6 +603,38 @@ export function App() {
});
}, [reload, users, requesterOptions, tagOptions]);
const handleSetRequester = useCallback(async (id: string, requester: string) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, requester } : c)) };
});
try {
await api.updateCard(id, { requester });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleJumpToCard = useCallback((cardId: string) => {
setActiveTab("board");
setHighlightCardId(cardId);
window.setTimeout(() => setHighlightCardId(null), 3000);
}, []);
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, deadline } : c)) };
});
try {
await api.updateCard(id, { deadline });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
setBoard((prev) => {
if (!prev) return prev;
@@ -858,16 +899,36 @@ export function App() {
<IconMessageChatbot size={16} />
</ActionIcon>
{auth.user && (
<Menu position="bottom-end" shadow="md" withArrow>
<Menu position="bottom-end" shadow="md" withArrow closeOnItemClick={false}>
<Menu.Target>
<ActionIcon variant="subtle" aria-label="Usuario">
<Avatar size={26} radius="xl" color="blue">
<Avatar size={26} radius="xl" color={auth.user.color || "blue"}>
{(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()}
</Avatar>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{auth.user.display_name || auth.user.username}</Menu.Label>
<Box p="xs">
<Text size="xs" c="dimmed" mb={4}>Color del avatar</Text>
<ColorPickerGrid
value={auth.user.color || ""}
onChange={async (c) => {
try {
const u = await api.updateMe({ color: c });
auth.setUser(u);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}}
options={AVATAR_COLORS}
onOpenCustom={() => {
setAvatarCustomColor(auth.user?.color?.startsWith("#") ? auth.user.color : "#888888");
setAvatarColorModalOpen(true);
}}
/>
</Box>
<Menu.Divider />
<Menu.Item
leftSection={<IconLogout size={14} />}
color="red"
@@ -929,6 +990,11 @@ export function App() {
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
requesterOptions={requesterOptions}
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
onRemoveSticker={handleRemoveSticker}
@@ -1004,7 +1070,7 @@ export function App() {
</Box>
) : activeTab === "calendar" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<CalendarView users={users} />
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
@@ -1045,6 +1111,12 @@ export function App() {
if (v) setFilterAssigneeId(null);
}}
/>
<Checkbox
size="xs"
label="Con deadline"
checked={filterDeadlineOnly}
onChange={(e) => setFilterDeadlineOnly(e.currentTarget.checked)}
/>
<Select
placeholder="Solicitante"
value={filterRequester}
@@ -1151,6 +1223,7 @@ export function App() {
setFilterTags([]);
setFilterDateFrom(null);
setFilterDateTo(null);
setFilterDeadlineOnly(false);
}}
>
Limpiar
@@ -1184,6 +1257,10 @@ export function App() {
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
requesterOptions={requesterOptions}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
onRemoveSticker={handleRemoveSticker}
@@ -1271,6 +1348,32 @@ export function App() {
</Box>
) : null}
</DragOverlay>
<CustomColorModal
opened={avatarColorModalOpen}
onClose={() => setAvatarColorModalOpen(false)}
value={avatarCustomColor}
onAccept={async (c) => {
setAvatarCustomColor(c);
try {
const u = await api.updateMe({ color: c });
auth.setUser(u);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}}
/>
<CustomColorModal
opened={!!cardColorModal}
onClose={() => setCardColorModal(null)}
value={cardColorModal?.color || "#888888"}
onAccept={(c) => {
if (!cardColorModal) return;
handleChangeCardColor(cardColorModal.cardId, c);
}}
/>
</DndContext>
);
}