Files
TREK/client/src/components/Trips/TripFormModal.tsx
T
Maurice bd58f17ec0 feat(trip-settings): edit the trip inside its settings, in one look across the areas and the trip form
Edit trip is an area of the settings dialog, beside People, Sharing and
Journal, instead of a second dialog. The side column names the areas only, the
dialog keeps one height, and every area uses the same cards, fields and
buttons; the trip form on its own takes the same head, cards and buttons. A
trip that loads quickly opens without the splash on the desktop too.
2026-11-13 12:40:16 +01:00

631 lines
30 KiB
TypeScript

import { useId, useState, useEffect, useRef } from 'react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
import { CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts'
import { CalendarDays, Camera, Image as ImageIcon, Pencil, Plane, Search, Type, X } from 'lucide-react'
import { tripsApi, authApi } from '../../api/client'
import CustomSelect from '../shared/CustomSelect'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { type Trip } from '../../types'
import { MAX_TRIP_DAYS, addIsoDays, tripSpanDays, type TripCreateRequest } from '@trek/shared'
import { NumericInput } from '../shared/NumericInput'
import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants'
import TripDateReview, { dateReviewIsWide } from './TripDateReview'
import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard'
import type { ShiftMode } from '../../utils/dayImpactLines'
import { useTripCoverPicker } from './useTripCoverPicker'
import { tripFormError } from './tripFormModel'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { tripChangeNeedsConnection } from '../../repo/tripRepo'
type DateShiftMode = ShiftMode
type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode }
/**
* A save held back for a look first: the start of a dated trip moved and the
* bookings need a direction (#1288), or the new range removes days with
* something on them. The payload waits here until the traveller confirms.
*/
interface PendingReview {
payload: TripPayload
askShift: boolean
removal: RangeCheck
}
interface TripFormModalProps {
isOpen: boolean
onClose: () => void
// Create returns the new trip (so we can attach members / upload the cover);
// update resolves without a payload.
onSave: (data: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => Promise<{ trip?: Trip } | void> | void
trip: Trip | null
onCoverUpdate?: (tripId: number, coverUrl: string | null) => void
/**
* Drawn inside the trip settings rather than as a dialog of its own (#2811): no
* frame and no Cancel, the settings around it close it. One column there, since
* beside the settings' own side column two would cut the dates and wrap the chips.
*/
embedded?: boolean
}
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate, embedded = false }: TripFormModalProps) {
const isEditing = !!trip
const labelId = useId()
const fileRef = useRef<HTMLInputElement>(null)
const toast = useToast()
const { t } = useTranslation()
const currentUser = useAuthStore(s => s.user)
const defaultCurrency = useSettingsStore(s => s.settings.default_currency) || 'EUR'
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
const can = useCanDo()
const canUploadCover = !isEditing || can('trip_cover_upload', trip)
const canEditTrip = !isEditing || can('trip_edit', trip)
const [formData, setFormData] = useState({
title: '',
description: '',
start_date: '',
end_date: '',
currency: 'EUR',
reminder_days: 0 as number,
day_count: 7 as number | '',
})
const [customReminder, setCustomReminder] = useState(false)
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const cover = useTripCoverPicker({ trip, title: formData.title, onCoverUpdate, releasePreviewOnChange: false })
const {
coverPreview, uploadingCover, searchQuery: coverSearchQuery, setSearchQuery: setCoverSearchQuery,
searchResults: coverSearchResults, searchError: coverSearchError, searching: searchingCover,
} = cover
// Drives the drop zone's hover look. Used to be four handlers writing
// element.style directly, which is how the indigo dragover colour survived
// the move to a configurable accent.
const [coverDragActive, setCoverDragActive] = useState(false)
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
const [memberSelectValue, setMemberSelectValue] = useState('')
const [pendingReview, setPendingReview] = useState<PendingReview | null>(null)
const [dateShiftMode, setDateShiftMode] = useState<DateShiftMode>('keep_bookings')
const rangeGuard = useTripRangeGuard()
const busy = isLoading || rangeGuard.checking
useEffect(() => {
if (trip) {
const rd = trip.reminder_days ?? 3
setFormData({
title: trip.title || '',
description: trip.description || '',
start_date: trip.start_date || '',
end_date: trip.end_date || '',
currency: trip.currency || 'EUR',
reminder_days: rd,
day_count: trip.day_count || 7,
})
setCustomReminder(![0, 1, 3, 9].includes(rd))
cover.resetCover(trip.cover_image || null)
} else {
setFormData({ title: '', description: '', start_date: '', end_date: '', currency: defaultCurrency, reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 })
setCustomReminder(false)
cover.resetCover(null)
}
setSelectedMembers([])
setPendingReview(null)
setDateShiftMode('keep_bookings')
setError('')
setExistingMembers([])
// The planner keeps this modal mounted while it is closed, so nothing may be
// fetched until it is actually open.
if (isOpen) {
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
if (trip) {
tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {})
}
}
}, [trip, isOpen])
useEffect(() => {
if (!trip && isOpen) {
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
}
}, [tripRemindersEnabled])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
const formError = tripFormError({ title: formData.title, startDate: formData.start_date, endDate: formData.end_date }, trip, t)
if (formError) { setError(formError); return }
if (!formData.start_date && !formData.end_date) {
const dc = Number(formData.day_count)
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > MAX_TRIP_DAYS) {
setError(t('dashboard.dayCountRequired')); return
}
}
// The day count goes along only when it says something new. The trip's own
// count goes stale in the planner (adding a day does not reload the trip),
// and sending the stale number trims the day just added off a trip that was
// only renamed. On a trip left without dates the server keeps its days anyway.
const dayCount = Number(formData.day_count)
const sendDayCount = !formData.start_date && !formData.end_date && (!isEditing || dayCount !== trip?.day_count)
const payload: TripPayload = {
title: formData.title.trim(),
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
currency: formData.currency,
reminder_days: formData.reminder_days,
...(sendDayCount ? { day_count: dayCount } : {}),
}
// Moving the start of a dated trip shifts the whole day grid, so let the user
// choose how bookings follow before anything is saved (#1288). End-date-only
// changes don't shift days and dateless transitions have nothing to shift.
if (refusedOffline(payload)) return
const askShift = !!(isEditing && trip?.start_date && trip?.end_date
&& payload.start_date && payload.end_date && payload.start_date !== trip.start_date)
// And say which days the new range removes, with what is on them, before
// they are gone: the server takes the last days by position.
const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null
if (askShift || removal) {
setDateShiftMode('keep_bookings')
setPendingReview({ payload, askShift, removal })
return
}
await performSave(payload)
}
// A new trip, new dates or a new currency are made by the server; a new name
// or description waits in the queue offline like any other change.
const refusedOffline = (payload: TripPayload): boolean => {
if (!isEffectivelyOffline() || (isEditing && !tripChangeNeedsConnection(trip ?? undefined, payload))) return false
setError(t(isEditing ? 'dashboard.tripChangeNeedsConnection' : 'common.needsConnection'))
return true
}
const performSave = async (payload: TripPayload) => {
if (refusedOffline(payload)) return
setIsLoading(true)
try {
const result = await onSave(payload)
const createdTrip = result ? result.trip : undefined
// Add selected members for newly created trips
if (selectedMembers.length > 0 && createdTrip?.id) {
let memberAddFailed = false
for (const userId of selectedMembers) {
const user = allUsers.find(u => u.id === userId)
if (user) {
try { await tripsApi.addMember(createdTrip.id, user.username) } catch { memberAddFailed = true }
}
}
if (memberAddFailed) toast.error(t('trips.memberAddError'))
}
// Upload pending cover for newly created trips
await cover.applyToCreatedTrip(createdTrip?.id)
onClose()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('places.saveError'))
} finally {
setIsLoading(false)
}
}
const handleCoverSelect = cover.selectFile
const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => {
void handleCoverSelect(e.target.files?.[0])
e.target.value = ''
}
const handleCoverSearch = cover.search
const handleUnsplashSelect = cover.selectPhoto
const handleRemoveCover = cover.removeCover
// Paste support for cover image
const handlePaste = (e: React.ClipboardEvent) => {
if (!canUploadCover) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (file) void handleCoverSelect(file)
return
}
}
}
// An edit answers whatever the last save complained about, so the complaint
// goes with it rather than standing over the fixed field until the next save.
const update = (field, value) => { setError(''); setFormData(prev => {
const next = { ...prev, [field]: value }
if (field === 'start_date' && value) {
if (!prev.end_date || prev.end_date < value) {
next.end_date = value
} else if (prev.start_date) {
// Keep the trip's length, counted in UTC calendar days: a local-time
// shift across a DST change used to drop or add a day.
next.end_date = addIsoDays(value, tripSpanDays(prev.start_date, prev.end_date) - 1)
}
}
return next
}) }
/* The dashboard's shapes, in the app's own tokens.
`.trek-dash` deliberately scopes its palette to that page, so none of its
variables are reachable from here — what carries over is the geometry:
generous radii, grouped panels instead of a single stack of labelled
fields, and overline captions rather than sentence-case labels. */
// The dialogs' own language: their fields, eyebrows, panels and buttons, so the
// form reads alike on its own and inside the trip settings.
const inputCls = INPUT
const labelCls = `${LABEL.replace('block ', '')} flex items-center gap-1`
/* Two columns once there is room for them: the form had grown to eight stacked
blocks and the create button sat a full screen below the title. Left is what
the trip looks like, right is when and with whom. One column below md. */
const columnCls = "flex flex-col gap-4"
const panelCls = PANEL
const reviewTitle = pendingReview ? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle') : ''
const buttons = pendingReview ? (
<>
<DialogButton onClick={() => setPendingReview(null)} disabled={busy}>{t('common.back')}</DialogButton>
{/* Removing days with something on them is the one save here that
loses work, so it asks in the danger tone and says so. */}
<DialogButton variant={pendingReview.removal ? 'danger' : 'primary'} disabled={busy}
onClick={() => performSave(pendingReview.askShift ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } : pendingReview.payload)}>
{isLoading ? t('common.saving') : t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')}
</DialogButton>
</>
) : (
<>
{!embedded && <DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>}
<DialogButton variant="primary" onClick={handleSubmit} disabled={busy}>
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
</DialogButton>
</>
)
const content = (
<>
{pendingReview && (
<TripDateReview
askShift={pendingReview.askShift}
shiftMode={dateShiftMode}
onShiftMode={setDateShiftMode}
removal={pendingReview.removal}
error={error}
/>
)}
<form onSubmit={handleSubmit} className={pendingReview ? 'hidden' : 'space-y-4'} onPaste={handlePaste}>
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
<div className={`grid grid-cols-1 gap-x-6 gap-y-4 items-start ${embedded ? '' : 'md:grid-cols-2'}`}>
{/* Left column — what the trip is: its picture, its name, what it is about. */}
<div className={columnCls}>
{/* Cover image — gated by trip_cover_upload permission */}
{canUploadCover && <div className={panelCls}>
<CardHead icon={<ImageIcon size={16} strokeWidth={2} />} title={t('trip.form.coverTitle')} hint={t('trip.form.coverHint')} />
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleCoverChange} />
{coverPreview ? (
<div className={`relative h-[110px] rounded-xl overflow-hidden`}>
<img src={coverPreview} alt="" className="w-full h-full object-cover" />
<div className="absolute bottom-2 end-2 flex gap-1.5">
{/* Chrome sitting on top of a photo, so it is deliberately dark in
both themes rather than following the surface tokens. */}
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
className="flex items-center gap-1 px-2.5 py-[5px] rounded-lg bg-black/55 backdrop-blur-sm text-white text-caption font-semibold">
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<button type="button" onClick={handleRemoveCover} aria-label={t('common.remove')}
className="flex items-center px-2 py-[5px] rounded-lg bg-black/55 backdrop-blur-sm text-white">
<X size={12} />
</button>
</div>
</div>
) : (
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
onDragOver={e => { e.preventDefault(); setCoverDragActive(true) }}
onDragLeave={() => setCoverDragActive(false)}
onDrop={e => { e.preventDefault(); setCoverDragActive(false); const file = e.dataTransfer.files?.[0]; if (file?.type.startsWith('image/')) void handleCoverSelect(file) }}
className={`w-full h-11 border-[1.5px] rounded-[10px] text-[length:calc(13px*var(--fs-scale-body,1))] px-4 border-dashed flex items-center justify-center gap-1.5 transition-colors ${
coverDragActive
? 'border-accent bg-accent-subtle text-content'
: 'border-edge text-content-faint hover:border-edge-secondary hover:text-content-muted'
}`}>
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
<div className="mt-2 flex gap-2">
<input
type="text"
value={coverSearchQuery}
onChange={e => setCoverSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void handleCoverSearch() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className={inputCls}
/>
<button type="button" onClick={handleCoverSearch} disabled={searchingCover || (!coverSearchQuery.trim() && !formData.title.trim())}
className='inline-flex flex-none items-center gap-1.5 whitespace-nowrap rounded-[10px] bg-accent px-4 py-2 text-[length:calc(13px*var(--fs-scale-body,1))] font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50'>
{searchingCover ? <div className="w-4 h-4 border-2 border-edge border-t-content-muted rounded-full animate-spin" /> : <Search size={14} />}
{t('dashboard.searchUnsplash')}
</button>
</div>
{coverSearchError && <p className="text-caption text-danger mt-1.5">{coverSearchError}</p>}
{coverSearchResults.length > 0 && (
<div className="grid grid-cols-3 gap-2 mt-2">
{coverSearchResults.map(photo => (
<button
type="button"
key={photo.id}
onClick={() => handleUnsplashSelect(photo)}
aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
className={`relative h-20 overflow-hidden rounded-xl border transition-colors ${coverPreview === photo.url ? 'border-accent ring-2 ring-accent/20' : 'border-edge hover:border-content-faint'}`}
>
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="w-full h-full object-cover" />
{photo.photographer && (
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1.5 py-1 text-caption text-white">
{photo.photographer}
</span>
)}
</button>
))}
</div>
)}
</div>}
<div className={`${panelCls} flex-1`}>
<CardHead icon={<Type size={16} strokeWidth={2} />} title={t('trip.form.aboutTitle')} hint={t('trip.form.aboutHint')} />
<div>
<label className={labelCls}>
{t('dashboard.tripTitle')} <span className="text-danger">*</span>
</label>
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
</div>
<div>
<label className={labelCls}>{t('dashboard.tripDescription')}</label>
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
className={`${inputCls} resize-none`} />
</div>
</div>
</div>{/* /left column */}
{/* Right column — when it happens, in what currency, with whom. */}
<div className={columnCls}>
<div className={panelCls}>
<CardHead icon={<CalendarDays size={16} strokeWidth={2} />} title={t('trip.form.whenTitle')} hint={t('trip.form.whenHint')} />
{/* min-w-0 on both cells: a grid item defaults to min-width:auto, so a
long date ("12. Sept. 2026" and the keyboard button beside it) pushed
the cell past its track and the two columns overlapped (#2267). */}
<div className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<label className={labelCls}>
{t('dashboard.startDate')}
</label>
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder={t('dashboard.startDate')} />
</div>
<div className="min-w-0">
<label className={labelCls}>
{t('dashboard.endDate')}
</label>
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder={t('dashboard.endDate')} />
</div>
</div>
{!formData.start_date && !formData.end_date && (
<div>
<label className={labelCls}>
{t('dashboard.dayCount')}
</label>
<NumericInput min={1} max={MAX_TRIP_DAYS} value={formData.day_count}
onValueChange={raw => {
if (raw === '') { update('day_count', ''); return }
const n = Math.floor(Number(raw))
if (Number.isFinite(n)) update('day_count', Math.min(MAX_TRIP_DAYS, Math.max(1, n)))
}}
className={inputCls} />
<p className="text-caption text-content-faint mt-1.5">{t('dashboard.dayCountHint')}</p>
</div>
)}
<div>
<label className={labelCls}>{t('dashboard.currency')}</label>
<CustomSelect
value={formData.currency}
onChange={v => canEditTrip && update('currency', v)}
disabled={!canEditTrip}
options={currenciesWith(formData.currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{/* Reminder — only visible to owner (or when creating) */}
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
<label className={labelCls}>
{t('trips.reminder')}
</label>
{!tripRemindersEnabled ? (
<p className="text-caption text-content-faint bg-surface rounded-xl p-3">
{t('trips.reminderDisabledHint')}
</p>
) : (
<>
<div className="flex flex-wrap gap-2">
{[
{ value: 0, label: t('trips.reminderNone') },
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
].map(opt => (
<button key={opt.value} type="button"
onClick={() => { update('reminder_days', opt.value); setCustomReminder(false) }}
className={`px-3 py-1.5 text-caption font-medium rounded-full border transition-colors ${
!customReminder && formData.reminder_days === opt.value
? 'bg-accent text-accent-text border-accent'
: 'bg-surface text-content-secondary border-edge hover:border-content-faint'
}`}>
{opt.label}
</button>
))}
<button type="button"
onClick={() => { setCustomReminder(true); if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7) }}
className={`px-3 py-1.5 text-caption font-medium rounded-full border transition-colors ${
customReminder
? 'bg-accent text-accent-text border-accent'
: 'bg-surface text-content-secondary border-edge hover:border-content-faint'
}`}>
{t('trips.reminderCustom')}
</button>
</div>
{customReminder && (
<div className="flex items-center gap-2 mt-2">
<NumericInput min={1} max={30}
value={formData.reminder_days}
onValueChange={raw => update('reminder_days', Math.max(1, Math.min(30, Number(raw) || 1)))}
className="w-20 px-3 py-1.5 border border-edge rounded-xl bg-surface-input text-body text-content focus:outline-none focus:ring-2 focus:ring-accent/40" />
<span className="text-caption text-content-muted">{t('trips.reminderDaysBefore')}</span>
</div>
)}
</>
)}
</div>
)}
{/* Members. Not inside the trip settings, whose People area is where they are managed. */}
{!embedded && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div>
<label className={labelCls}>
{t('dashboard.addMembers')}
</label>
{/* Existing members (editing mode) */}
{isEditing && existingMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{existingMembers.map(m => (
<button type="button" key={m.id} disabled={m.id === currentUser?.id}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-[3px] text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content shadow-sm ring-1 ring-edge-faint"
onClick={async () => {
if (m.id === currentUser?.id) return
if (isEffectivelyOffline()) { toast.error(t('common.needsConnection')); return }
try {
await tripsApi.removeMember(trip!.id, m.id)
setExistingMembers(prev => prev.filter(x => x.id !== m.id))
toast.success(t('trips.memberRemoved', { username: m.username }))
} catch { toast.error(t('trips.memberRemoveError')) }
}}
style={{ cursor: m.id === currentUser?.id ? 'default' : 'pointer' }}>
{m.username}
{m.id !== currentUser?.id && <X size={11} className="text-content-faint" />}
</button>
))}
</div>
)}
{/* Newly selected members (both modes) */}
{selectedMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedMembers.map(uid => {
const user = allUsers.find(u => u.id === uid)!
return (
<button type="button" key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-[3px] text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content shadow-sm ring-1 ring-edge-faint cursor-pointer">
{user.username}
<X size={11} className="text-content-faint" />
</button>
)
})}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect
value={memberSelectValue}
onChange={async value => {
if (isEditing && trip?.id) {
const user = allUsers.find(u => u.id === Number(value))
if (user && isEffectivelyOffline()) {
toast.error(t('common.needsConnection'))
} else if (user) {
try {
await tripsApi.addMember(trip.id, user.username)
setExistingMembers(prev => [...prev, { id: user.id, username: user.username }])
toast.success(t('trips.memberAdded', { username: user.username }))
} catch { toast.error(t('trips.memberAddError')) }
}
} else {
setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)])
}
setMemberSelectValue('')
}}
placeholder={t('dashboard.addMember')}
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id) && !existingMembers.some(m => m.id === u.id)).map(u => ({ value: u.id, label: u.username }))}
searchable
size="sm"
/>
</div>
</div>
)}
</div>{/* /right panel */}
</div>{/* /right column */}
</div>{/* /two-column grid */}
</form>
</>
)
if (embedded) {
return (
<div className="flex flex-col gap-4">
{pendingReview && <h4 className="m-0 text-subtitle font-semibold text-content">{reviewTitle}</h4>}
{content}
<div className="flex justify-end gap-2">{buttons}</div>
</div>
)
}
return (
<DialogShell
open={isOpen}
// In the review step Escape, the close button and a click beside the
// dialog go back to the form, like Back, instead of throwing the whole
// edit away. Outside that step they close the dialog as before.
onClose={pendingReview ? () => { if (!busy) setPendingReview(null) } : onClose}
labelledBy={labelId}
/* The review step is a few radio buttons and a short list; alone, either
takes the width of the day dialog, where the same list stands. With both,
they stand side by side at the form's own width. */
width={pendingReview && !dateReviewIsWide(pendingReview.askShift, pendingReview.removal) ? 'detail' : 'wide'}
header={(
<DialogHeader
tile={<DialogTile>{isEditing ? <Pencil size={20} strokeWidth={1.9} className="text-content-muted" /> : <Plane size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={pendingReview ? () => { if (!busy) setPendingReview(null) } : onClose}
eyebrow={pendingReview ? undefined : isEditing ? t('dashboard.editTrip') : undefined}
title={pendingReview ? reviewTitle : isEditing ? (trip?.title || t('dashboard.editTrip')) : t('dashboard.createTrip')}
/>
)}
footer={<DialogFooter><FooterSpacer />{buttons}</DialogFooter>}
>
{content}
</DialogShell>
)
}