From 44138af11a3aec6b0130a67148cb48e39edc5cbf Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 29 Mar 2026 13:37:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20assign=20trip=20members=20to=20packing?= =?UTF-8?q?=20list=20categories=20=E2=80=94=20closes=20#71?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/client.ts | 2 + .../components/Packing/PackingListPanel.tsx | 157 +++++++++++++++++- client/src/i18n/translations/de.ts | 2 + client/src/i18n/translations/en.ts | 2 + server/src/db/migrations.ts | 9 + server/src/routes/packing.ts | 52 ++++++ 6 files changed, 220 insertions(+), 4 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 279ef67..3197523 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -111,6 +111,8 @@ export const packingApi = { update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data), + getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), } export const tagsApi = { diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index 7a45653..917b647 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -1,10 +1,11 @@ -import { useState, useMemo, useRef } from 'react' +import { useState, useMemo, useRef, useEffect } from 'react' import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' +import { packingApi, tripsApi } from '../../api/client' import { CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight, - Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, + Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, } from 'lucide-react' import type { PackingItem } from '../../types' @@ -186,6 +187,19 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZei } // ── Kategorie-Gruppe ─────────────────────────────────────────────────────── +interface TripMember { + id: number + username: string + avatar?: string | null + avatar_url?: string | null +} + +interface CategoryAssignee { + user_id: number + username: string + avatar?: string | null +} + interface KategorieGruppeProps { kategorie: string items: PackingItem[] @@ -193,16 +207,32 @@ interface KategorieGruppeProps { allCategories: string[] onRename: (oldName: string, newName: string) => Promise onDeleteAll: (items: PackingItem[]) => Promise + assignees: CategoryAssignee[] + tripMembers: TripMember[] + onSetAssignees: (category: string, userIds: number[]) => Promise } -function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }: KategorieGruppeProps) { +function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, assignees, tripMembers, onSetAssignees }: KategorieGruppeProps) { const [offen, setOffen] = useState(true) const [editingName, setEditingName] = useState(false) const [editKatName, setEditKatName] = useState(kategorie) const [showMenu, setShowMenu] = useState(false) + const [showAssigneeDropdown, setShowAssigneeDropdown] = useState(false) + const assigneeDropdownRef = useRef(null) const { togglePackingItem } = useTripStore() const toast = useToast() const { t } = useTranslation() + useEffect(() => { + if (!showAssigneeDropdown) return + const handleClickOutside = (e: MouseEvent) => { + if (assigneeDropdownRef.current && !assigneeDropdownRef.current.contains(e.target as Node)) { + setShowAssigneeDropdown(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showAssigneeDropdown]) + const abgehakt = items.filter(i => i.checked).length const alleAbgehakt = abgehakt === items.length const dot = katColor(kategorie, allCategories) @@ -247,11 +277,98 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }} /> ) : ( - + {kategorie} )} + {/* Assignee chips */} +
+ {assignees.map(a => ( +
{ e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }} + > +
+ {a.username[0]} +
+
+ {a.username} +
+
+ ))} +
+ + {showAssigneeDropdown && ( +
+ {tripMembers.map(m => { + const isAssigned = assignees.some(a => a.user_id === m.id) + return ( + + ) + })} + {tripMembers.length === 0 && ( +
{t('packing.noMembers')}
+ )} +
+ )} +
+
+ ([]) + const [categoryAssignees, setCategoryAssignees] = useState>({}) + + useEffect(() => { + tripsApi.getMembers(tripId).then(data => { + const all: TripMember[] = [] + if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url }) + if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url }))) + setTripMembers(all) + }).catch(() => {}) + packingApi.getCategoryAssignees(tripId).then(data => { + setCategoryAssignees(data.assignees || {}) + }).catch(() => {}) + }, [tripId]) + + const handleSetAssignees = async (category: string, userIds: number[]) => { + try { + const data = await packingApi.setCategoryAssignees(tripId, category, userIds) + setCategoryAssignees(prev => ({ ...prev, [category]: data.assignees || [] })) + } catch { + toast.error(t('packing.toast.saveError')) + } + } + const allCategories = useMemo(() => { const cats = new Set(items.map(i => i.category || t('packing.defaultCategory'))) return Array.from(cats).sort() @@ -546,11 +688,18 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp allCategories={allCategories} onRename={handleRenameCategory} onDeleteAll={handleDeleteCategory} + assignees={categoryAssignees[kat] || []} + tripMembers={tripMembers} + onSetAssignees={handleSetAssignees} /> ))} )} + ) } diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 506122d..529763a 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -813,6 +813,8 @@ const de: Record = { 'packing.menuCheckAll': 'Alle abhaken', 'packing.menuUncheckAll': 'Alle Haken entfernen', 'packing.menuDeleteCat': 'Kategorie löschen', + 'packing.assignUser': 'Benutzer zuweisen', + 'packing.noMembers': 'Keine Mitglieder', 'packing.changeCategory': 'Kategorie ändern', 'packing.confirm.clearChecked': 'Möchtest du {count} abgehakte Gegenstände wirklich entfernen?', 'packing.confirm.deleteCat': 'Möchtest du die Kategorie "{name}" mit {count} Gegenständen wirklich löschen?', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index c6a1300..a6e2ed5 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -813,6 +813,8 @@ const en: Record = { 'packing.menuCheckAll': 'Check All', 'packing.menuUncheckAll': 'Uncheck All', 'packing.menuDeleteCat': 'Delete Category', + 'packing.assignUser': 'Assign user', + 'packing.noMembers': 'No trip members', 'packing.changeCategory': 'Change Category', 'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?', 'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?', diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f5cbac1..345f3ab 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -220,6 +220,15 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {} try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {} }, + () => { + db.exec(`CREATE TABLE IF NOT EXISTS packing_category_assignees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category_name TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(trip_id, category_name, user_id) + )`); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts index 22cb7b7..16b0eec 100644 --- a/server/src/routes/packing.ts +++ b/server/src/routes/packing.ts @@ -91,6 +91,58 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string); }); +// ── Category assignees ────────────────────────────────────────────────────── + +router.get('/category-assignees', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const rows = db.prepare(` + SELECT pca.category_name, pca.user_id, u.username, u.avatar + FROM packing_category_assignees pca + JOIN users u ON pca.user_id = u.id + WHERE pca.trip_id = ? + `).all(tripId); + + // Group by category + const assignees: Record = {}; + for (const row of rows as any[]) { + if (!assignees[row.category_name]) assignees[row.category_name] = []; + assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar }); + } + + res.json({ assignees }); +}); + +router.put('/category-assignees/:categoryName', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, categoryName } = req.params; + const { user_ids } = req.body; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const cat = decodeURIComponent(categoryName); + db.prepare('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?').run(tripId, cat); + + if (Array.isArray(user_ids) && user_ids.length > 0) { + const insert = db.prepare('INSERT OR IGNORE INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)'); + for (const uid of user_ids) insert.run(tripId, cat, uid); + } + + const rows = db.prepare(` + SELECT pca.user_id, u.username, u.avatar + FROM packing_category_assignees pca + JOIN users u ON pca.user_id = u.id + WHERE pca.trip_id = ? AND pca.category_name = ? + `).all(tripId, cat); + + res.json({ assignees: rows }); + broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string); +}); + router.put('/reorder', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params;