diff --git a/client/src/api/client.ts b/client/src/api/client.ts index bae57b6..eb3a236 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -131,6 +131,8 @@ export const packingApi = { 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), applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data), + saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data), + setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data), listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data), createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data), updateBag: (tripId: number | string, bagId: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data), diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index aa6abf9..ce85650 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -67,7 +67,134 @@ function katColor(kat, allCategories) { return KAT_COLORS[Math.abs(h) % KAT_COLORS.length] } -interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null } +interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null } + +// ── Bag Card ────────────────────────────────────────────────────────────── + +interface BagCardProps { + bag: PackingBag; bagItems: PackingItem[]; totalWeight: number; pct: number; tripId: number + tripMembers: TripMember[]; canEdit: boolean; onDelete: () => void + onUpdate: (bagId: number, data: Record) => void + onSetMembers: (bagId: number, userIds: number[]) => void; t: any; compact?: boolean +} + +function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers, canEdit, onDelete, onUpdate, onSetMembers, t, compact }: BagCardProps) { + const [editingName, setEditingName] = useState(false) + const [nameVal, setNameVal] = useState(bag.name) + const [showUserPicker, setShowUserPicker] = useState(false) + useEffect(() => setNameVal(bag.name), [bag.name]) + + const saveName = () => { + if (nameVal.trim() && nameVal.trim() !== bag.name) onUpdate(bag.id, { name: nameVal.trim() }) + setEditingName(false) + } + + const memberIds = (bag.members || []).map(m => m.user_id) + const toggleMember = (userId: number) => { + const next = memberIds.includes(userId) ? memberIds.filter(id => id !== userId) : [...memberIds, userId] + onSetMembers(bag.id, next) + } + + const sz = compact ? { dot: 10, name: 12, weight: 11, bar: 6, count: 10, gap: 6, mb: 14, icon: 11, avatar: 18 } : { dot: 12, name: 14, weight: 13, bar: 8, count: 11, gap: 8, mb: 16, icon: 13, avatar: 22 } + + return ( +
+
+ + {editingName && canEdit ? ( + setNameVal(e.target.value)} + onBlur={saveName} onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') { setEditingName(false); setNameVal(bag.name) } }} + style={{ flex: 1, fontSize: sz.name, fontWeight: 600, padding: '1px 4px', borderRadius: 4, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', color: 'var(--text-primary)', background: 'transparent' }} /> + ) : ( + canEdit && setEditingName(true)} style={{ flex: 1, fontSize: sz.name, fontWeight: 600, color: compact ? 'var(--text-secondary)' : 'var(--text-primary)', cursor: canEdit ? 'text' : 'default' }}>{bag.name} + )} + + {totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`} + + {canEdit && } +
+ {/* Members */} +
+ {(bag.members || []).map(m => ( + canEdit && toggleMember(m.user_id)} style={{ cursor: canEdit ? 'pointer' : 'default', display: 'inline-flex' }}> + {m.avatar ? ( + {m.username} + ) : ( + + {m.username[0].toUpperCase()} + + )} + + ))} + {canEdit && ( + + )} + {showUserPicker && ( +
+ {tripMembers.map(m => { + const isSelected = memberIds.includes(m.id) + return ( + + ) + })} + {tripMembers.length === 0 &&
{t('packing.noMembers')}
} +
+ +
+
+ )} +
+
+
+
+
{bagItems.length} {t('admin.packingTemplates.items')}
+
+ ) +} + +// ── Quantity Input ───────────────────────────────────────────────────────── + +function QuantityInput({ value, onSave }: { value: number; onSave: (qty: number) => void }) { + const [local, setLocal] = useState(String(value)) + useEffect(() => setLocal(String(value)), [value]) + + const commit = () => { + const qty = Math.max(1, Math.min(999, Number(local) || 1)) + setLocal(String(qty)) + if (qty !== value) onSave(qty) + } + + return ( +
+ setLocal(e.target.value.replace(/\D/g, ''))} + onBlur={commit} + onKeyDown={e => { if (e.key === 'Enter') { commit(); (e.target as HTMLInputElement).blur() } }} + style={{ width: 24, border: 'none', outline: 'none', background: 'transparent', fontSize: 12, textAlign: 'right', fontFamily: 'inherit', color: 'var(--text-secondary)', padding: 0 }} + /> + x +
+ ) +} // ── Artikel-Zeile ────────────────────────────────────────────────────────── interface ArtikelZeileProps { @@ -154,6 +281,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE )} + {/* Quantity */} + {canEdit && updatePackingItem(tripId, item.id, { quantity: qty })} />} + {/* Weight + Bag (when enabled) */} {bagTrackingEnabled && (
@@ -738,10 +868,26 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp } catch { toast.error(t('packing.toast.deleteError')) } } + const handleUpdateBag = async (bagId: number, data: Record) => { + try { + const result = await packingApi.updateBag(tripId, bagId, data) + setBags(prev => prev.map(b => b.id === bagId ? { ...b, ...result.bag } : b)) + } catch { toast.error(t('common.error')) } + } + + const handleSetBagMembers = async (bagId: number, userIds: number[]) => { + try { + const result = await packingApi.setBagMembers(tripId, bagId, userIds) + setBags(prev => prev.map(b => b.id === bagId ? { ...b, members: result.members } : b)) + } catch { toast.error(t('common.error')) } + } + // Templates const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([]) const [showTemplateDropdown, setShowTemplateDropdown] = useState(false) const [applyingTemplate, setApplyingTemplate] = useState(false) + const [showSaveTemplate, setShowSaveTemplate] = useState(false) + const [saveTemplateName, setSaveTemplateName] = useState('') const [showImportModal, setShowImportModal] = useState(false) const [importText, setImportText] = useState('') const csvInputRef = useRef(null) @@ -775,6 +921,19 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp } } + const handleSaveAsTemplate = async () => { + if (!saveTemplateName.trim()) return + try { + await packingApi.saveAsTemplate(tripId, saveTemplateName.trim()) + toast.success(t('packing.templateSaved')) + setShowSaveTemplate(false) + setSaveTemplateName('') + adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {}) + } catch { + toast.error(t('common.error')) + } + } + // Parse CSV line respecting quoted values (e.g. "Shirt, blue" stays as one field) const parseCsvLine = (line: string): string[] => { const parts: string[] = [] @@ -900,6 +1059,32 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp )}
)} + {canEdit && items.length > 0 && ( +
+ {showSaveTemplate ? ( +
+ setSaveTemplateName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }} + placeholder={t('packing.templateName')} + style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }} + /> + + +
+ ) : ( + + )} +
+ )} {bagTrackingEnabled && ( - )} -
-
-
-
-
{bagItems.length} {t('admin.packingTemplates.items')}
-
+ handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact /> ) })} @@ -1110,25 +1277,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1) const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100)) return ( -
-
- - {bag.name} - - {totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`} - - {canEdit && ( - - )} -
-
-
-
-
{bagItems.length} {t('admin.packingTemplates.items')}
-
+ handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} /> ) })} diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index e2bbf30..1c76a6c 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1106,6 +1106,10 @@ const de: Record = { 'packing.template': 'Vorlage', 'packing.templateApplied': '{count} Einträge aus Vorlage hinzugefügt', 'packing.templateError': 'Vorlage konnte nicht angewendet werden', + 'packing.saveAsTemplate': 'Als Vorlage speichern', + 'packing.templateName': 'Vorlagenname', + 'packing.templateSaved': 'Packliste als Vorlage gespeichert', + 'packing.assignUser': 'Person zuweisen', 'packing.bags': 'Gepäck', 'packing.noBag': 'Nicht zugeordnet', 'packing.totalWeight': 'Gesamtgewicht', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index e62d70a..6e6cc0b 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1125,6 +1125,10 @@ const en: Record = { 'packing.template': 'Template', 'packing.templateApplied': '{count} items added from template', 'packing.templateError': 'Failed to apply template', + 'packing.saveAsTemplate': 'Save as template', + 'packing.templateName': 'Template name', + 'packing.templateSaved': 'Packing list saved as template', + 'packing.assignUser': 'Assign user', 'packing.bags': 'Bags', 'packing.noBag': 'Unassigned', 'packing.totalWeight': 'Total weight', diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 3336626..a061d1c 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -826,6 +826,23 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec('ALTER TABLE budget_items ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, + // Migration 74: Add quantity to packing_items + user_id to packing_bags + bag_members table + () => { + try { db.exec('ALTER TABLE packing_items ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE packing_bags ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + db.exec(` + CREATE TABLE IF NOT EXISTS packing_bag_members ( + bag_id INTEGER NOT NULL REFERENCES packing_bags(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (bag_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_packing_bag_members_bag ON packing_bag_members(bag_id); + `); + // Migrate existing single user_id to bag_members + const bagsWithUser = db.prepare('SELECT id, user_id FROM packing_bags WHERE user_id IS NOT NULL').all() as { id: number; user_id: number }[]; + const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); + for (const b of bagsWithUser) ins.run(b.id, b.user_id); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts index 019f266..7030b67 100644 --- a/server/src/routes/packing.ts +++ b/server/src/routes/packing.ts @@ -16,6 +16,8 @@ import { updateBag, deleteBag, applyTemplate, + saveAsTemplate, + setBagMembers, getCategoryAssignees, updateCategoryAssignees, reorderItems, @@ -92,7 +94,7 @@ router.put('/reorder', authenticate, (req: Request, res: Response) => { router.put('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const { name, checked, category, weight_grams, bag_id } = req.body; + const { name, checked, category, weight_grams, bag_id, quantity } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); @@ -100,7 +102,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const updated = updateItem(tripId, id, { name, checked, category, weight_grams, bag_id }, Object.keys(req.body)); + const updated = updateItem(tripId, id, { name, checked, category, weight_grams, bag_id, quantity }, Object.keys(req.body)); if (!updated) return res.status(404).json({ error: 'Item not found' }); res.json({ item: updated }); @@ -151,12 +153,12 @@ router.post('/bags', authenticate, (req: Request, res: Response) => { router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, bagId } = req.params; - const { name, color, weight_limit_grams } = req.body; + const { name, color, weight_limit_grams, user_id } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const updated = updateBag(tripId, bagId, { name, color, weight_limit_grams }); + const updated = updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(req.body)); if (!updated) return res.status(404).json({ error: 'Bag not found' }); res.json({ bag: updated }); broadcast(tripId, 'packing:bag-updated', { bag: updated }, req.headers['x-socket-id'] as string); @@ -193,6 +195,40 @@ router.post('/apply-template/:templateId', authenticate, (req: Request, res: Res broadcast(tripId, 'packing:template-applied', { items: added }, req.headers['x-socket-id'] as string); }); +// ── Bag Members ──────────────────────────────────────────────────────────── + +router.put('/bags/:bagId/members', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, bagId } = req.params; + const { user_ids } = req.body; + const trip = verifyTripAccess(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) + return res.status(403).json({ error: 'No permission' }); + const members = setBagMembers(tripId, bagId, Array.isArray(user_ids) ? user_ids : []); + if (!members) return res.status(404).json({ error: 'Bag not found' }); + res.json({ members }); + broadcast(tripId, 'packing:bag-members-updated', { bagId: Number(bagId), members }, req.headers['x-socket-id'] as string); +}); + +// ── Save as Template ─────────────────────────────────────────────────────── + +router.post('/save-as-template', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const { name } = req.body; + + const trip = verifyTripAccess(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + if (!name?.trim()) return res.status(400).json({ error: 'Template name is required' }); + + const template = saveAsTemplate(tripId, authReq.user.id, name.trim()); + if (!template) return res.status(400).json({ error: 'No items to save' }); + + res.status(201).json({ template }); +}); + // ── Category assignees ────────────────────────────────────────────────────── router.get('/category-assignees', authenticate, (req: Request, res: Response) => { diff --git a/server/src/services/packingService.ts b/server/src/services/packingService.ts index 4cbd911..a9eddb2 100644 --- a/server/src/services/packingService.ts +++ b/server/src/services/packingService.ts @@ -14,13 +14,14 @@ export function listItems(tripId: string | number) { ).all(tripId); } -export function createItem(tripId: string | number, data: { name: string; category?: string; checked?: boolean }) { +export function createItem(tripId: string | number, data: { name: string; category?: string; checked?: boolean; quantity?: number }) { const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null }; const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; + const qty = Math.max(1, Math.min(999, Number(data.quantity) || 1)); const result = db.prepare( - 'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder); + 'INSERT INTO packing_items (trip_id, name, checked, category, sort_order, quantity) VALUES (?, ?, ?, ?, ?, ?)' + ).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder, qty); return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid); } @@ -28,7 +29,7 @@ export function createItem(tripId: string | number, data: { name: string; catego export function updateItem( tripId: string | number, id: string | number, - data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null }, + data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null; quantity?: number }, bodyKeys: string[] ) { const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId); @@ -40,7 +41,8 @@ export function updateItem( checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END, category = COALESCE(?, category), weight_grams = CASE WHEN ? THEN ? ELSE weight_grams END, - bag_id = CASE WHEN ? THEN ? ELSE bag_id END + bag_id = CASE WHEN ? THEN ? ELSE bag_id END, + quantity = CASE WHEN ? THEN ? ELSE quantity END WHERE id = ? `).run( data.name || null, @@ -51,6 +53,8 @@ export function updateItem( data.weight_grams ?? null, bodyKeys.includes('bag_id') ? 1 : 0, data.bag_id ?? null, + bodyKeys.includes('quantity') ? 1 : 0, + data.quantity ? Math.max(1, Math.min(999, Number(data.quantity))) : 1, id ); @@ -114,7 +118,33 @@ export function bulkImport(tripId: string | number, items: ImportItem[]) { // ── Bags ─────────────────────────────────────────────────────────────────── export function listBags(tripId: string | number) { - return db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId); + const bags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId) as any[]; + const members = db.prepare(` + SELECT bm.bag_id, bm.user_id, u.username, u.avatar + FROM packing_bag_members bm + JOIN users u ON bm.user_id = u.id + JOIN packing_bags b ON bm.bag_id = b.id + WHERE b.trip_id = ? + `).all(tripId) as { bag_id: number; user_id: number; username: string; avatar: string | null }[]; + const membersByBag = new Map(); + for (const m of members) { + if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []); + membersByBag.get(m.bag_id)!.push(m); + } + return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] })); +} + +export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) { + const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId); + if (!bag) return null; + db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId); + const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); + for (const uid of userIds) ins.run(bagId, uid); + return db.prepare(` + SELECT bm.user_id, u.username, u.avatar + FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id + WHERE bm.bag_id = ? + `).all(bagId); } export function createBag(tripId: string | number, data: { name: string; color?: string }) { @@ -128,15 +158,26 @@ export function createBag(tripId: string | number, data: { name: string; color?: export function updateBag( tripId: string | number, bagId: string | number, - data: { name?: string; color?: string; weight_limit_grams?: number | null } + data: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null }, + bodyKeys?: string[] ) { const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId); if (!bag) return null; - db.prepare('UPDATE packing_bags SET name = COALESCE(?, name), color = COALESCE(?, color), weight_limit_grams = ? WHERE id = ?').run( - data.name?.trim() || null, data.color || null, data.weight_limit_grams ?? null, bagId + db.prepare(`UPDATE packing_bags SET + name = COALESCE(?, name), + color = COALESCE(?, color), + weight_limit_grams = ?, + user_id = CASE WHEN ? THEN ? ELSE user_id END + WHERE id = ?`).run( + data.name?.trim() || null, + data.color || null, + data.weight_limit_grams ?? (bag as any).weight_limit_grams ?? null, + bodyKeys?.includes('user_id') ? 1 : 0, + data.user_id ?? null, + bagId ); - return db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(bagId); + return db.prepare('SELECT b.*, u.username as assigned_username FROM packing_bags b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = ?').get(bagId); } export function deleteBag(tripId: string | number, bagId: string | number) { @@ -174,6 +215,37 @@ export function applyTemplate(tripId: string | number, templateId: string | numb return added; } +// ── Save as Template ────────────────────────────────────────────────────── + +export function saveAsTemplate(tripId: string | number, userId: number, templateName: string) { + const items = db.prepare( + 'SELECT name, category FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC' + ).all(tripId) as { name: string; category: string }[]; + + if (items.length === 0) return null; + + const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(templateName, userId); + const templateId = result.lastInsertRowid; + + const categories = [...new Set(items.map(i => i.category || 'Other'))]; + const catIdMap = new Map(); + + for (let i = 0; i < categories.length; i++) { + const catResult = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, categories[i], i); + catIdMap.set(categories[i], catResult.lastInsertRowid); + } + + const itemsByCategory = new Map(); + for (const item of items) { + const catId = catIdMap.get(item.category || 'Other')!; + const order = itemsByCategory.get(item.category || 'Other') || 0; + db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, item.name, order); + itemsByCategory.set(item.category || 'Other', order + 1); + } + + return { id: Number(templateId), name: templateName, categoryCount: categories.length, itemCount: items.length }; +} + // ── Category Assignees ───────────────────────────────────────────────────── export function getCategoryAssignees(tripId: string | number) {