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.
@@ -55,8 +55,7 @@ const cat = (page: Page, name: string): Locator =>
|
||||
page.getByText(name, { exact: true }).first().locator('xpath=ancestor::div[2]')
|
||||
/**
|
||||
* The header row of a card. The ⋯ has to be taken from here: every item row
|
||||
* carries a phone-only twin of it that `display:none` hides but CSS still
|
||||
* matches, so the card as a whole holds four of them.
|
||||
* carries a ⋯ of its own, so the card as a whole holds several of them.
|
||||
*/
|
||||
const catHeader = (page: Page, name: string): Locator => cat(page, name).locator('> div').first()
|
||||
const catMenu = (page: Page, name: string): Locator =>
|
||||
@@ -67,6 +66,13 @@ const item = (page: Page, name: string): Locator =>
|
||||
page.locator('.packing-item-row').filter({ hasText: name }).first()
|
||||
/** The only item of the list the first guide builds; its name changes mid-guide. */
|
||||
const onlyRow = (page: Page): Locator => cat(page, NEW_LIST).locator('.packing-item-row').first()
|
||||
/** An item's own ⋯, which holds Move to List, Sharing, Rename and Delete. */
|
||||
const rowMenu = (page: Page, name: string): Locator => item(page, name).locator('.packing-row-overflow button')
|
||||
/** The open item menu; Sharing is a whole line in it. */
|
||||
const rowMenuSharing = (page: Page): Locator =>
|
||||
page.locator('.trek-menu-enter').getByRole('button', { name: /^Sharing/ })
|
||||
/** The name dialog that Add list and Save as template open. */
|
||||
const nameDialog = (page: Page, confirm: string): Locator => page.getByRole('button', { name: confirm, exact: true })
|
||||
/** The sharing dropdown is portalled to <body>, so the hint's parent is the card itself. */
|
||||
const shareMenu = (page: Page): Locator =>
|
||||
page.getByText('In the group pool, visible to everyone').locator('xpath=ancestor::div[1]')
|
||||
@@ -190,7 +196,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await p.getByRole('button', { name: 'Add list' }).click()
|
||||
const field = p.getByPlaceholder('List name (e.g. Clothing)')
|
||||
await typeInto(p, field, NEW_LIST)
|
||||
await field.locator('xpath=..').locator('button:has(svg.lucide-check)').click()
|
||||
await nameDialog(p, 'Add').click()
|
||||
await expect(cat(p, NEW_LIST)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
@@ -211,9 +217,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => { await onlyRow(p).hover() },
|
||||
target: p => onlyRow(p).getByRole('button', { name: 'Rename' }),
|
||||
// A click on the name renames it; the row has no pencil of its own.
|
||||
target: p => onlyRow(p).getByRole('button', { name: FIRST_ITEM }),
|
||||
act: async p => {
|
||||
await onlyRow(p).getByRole('button', { name: 'Rename' }).click()
|
||||
await onlyRow(p).getByRole('button', { name: FIRST_ITEM }).click()
|
||||
// While editing, the name is the row's first input; the quantity and
|
||||
// the weight come after it.
|
||||
const field = onlyRow(p).locator('input').first()
|
||||
@@ -265,13 +272,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => item(p, 'Passport').getByRole('button').first(),
|
||||
act: async p => {
|
||||
await item(p, 'Passport').getByRole('button').first().click()
|
||||
// Both box SVGs stay in the DOM and cross-fade, so the assertion is on
|
||||
// the name instead: a checked row renders it as a span, not a button.
|
||||
await expect(item(p, 'Passport').getByRole('button', { name: 'Passport' })).toHaveCount(0, { timeout: 20_000 })
|
||||
await expect(item(p, 'Passport').locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||||
// The whole progress card: the count, the bar, and the clean-up beside it.
|
||||
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[4]')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await catMenu(p, 'Documents').click()
|
||||
@@ -335,7 +341,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await p.getByRole('button', { name: 'Save as template' }).click()
|
||||
const field = p.getByPlaceholder('Template name')
|
||||
await typeInto(p, field, SAVED_TEMPLATE)
|
||||
await field.locator('xpath=..').locator('button:has(svg.lucide-check)').click()
|
||||
await nameDialog(p, 'Save').click()
|
||||
await expect(p.getByText('Packing list saved as template')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
@@ -432,10 +438,15 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await item(p, SHARED_ITEM).hover() },
|
||||
target: p => item(p, SHARED_ITEM).getByRole('button', { name: 'Sharing' }),
|
||||
prepare: async p => {
|
||||
await item(p, SHARED_ITEM).hover()
|
||||
await rowMenu(p, SHARED_ITEM).click()
|
||||
await expect(rowMenuSharing(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: rowMenuSharing,
|
||||
act: async p => {
|
||||
await item(p, SHARED_ITEM).getByRole('button', { name: 'Sharing' }).click()
|
||||
await rowMenuSharing(p).click()
|
||||
await expect(shareMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -459,16 +470,20 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
prepare: async p => {
|
||||
await item(p, SHARED_ITEM).hover()
|
||||
await item(p, SHARED_ITEM).getByRole('button', { name: 'Sharing' }).click()
|
||||
await rowMenu(p, SHARED_ITEM).click()
|
||||
await rowMenuSharing(p).click()
|
||||
await expect(shareMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => shareMenu(p).getByRole('button', { name: MEMBER }),
|
||||
act: async p => {
|
||||
await shareMenu(p).getByRole('button', { name: MEMBER }).click()
|
||||
// Ticking a name leaves the menu open on purpose; its catcher closes it.
|
||||
// Ticking a name leaves the menu open on purpose; its catcher closes it,
|
||||
// and the item menu under it has one of its own.
|
||||
await shareOverlay(p).click({ position: { x: 5, y: 5 } })
|
||||
await expect(item(p, SHARED_ITEM).getByText('shared with 1')).toBeVisible({ timeout: 20_000 })
|
||||
await p.mouse.click(20, VIEWPORT.height - 40)
|
||||
await expect(p.locator('.trek-menu-enter')).toHaveCount(0)
|
||||
await expect(item(p, SHARED_ITEM).getByLabel('shared with 1')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -531,12 +546,14 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
// The card's member picker; the column's other plus is Add bag, below it.
|
||||
target: p => bagSidebar(p).locator('button:has(svg.lucide-plus)').first(),
|
||||
// The card's member picker is the dashed plus beside the bag's name; the
|
||||
// column's other plus, + Bags in its head, adds a bag.
|
||||
target: p => bagSidebar(p).locator('button[style*="dashed"]').first(),
|
||||
act: async p => {
|
||||
await bagSidebar(p).locator('button:has(svg.lucide-plus)').first().click()
|
||||
await bagSidebar(p).locator('button[style*="dashed"]').first().click()
|
||||
await bagSidebar(p).getByRole('button', { name: MEMBER }).click()
|
||||
await bagSidebar(p).getByRole('button', { name: 'Close' }).click()
|
||||
// The picker closes on a click anywhere beside it.
|
||||
await p.mouse.click(20, VIEWPORT.height - 40)
|
||||
await expect(bagSidebar(p).getByRole('button', { name: MEMBER })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
@@ -27,6 +27,7 @@ import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import CostsToolbar from './CostsToolbar'
|
||||
|
||||
interface CostsPanelProps {
|
||||
tripId: number
|
||||
@@ -355,44 +356,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
|
||||
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '24px 28px 48px' }}>
|
||||
{isMobile ? MobileBody() : (
|
||||
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
|
||||
{/* ── Header bar ── */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{dateMeta && (
|
||||
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 999, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px 8px 10px', borderRadius: 999, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
{people.slice(0, 4).map((p, i) => {
|
||||
const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const
|
||||
return p.avatar_url
|
||||
? <img key={p.id} src={p.avatar_url} alt="" style={{ ...common, objectFit: 'cover', display: 'block' }} />
|
||||
: <span key={p.id} style={{ ...common, background: colorFor(p.id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>
|
||||
})}
|
||||
</span>
|
||||
<b className="text-content">{t('costs.travelers', { count: people.length })}</b>
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button type="button" onClick={settleAll} disabled={!(settlement?.flows || []).length}
|
||||
className="bg-surface-card border border-edge text-content disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Check size={16} /> {t('costs.settleUp')}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }}
|
||||
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={16} /> {t('costs.addExpense')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CostsToolbar dateMeta={dateMeta} people={people} me={me} colorFor={colorFor}
|
||||
canEdit={canEdit} canSettle={(settlement?.flows || []).length > 0}
|
||||
onSettleAll={settleAll} onAddExpense={() => { setEditing(null); setModalOpen(true) }} />
|
||||
|
||||
{/* ── Summary cards ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 36 }} className="costs-summary">
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// FE-COSTSBAR-001 to FE-COSTSBAR-007
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import CostsToolbar from './CostsToolbar'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
const PEOPLE: TripMember[] = [
|
||||
{ id: 1, username: 'ada', avatar_url: '/uploads/avatars/ada.png' },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
]
|
||||
|
||||
function setup(props: Partial<Parameters<typeof CostsToolbar>[0]> = {}) {
|
||||
const onSettleAll = vi.fn()
|
||||
const onAddExpense = vi.fn()
|
||||
render(
|
||||
<CostsToolbar dateMeta={{ range: 'Oct 9 – Oct 16', days: 8 }} people={PEOPLE} me={1} colorFor={() => 'linear-gradient(#000,#111)'}
|
||||
canEdit canSettle onSettleAll={onSettleAll} onAddExpense={onAddExpense} {...props} />,
|
||||
)
|
||||
return { onSettleAll, onAddExpense }
|
||||
}
|
||||
|
||||
describe('CostsToolbar', () => {
|
||||
it('FE-COSTSBAR-001: names the tab like the other planner bars', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Costs' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COSTSBAR-002: splits the trip span and its days with a rule instead of a dot', () => {
|
||||
setup()
|
||||
const days = screen.getByText('8 days')
|
||||
const pill = days.parentElement!
|
||||
expect(pill).toHaveTextContent('Oct 9 – Oct 16')
|
||||
expect(pill.textContent).not.toContain('·')
|
||||
expect(pill.querySelector('[aria-hidden]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COSTSBAR-003: leaves the span out for a trip without dates', () => {
|
||||
setup({ dateMeta: null })
|
||||
expect(screen.queryByText(/^\d+ days$/)).toBeNull()
|
||||
expect(screen.getByText('2 travelers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COSTSBAR-004: shows each traveler by avatar, or by initial with "you" for the viewer', () => {
|
||||
setup({ people: [{ id: 1, username: 'ada', avatar_url: null }, ...PEOPLE.slice(1), { id: 3, username: 'cy', avatar_url: '/uploads/avatars/cy.png' }] })
|
||||
expect(screen.getByText('Y')).toBeInTheDocument()
|
||||
expect(screen.getByText('B')).toBeInTheDocument()
|
||||
expect(document.querySelector('img[src="/uploads/avatars/cy.png"]')).not.toBeNull()
|
||||
expect(screen.getByText('3 travelers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COSTSBAR-005: runs settle up and add expense from the right of the bar', () => {
|
||||
const { onSettleAll, onAddExpense } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settle up' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add expense' }))
|
||||
expect(onSettleAll).toHaveBeenCalledTimes(1)
|
||||
expect(onAddExpense).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COSTSBAR-006: disables settle up while there is nothing to settle', () => {
|
||||
setup({ canSettle: false })
|
||||
expect(screen.getByRole('button', { name: 'Settle up' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-COSTSBAR-007: a viewer without edit rights gets the bar without actions', () => {
|
||||
setup({ canEdit: false })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByText('8 days')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Check, Plus } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
interface CostsToolbarProps {
|
||||
/** The trip's span as a formatted range and its length in days; null for a trip without dates. */
|
||||
dateMeta: { range: string; days: number } | null
|
||||
people: TripMember[]
|
||||
me: number
|
||||
colorFor: (userId: number) => string
|
||||
canEdit: boolean
|
||||
/** Whether there is any suggested transfer left to settle. */
|
||||
canSettle: boolean
|
||||
onSettleAll: () => void
|
||||
onAddExpense: () => void
|
||||
}
|
||||
|
||||
const BODY_SIZE = 'calc(13px * var(--fs-scale-body, 1))'
|
||||
|
||||
/**
|
||||
* The bar on top of the Costs tab, in the same shape as the one Transports,
|
||||
* Bookings, Lists and Files open with: the tab's name, then what the numbers
|
||||
* cover (the trip's days and who travels), and the two actions on the right.
|
||||
*/
|
||||
export default function CostsToolbar({ dateMeta, people, me, colorFor, canEdit, canSettle, onSettleAll, onAddExpense }: CostsToolbarProps) {
|
||||
const { t } = useTranslation()
|
||||
const chip = 'inline-flex items-center whitespace-nowrap rounded-full bg-surface-card px-3 py-1.5 font-medium text-content-muted shadow-sm'
|
||||
const button = 'inline-flex items-center gap-1.5 rounded-[10px] border-0 px-[14px] py-[9px] font-medium hover:opacity-[0.88]'
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4 rounded-[18px] bg-surface-tertiary py-3.5 pl-[22px] pr-4">
|
||||
<h2 className="m-0 shrink-0 text-subtitle font-semibold tracking-[-0.01em] text-content">{t('trip.tabs.budget')}</h2>
|
||||
<div className="h-[22px] w-px shrink-0 bg-edge-faint" />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5" style={{ fontSize: BODY_SIZE }}>
|
||||
{dateMeta && (
|
||||
<span className={`${chip} gap-3`}>
|
||||
{dateMeta.range}
|
||||
{/* One pill in two fields, cut straight through in the bar's colour rather than two rounded halves. */}
|
||||
<span aria-hidden className="-my-1.5 w-[3px] self-stretch bg-surface-tertiary" />
|
||||
<b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
|
||||
</span>
|
||||
)}
|
||||
<span className={`${chip} gap-2 pl-1.5`}>
|
||||
<span className="inline-flex">
|
||||
{people.slice(0, 4).map((p, i) => {
|
||||
const ring = { width: 22, height: 22, marginLeft: i ? -8 : 0 }
|
||||
return p.avatar_url
|
||||
? <img key={p.id} src={p.avatar_url} alt="" className="block shrink-0 rounded-full border-2 border-surface-card object-cover" style={ring} />
|
||||
: (
|
||||
<span key={p.id} className="grid shrink-0 place-items-center rounded-full border-2 border-surface-card font-bold" style={{
|
||||
...ring, background: colorFor(p.id), fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
color: '#fff', // theme-lint-disable: an initial on the member's own colour
|
||||
}}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
<b className="text-content">{t('costs.travelers', { count: people.length })}</b>
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="ml-auto flex shrink-0 flex-wrap gap-1.5" style={{ fontSize: BODY_SIZE }}>
|
||||
<button type="button" onClick={onSettleAll} disabled={!canSettle}
|
||||
className={`${button} bg-surface-card text-content disabled:cursor-default disabled:opacity-40`}>
|
||||
<Check size={14} strokeWidth={2.5} />
|
||||
{t('costs.settleUp')}
|
||||
</button>
|
||||
<button type="button" onClick={onAddExpense} className={`${button} bg-accent text-accent-text`}>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
{t('costs.addExpense')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -248,8 +248,8 @@ describe('PackingListPanel', () => {
|
||||
);
|
||||
render(<PackingListPanel tripId={1} items={[item]} />);
|
||||
|
||||
// Click the rename (pencil) button
|
||||
await user.click(screen.getByTitle('Rename'));
|
||||
// A click on the name opens it for renaming
|
||||
await user.click(screen.getByText('Sunscreen'));
|
||||
|
||||
// Input appears pre-filled with 'Sunscreen'
|
||||
const input = screen.getByDisplayValue('Sunscreen');
|
||||
@@ -275,8 +275,8 @@ describe('PackingListPanel', () => {
|
||||
);
|
||||
const { container } = render(<PackingListPanel tripId={1} items={[item]} />);
|
||||
|
||||
// The toggle button contains the Square icon for unchecked items
|
||||
const toggleBtn = container.querySelector('svg.lucide-square')?.closest('button');
|
||||
// The checkbox is the button marked packing-check
|
||||
const toggleBtn = container.querySelector<HTMLButtonElement>('.packing-check');
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await user.click(toggleBtn!);
|
||||
|
||||
@@ -861,9 +861,9 @@ describe('PackingListPanel', () => {
|
||||
// In jsdom, CSS classes don't apply so the buttons are accessible
|
||||
// The dot button has a circle span inside with category color
|
||||
// Find all buttons with the 'Move to List' title
|
||||
const catChangeBtn = screen.getAllByTitle('Move to List');
|
||||
expect(catChangeBtn.length).toBeGreaterThan(0);
|
||||
await user.click(catChangeBtn[0]);
|
||||
// Moving an item goes through its menu: the three dots, then Move to List.
|
||||
await user.click(container.querySelector<HTMLButtonElement>('.packing-row-overflow button')!);
|
||||
await user.click(screen.getByText('Move to List'));
|
||||
|
||||
// Category picker shows both category names
|
||||
await waitFor(() => {
|
||||
@@ -1059,11 +1059,11 @@ describe('PackingListPanel', () => {
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 74, category: 'Documents' }) });
|
||||
})
|
||||
);
|
||||
render(<PackingListPanel tripId={1} items={[itemA, itemB]} />);
|
||||
const { container } = render(<PackingListPanel tripId={1} items={[itemA, itemB]} />);
|
||||
|
||||
// Use fireEvent (no pointer events) to open the category picker — avoids mouseLeave closing picker
|
||||
const catChangeBtns = screen.getAllByTitle('Move to List');
|
||||
fireEvent.click(catChangeBtns[0]);
|
||||
// Open the first item's menu and its Move to List entry
|
||||
fireEvent.click(container.querySelector<HTMLButtonElement>('.packing-row-overflow button')!);
|
||||
fireEvent.click(screen.getByText('Move to List'));
|
||||
|
||||
// Picker shows available categories — find and click the 'Documents' button (role=button, text=Documents)
|
||||
const docBtn = await screen.findByRole('button', { name: 'Documents' });
|
||||
@@ -1248,9 +1248,10 @@ describe('PackingListPanel', () => {
|
||||
const items = [buildPackingItem({ name: 'Boots', category: 'Clothing' })];
|
||||
render(<PackingListPanel tripId={1} items={items} />);
|
||||
|
||||
// Wait for sidebar "Add bag" button (sidebar renders when bags.length > 0)
|
||||
await waitFor(() => expect(screen.getAllByText('Add bag').length).toBeGreaterThan(0));
|
||||
const addBagBtns = screen.getAllByText('Add bag');
|
||||
// Wait for the sidebar's add button (sidebar renders when bags.length > 0); it
|
||||
// reads just "Bags" and carries "Add bag" as its accessible name.
|
||||
await waitFor(() => expect(screen.getAllByRole('button', { name: 'Add bag' }).length).toBeGreaterThan(0));
|
||||
const addBagBtns = screen.getAllByRole('button', { name: 'Add bag' });
|
||||
await user.click(addBagBtns[0]);
|
||||
|
||||
// Bag name input appears
|
||||
@@ -1637,7 +1638,55 @@ describe('PackingListPanel', () => {
|
||||
render(<PackingListPanel tripId={1} items={items} />);
|
||||
await userEvent.click(screen.getByText('My list'));
|
||||
await screen.findByText('Power bank');
|
||||
// "by Bob" — taken care of by the bringer.
|
||||
expect(screen.getByText('by Bob')).toBeInTheDocument();
|
||||
// "by Bob" — taken care of by the bringer, as the chip's tooltip and label.
|
||||
expect(screen.getByLabelText('by Bob')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PackingListPanel — under the Lists bar', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildAdmin({ id: 1 }), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-090: the clean-up sits in the progress card and clears what is ticked', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
let deleted = false;
|
||||
server.use(http.delete('/api/trips/1/packing/91', () => { deleted = true; return HttpResponse.json({ success: true }); }));
|
||||
render(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 91, name: 'Towel', checked: 1 }), buildPackingItem({ id: 92, name: 'Soap' })]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove 1 checked' }));
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
// No dashed Add list under the bar: the Lists bar carries it.
|
||||
expect(screen.queryByRole('button', { name: 'Add list' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-091: a raised add-list signal opens the name dialog, which creates the list', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(http.post('/api/trips/1/packing', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 93, name: '...', category: 'Rain kit' }) });
|
||||
}));
|
||||
const { rerender } = render(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 92, name: 'Soap' })]} addCategorySignal={0} />);
|
||||
rerender(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 92, name: 'Soap' })]} addCategorySignal={1} />);
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText('List name (e.g. Clothing)'), { target: { value: 'Rain kit' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
await waitFor(() => expect(body).toMatchObject({ category: 'Rain kit' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-092: Save as template asks for the name in a dialog', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(http.post('/api/trips/1/packing/save-as-template', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ template: { id: 1, name: 'Beach' } });
|
||||
}));
|
||||
const { rerender } = render(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 92, name: 'Soap' })]} saveTemplateSignal={0} />);
|
||||
rerender(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 92, name: 'Soap' })]} saveTemplateSignal={1} />);
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText('Template name'), { target: { value: 'Beach' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(body).toMatchObject({ name: 'Beach' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Plus, Check } from 'lucide-react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
import { PopoverItem } from './PackingPopover'
|
||||
import { POPOVER, POPOVER_CAPTION, useDismissOnOutside } from './packingPopoverStyles'
|
||||
|
||||
interface BagCardProps {
|
||||
bag: PackingBag; bagItems: PackingItem[]; totalWeight: number; pct: number; tripId: number
|
||||
@@ -14,6 +16,9 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameVal, setNameVal] = useState(bag.name)
|
||||
const [showUserPicker, setShowUserPicker] = useState(false)
|
||||
const membersRef = useRef<HTMLDivElement>(null)
|
||||
const closePicker = useCallback(() => setShowUserPicker(false), [])
|
||||
useDismissOnOutside(membersRef, showUserPicker, closePicker)
|
||||
useEffect(() => setNameVal(bag.name), [bag.name])
|
||||
|
||||
const saveName = () => {
|
||||
@@ -50,9 +55,11 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
|
||||
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 }
|
||||
|
||||
// Three lines, each with one job: who the bag is (name, who carries it, delete),
|
||||
// how full it is, and the numbers under the bar (items left, weight and limit right).
|
||||
return (
|
||||
<div style={{ marginBottom: sz.mb }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: sz.gap, marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: sz.gap }}>
|
||||
<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)}
|
||||
@@ -60,9 +67,56 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
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' }} />
|
||||
) : (
|
||||
<button type="button" disabled={!canEdit} onClick={() => setEditingName(true)}
|
||||
style={{ flex: 1, fontSize: sz.name, fontWeight: 600, color: compact ? 'var(--text-secondary)' : 'var(--text-primary)', cursor: canEdit ? 'text' : 'default', background: 'none', border: 'none', padding: 0, textAlign: 'left', fontFamily: 'inherit' }}>{bag.name}</button>
|
||||
style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: sz.name, fontWeight: 700, color: 'var(--text-primary)', cursor: canEdit ? 'text' : 'default', background: 'none', border: 'none', padding: 0, textAlign: 'left', fontFamily: 'inherit' }}>{bag.name}</button>
|
||||
)}
|
||||
<span style={{ fontSize: sz.weight, color: 'var(--text-faint)', fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
{/* Members */}
|
||||
<div ref={membersRef} style={{ display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0, position: 'relative' }}>
|
||||
{(bag.members || []).map(m => {
|
||||
const face = 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>
|
||||
)
|
||||
if (!canEdit) return <span key={m.user_id} title={m.username} style={{ display: 'inline-flex' }}>{face}</span>
|
||||
return (
|
||||
<button type="button" key={m.user_id} title={m.username} aria-label={m.username} onClick={() => toggleMember(m.user_id)}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', background: 'none', border: 'none', padding: 0 }}>
|
||||
{face}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canEdit && (
|
||||
<button type="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 className="trek-menu-enter" style={{ ...POPOVER, position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50, width: 200 }}>
|
||||
<div style={POPOVER_CAPTION}>{t('packing.assignMembers')}</div>
|
||||
{tripMembers.map(m => (
|
||||
<PopoverItem key={m.id} label={m.username} active={memberIds.includes(m.id)} onClick={() => toggleMember(m.id)}
|
||||
icon={m.avatar ? (
|
||||
<img src={m.avatar} alt="" style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}>
|
||||
{m.username[0].toUpperCase()}
|
||||
</span>
|
||||
)} />
|
||||
))}
|
||||
{tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && <button type="button" onClick={onDelete} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}><X size={sz.icon} /></button>}
|
||||
</div>
|
||||
<div style={{ height: sz.bar, marginTop: 9, 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={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 5 }}>
|
||||
<span style={{ fontSize: sz.count, color: 'var(--text-faint)' }}>{bagItems.length} {t('admin.packingTemplates.items')}</span>
|
||||
<span style={{ fontSize: sz.weight, color: 'var(--text-muted)', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
{editingLimit && canEdit ? (
|
||||
<>
|
||||
@@ -86,65 +140,7 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{canEdit && <button type="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 => {
|
||||
const face = 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>
|
||||
)
|
||||
if (!canEdit) return <span key={m.user_id} title={m.username} style={{ display: 'inline-flex' }}>{face}</span>
|
||||
return (
|
||||
<button type="button" key={m.user_id} title={m.username} aria-label={m.username} onClick={() => toggleMember(m.user_id)}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', background: 'none', border: 'none', padding: 0 }}>
|
||||
{face}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canEdit && (
|
||||
<button type="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 type="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: 'calc(11px * var(--fs-scale-caption, 1))', 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: 'calc(10px * var(--fs-scale-caption, 1))', 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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary)', marginTop: 4, paddingTop: 4 }}>
|
||||
<button type="button" onClick={() => setShowUserPicker(false)} style={{ width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// FE-BAGSIDE-001 to FE-BAGSIDE-006
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { BagSidebar } from './PackingListPanelBagSidebar'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
|
||||
const ITEMS = [
|
||||
{ id: 1, name: 'Stove', weight_grams: 300, quantity: 1, bag_id: 10, is_private: 0, owner_id: 1, checked: 0, category: 'Kitchen' },
|
||||
{ id: 2, name: 'Sandwich', weight_grams: 150, quantity: 1, bag_id: null, is_private: 0, owner_id: 1, checked: 0, category: 'Food' },
|
||||
{ id: 3, name: 'Apple', weight_grams: 100, quantity: 1, bag_id: null, is_private: 0, owner_id: 1, checked: 0, category: 'Food' },
|
||||
]
|
||||
|
||||
function buildState(over: Partial<PackingState> = {}): PackingState {
|
||||
return {
|
||||
t: (k: string) => k,
|
||||
bags: [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [], total_weight_grams: 300 }],
|
||||
items: ITEMS,
|
||||
tripId: 1,
|
||||
tripMembers: [],
|
||||
canEdit: true,
|
||||
currentUserId: 1,
|
||||
unassignedWeightGrams: 250,
|
||||
serverWeightsFresh: true,
|
||||
handleDeleteBag: vi.fn(),
|
||||
handleUpdateBag: vi.fn(),
|
||||
handleSetBagMembers: vi.fn(),
|
||||
handleCreateBag: vi.fn(),
|
||||
showAddBag: false,
|
||||
setShowAddBag: vi.fn(),
|
||||
newBagName: '',
|
||||
setNewBagName: vi.fn(),
|
||||
...over,
|
||||
} as unknown as PackingState
|
||||
}
|
||||
|
||||
describe('BagSidebar', () => {
|
||||
it('FE-BAGSIDE-001: the head band carries the title and a short add button', () => {
|
||||
const S = buildState()
|
||||
render(<BagSidebar {...S} />)
|
||||
|
||||
const add = screen.getByRole('button', { name: 'packing.addBag' })
|
||||
expect(add).toHaveTextContent('packing.bags')
|
||||
fireEvent.click(add)
|
||||
expect(S.setShowAddBag).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-BAGSIDE-002: what sits in no bag is one row with its count as a badge', () => {
|
||||
render(<BagSidebar {...buildState()} />)
|
||||
|
||||
const badge = screen.getByTitle('2 admin.packingTemplates.items')
|
||||
expect(badge).toHaveTextContent(/^2$/)
|
||||
expect(screen.getByText('packing.noBag')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-BAGSIDE-003: the composer adds on Enter and on its button, once there is a name', () => {
|
||||
const S = buildState({ showAddBag: true, newBagName: 'Daypack' } as Partial<PackingState>)
|
||||
render(<BagSidebar {...S} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'packing.addBag' })).toBeNull()
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
expect(S.handleCreateBag).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-BAGSIDE-004: Escape and the cross both close the composer and drop the name', () => {
|
||||
const S = buildState({ showAddBag: true, newBagName: 'Daypack' } as Partial<PackingState>)
|
||||
render(<BagSidebar {...S} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(S.setShowAddBag).toHaveBeenCalledWith(false)
|
||||
expect(S.setNewBagName).toHaveBeenCalledWith('')
|
||||
expect(S.setShowAddBag).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-BAGSIDE-005: the confirm waits for a name', () => {
|
||||
render(<BagSidebar {...buildState({ showAddBag: true, newBagName: ' ' } as Partial<PackingState>)} />)
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-BAGSIDE-006: the dashed plus beside a bag opens who carries it, or says there is nobody', () => {
|
||||
render(<BagSidebar {...buildState()} />)
|
||||
|
||||
const plus = document.querySelector<HTMLButtonElement>('button[style*="dashed"]')!
|
||||
fireEvent.click(plus)
|
||||
expect(screen.getByText('packing.assignMembers')).toBeInTheDocument()
|
||||
expect(screen.getByText('packing.noMembers')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ describe('KategorieGruppe — bulk actions', () => {
|
||||
expect(checkAll.style.background).toBe('none')
|
||||
|
||||
fireEvent.mouseEnter(deleteAll)
|
||||
expect(deleteAll.style.background).toBe('rgb(254, 242, 242)')
|
||||
expect(deleteAll.style.background).toBe('rgba(239, 68, 68, 0.1)')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
import type { TripMember, CategoryAssignee } from './usePackingListPanel'
|
||||
import { ArtikelZeile } from './PackingListPanelItemRow'
|
||||
import { PopoverItem } from './PackingPopover'
|
||||
import { COMPOSER, POPOVER, POPOVER_DIVIDER, composerConfirm } from './packingPopoverStyles'
|
||||
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
|
||||
@@ -113,12 +115,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderBottom: offen ? '1px solid var(--border-secondary)' : 'none' }}>
|
||||
<button type="button" onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
|
||||
// Shaped after the phone's category card: a tinted head band carrying the
|
||||
// name as written, the count, and the fold arrow on the far right.
|
||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '11px 12px 11px 14px', background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName && canEdit ? (
|
||||
@@ -127,10 +127,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
onChange={e => setEditKatName(e.target.value)}
|
||||
onBlur={handleSaveKatName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }}
|
||||
style={{ flex: 1, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
|
||||
style={{ flex: 1, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.01em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}>
|
||||
{kategorie}
|
||||
</span>
|
||||
)}
|
||||
@@ -188,11 +188,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
<UserPlus size={10} />
|
||||
</button>
|
||||
{showAssigneeDropdown && (
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: '100%', marginTop: 4, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 160,
|
||||
}}>
|
||||
<div className="trek-menu-enter" style={{ ...POPOVER, position: 'absolute', left: 0, top: '100%', marginTop: 6, zIndex: 50, minWidth: 190 }}>
|
||||
{tripMembers.map(m => {
|
||||
const isAssigned = assignees.some(a => a.user_id === m.id)
|
||||
return (
|
||||
@@ -239,8 +235,9 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
||||
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '2px 8px', borderRadius: 99,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-card)',
|
||||
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
|
||||
}}>
|
||||
{abgehakt}/{items.length}
|
||||
@@ -256,28 +253,32 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
return (
|
||||
<>
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => setShowMenu(false)} />
|
||||
<div style={{ position: 'fixed', right: rect ? window.innerWidth - rect.right : 0, top: rect ? rect.bottom + 4 : 0, zIndex: 100, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}>
|
||||
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
|
||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
<div className="trek-menu-enter" style={{ ...POPOVER, position: 'fixed', right: rect ? window.innerWidth - rect.right : 0, top: rect ? rect.bottom + 6 : 0, zIndex: 100, minWidth: 200 }}>
|
||||
{canEdit && <PopoverItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
|
||||
<PopoverItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||
<PopoverItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
{canEdit && <>
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||
<div style={POPOVER_DIVIDER} />
|
||||
<PopoverItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||
</>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{offen && (
|
||||
<div style={{ padding: '4px 4px 6px' }}>
|
||||
{items.map(item => {
|
||||
{items.map((item, index) => {
|
||||
const contributions = contribFor(item.id)
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit}
|
||||
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
|
||||
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
|
||||
drag={canEdit ? {
|
||||
isDragging: dragId === item.id,
|
||||
@@ -293,7 +294,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
})}
|
||||
{/* Inline add item */}
|
||||
{canEdit && (showAddItem ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
|
||||
// The new item already looks like a row: an empty box in the checkbox
|
||||
// column, the name typed where names sit, and the add as the accent.
|
||||
<div style={{ ...COMPOSER, gap: 9, margin: '6px 4px 0', padding: '4px 4px 4px 27px' }}>
|
||||
<span aria-hidden style={{ width: 20, height: 20, borderRadius: 6, border: '1.5px solid var(--text-faint)', opacity: 0.6, flexShrink: 0 }} />
|
||||
<input
|
||||
ref={addItemRef}
|
||||
autoFocus
|
||||
@@ -308,25 +312,31 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
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: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
|
||||
style={{ flex: 1, minWidth: 0, padding: '5px 0', border: 'none', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'transparent' }}
|
||||
/>
|
||||
{/* disabled while the field is empty, so no extra guard here */}
|
||||
<button type="button" onClick={() => { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
disabled={!newItemName.trim()}
|
||||
style={{ padding: '5px 8px', borderRadius: 8, border: 'none', background: newItemName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newItemName.trim() ? 'pointer' : 'default', display: 'flex' }}>
|
||||
<Plus size={14} />
|
||||
disabled={!newItemName.trim()} aria-label={t('common.add')}
|
||||
style={composerConfirm(!!newItemName.trim())}>
|
||||
<Plus size={15} strokeWidth={2.5} />
|
||||
</button>
|
||||
<button type="button" onClick={() => { setShowAddItem(false); setNewItemName('') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||
<button type="button" onClick={() => { setShowAddItem(false); setNewItemName('') }} aria-label={t('common.cancel')}
|
||||
style={{ width: 28, height: 28, borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', display: 'grid', placeItems: 'center', flexShrink: 0, color: 'var(--text-faint)' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// A row of its own on a faint tint, so it closes the card instead
|
||||
// of trailing off: a dashed box in the checkbox column (past the drag
|
||||
// grip's width) and the label where an item's name sits.
|
||||
<button type="button" onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px', margin: '2px 4px', borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: 'inherit' }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 9, width: 'calc(100% - 8px)', margin: '6px 4px 0', padding: '8px 10px 8px 28px', borderRadius: 10, border: 'none', background: 'var(--bg-secondary)', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Plus size={12} /> {t('packing.addItem')}
|
||||
<span style={{ width: 20, height: 20, borderRadius: 6, border: '1.5px dashed currentColor', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
<Plus size={12} strokeWidth={2.5} />
|
||||
</span>
|
||||
{t('packing.addItem')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -334,26 +344,3 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger = false }: MenuItemProps) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', borderRadius: 7, textAlign: 'left',
|
||||
color: danger ? '#ef4444' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
{icon}{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
X, Check, CheckCheck, Luggage, Package, FolderPlus, Download,
|
||||
CheckCheck, Luggage, Package, FolderPlus, Download, Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import PackingNameDialog from './PackingNameDialog'
|
||||
|
||||
export function PackingHeader(S: PackingState) {
|
||||
const {
|
||||
@@ -26,20 +27,6 @@ export function PackingHeader(S: PackingState) {
|
||||
</div>
|
||||
) : <span />}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{canEdit && isAdmin && items.length > 0 && 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: 'calc(12px * var(--fs-scale-body, 1))', 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 type="button" onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
|
||||
<button type="button" onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
{inlineHeader && canEdit && (
|
||||
<button type="button" onClick={() => setShowImportModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
@@ -122,8 +109,9 @@ export function PackingHeader(S: PackingState) {
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}>
|
||||
<div className="flex items-center" style={{ gap: 14 }}>
|
||||
// The phone's progress card: count and share on the left, the bar filling the rest.
|
||||
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 2, padding: '14px 18px', borderRadius: 16, background: 'var(--bg-card)', border: '1px solid var(--border-secondary)' }}>
|
||||
<div className="flex items-center" style={{ gap: 16 }}>
|
||||
{fortschritt === 100 ? (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -158,7 +146,7 @@ export function PackingHeader(S: PackingState) {
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: 8,
|
||||
height: 7,
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: 99,
|
||||
overflow: 'hidden',
|
||||
@@ -184,36 +172,53 @@ export function PackingHeader(S: PackingState) {
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Under the Lists bar the clean-up sits with the count it clears. */}
|
||||
{!inlineHeader && canEdit && abgehakt > 0 && (
|
||||
<button type="button" onClick={handleClearChecked} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5, flexShrink: 0, padding: '5px 11px', borderRadius: 99,
|
||||
border: 'none', background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
}}>
|
||||
<Trash2 size={13} strokeWidth={2.2} />
|
||||
{t('packing.clearChecked', { count: abgehakt })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && (addingCategory ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
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: 'calc(13.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button type="button" onClick={handleAddNewCategory} disabled={!newCatName.trim()}
|
||||
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newCatName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newCatName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }}
|
||||
style={{ padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
{canEdit && inlineHeader && (
|
||||
<button type="button" onClick={() => setAddingCategory(true)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%', padding: '10px 14px', borderRadius: 99, border: '1.5px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<FolderPlus size={14} /> {t('packing.addCategory')}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Naming a new list or a template happens in a dialog of its own, not in a
|
||||
field pushed into the page. */}
|
||||
<PackingNameDialog
|
||||
open={canEdit && addingCategory}
|
||||
title={t('packing.addCategory')}
|
||||
placeholder={t('packing.newCategoryPlaceholder')}
|
||||
confirmLabel={t('common.add')}
|
||||
value={newCatName}
|
||||
onChange={setNewCatName}
|
||||
onConfirm={handleAddNewCategory}
|
||||
onClose={() => { setAddingCategory(false); setNewCatName('') }}
|
||||
/>
|
||||
<PackingNameDialog
|
||||
open={canEdit && isAdmin && items.length > 0 && showSaveTemplate}
|
||||
title={t('packing.saveAsTemplate')}
|
||||
placeholder={t('packing.templateName')}
|
||||
confirmLabel={t('common.save')}
|
||||
value={saveTemplateName}
|
||||
onChange={setSaveTemplateName}
|
||||
onConfirm={handleSaveAsTemplate}
|
||||
onClose={() => { setShowSaveTemplate(false); setSaveTemplateName('') }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ function fieldOf(container: HTMLElement, label: string) {
|
||||
return within(menu(container).getByText(label).parentElement!).getByRole('textbox', { hidden: true })
|
||||
}
|
||||
|
||||
/** Opens the item menu and its Move to List entry. */
|
||||
function openMoveToList(container: HTMLElement) {
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
fireEvent.click(menuButton(container, 'Move to List'))
|
||||
}
|
||||
|
||||
function bagButton(container: HTMLElement) {
|
||||
// The bag picker trigger is the round button right after the weight field.
|
||||
return container.querySelectorAll<HTMLButtonElement>('button[style*="border-radius: 50%"]')[0]
|
||||
@@ -92,19 +98,21 @@ describe('ArtikelZeile — basics', () => {
|
||||
const { container } = setup()
|
||||
|
||||
expect(screen.getByText('Tent')).toBeInTheDocument()
|
||||
fireEvent.click(container.querySelector('svg.lucide-square')!.closest('button')!)
|
||||
const box = container.querySelector<HTMLButtonElement>('.packing-check')!
|
||||
expect(box).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(box)
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ checked: true }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-002: a checked item is struck through and not editable by click', () => {
|
||||
it('FE-W5ROW-002: a checked item is struck through and still renames on a click', () => {
|
||||
setup({ item: buildPackingItem({ id: 1, name: 'Tent', checked: 1 }) })
|
||||
const label = screen.getByText('Tent')
|
||||
|
||||
expect(label).toHaveStyle({ textDecoration: 'line-through' })
|
||||
fireEvent.click(label)
|
||||
|
||||
expect(screen.queryByDisplayValue('Tent')).toBeNull()
|
||||
expect(screen.getByDisplayValue('Tent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-003: the placeholder row edits from an empty field', () => {
|
||||
@@ -126,19 +134,43 @@ describe('ArtikelZeile — basics', () => {
|
||||
expect(screen.queryByDisplayValue('Tent')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-005: hovering the row lifts its background and dismisses open pickers', () => {
|
||||
it('FE-W5ROW-005: hovering the row lifts its background and brings its controls up', () => {
|
||||
const { container } = setup()
|
||||
const row = container.querySelector<HTMLElement>('.packing-item-row')!
|
||||
const actions = container.querySelector<HTMLElement>('.packing-row-overflow')!
|
||||
expect(actions.style.opacity).toBe('0.4')
|
||||
|
||||
fireEvent.mouseEnter(row)
|
||||
expect(row.style.background).toBe('var(--bg-secondary)')
|
||||
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
|
||||
expect(actions.style.opacity).toBe('1')
|
||||
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(row.style.background).toBe('transparent')
|
||||
expect(screen.queryByRole('button', { name: 'Docs' })).toBeNull()
|
||||
expect(actions.style.opacity).toBe('0.4')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-005b: keyboard focus inside the row counts as hovering it', () => {
|
||||
const { container } = setup()
|
||||
const actions = container.querySelector<HTMLElement>('.packing-row-overflow')!
|
||||
|
||||
fireEvent.focus(overflowTrigger(container))
|
||||
expect(actions.style.opacity).toBe('1')
|
||||
|
||||
fireEvent.blur(overflowTrigger(container), { relatedTarget: document.body })
|
||||
expect(actions.style.opacity).toBe('0.4')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-005c: the bag picker stays open when the pointer leaves and closes on a click beside it', () => {
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
const row = container.querySelector<HTMLElement>('.packing-item-row')!
|
||||
fireEvent.click(bagButton(container))
|
||||
expect(screen.getByRole('button', { name: 'Trolley' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(screen.getByRole('button', { name: 'Trolley' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(container.querySelector('div[role="presentation"]')!)
|
||||
expect(screen.queryByRole('button', { name: 'Trolley' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,7 +204,7 @@ describe('ArtikelZeile — renaming', () => {
|
||||
}),
|
||||
)
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Rename'))
|
||||
fireEvent.click(screen.getByText('Tent'))
|
||||
|
||||
const input = screen.getByDisplayValue('Tent')
|
||||
fireEvent.change(input, { target: { value: ' Tarp ' } })
|
||||
@@ -185,7 +217,7 @@ describe('ArtikelZeile — renaming', () => {
|
||||
let called = false
|
||||
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Rename'))
|
||||
fireEvent.click(screen.getByText('Tent'))
|
||||
|
||||
const input = screen.getByDisplayValue('Tent')
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
@@ -227,7 +259,7 @@ describe('ArtikelZeile — renaming', () => {
|
||||
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Rename'))
|
||||
fireEvent.click(screen.getByText('Tent'))
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Tent'), { target: { value: 'Tarp' } })
|
||||
fireEvent.keyDown(screen.getByDisplayValue('Tarp'), { key: 'Enter' })
|
||||
@@ -265,16 +297,10 @@ describe('ArtikelZeile — deleting', () => {
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-013: the rename and delete buttons highlight on hover', () => {
|
||||
it('FE-W5ROW-013: the delete button turns red on hover', () => {
|
||||
setup()
|
||||
const rename = screen.getByTitle('Rename')
|
||||
const remove = screen.getByTitle('Delete')
|
||||
|
||||
fireEvent.mouseEnter(rename)
|
||||
expect(rename.style.color).toBe('var(--text-secondary)')
|
||||
fireEvent.mouseLeave(rename)
|
||||
expect(rename.style.color).toBe('var(--text-faint)')
|
||||
|
||||
fireEvent.mouseEnter(remove)
|
||||
expect(remove.style.color).toBe('rgb(239, 68, 68)')
|
||||
fireEvent.mouseLeave(remove)
|
||||
@@ -291,8 +317,8 @@ describe('ArtikelZeile — category picker', () => {
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1, category: 'Docs' }) })
|
||||
}),
|
||||
)
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
const { container } = setup()
|
||||
openMoveToList(container)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Docs' }))
|
||||
|
||||
@@ -303,8 +329,8 @@ describe('ArtikelZeile — category picker', () => {
|
||||
it('FE-W5ROW-015: picking the current list closes the picker without a request', async () => {
|
||||
let called = false
|
||||
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
const { container } = setup()
|
||||
openMoveToList(container)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Gear' }))
|
||||
|
||||
@@ -314,8 +340,8 @@ describe('ArtikelZeile — category picker', () => {
|
||||
|
||||
it('FE-W5ROW-016: a failing move surfaces a generic error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
const { container } = setup()
|
||||
openMoveToList(container)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Docs' }))
|
||||
|
||||
@@ -323,11 +349,11 @@ describe('ArtikelZeile — category picker', () => {
|
||||
})
|
||||
|
||||
it('FE-W5ROW-017: an uncategorized item marks the default list as current', () => {
|
||||
setup({
|
||||
const { container } = setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Soap', category: null }),
|
||||
categories: ['Other', 'Gear'],
|
||||
})
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
openMoveToList(container)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Other' })).toHaveStyle({ background: 'var(--bg-tertiary)' })
|
||||
expect(screen.getByRole('button', { name: 'Gear' })).not.toHaveStyle({ background: 'var(--bg-tertiary)' })
|
||||
@@ -341,7 +367,7 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByText('by Bob')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('by Bob')).toHaveAttribute('title', 'by Bob')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-019: a nameless bringer degrades to an empty name', () => {
|
||||
@@ -362,8 +388,8 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByText('shared with 2')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('alice, bob')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('shared with 2')).toHaveTextContent('2')
|
||||
expect(screen.getByTitle('shared with 2: alice, bob')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-021: a common item names its bringer and counts co-bringers', () => {
|
||||
@@ -375,7 +401,8 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('Bob +1')
|
||||
// The bringer's initial on an avatar, the co-bringers as a count.
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('B+1')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
|
||||
@@ -384,7 +411,7 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent(/^Bob$/)
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent(/^B$/)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-023: the placeholder row carries no badges and no share control', () => {
|
||||
@@ -403,7 +430,7 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
|
||||
it('FE-W5ROW-024: the share control is wired up for an editable item', () => {
|
||||
const onSetSharing = vi.fn()
|
||||
setup({
|
||||
const { container } = setup({
|
||||
currentUserId: 1,
|
||||
tripMembers: MEMBERS,
|
||||
onSetSharing,
|
||||
@@ -412,7 +439,9 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
onLeave: () => {},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTitle('Sharing'))
|
||||
// Sharing lives in the item menu, as a whole line to click.
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
fireEvent.click(menu(container).getByText('Sharing').closest('button')!)
|
||||
fireEvent.click(screen.getByText(/^Personal$/))
|
||||
|
||||
expect(onSetSharing).toHaveBeenCalledWith(1, 'personal', [])
|
||||
@@ -799,9 +828,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
||||
const add = screen.getByText('Add bag').closest('button')!
|
||||
|
||||
fireEvent.mouseEnter(add)
|
||||
expect(add.style.color).toBe('var(--text-secondary)')
|
||||
expect(add.style.background).toBe('var(--bg-tertiary)')
|
||||
fireEvent.mouseLeave(add)
|
||||
expect(add.style.color).toBe('var(--text-faint)')
|
||||
expect(add.style.background).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -988,9 +1017,7 @@ describe('ArtikelZeile — overflow menu', () => {
|
||||
})
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
expect(screen.getByText('Sharing')).toBeInTheDocument()
|
||||
const triggers = screen.getAllByTitle('Sharing')
|
||||
fireEvent.click(triggers[triggers.length - 1])
|
||||
fireEvent.click(menu(container).getByText('Sharing').closest('button')!)
|
||||
fireEvent.click(screen.getByText(/^Personal$/))
|
||||
|
||||
expect(onSetSharing).toHaveBeenCalledWith(1, 'personal', [])
|
||||
@@ -1015,6 +1042,6 @@ describe('ArtikelZeile — overflow menu', () => {
|
||||
expect(active.style.background).toBe('var(--bg-tertiary)')
|
||||
|
||||
fireEvent.mouseEnter(danger)
|
||||
expect(danger.style.background).toBe('rgb(254, 242, 242)')
|
||||
expect(danger.style.background).toBe('rgba(239, 68, 68, 0.1)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useRef, useState, type CSSProperties } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, UserRound, Users, HandHelping,
|
||||
MoreHorizontal,
|
||||
Check, Trash2, Plus, Pencil, Package, GripVertical, UserRound, HandHelping,
|
||||
MoreHorizontal, ChevronDown, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||||
import { QuantityInput } from './PackingListPanelQuantityInput'
|
||||
import PackingShareControl from './PackingShareControl'
|
||||
import { PopoverItem } from './PackingPopover'
|
||||
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
|
||||
@@ -31,6 +33,8 @@ interface ArtikelZeileProps {
|
||||
onClone?: (id: number) => void
|
||||
onJoin?: (id: number) => void
|
||||
onLeave?: (id: number, userId: number) => void
|
||||
/** Draws the hairline above the row; the category group leaves it off its first row. */
|
||||
divider?: boolean
|
||||
// Drag-to-reorder (#969) — wired by the category group, which owns the order.
|
||||
drag?: {
|
||||
isDragging: boolean
|
||||
@@ -42,12 +46,14 @@ interface ArtikelZeileProps {
|
||||
}
|
||||
}
|
||||
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, drag }: ArtikelZeileProps) {
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
|
||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [showCatPicker, setShowCatPicker] = useState(false)
|
||||
// Keyboard focus anywhere in the row counts as hovering it, so every control
|
||||
// that only shows on hover is also there for someone tabbing through.
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [showBagPicker, setShowBagPicker] = useState(false)
|
||||
const [showItemMenu, setShowItemMenu] = useState(false)
|
||||
const [showMenuCategories, setShowMenuCategories] = useState(false)
|
||||
@@ -83,7 +89,6 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
}
|
||||
|
||||
const handleCatChange = async (cat: string) => {
|
||||
setShowCatPicker(false)
|
||||
setShowMenuCategories(false)
|
||||
setShowItemMenu(false)
|
||||
if (cat === item.category) return
|
||||
@@ -93,13 +98,28 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
|
||||
const canDrag = canEdit && !isPlaceholder && !!drag
|
||||
const selectedBag = bags.find(b => b.id === item.bag_id)
|
||||
const quantity = item.quantity || 1
|
||||
const owner = tripMembers.find(m => m.id === item.owner_id)
|
||||
const ownerAvatar = owner?.avatar_url ?? owner?.avatar ?? null
|
||||
|
||||
// At rest a row reads as what it holds (owner, a quantity above one, a
|
||||
// weight, the bag) and everything else stays in place but dimmed: nothing
|
||||
// leaves a hole and nothing jumps. Pointer or focus brings it all up.
|
||||
const active = hovered || focused || showItemMenu || showBagPicker
|
||||
const reveal = (visible: boolean): CSSProperties => ({ opacity: visible ? 1 : 0.4, transition: 'opacity 120ms ease' })
|
||||
// Quantity and weight read as quiet pills; under the pointer an outline
|
||||
// shows they can be typed into.
|
||||
const badge = (visible: boolean): CSSProperties => ({
|
||||
...reveal(visible), background: 'var(--bg-tertiary)', borderRadius: 99, padding: '2px 8px',
|
||||
borderColor: active ? 'var(--border-primary)' : 'transparent',
|
||||
})
|
||||
|
||||
// Shared by both shells of the name: a renameable name is a real button so
|
||||
// the rename can be reached with the keyboard, everything else is plain text.
|
||||
const nameStyle: CSSProperties = {
|
||||
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
fontSize: 'calc(13.5px * var(--fs-scale-body, 1))',
|
||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||
fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: canEdit ? 'text' : 'default',
|
||||
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
|
||||
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
textDecoration: item.checked ? 'line-through' : 'none',
|
||||
@@ -109,19 +129,22 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
<div
|
||||
className="group packing-item-row"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false) }}
|
||||
onDragOver={canDrag ? (e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) }) : undefined}
|
||||
onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined}
|
||||
onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 10px', borderRadius: 10, position: 'relative',
|
||||
display: 'flex', alignItems: 'center', gap: 9,
|
||||
padding: '8px 10px', borderRadius: 10, position: 'relative',
|
||||
background: hovered ? 'var(--bg-secondary)' : 'transparent',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none',
|
||||
transition: 'background 0.1s, opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{divider && !hovered && <span aria-hidden className="pointer-events-none absolute inset-x-2.5 top-0 h-px bg-edge-faint" />}
|
||||
{canDrag && (
|
||||
<div
|
||||
draggable
|
||||
@@ -133,20 +156,16 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
<GripVertical size={13} />
|
||||
</div>
|
||||
)}
|
||||
<button type="button" onClick={handleToggle} style={{
|
||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
|
||||
width: 18, height: 18,
|
||||
color: item.checked ? '#10b981' : 'var(--text-faint)',
|
||||
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
{/* The phone's box: filled with the accent once packed, an outline until then. */}
|
||||
<button type="button" onClick={handleToggle} className="packing-check" aria-pressed={!!item.checked} style={{
|
||||
flexShrink: 0, cursor: 'pointer', padding: 0, width: 20, height: 20, borderRadius: 6,
|
||||
display: 'grid', placeItems: 'center',
|
||||
border: `1.5px solid ${item.checked ? 'var(--accent)' : 'var(--text-faint)'}`,
|
||||
background: item.checked ? 'var(--accent)' : 'transparent',
|
||||
color: 'var(--accent-text)',
|
||||
transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}>
|
||||
<Square size={18} style={{
|
||||
position: 'absolute', inset: 0,
|
||||
opacity: item.checked ? 0 : 1,
|
||||
transform: item.checked ? 'scale(0.7)' : 'scale(1)',
|
||||
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} />
|
||||
<CheckSquare size={18} style={{
|
||||
position: 'absolute', inset: 0,
|
||||
<Check size={12} strokeWidth={3} style={{
|
||||
opacity: item.checked ? 1 : 0,
|
||||
transform: item.checked ? 'scale(1)' : 'scale(0.5)',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 220ms cubic-bezier(0.34,1.56,0.64,1)',
|
||||
@@ -162,7 +181,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : canEdit && !item.checked ? (
|
||||
) : canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
@@ -176,34 +195,44 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Sharing badges (#858 three-tier) */}
|
||||
{/* Sharing badges (#858 three-tier), as compact chips like the phone's
|
||||
(#1525): the full sentence is the tooltip and the accessible name. */}
|
||||
{!isPlaceholder && sharedToMe && (
|
||||
<span className="packing-row-badge" title={t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)', padding: '1px 7px', borderRadius: 99 }}>
|
||||
<HandHelping size={10} /> {t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
<span className="packing-row-badge" title={t('packing.takenCareOf', { name: item.owner_username || '' })} aria-label={t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, width: 22, height: 22, borderRadius: 99, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)' }}>
|
||||
<HandHelping size={12} />
|
||||
</span>
|
||||
)}
|
||||
{!isPlaceholder && sharedByMe && (
|
||||
<span className="packing-row-badge" title={recipients.map(r => r.username).join(', ')}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 7px', borderRadius: 99 }}>
|
||||
<UserRound size={10} /> {t('packing.sharedWithCount', { count: recipients.length })}
|
||||
<span className="packing-row-badge" title={`${t('packing.sharedWithCount', { count: recipients.length })}: ${recipients.map(r => r.username).join(', ')}`} aria-label={t('packing.sharedWithCount', { count: recipients.length })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, height: 22, padding: '0 7px', borderRadius: 99, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', background: 'var(--bg-tertiary)' }}>
|
||||
<UserRound size={11} /> {recipients.length}
|
||||
</span>
|
||||
)}
|
||||
{!isPlaceholder && broughtBy && (
|
||||
<span className="packing-row-badge" title={t('packing.broughtBy', { name: broughtBy })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '1px 4px' }}>
|
||||
<Users size={10} /> {broughtBy}{contributors.length > 0 ? ` +${contributors.length}` : ''}
|
||||
<span className="packing-row-badge" title={t('packing.broughtBy', { name: broughtBy })} aria-label={t('packing.broughtBy', { name: broughtBy })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
|
||||
{ownerAvatar
|
||||
? <img src={ownerAvatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover', display: 'block' }} />
|
||||
: (
|
||||
<span aria-hidden style={{
|
||||
width: 20, height: 20, borderRadius: '50%', display: 'grid', placeItems: 'center',
|
||||
background: `hsl(${(broughtBy.codePointAt(0) ?? 0) * 37 % 360}, 55%, 55%)`, // theme-lint-disable: the colour comes from the bringer's name
|
||||
color: 'white', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase',
|
||||
}}>{broughtBy[0]}</span>
|
||||
)}
|
||||
{contributors.length > 0 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)' }}>+{contributors.length}</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="packing-row-inline-actions" style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
{/* Quantity */}
|
||||
{canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />}
|
||||
{canEdit && <QuantityInput value={quantity} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} style={badge(active || quantity > 1)} />}
|
||||
|
||||
{/* Weight + Bag (when enabled) */}
|
||||
{bagTrackingEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', ...badge(active || item.weight_grams != null) }}>
|
||||
<NumericInput
|
||||
value={item.weight_grams ?? ''}
|
||||
readOnly={!canEdit}
|
||||
@@ -224,127 +253,65 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: item.bag_id ? `2.5px solid ${selectedBag?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
|
||||
background: item.bag_id ? `${selectedBag?.color || 'var(--border-primary)'}30` : 'transparent',
|
||||
opacity: item.bag_id || active ? 1 : 0.45, transition: 'opacity 120ms ease',
|
||||
}}
|
||||
>
|
||||
{!item.bag_id && <Package size={9} className="text-content-faint" />}
|
||||
</button>
|
||||
{showBagPicker && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: '100%', marginTop: 4, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 160,
|
||||
}}>
|
||||
<>
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 49 }} onClick={() => { setShowBagPicker(false); setBagInlineCreate(false); setBagInlineName('') }} />
|
||||
<div className="trek-menu-enter" style={{ ...POPOVER, position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50, width: 210 }}>
|
||||
<div style={POPOVER_CAPTION}>{t('packing.bags')}</div>
|
||||
{item.bag_id && (
|
||||
<button type="button" onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch { toast.error(t('packing.toast.saveError')) } }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)' }} />
|
||||
{t('packing.noBag')}
|
||||
</button>
|
||||
<PopoverItem icon={<span style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', display: 'inline-block' }} />} label={t('packing.noBag')}
|
||||
onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch { toast.error(t('packing.toast.saveError')) } }} />
|
||||
)}
|
||||
{bags.map(b => (
|
||||
<button type="button" key={b.id} onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch { toast.error(t('packing.toast.saveError')) } }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px',
|
||||
background: item.bag_id === b.id ? 'var(--bg-tertiary)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7,
|
||||
}}
|
||||
onMouseEnter={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||
onMouseLeave={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'none' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, flexShrink: 0 }} />
|
||||
{b.name}
|
||||
</button>
|
||||
<PopoverItem key={b.id} icon={<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, display: 'inline-block' }} />} label={b.name} active={item.bag_id === b.id}
|
||||
onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch { toast.error(t('packing.toast.saveError')) } }} />
|
||||
))}
|
||||
{bags.length > 0 && <div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />}
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
{bagInlineCreate ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input autoFocus value={bagInlineName} onChange={e => 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 { toast.error(t('packing.toast.saveError')) } }
|
||||
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: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: 'inherit', outline: 'none' }} />
|
||||
<button type="button" onClick={async () => {
|
||||
if (bagInlineName.trim()) {
|
||||
{bags.length > 0 && <div style={POPOVER_DIVIDER} />}
|
||||
{bagInlineCreate ? (
|
||||
<div style={{ display: 'flex', gap: 6, padding: 4 }}>
|
||||
<input autoFocus value={bagInlineName} onChange={e => 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 { toast.error(t('packing.toast.saveError')) } }
|
||||
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
|
||||
}
|
||||
if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') }
|
||||
}}
|
||||
style={{ padding: '3px 6px', borderRadius: 6, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
<Plus size={11} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setBagInlineCreate(true)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 5, width: '100%', padding: '5px 6px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Plus size={11} /> {t('packing.addBag')}
|
||||
placeholder={t('packing.bagName')}
|
||||
style={{ flex: 1, minWidth: 0, padding: '6px 9px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }} />
|
||||
<button type="button" aria-label={t('common.add')} onClick={async () => {
|
||||
if (bagInlineName.trim()) {
|
||||
const newBag = await onCreateBag(bagInlineName.trim())
|
||||
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch { toast.error(t('packing.toast.saveError')) } }
|
||||
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
|
||||
}
|
||||
}}
|
||||
style={{ width: 30, borderRadius: 8, border: 'none', background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', display: 'grid', placeItems: 'center', flexShrink: 0, opacity: bagInlineName.trim() ? 1 : 0.35 }}>
|
||||
<Plus size={13} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PopoverItem icon={<Plus size={13} />} label={t('packing.addBag')} muted onClick={() => setBagInlineCreate(true)} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deleting stays one click away; moving, sharing and renaming live in the
|
||||
menu next to it, and a click on the name renames as well. */}
|
||||
{canEdit && (
|
||||
<div className="packing-row-inline-actions" style={{ display: 'flex', gap: 2, alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button"
|
||||
onClick={() => setShowCatPicker(p => !p)}
|
||||
title={t('packing.changeCategory')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', gap: 2 }}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />
|
||||
</button>
|
||||
{showCatPicker && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
|
||||
padding: 4, minWidth: 140,
|
||||
}}>
|
||||
{categories.map(cat => (
|
||||
<button type="button" key={cat} onClick={() => handleCatChange(cat)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
|
||||
padding: '6px 10px', background: cat === (item.category || t('packing.defaultCategory')) ? 'var(--bg-tertiary)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit',
|
||||
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), flexShrink: 0 }} />
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canShare && onClone && onJoin && onLeave && (
|
||||
<PackingShareControl
|
||||
item={item}
|
||||
tripMembers={tripMembers}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={onSetSharing!}
|
||||
onClone={onClone}
|
||||
onJoin={onJoin}
|
||||
onLeave={onLeave}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={handleDelete} title={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
<div className="packing-row-inline-actions" style={{ display: 'flex', gap: 2, alignItems: 'center', flexShrink: 0, ...reveal(active) }}>
|
||||
<button type="button" onClick={handleDelete} title={t('common.delete')} aria-label={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
@@ -352,12 +319,13 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="packing-row-overflow" style={{ display: 'none', flexShrink: 0, position: 'relative' }}>
|
||||
<div className="packing-row-overflow" style={{ display: 'flex', flexShrink: 0, position: 'relative', ...reveal(active) }}>
|
||||
<button type="button"
|
||||
ref={itemMenuBtnRef}
|
||||
onClick={() => setShowItemMenu(m => !m)}
|
||||
title={t('common.showMore')}
|
||||
style={{ width: 30, height: 30, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)', padding: 0 }}
|
||||
aria-label={t('common.showMore')} aria-expanded={showItemMenu}
|
||||
style={{ width: 26, height: 26, borderRadius: 7, border: 'none', background: showItemMenu ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)', padding: 0 }}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
@@ -367,19 +335,17 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
<>
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 1098 }} onClick={() => { setShowItemMenu(false); setShowMenuCategories(false) }} />
|
||||
<div className="trek-menu-enter" style={{
|
||||
...POPOVER,
|
||||
position: 'fixed',
|
||||
right: rect ? Math.max(8, window.innerWidth - rect.right) : 8,
|
||||
top: rect ? rect.bottom + 4 : 0,
|
||||
top: rect ? rect.bottom + 6 : 0,
|
||||
zIndex: 1099,
|
||||
width: 'min(260px, calc(100vw - 16px))',
|
||||
width: 'min(240px, calc(100vw - 16px))',
|
||||
maxHeight: '70vh',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 8px 28px rgba(0,0,0,0.18)',
|
||||
padding: 6,
|
||||
}}>
|
||||
{/* Quantity, weight and bag sit in the row itself on a wide screen. */}
|
||||
<div className="packing-menu-phone-only">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0 }}>{t('packing.quantity')}</span>
|
||||
<QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />
|
||||
@@ -405,13 +371,13 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '2px 0' }}>
|
||||
<OverflowMenuItem icon={<Package size={13} />} label={selectedBag?.name || t('packing.noBag')} onClick={async () => {
|
||||
<PopoverItem icon={<Package size={13} />} label={selectedBag?.name || t('packing.noBag')} onClick={async () => {
|
||||
if (item.bag_id) {
|
||||
try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch { toast.error(t('packing.toast.saveError')) }
|
||||
}
|
||||
}} />
|
||||
{bags.map(b => (
|
||||
<OverflowMenuItem key={b.id} icon={<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, display: 'inline-block' }} />} label={b.name} active={item.bag_id === b.id} onClick={async () => {
|
||||
<PopoverItem key={b.id} icon={<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, display: 'inline-block' }} />} label={b.name} active={item.bag_id === b.id} onClick={async () => {
|
||||
setShowItemMenu(false)
|
||||
try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch { toast.error(t('packing.toast.saveError')) }
|
||||
}} />
|
||||
@@ -420,34 +386,35 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<OverflowMenuItem icon={<span style={{ width: 9, height: 9, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />} label={t('packing.changeCategory')} onClick={() => setShowMenuCategories(v => !v)} />
|
||||
<div style={POPOVER_DIVIDER} />
|
||||
</div>
|
||||
<PopoverItem icon={<span style={{ width: 9, height: 9, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />} label={t('packing.changeCategory')} onClick={() => setShowMenuCategories(v => !v)}
|
||||
trailing={showMenuCategories ? <ChevronDown size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} /> : <ChevronRight size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />} />
|
||||
{showMenuCategories && (
|
||||
<div style={{ padding: '2px 0 4px 18px' }}>
|
||||
<div style={{ margin: '2px 0 4px 18px', paddingLeft: 6, borderLeft: '1px solid var(--border-faint)' }}>
|
||||
{categories.map(cat => (
|
||||
<OverflowMenuItem key={cat} icon={<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), display: 'inline-block' }} />} label={cat} active={cat === (item.category || t('packing.defaultCategory'))} onClick={() => handleCatChange(cat)} />
|
||||
<PopoverItem key={cat} icon={<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), display: 'inline-block' }} />} label={cat} active={cat === (item.category || t('packing.defaultCategory'))} onClick={() => handleCatChange(cat)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canShare && onClone && onJoin && onLeave && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
|
||||
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', color: 'var(--text-secondary)' }}>{t('packing.share')}</span>
|
||||
<PackingShareControl
|
||||
item={item}
|
||||
tripMembers={tripMembers}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={onSetSharing!}
|
||||
onClone={onClone}
|
||||
onJoin={onJoin}
|
||||
onLeave={onLeave}
|
||||
/>
|
||||
</div>
|
||||
<PackingShareControl
|
||||
variant="menu"
|
||||
item={item}
|
||||
tripMembers={tripMembers}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={onSetSharing!}
|
||||
onClone={onClone}
|
||||
onJoin={onJoin}
|
||||
onLeave={onLeave}
|
||||
onAction={() => setShowItemMenu(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<OverflowMenuItem icon={<Pencil size={13} />} label={t('common.rename')} onClick={() => { setEditing(true); setShowItemMenu(false) }} />
|
||||
<OverflowMenuItem icon={<Trash2 size={13} />} label={t('common.delete')} danger onClick={() => { setShowItemMenu(false); handleDelete() }} />
|
||||
<div style={POPOVER_DIVIDER} />
|
||||
<PopoverItem icon={<Pencil size={13} />} label={t('common.rename')} onClick={() => { setEditing(true); setShowItemMenu(false) }} />
|
||||
<PopoverItem icon={<Trash2 size={13} />} label={t('common.delete')} danger onClick={() => { setShowItemMenu(false); handleDelete() }} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -457,31 +424,3 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface OverflowMenuItemProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
function OverflowMenuItem({ icon, label, onClick, active = false, danger = false }: OverflowMenuItemProps) {
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 9px', borderRadius: 7, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--bg-tertiary)' : 'none',
|
||||
color: danger ? '#ef4444' : 'var(--text-secondary)',
|
||||
fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<span style={{ width: 14, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{icon}</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, type CSSProperties } from 'react'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
|
||||
export function QuantityInput({ value, onSave }: { value: number; onSave: (qty: number) => void }) {
|
||||
/** `style` lays over the frame, for a row that wants the field quieter at rest. */
|
||||
export function QuantityInput({ value, onSave, style }: { value: number; onSave: (qty: number) => void; style?: CSSProperties }) {
|
||||
const [local, setLocal] = useState(String(value))
|
||||
useEffect(() => setLocal(String(value)), [value])
|
||||
|
||||
@@ -12,7 +13,7 @@ export function QuantityInput({ value, onSave }: { value: number; 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 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', flexShrink: 0, ...style }}>
|
||||
<NumericInput
|
||||
value={local}
|
||||
onValueChange={setLocal}
|
||||
|
||||
@@ -3,61 +3,57 @@ import type { PackingState } from './usePackingListPanel'
|
||||
|
||||
/**
|
||||
* One tab row: the three-tier view switch (Gemeinsam / Meine Liste, #858) on the
|
||||
* left, and the all/open/done filter on the right, separated by a vertical rule
|
||||
* and sharing the same height. Left-aligned with the list content.
|
||||
* left, and the all/open/done filter on the right. Each sits in its own tinted
|
||||
* track, the way the phone shows them, so the row reads as two questions
|
||||
* (whose list, which state) rather than five pills of equal weight.
|
||||
*/
|
||||
export function PackingViewTabs(S: PackingState) {
|
||||
const { view, setView, filter, setFilter, t, items } = S
|
||||
const commonCount = items.filter(i => !i.is_private).length
|
||||
const personalCount = items.filter(i => !!i.is_private).length
|
||||
|
||||
const pillBase: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, height: 30, padding: '0 12px', borderRadius: 999,
|
||||
border: '1px solid', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, transition: 'all 0.12s',
|
||||
const track: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: 3, borderRadius: 999, background: 'var(--bg-tertiary)',
|
||||
}
|
||||
const segment = (active: boolean): React.CSSProperties => ({
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, height: 28, padding: '0 13px', borderRadius: 999,
|
||||
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, transition: 'all 0.12s',
|
||||
background: active ? 'var(--text-primary)' : 'transparent',
|
||||
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.12)' : 'none',
|
||||
})
|
||||
|
||||
const viewPill = (id: 'common' | 'personal', icon: React.ReactNode, label: string, count: number) => {
|
||||
const active = view === id
|
||||
return (
|
||||
<button type="button" onClick={() => setView(id)} style={{
|
||||
...pillBase,
|
||||
background: active ? 'var(--text-primary)' : 'transparent',
|
||||
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: active ? 'var(--bg-primary)' : 'var(--text-secondary)',
|
||||
}}>
|
||||
<button type="button" onClick={() => setView(id)} style={segment(active)}>
|
||||
{icon}{label}
|
||||
<span style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, borderRadius: 99, padding: '0 6px',
|
||||
background: active ? 'var(--bg-primary)' : 'var(--bg-tertiary)',
|
||||
background: active ? 'var(--bg-primary)' : 'var(--bg-card)',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const filterPill = (id: string, label: string) => {
|
||||
const active = filter === id
|
||||
return (
|
||||
<button type="button" key={id} onClick={() => setFilter(id)} style={{
|
||||
...pillBase, gap: 0, border: '1px solid transparent', fontWeight: active ? 600 : 400,
|
||||
background: active ? 'var(--text-primary)' : 'transparent',
|
||||
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>{label}</button>
|
||||
)
|
||||
}
|
||||
const filterPill = (id: string, label: string) => (
|
||||
<button type="button" key={id} onClick={() => setFilter(id)} style={segment(filter === id)}>{label}</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '10px 0 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{viewPill('common', <Users size={14} />, t('packing.viewCommon'), commonCount)}
|
||||
{viewPill('personal', <UserRound size={14} />, t('packing.viewPersonal'), personalCount)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 0 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
<div style={track}>
|
||||
{viewPill('common', <Users size={14} />, t('packing.viewCommon'), commonCount)}
|
||||
{viewPill('personal', <UserRound size={14} />, t('packing.viewPersonal'), personalCount)}
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<span style={{ alignSelf: 'stretch', width: 1, background: 'var(--border-primary)', margin: '3px 4px' }} />
|
||||
<div style={track}>
|
||||
{filterPill('alle', t('packing.filterAll'))}
|
||||
{filterPill('offen', t('packing.filterOpen'))}
|
||||
{filterPill('erledigt', t('packing.filterDone'))}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||