diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 9afe882..7e8f28b 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -114,6 +114,10 @@ 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), + 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), + deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data), } export const tagsApi = { @@ -143,6 +147,8 @@ 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), + getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), + updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).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), diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index b82d6df..4cfb4bf 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -27,7 +27,7 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) { return } -export default function AddonManager() { +export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) { const { t } = useTranslation() const dm = useSettingsStore(s => s.settings.dark_mode) const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -104,7 +104,28 @@ export default function AddonManager() { {tripAddons.map(addon => ( - +
+ + {addon.id === 'packing' && addon.enabled && onToggleBagTracking && ( +
+
+
{t('admin.bagTracking.title')}
+
{t('admin.bagTracking.subtitle')}
+
+
+ + {bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ )} +
))} )} @@ -179,7 +200,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {/* Toggle */}
- + {isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
diff --git a/client/src/components/Admin/PackingTemplateManager.tsx b/client/src/components/Admin/PackingTemplateManager.tsx index 9fbf851..adcbe9a 100644 --- a/client/src/components/Admin/PackingTemplateManager.tsx +++ b/client/src/components/Admin/PackingTemplateManager.tsx @@ -159,7 +159,7 @@ export default function PackingTemplateManager() { diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index f07efb1..0fd5c2e 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -65,19 +65,27 @@ 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 } + // ── Artikel-Zeile ────────────────────────────────────────────────────────── interface ArtikelZeileProps { item: PackingItem tripId: number categories: string[] onCategoryChange: () => void + bagTrackingEnabled?: boolean + bags?: PackingBag[] + onCreateBag: (name: string) => Promise } -function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZeileProps) { +function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) { const [editing, setEditing] = useState(false) const [editName, setEditName] = useState(item.name) const [hovered, setHovered] = useState(false) const [showCatPicker, setShowCatPicker] = useState(false) + const [showBagPicker, setShowBagPicker] = useState(false) + const [bagInlineCreate, setBagInlineCreate] = useState(false) + const [bagInlineName, setBagInlineName] = useState('') const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore() const toast = useToast() const { t } = useTranslation() @@ -104,8 +112,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZei return (
setHovered(true)} - onMouseLeave={() => { setHovered(false); setShowCatPicker(false) }} + onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderRadius: 10, position: 'relative', @@ -142,7 +151,102 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZei )} -
+ {/* Weight + Bag (when enabled) */} + {bagTrackingEnabled && ( +
+
+ { + const raw = e.target.value.replace(/[^0-9]/g, '') + const v = raw === '' ? null : parseInt(raw) + try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {} + }} + placeholder="—" + style={{ width: 36, border: 'none', fontSize: 12, textAlign: 'right', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }} + /> + g +
+
+ + {showBagPicker && ( +
+ {item.bag_id && ( + + )} + {bags.map(b => ( + + ))} + {bags.length > 0 &&
} +
+ {bagInlineCreate ? ( +
+ setBagInlineName(e.target.value)} + onKeyDown={async e => { + if (e.key === 'Enter' && bagInlineName.trim()) { + const newBag = await onCreateBag(bagInlineName.trim()) + if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch {} } + setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false) + } + if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') } + }} + placeholder={t('packing.bagName')} + style={{ flex: 1, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} /> + +
+ ) : ( + + )} +
+
+ )} +
+
+ )} + +
{showTemplateDropdown && (
)} + {bagTrackingEnabled && ( + + )}
@@ -725,7 +888,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
)} - {/* ── Liste ── */} + {/* ── Liste + Bags Sidebar ── */} +
{items.length === 0 ? (
@@ -752,11 +916,184 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp assignees={categoryAssignees[kat] || []} tripMembers={tripMembers} onSetAssignees={handleSetAssignees} + bagTrackingEnabled={bagTrackingEnabled} + bags={bags} + onCreateBag={handleCreateBagByName} /> ))}
)}
+ + {/* ── Bag Weight Sidebar ── */} + {bagTrackingEnabled && bags.length > 0 && ( +
+
+ {t('packing.bags')} +
+ + {bags.map(bag => { + const bagItems = items.filter(i => i.bag_id === bag.id) + const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0) + const maxWeight = bag.weight_limit_grams || 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`} + + +
+
+
+
+
{bagItems.length} {t('admin.packingTemplates.items')}
+
+ ) + })} + + {/* Unassigned */} + {(() => { + const unassigned = items.filter(i => !i.bag_id) + const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0) + if (unassigned.length === 0) return null + return ( +
+
+ + {t('packing.noBag')} + + {unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`} + +
+
{unassigned.length} {t('admin.packingTemplates.items')}
+
+ ) + })()} + + {/* Total */} +
+
+ {t('packing.totalWeight')} + {(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()} +
+
+ + {/* Add bag */} + {showAddBag ? ( +
+ setNewBagName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }} + placeholder={t('packing.bagName')} + style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} /> + +
+ ) : ( + + )} +
+ )} +
+ + {/* ── Bag Modal (mobile + click) ── */} + {showBagModal && bagTrackingEnabled && ( +
setShowBagModal(false)}> +
e.stopPropagation()}> +
+

{t('packing.bags')}

+ +
+ + {bags.map(bag => { + const bagItems = items.filter(i => i.bag_id === bag.id) + const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0) + 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`} + + +
+
+
+
+
{bagItems.length} {t('admin.packingTemplates.items')}
+
+ ) + })} + + {/* Unassigned */} + {(() => { + const unassigned = items.filter(i => !i.bag_id) + const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0) + if (unassigned.length === 0) return null + return ( +
+
+ + {t('packing.noBag')} + + {unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`} + +
+
{unassigned.length} {t('admin.packingTemplates.items')}
+
+ ) + })()} + + {/* Total */} +
+
+ {t('packing.totalWeight')} + {(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()} +
+
+ + {/* Add bag */} + {showAddBag ? ( +
+ setNewBagName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }} + placeholder={t('packing.bagName')} + style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none' }} /> + +
+ ) : ( + + )} +
+
+ )} +