mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
9cbf31da27
Inside the trip settings, Escape and the backdrop no longer close the dialog while the date question is up, and the embedded form does not load the members it does not show. Every trip open is timed afresh for the splash. The tests follow the cover image card, the danger button, the delayed splash and the open and download mocks.
849 lines
36 KiB
TypeScript
849 lines
36 KiB
TypeScript
import { addIsoDays, MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared';
|
|
import { CalendarDays, Camera, Image as ImageIcon, Pencil, Plane, Search, Type, X } from 'lucide-react';
|
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
import { authApi, tripsApi } from '../../api/client';
|
|
import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard';
|
|
import { useTranslation } from '../../i18n';
|
|
import { tripChangeNeedsConnection } from '../../repo/tripRepo';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { useCanDo } from '../../store/permissionsStore';
|
|
import { useSettingsStore } from '../../store/settingsStore';
|
|
import { isEffectivelyOffline } from '../../sync/networkMode';
|
|
import { type Trip } from '../../types';
|
|
import type { ShiftMode } from '../../utils/dayImpactLines';
|
|
import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants';
|
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker';
|
|
import CustomSelect from '../shared/CustomSelect';
|
|
import {
|
|
DialogButton,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogShell,
|
|
DialogTile,
|
|
FooterSpacer,
|
|
NEUTRAL_TINT,
|
|
} from '../shared/DialogShell';
|
|
import { NumericInput } from '../shared/NumericInput';
|
|
import { useToast } from '../shared/Toast';
|
|
import { CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts';
|
|
import TripDateReview, { dateReviewIsWide } from './TripDateReview';
|
|
import { tripFormError } from './tripFormModel';
|
|
import { useTripCoverPicker } from './useTripCoverPicker';
|
|
|
|
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;
|
|
/** Embedded, told while the date question is up, so the dialog around it keeps Escape and its backdrop off. */
|
|
onReviewChange?: (reviewing: boolean) => void;
|
|
}
|
|
|
|
export default function TripFormModal({
|
|
isOpen,
|
|
onClose,
|
|
onSave,
|
|
trip,
|
|
onCoverUpdate,
|
|
embedded = false,
|
|
onReviewChange,
|
|
}: 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(() => {});
|
|
// The members block is not drawn inside the trip settings, whose People area holds them.
|
|
if (!embedded) {
|
|
authApi
|
|
.listUsers()
|
|
.then((d) => setAllUsers(d.users || []))
|
|
.catch(() => {});
|
|
if (trip)
|
|
tripsApi
|
|
.getMembers(trip.id)
|
|
.then((d) => setExistingMembers(d.members || []))
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
}, [trip, isOpen, embedded]);
|
|
|
|
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;
|
|
|
|
// Escape in the date question goes back to the form; embedded, the settings around it must not close on it.
|
|
const reviewing = pendingReview != null;
|
|
useEffect(() => {
|
|
onReviewChange?.(reviewing);
|
|
}, [reviewing, onReviewChange]);
|
|
|
|
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="border-danger/30 rounded-xl border bg-danger-soft p-3 text-body text-danger">{error}</div>
|
|
)}
|
|
|
|
<div className={`grid grid-cols-1 items-start gap-x-6 gap-y-4 ${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] overflow-hidden rounded-xl`}>
|
|
<img src={coverPreview} alt="" className="h-full w-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 rounded-lg bg-black/55 px-2.5 py-[5px] text-caption font-semibold text-white backdrop-blur-sm"
|
|
>
|
|
<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 rounded-lg bg-black/55 px-2 py-[5px] text-white backdrop-blur-sm"
|
|
>
|
|
<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={`flex h-11 w-full items-center justify-center gap-1.5 rounded-[10px] border-[1.5px] border-dashed px-4 text-[length:calc(13px*var(--fs-scale-body,1))] 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="h-4 w-4 animate-spin rounded-full border-2 border-edge border-t-content-muted" />
|
|
) : (
|
|
<Search size={14} />
|
|
)}
|
|
{t('dashboard.searchUnsplash')}
|
|
</button>
|
|
</div>
|
|
{coverSearchError && <p className="mt-1.5 text-caption text-danger">{coverSearchError}</p>}
|
|
{coverSearchResults.length > 0 && (
|
|
<div className="mt-2 grid grid-cols-3 gap-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 ? 'ring-accent/20 border-accent ring-2' : 'border-edge hover:border-content-faint'}`}
|
|
>
|
|
<img
|
|
src={photo.thumb}
|
|
alt={photo.description || ''}
|
|
loading="lazy"
|
|
className="h-full w-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="mt-1.5 text-caption text-content-faint">{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="rounded-xl bg-surface p-3 text-caption text-content-faint">
|
|
{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={`rounded-full border px-3 py-1.5 text-caption font-medium transition-colors ${
|
|
!customReminder && formData.reminder_days === opt.value
|
|
? 'border-accent bg-accent text-accent-text'
|
|
: 'border-edge bg-surface text-content-secondary 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={`rounded-full border px-3 py-1.5 text-caption font-medium transition-colors ${
|
|
customReminder
|
|
? 'border-accent bg-accent text-accent-text'
|
|
: 'border-edge bg-surface text-content-secondary hover:border-content-faint'
|
|
}`}
|
|
>
|
|
{t('trips.reminderCustom')}
|
|
</button>
|
|
</div>
|
|
{customReminder && (
|
|
<div className="mt-2 flex items-center gap-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="focus:ring-accent/40 w-20 rounded-xl border border-edge bg-surface-input px-3 py-1.5 text-body text-content focus:outline-none focus:ring-2"
|
|
/>
|
|
<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="mb-2 flex flex-wrap gap-1.5">
|
|
{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="mb-2 flex flex-wrap gap-1.5">
|
|
{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 cursor-pointer 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"
|
|
>
|
|
{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>
|
|
);
|
|
}
|