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