feat(packing): item quantity, bag rename, multi-user bags, save as template
- Add quantity field to packing items (persisted, visible per item) - Bags are now renamable (click to edit in sidebar) - Bags support multiple user assignments with avatar display - New packing_bag_members table for multi-user bag ownership - Save current packing list as reusable template - Add bag members API endpoint (PUT /bags/:bagId/members) - Migration 74: quantity on packing_items, user_id on packing_bags, packing_bag_members table
This commit is contained in:
@@ -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<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||
|
||||
@@ -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<string, any>) => 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 (
|
||||
<div style={{ marginBottom: sz.mb }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: sz.gap, marginBottom: 4 }}>
|
||||
<span style={{ width: sz.dot, height: sz.dot, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
|
||||
{editingName && canEdit ? (
|
||||
<input autoFocus value={nameVal} onChange={e => 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' }} />
|
||||
) : (
|
||||
<span onClick={() => 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}</span>
|
||||
)}
|
||||
<span style={{ fontSize: sz.weight, color: 'var(--text-faint)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
{canEdit && <button onClick={onDelete} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}><X size={sz.icon} /></button>}
|
||||
</div>
|
||||
{/* Members */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, flexWrap: 'wrap', position: 'relative' }}>
|
||||
{(bag.members || []).map(m => (
|
||||
<span key={m.user_id} title={m.username} onClick={() => canEdit && toggleMember(m.user_id)} style={{ cursor: canEdit ? 'pointer' : 'default', display: 'inline-flex' }}>
|
||||
{m.avatar ? (
|
||||
<img src={m.avatar} alt={m.username} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', objectFit: 'cover', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }} />
|
||||
) : (
|
||||
<span style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', background: bag.color + '25', color: bag.color, fontSize: sz.avatar * 0.45, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }}>
|
||||
{m.username[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowUserPicker(v => !v)} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', border: '1.5px dashed var(--border-primary)', background: 'none', color: 'var(--text-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, boxSizing: 'border-box' }}>
|
||||
<Plus size={sz.avatar * 0.5} />
|
||||
</button>
|
||||
)}
|
||||
{showUserPicker && (
|
||||
<div style={{ position: 'absolute', left: 0, top: '100%', marginTop: 4, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.1)', padding: 4, minWidth: 160 }}>
|
||||
{tripMembers.map(m => {
|
||||
const isSelected = memberIds.includes(m.id)
|
||||
return (
|
||||
<button key={m.id} onClick={() => { toggleMember(m.id); }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: isSelected ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-primary)', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-secondary)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
|
||||
{m.avatar ? (
|
||||
<img src={m.avatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<span style={{ width: 20, height: 20, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 10, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}>
|
||||
{m.username[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1, fontWeight: isSelected ? 600 : 400 }}>{m.username}</span>
|
||||
{isSelected && <Check size={12} style={{ color: '#10b981' }} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary)', marginTop: 4, paddingTop: 4 }}>
|
||||
<button onClick={() => setShowUserPicker(false)} style={{ width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'center' }}>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: sz.bar, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: sz.count, color: 'var(--text-faint)', marginTop: 2 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', flexShrink: 0 }}>
|
||||
<input
|
||||
type="text" inputMode="numeric"
|
||||
value={local}
|
||||
onChange={e => 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 }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>x</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
|
||||
interface ArtikelZeileProps {
|
||||
@@ -154,6 +281,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Quantity */}
|
||||
{canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />}
|
||||
|
||||
{/* Weight + Bag (when enabled) */}
|
||||
{bagTrackingEnabled && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
@@ -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<string, any>) => {
|
||||
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<HTMLInputElement>(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
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && items.length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{showSaveTemplate ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input
|
||||
type="text" autoFocus
|
||||
value={saveTemplateName}
|
||||
onChange={e => 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)' }}
|
||||
/>
|
||||
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
|
||||
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowSaveTemplate(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{bagTrackingEnabled && (
|
||||
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
|
||||
style={{
|
||||
@@ -1023,25 +1208,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
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 (
|
||||
<div key={bag.id} style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{bag.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteBag(bag.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
|
||||
</div>
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => 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 (
|
||||
<div key={bag.id} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{bag.name}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteBag(bag.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 8, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
|
||||
</div>
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
@@ -1106,6 +1106,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1125,6 +1125,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<number, typeof members>();
|
||||
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<string, number | bigint>();
|
||||
|
||||
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<string, number>();
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user