feat(kanban): stickers feature + dashboard null guards (#0063)

- backend: Sticker type, idempotent stickers column, PUT /api/cards/:id/stickers, 4 tests
- frontend: emoji-mart picker, toolbar button + ESC, draggable overlay with right-click delete, % coords for resize survival
- dashboard: null guards on metrics arrays

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 21:00:30 +02:00
parent 2a727eb7c1
commit 656516f219
12 changed files with 552 additions and 46 deletions
+117
View File
@@ -56,6 +56,7 @@ import {
IconLogout,
IconMenu2,
IconMessageChatbot,
IconMoodSmile,
IconPlus,
IconRefresh,
IconSearch,
@@ -73,6 +74,7 @@ import { Dashboard } from "./components/Dashboard";
import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard";
import { KanbanColumn } from "./components/KanbanColumn";
import { StickerPicker } from "./components/StickerPicker";
import { colorBg, colorBorder } from "./components/colors";
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
@@ -122,6 +124,8 @@ export function App() {
const [filterUnassigned, setFilterUnassigned] = useState(false);
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
const [activeSticker, setActiveSticker] = useState<string | null>(null);
const [navOpen, setNavOpen] = useState(false);
const [navWidth, setNavWidth] = useState<number>(() => {
const stored = localStorage.getItem("kanban_nav_width");
@@ -227,6 +231,15 @@ export function App() {
return () => clearInterval(t);
}, []);
useEffect(() => {
if (!activeSticker) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActiveSticker(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [activeSticker]);
const usersById = useMemo(() => {
const m = new Map<string, User>();
for (const u of users) m.set(u.id, u);
@@ -638,6 +651,73 @@ export function App() {
}
}, [reload]);
const persistStickers = useCallback(async (id: string, stickers: Card["stickers"]) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, stickers } : c)) };
});
try {
await api.updateCardStickers(id, stickers);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleAddSticker = useCallback((cardId: string, x: number, y: number) => {
if (!activeSticker) return;
setBoard((prev) => {
if (!prev) return prev;
const cards = prev.cards.map((c) => {
if (c.id !== cardId) return c;
const stickers = [...(c.stickers || []), { emoji: activeSticker, x, y }];
api.updateCardStickers(cardId, stickers).catch((e) => {
notifications.show({ color: "red", message: (e as Error).message });
reload();
});
return { ...c, stickers };
});
return { ...prev, cards };
});
}, [activeSticker, reload]);
const handleRemoveSticker = useCallback((cardId: string, index: number) => {
setBoard((prev) => {
if (!prev) return prev;
const cards = prev.cards.map((c) => {
if (c.id !== cardId) return c;
const stickers = (c.stickers || []).filter((_, i) => i !== index);
api.updateCardStickers(cardId, stickers).catch((e) => {
notifications.show({ color: "red", message: (e as Error).message });
reload();
});
return { ...c, stickers };
});
return { ...prev, cards };
});
}, [reload]);
const handleMoveSticker = useCallback((cardId: string, index: number, x: number, y: number) => {
setBoard((prev) => {
if (!prev) return prev;
const cards = prev.cards.map((c) => {
if (c.id !== cardId) return c;
const stickers = (c.stickers || []).map((s, i) => (i === index ? { ...s, x, y } : s));
return { ...c, stickers };
});
return { ...prev, cards };
});
}, []);
const handleCommitSticker = useCallback((cardId: string) => {
setBoard((prev) => {
if (!prev) return prev;
const card = prev.cards.find((c) => c.id === cardId);
if (card) persistStickers(cardId, card.stickers || []);
return prev;
});
}, [persistStickers]);
const handleShowHistory = useCallback((card: Card) => {
modals.open({
title: card.title,
@@ -843,6 +923,11 @@ export function App() {
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
onRemoveSticker={handleRemoveSticker}
onMoveSticker={handleMoveSticker}
onCommitSticker={handleCommitSticker}
users={users}
usersById={usersById}
/>
@@ -1013,6 +1098,33 @@ export function App() {
setFilterDateTo(t);
}}>30d</Button>
</Group>
<StickerPicker
opened={stickerPickerOpen}
onClose={() => setStickerPickerOpen(false)}
onSelect={(emoji) => setActiveSticker(emoji)}
target={
<Button
size="xs"
variant={activeSticker ? "filled" : "default"}
color={activeSticker ? "yellow" : undefined}
leftSection={<IconMoodSmile size={14} />}
onClick={() => setStickerPickerOpen((v) => !v)}
>
{activeSticker ? `Modo sticker: ${activeSticker}` : "Stickers"}
</Button>
}
/>
{activeSticker && (
<Button
size="xs"
variant="subtle"
color="gray"
leftSection={<IconX size={12} />}
onClick={() => setActiveSticker(null)}
>
ESC
</Button>
)}
{filtersActive && (
<Button
size="xs"
@@ -1060,6 +1172,11 @@ export function App() {
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
onRemoveSticker={handleRemoveSticker}
onMoveSticker={handleMoveSticker}
onCommitSticker={handleCommitSticker}
users={users}
usersById={usersById}
/>