mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
bd58f17ec0
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.
631 lines
30 KiB
TypeScript
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>
|
|
)
|
|
}
|