feat(ui): Costs bar like the other tabs, desktop packing list in the phone's look

The Costs tab opens with the same bar as Transports, Bookings, Lists and
Files: the tab's name, the trip's dates and days in one pill, the
travelers, and Settle up and Add expense on the right.

The desktop packing list takes the phone's look without losing anything
it shows. Category cards get a tinted head band, rows get hairlines and
filled checkboxes, owner, quantity and weight read as small badges, and
the bin and a menu with Move to List, Sharing, Rename and Delete replace
the row of icons. What a row does not use stays dimmed until hover or
focus, so nothing leaves a gap. A click on the name renames, ticked
items too. Add item opens a composer shaped like the next row, Add list
moved into the Lists bar, Add list and Save as template ask for the name
in a dialog, and Remove checked sits beside the progress bar. The Bags
panel becomes a card, and the item menu, the bag picker, the list menu
and the bag member picker share one popover look and close on a click
beside them.

Help center pictures for Costs and Lists are re-rendered, the Lists
guide texts follow the new controls in all 27 languages, and the wiki
pages are updated. The phone screens are unchanged.
This commit is contained in:
Maurice
2026-09-25 12:57:15 +02:00
parent 5c312a8497
commit dc60f39554
141 changed files with 1574 additions and 960 deletions
@@ -1,8 +1,17 @@
import { Plus } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { Briefcase, Plus, X } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
import { BagCard } from './PackingListPanelBagCard'
import { COMPOSER, composerConfirm, useDismissOnOutside } from './packingPopoverStyles'
const kgOrG = (grams: number) => (grams >= 1000 ? `${(grams / 1000).toFixed(1)} kg` : `${grams} g`)
/**
* The bags beside the list, as a card like the categories: a tinted head band
* with the add action, one block per bag between hairlines, what sits in no bag
* as a quiet last row, and the total in a matching foot band.
*/
export function BagSidebar(S: PackingState) {
const {
t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
@@ -16,72 +25,77 @@ export function BagSidebar(S: PackingState) {
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
// Reference for bags without a limit of their own — computed once instead of per bag.
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
// Same rule as the rows above it (#2191).
const totalWeight = bags.reduce((s, b) => s + bagWeightOf(b), 0) + unassignedWeight
const hairline = (first: boolean): React.CSSProperties => ({ borderTop: first ? 'none' : '1px solid var(--border-faint)' })
const composerRef = useRef<HTMLDivElement>(null)
const closeComposer = useCallback(() => { setShowAddBag(false); setNewBagName('') }, [setShowAddBag, setNewBagName])
useDismissOnOutside(composerRef, showAddBag, closeComposer)
return (
<div className="hidden xl:block" style={{ width: 260, marginLeft: 16, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
{t('packing.bags')}
</div>
<div className="hidden xl:block" style={{ width: 272, marginLeft: 16, overflowY: 'auto', padding: '10px 0 16px', flexShrink: 0 }}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 10px 9px 14px', background: 'var(--bg-tertiary)', borderRadius: '15px 15px 0 0' }}>
<Briefcase size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.01em' }}>
{t('packing.bags')}
</span>
{canEdit && !showAddBag && (
<button type="button" onClick={() => setShowAddBag(true)} aria-label={t('packing.addBag')} title={t('packing.addBag')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 9px', borderRadius: 99, border: '1px solid var(--border-secondary)', background: 'var(--bg-card)', cursor: 'pointer', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', fontFamily: 'inherit' }}>
<Plus size={12} /> {t('packing.bags')}
</button>
)}
</div>
{bags.map(bag => {
const bagItems = myItems.filter(i => i.bag_id === bag.id)
const totalWeight = bagWeightOf(bag)
const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight)
return (
<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 />
)
})}
{/* Unassigned */}
{(() => {
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
// Shown whenever there is weight to account for, even with no visible
// items — the grand total counts it either way (#2191).
if (unassigned.length === 0 && unassignedWeight === 0) return null
return (
<div style={{ marginBottom: 14, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span>
</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
{canEdit && showAddBag && (
<div ref={composerRef} style={{ ...COMPOSER, margin: '10px 10px 0', padding: '4px 4px 4px 10px' }}>
<span aria-hidden style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', flexShrink: 0 }} />
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') closeComposer() }}
placeholder={t('packing.bagName')}
style={{ flex: 1, minWidth: 0, padding: '5px 0', border: 'none', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'transparent' }} />
<button type="button" onClick={handleCreateBag} disabled={!newBagName.trim()} aria-label={t('common.add')} style={composerConfirm(!!newBagName.trim())}>
<Plus size={14} strokeWidth={2.5} />
</button>
<button type="button" onClick={closeComposer} aria-label={t('common.cancel')} style={{ width: 26, height: 28, border: 'none', background: 'none', cursor: 'pointer', display: 'grid', placeItems: 'center', flexShrink: 0, color: 'var(--text-faint)' }}>
<X size={13} />
</button>
</div>
)
})()}
)}
{/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<div style={{ padding: '0 14px' }}>
{bags.map((bag, index) => {
const bagItems = myItems.filter(i => i.bag_id === bag.id)
const weight = bagWeightOf(bag)
const pct = bagFillPct(weight, bag.weight_limit_grams, heaviestBagWeight)
return (
<div key={bag.id} style={{ ...hairline(index === 0), paddingTop: 12 }}>
<BagCard bag={bag} bagItems={bagItems} totalWeight={weight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
</div>
)
})}
{/* Whatever sits in no bag. Shown whenever there is weight to account
for, even with no visible items: the total counts it either way (#2191). */}
{(unassigned.length > 0 || unassignedWeight > 0) && (
<div style={{ ...hairline(bags.length === 0), display: 'flex', alignItems: 'center', gap: 6, padding: '11px 0' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)' }}>{t('packing.noBag')}</span>
<span title={`${unassigned.length} ${t('admin.packingTemplates.items')}`} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 6px', borderRadius: 99, minWidth: 18, textAlign: 'center', background: 'var(--bg-tertiary)', color: 'var(--text-faint)', fontVariantNumeric: 'tabular-nums' }}>{unassigned.length}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{kgOrG(unassignedWeight)}</span>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg-tertiary)', borderRadius: '0 0 15px 15px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => {
// Same rule as the rows above it (#2191).
const w = bags.reduce((s, b) => s + bagWeightOf(b), 0)
+ unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)
return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g`
})()}</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{kgOrG(totalWeight)}</span>
</div>
</div>
{/* Add bag */}
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
<input autoFocus value={newBagName} onChange={e => 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: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: 'inherit', outline: 'none' }} />
<button type="button" onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={12} />
</button>
</div>
) : (
<button type="button" onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
<Plus size={11} /> {t('packing.addBag')}
</button>
))}
</div>
)
}