From 2f8a1893196993dc8cfc9dcd1007aef9b277621b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 29 Mar 2026 14:19:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20packing=20templates=20with=20category-b?= =?UTF-8?q?ased=20workflow=20=E2=80=94=20closes=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin: create/edit/delete packing templates with categories and items - Trip packing: category-first workflow (add category → add items inside) - Apply template button adds items additively (preserves existing) - Replaces old item+category freetext input --- client/src/api/client.ts | 12 + .../Admin/PackingTemplateManager.tsx | 306 ++++++++++++++++++ .../components/Packing/PackingListPanel.tsx | 243 ++++++++------ client/src/i18n/translations/de.ts | 27 ++ client/src/i18n/translations/en.ts | 27 ++ client/src/pages/AdminPage.tsx | 4 + server/src/db/migrations.ts | 22 ++ server/src/routes/admin.ts | 102 ++++++ server/src/routes/packing.ts | 33 ++ 9 files changed, 685 insertions(+), 91 deletions(-) create mode 100644 client/src/components/Admin/PackingTemplateManager.tsx diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 3197523..9afe882 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -113,6 +113,7 @@ export const packingApi = { 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), + applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data), } export const tagsApi = { @@ -142,6 +143,17 @@ export const adminApi = { updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data), + packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), + getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data), + createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data), + updatePackingTemplate: (id: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${id}`, data).then(r => r.data), + deletePackingTemplate: (id: number) => apiClient.delete(`/admin/packing-templates/${id}`).then(r => r.data), + addTemplateCategory: (templateId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories`, data).then(r => r.data), + updateTemplateCategory: (templateId: number, catId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/categories/${catId}`, data).then(r => r.data), + deleteTemplateCategory: (templateId: number, catId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/categories/${catId}`).then(r => r.data), + addTemplateItem: (templateId: number, catId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories/${catId}/items`, data).then(r => r.data), + updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data), + deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data), listInvites: () => apiClient.get('/admin/invites').then(r => r.data), createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), diff --git a/client/src/components/Admin/PackingTemplateManager.tsx b/client/src/components/Admin/PackingTemplateManager.tsx new file mode 100644 index 0000000..9fbf851 --- /dev/null +++ b/client/src/components/Admin/PackingTemplateManager.tsx @@ -0,0 +1,306 @@ +import { useState, useEffect, useRef } from 'react' +import { adminApi } from '../../api/client' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' +import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react' + +interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number } +interface TemplateItem { id: number; category_id: number; name: string; sort_order: number } +interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string } + +export default function PackingTemplateManager() { + const [templates, setTemplates] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [createName, setCreateName] = useState('') + + // Expanded template state + const [expandedId, setExpandedId] = useState(null) + const [categories, setCategories] = useState([]) + const [items, setItems] = useState([]) + + // Editing states + const [editingTemplate, setEditingTemplate] = useState(null) + const [editTemplateName, setEditTemplateName] = useState('') + const [editingCatId, setEditingCatId] = useState(null) + const [editCatName, setEditCatName] = useState('') + const [editingItemId, setEditingItemId] = useState(null) + const [editItemName, setEditItemName] = useState('') + + // Adding states + const [addingCategory, setAddingCategory] = useState(false) + const [newCatName, setNewCatName] = useState('') + const [addingItemToCatId, setAddingItemToCatId] = useState(null) + const [newItemName, setNewItemName] = useState('') + const addItemRef = useRef(null) + + const toast = useToast() + const { t } = useTranslation() + + useEffect(() => { loadTemplates() }, []) + + const loadTemplates = async () => { + setIsLoading(true) + try { + const data = await adminApi.packingTemplates() + setTemplates(data.templates || []) + } catch { toast.error(t('admin.packingTemplates.loadError')) } + finally { setIsLoading(false) } + } + + const toggleExpand = async (id: number) => { + if (expandedId === id) { setExpandedId(null); return } + setExpandedId(id) + setAddingCategory(false) + setAddingItemToCatId(null) + try { + const data = await adminApi.getPackingTemplate(id) + setCategories(data.categories || []) + setItems(data.items || []) + } catch { toast.error(t('admin.packingTemplates.loadError')) } + } + + // Template CRUD + const handleCreateTemplate = async () => { + if (!createName.trim()) return + try { + const data = await adminApi.createPackingTemplate({ name: createName.trim() }) + setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev]) + setCreateName(''); setShowCreate(false) + setExpandedId(data.template.id); setCategories([]); setItems([]) + toast.success(t('admin.packingTemplates.created')) + } catch { toast.error(t('admin.packingTemplates.createError')) } + } + + const handleDeleteTemplate = async (id: number) => { + try { + await adminApi.deletePackingTemplate(id) + setTemplates(prev => prev.filter(t => t.id !== id)) + if (expandedId === id) setExpandedId(null) + toast.success(t('admin.packingTemplates.deleted')) + } catch { toast.error(t('admin.packingTemplates.deleteError')) } + } + + const handleRenameTemplate = async (id: number) => { + if (!editTemplateName.trim()) { setEditingTemplate(null); return } + try { + await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() }) + setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t)) + setEditingTemplate(null) + } catch { toast.error(t('admin.packingTemplates.saveError')) } + } + + // Category CRUD + const handleAddCategory = async () => { + if (!newCatName.trim() || !expandedId) return + try { + const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() }) + setCategories(prev => [...prev, data.category]) + setNewCatName(''); setAddingCategory(false) + } catch { toast.error(t('admin.packingTemplates.saveError')) } + } + + const handleRenameCategory = async (catId: number) => { + if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return } + try { + await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() }) + setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c)) + setEditingCatId(null) + } catch { toast.error(t('admin.packingTemplates.saveError')) } + } + + const handleDeleteCategory = async (catId: number) => { + if (!expandedId) return + try { + await adminApi.deleteTemplateCategory(expandedId, catId) + setCategories(prev => prev.filter(c => c.id !== catId)) + setItems(prev => prev.filter(i => i.category_id !== catId)) + } catch { toast.error(t('admin.packingTemplates.deleteError')) } + } + + // Item CRUD + const handleAddItem = async (catId: number) => { + if (!newItemName.trim() || !expandedId) return + try { + const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() }) + setItems(prev => [...prev, data.item]) + setNewItemName('') + setTimeout(() => addItemRef.current?.focus(), 30) + } catch { toast.error(t('admin.packingTemplates.saveError')) } + } + + const handleRenameItem = async (itemId: number) => { + if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return } + try { + await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() }) + setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i)) + setEditingItemId(null) + } catch { toast.error(t('admin.packingTemplates.saveError')) } + } + + const handleDeleteItem = async (itemId: number) => { + if (!expandedId) return + try { + await adminApi.deleteTemplateItem(expandedId, itemId) + setItems(prev => prev.filter(i => i.id !== itemId)) + } catch { toast.error(t('admin.packingTemplates.deleteError')) } + } + + const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none' + const btnIcon = 'p-1.5 rounded-lg transition-colors' + + return ( +
+ {/* Header */} +
+
+

{t('admin.packingTemplates.title')}

+

{t('admin.packingTemplates.subtitle')}

+
+ +
+ + {/* Create template */} + {showCreate && ( +
+ + setCreateName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }} + placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} /> + + +
+ )} + + {/* Template list */} + {isLoading ? ( +
+ ) : templates.length === 0 ? ( +
{t('admin.packingTemplates.empty')}
+ ) : ( +
+ {templates.map(tmpl => ( +
+ {/* Template row */} +
+ + + {editingTemplate === tmpl.id ? ( + setEditTemplateName(e.target.value)} + onBlur={() => handleRenameTemplate(tmpl.id)} + onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }} + className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" /> + ) : ( + toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name} + )} + + {tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')} + + + +
+ + {/* Expanded content */} + {expandedId === tmpl.id && ( +
+ {categories.map(cat => { + const catItems = items.filter(i => i.category_id === cat.id) + return ( +
+ {/* Category header */} +
+ {editingCatId === cat.id ? ( + <> + setEditCatName(e.target.value)} + onBlur={() => handleRenameCategory(cat.id)} + onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }} + className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" /> + + ) : ( + {cat.name} + )} + {catItems.length} + + + +
+ + {/* Items */} + {(catItems.length > 0 || addingItemToCatId === cat.id) && ( +
+ {catItems.map(item => ( +
+ {editingItemId === item.id ? ( + <> + setEditItemName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }} + className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" /> + + + + ) : ( + <> + {item.name} + + + + )} +
+ ))} + + {/* Add item inline */} + {addingItemToCatId === cat.id && ( +
+ setNewItemName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }} + placeholder={t('admin.packingTemplates.itemName')} + className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" /> + + +
+ )} +
+ )} +
+ ) + })} + + {/* Add category button */} + {addingCategory ? ( +
+ setNewCatName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }} + placeholder={t('admin.packingTemplates.categoryName')} + className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" /> + + +
+ ) : ( + + )} +
+ )} +
+ ))} +
+ )} +
+ ) +} diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index 917b647..f07efb1 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -2,10 +2,10 @@ 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 { packingApi, tripsApi, adminApi } from '../../api/client' import { CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight, - Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, + X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, } from 'lucide-react' import type { PackingItem } from '../../types' @@ -207,17 +207,21 @@ interface KategorieGruppeProps { allCategories: string[] onRename: (oldName: string, newName: string) => Promise onDeleteAll: (items: PackingItem[]) => Promise + onAddItem: (category: string, name: string) => Promise assignees: CategoryAssignee[] tripMembers: TripMember[] onSetAssignees: (category: string, userIds: number[]) => Promise } -function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, assignees, tripMembers, onSetAssignees }: KategorieGruppeProps) { +function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, 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 [showAddItem, setShowAddItem] = useState(false) + const [newItemName, setNewItemName] = useState('') + const addItemRef = useRef(null) const assigneeDropdownRef = useRef(null) const { togglePackingItem } = useTripStore() const toast = useToast() @@ -400,6 +404,43 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on {items.map(item => ( {}} /> ))} + {/* Inline add item */} + {showAddItem ? ( +
+ setNewItemName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && newItemName.trim()) { + onAddItem(kategorie, newItemName.trim()) + setNewItemName('') + setTimeout(() => addItemRef.current?.focus(), 30) + } + if (e.key === 'Escape') { setShowAddItem(false); setNewItemName('') } + }} + placeholder={t('packing.addItemPlaceholder')} + style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }} + /> + + +
+ ) : ( + + )}
)} @@ -436,12 +477,9 @@ interface PackingListPanelProps { } export default function PackingListPanel({ tripId, items }: PackingListPanelProps) { - const [neuerName, setNeuerName] = useState('') - const [neueKategorie, setNeueKategorie] = useState('') - const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false) const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt' - const [showKatDropdown, setShowKatDropdown] = useState(false) - const katInputRef = useRef(null) + const [addingCategory, setAddingCategory] = useState(false) + const [newCatName, setNewCatName] = useState('') const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore() const toast = useToast() const { t } = useTranslation() @@ -494,21 +532,20 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp const abgehakt = items.filter(i => i.checked).length const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0 - const handleAdd = async (e) => { - e.preventDefault() - if (!neuerName.trim()) return - const kat = neueKategorie.trim() || (allCategories[0] || t('packing.defaultCategory')) + const handleAddItemToCategory = async (category: string, name: string) => { try { - await addPackingItem(tripId, { name: neuerName.trim(), category: kat }) - setNeuerName('') + await addPackingItem(tripId, { name, category }) } catch { toast.error(t('packing.toast.addError')) } } - const vorschlaege = t('packing.suggestions.items') || VORSCHLAEGE - - const handleVorschlag = async (v) => { - try { await addPackingItem(tripId, { name: v.name, category: v.category || v.kategorie }) } - catch { toast.error(t('packing.toast.addError')) } + const handleAddNewCategory = async () => { + if (!newCatName.trim()) return + // Create a first item in the new category to make it appear + try { + await addPackingItem(tripId, { name: '...', category: newCatName.trim() }) + setNewCatName('') + setAddingCategory(false) + } catch { toast.error(t('packing.toast.addError')) } } const handleRenameCategory = async (oldName, newName) => { @@ -531,8 +568,39 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp } } - const vorhandeneNamen = new Set(items.map(i => i.name.toLowerCase())) - const verfuegbareVorschlaege = vorschlaege.filter(v => !vorhandeneNamen.has(v.name.toLowerCase())) + // Templates + const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([]) + const [showTemplateDropdown, setShowTemplateDropdown] = useState(false) + const [applyingTemplate, setApplyingTemplate] = useState(false) + const templateDropdownRef = useRef(null) + + useEffect(() => { + adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {}) + }, [tripId]) + + useEffect(() => { + if (!showTemplateDropdown) return + const handler = (e: MouseEvent) => { + if (templateDropdownRef.current && !templateDropdownRef.current.contains(e.target as Node)) setShowTemplateDropdown(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [showTemplateDropdown]) + + const handleApplyTemplate = async (templateId: number) => { + setApplyingTemplate(true) + try { + const data = await packingApi.applyTemplate(tripId, templateId) + toast.success(t('packing.templateApplied', { count: data.count })) + setShowTemplateDropdown(false) + // Reload packing items + window.location.reload() + } catch { + toast.error(t('packing.templateError')) + } finally { + setApplyingTemplate(false) + } + } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } @@ -558,15 +626,45 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp {t('packing.clearCheckedShort', { count: abgehakt })} )} - + {availableTemplates.length > 0 && ( +
+ + {showTemplateDropdown && ( +
+ {availableTemplates.map(tmpl => ( + + ))} +
+ )} +
+ )} @@ -585,71 +683,33 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp )} -
- setNeuerName(e.target.value)} - placeholder={t('packing.addPlaceholder')} - style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }} - /> -
+ {addingCategory ? ( +
{ setNeueKategorie(e.target.value); setShowKatDropdown(true) }} - onFocus={() => setShowKatDropdown(true)} - onBlur={() => setTimeout(() => setShowKatDropdown(false), 150)} - placeholder={allCategories[0] || t('packing.categoryPlaceholder')} - style={{ width: 120, padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)' }} + autoFocus + type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }} + placeholder={t('packing.newCategoryPlaceholder')} + style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }} /> - {showKatDropdown && allCategories.length > 0 && ( -
- {allCategories.filter(c => !neueKategorie || c.toLowerCase().includes(neueKategorie.toLowerCase())).map(cat => ( - - ))} -
- )} -
- - -
- - {/* ── Vorschläge ── */} - {zeigeVorschlaege && ( -
-
- {t('packing.suggestionsTitle')} - +
-
- {verfuegbareVorschlaege.map((v, i) => ( - - ))} - {verfuegbareVorschlaege.length === 0 &&

{t('packing.allSuggested')}

} -
-
- )} + ) : ( + + )} + {/* ── Filter-Tabs ── */} {items.length > 0 && ( @@ -688,6 +748,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp allCategories={allCategories} onRename={handleRenameCategory} onDeleteAll={handleDeleteCategory} + onAddItem={handleAddItemToCategory} assignees={categoryAssignees[kat] || []} tripMembers={tripMembers} onSetAssignees={handleSetAssignees} diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 529763a..760ea20 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -345,6 +345,26 @@ const de: Record = { 'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.', 'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert', + // Packing Templates + 'admin.tabs.templates': 'Packvorlagen', + 'admin.packingTemplates.title': 'Packvorlagen', + 'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen', + 'admin.packingTemplates.create': 'Neue Vorlage', + 'admin.packingTemplates.namePlaceholder': 'Vorlagenname (z.B. Strandurlaub)', + 'admin.packingTemplates.empty': 'Noch keine Vorlagen erstellt', + 'admin.packingTemplates.items': 'Einträge', + 'admin.packingTemplates.categories': 'Kategorien', + 'admin.packingTemplates.itemName': 'Artikelname', + 'admin.packingTemplates.itemCategory': 'Kategorie', + 'admin.packingTemplates.categoryName': 'Kategoriename (z.B. Kleidung)', + 'admin.packingTemplates.addCategory': 'Kategorie hinzufügen', + 'admin.packingTemplates.created': 'Vorlage erstellt', + 'admin.packingTemplates.deleted': 'Vorlage gelöscht', + 'admin.packingTemplates.loadError': 'Vorlagen konnten nicht geladen werden', + 'admin.packingTemplates.createError': 'Vorlage konnte nicht erstellt werden', + 'admin.packingTemplates.deleteError': 'Vorlage konnte nicht gelöscht werden', + 'admin.packingTemplates.saveError': 'Fehler beim Speichern', + // Addons 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', @@ -815,6 +835,13 @@ const de: Record = { 'packing.menuDeleteCat': 'Kategorie löschen', 'packing.assignUser': 'Benutzer zuweisen', 'packing.noMembers': 'Keine Mitglieder', + 'packing.addItem': 'Eintrag hinzufügen', + 'packing.addItemPlaceholder': 'Artikelname...', + 'packing.addCategory': 'Kategorie hinzufügen', + 'packing.newCategoryPlaceholder': 'Kategoriename (z.B. Kleidung)', + 'packing.applyTemplate': 'Vorlage anwenden', + 'packing.templateApplied': '{count} Einträge aus Vorlage hinzugefügt', + 'packing.templateError': 'Vorlage konnte nicht angewendet werden', '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 a6e2ed5..6acd7ff 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -345,6 +345,26 @@ const en: Record = { 'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.', 'admin.fileTypesSaved': 'File type settings saved', + // Packing Templates + 'admin.tabs.templates': 'Packing Templates', + 'admin.packingTemplates.title': 'Packing Templates', + 'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips', + 'admin.packingTemplates.create': 'New Template', + 'admin.packingTemplates.namePlaceholder': 'Template name (e.g. Beach Holiday)', + 'admin.packingTemplates.empty': 'No templates created yet', + 'admin.packingTemplates.items': 'items', + 'admin.packingTemplates.categories': 'categories', + 'admin.packingTemplates.itemName': 'Item name', + 'admin.packingTemplates.itemCategory': 'Category', + 'admin.packingTemplates.categoryName': 'Category name (e.g. Clothing)', + 'admin.packingTemplates.addCategory': 'Add category', + 'admin.packingTemplates.created': 'Template created', + 'admin.packingTemplates.deleted': 'Template deleted', + 'admin.packingTemplates.loadError': 'Failed to load templates', + 'admin.packingTemplates.createError': 'Failed to create template', + 'admin.packingTemplates.deleteError': 'Failed to delete template', + 'admin.packingTemplates.saveError': 'Failed to save', + // Addons 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', @@ -815,6 +835,13 @@ const en: Record = { 'packing.menuDeleteCat': 'Delete Category', 'packing.assignUser': 'Assign user', 'packing.noMembers': 'No trip members', + 'packing.addItem': 'Add item', + 'packing.addItemPlaceholder': 'Item name...', + 'packing.addCategory': 'Add category', + 'packing.newCategoryPlaceholder': 'Category name (e.g. Clothing)', + 'packing.applyTemplate': 'Apply template', + 'packing.templateApplied': '{count} items added from template', + 'packing.templateError': 'Failed to apply template', '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/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 1846773..84c6be7 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -12,6 +12,7 @@ import CategoryManager from '../components/Admin/CategoryManager' import BackupPanel from '../components/Admin/BackupPanel' import GitHubPanel from '../components/Admin/GitHubPanel' import AddonManager from '../components/Admin/AddonManager' +import PackingTemplateManager from '../components/Admin/PackingTemplateManager' import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' @@ -57,6 +58,7 @@ export default function AdminPage(): React.ReactElement { const TABS = [ { id: 'users', label: t('admin.tabs.users') }, { id: 'categories', label: t('admin.tabs.categories') }, + { id: 'templates', label: t('admin.tabs.templates') }, { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'backup', label: t('admin.tabs.backup') }, @@ -645,6 +647,8 @@ export default function AdminPage(): React.ReactElement { {activeTab === 'categories' && } + {activeTab === 'templates' && } + {activeTab === 'addons' && } {activeTab === 'settings' && ( diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 345f3ab..61537f5 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -229,6 +229,28 @@ function runMigrations(db: Database.Database): void { UNIQUE(trip_id, category_name, user_id) )`); }, + () => { + db.exec(`CREATE TABLE IF NOT EXISTS packing_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + db.exec(`CREATE TABLE IF NOT EXISTS packing_template_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 + )`); + // Recreate items table with category_id FK (replaces old template_id-based schema) + try { db.exec('DROP TABLE IF EXISTS packing_template_items'); } catch {} + db.exec(`CREATE TABLE packing_template_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 + )`); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 14512b8..c520878 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -266,6 +266,108 @@ router.delete('/invites/:id', (_req: Request, res: Response) => { res.json({ success: true }); }); +// ── Packing Templates ─────────────────────────────────────────────────────── + +router.get('/packing-templates', (_req: Request, res: Response) => { + const templates = db.prepare(` + SELECT pt.*, u.username as created_by_name, + (SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count, + (SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count + FROM packing_templates pt + JOIN users u ON pt.created_by = u.id + ORDER BY pt.created_at DESC + `).all(); + res.json({ templates }); +}); + +router.get('/packing-templates/:id', (_req: Request, res: Response) => { + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id); + if (!template) return res.status(404).json({ error: 'Template not found' }); + const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(_req.params.id) as any[]; + const items = db.prepare(` + SELECT ti.* FROM packing_template_items ti + JOIN packing_template_categories tc ON ti.category_id = tc.id + WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id + `).all(_req.params.id); + res.json({ template, categories, items }); +}); + +router.post('/packing-templates', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { name } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); + const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), authReq.user.id); + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ template }); +}); + +router.put('/packing-templates/:id', (req: Request, res: Response) => { + const { name } = req.body; + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id); + if (!template) return res.status(404).json({ error: 'Template not found' }); + if (name?.trim()) db.prepare('UPDATE packing_templates SET name = ? WHERE id = ?').run(name.trim(), req.params.id); + res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) }); +}); + +router.delete('/packing-templates/:id', (_req: Request, res: Response) => { + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id); + if (!template) return res.status(404).json({ error: 'Template not found' }); + db.prepare('DELETE FROM packing_templates WHERE id = ?').run(_req.params.id); + res.json({ success: true }); +}); + +// Template categories +router.post('/packing-templates/:id/categories', (req: Request, res: Response) => { + const { name } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Category name is required' }); + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id); + if (!template) return res.status(404).json({ error: 'Template not found' }); + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(req.params.id) as { max: number | null }; + const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(req.params.id, name.trim(), (maxOrder.max ?? -1) + 1); + res.status(201).json({ category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) }); +}); + +router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => { + const { name } = req.body; + const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId); + if (!cat) return res.status(404).json({ error: 'Category not found' }); + if (name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(name.trim(), req.params.catId); + res.json({ category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(req.params.catId) }); +}); + +router.delete('/packing-templates/:templateId/categories/:catId', (_req: Request, res: Response) => { + const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(_req.params.catId, _req.params.templateId); + if (!cat) return res.status(404).json({ error: 'Category not found' }); + db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(_req.params.catId); + res.json({ success: true }); +}); + +// Template items +router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => { + const { name } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Item name is required' }); + const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId); + if (!cat) return res.status(404).json({ error: 'Category not found' }); + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(req.params.catId) as { max: number | null }; + const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(req.params.catId, name.trim(), (maxOrder.max ?? -1) + 1); + res.status(201).json({ item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) }); +}); + +router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => { + const { name } = req.body; + const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(req.params.itemId); + if (!item) return res.status(404).json({ error: 'Item not found' }); + if (name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(name.trim(), req.params.itemId); + res.json({ item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(req.params.itemId) }); +}); + +router.delete('/packing-templates/:templateId/items/:itemId', (_req: Request, res: Response) => { + const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(_req.params.itemId); + if (!item) return res.status(404).json({ error: 'Item not found' }); + db.prepare('DELETE FROM packing_template_items WHERE id = ?').run(_req.params.itemId); + res.json({ success: true }); +}); + router.get('/addons', (_req: Request, res: Response) => { const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) }); diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts index 16b0eec..4cc0cd4 100644 --- a/server/src/routes/packing.ts +++ b/server/src/routes/packing.ts @@ -91,6 +91,39 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string); }); +// ── Apply template ────────────────────────────────────────────────────────── + +router.post('/apply-template/:templateId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, templateId } = req.params; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const templateItems = db.prepare(` + SELECT ti.name, tc.name as category + FROM packing_template_items ti + JOIN packing_template_categories tc ON ti.category_id = tc.id + WHERE tc.template_id = ? + ORDER BY tc.sort_order, ti.sort_order + `).all(templateId) as { name: string; category: string }[]; + if (templateItems.length === 0) return res.status(404).json({ error: 'Template not found or empty' }); + + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null }; + let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; + + const insert = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, 0, ?, ?)'); + const added: any[] = []; + for (const ti of templateItems) { + const result = insert.run(tripId, ti.name, ti.category, sortOrder++); + const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid); + added.push(item); + } + + res.json({ items: added, count: added.length }); + broadcast(tripId, 'packing:template-applied', { items: added }, req.headers['x-socket-id'] as string); +}); + // ── Category assignees ────────────────────────────────────────────────────── router.get('/category-assignees', authenticate, (req: Request, res: Response) => {