mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
b06e3e6266
Saving a booking or a transport closed the editor and reported success before the travellers and picked files were written. A failed upload left a booking without some of its PDFs, no list to retry from, and a create form that would add a duplicate when saved again. On desktop a file picked on an existing booking went up at once and vanished when that failed. The planner's save now takes the editor's follow-up as a step it waits for: it stores the booking, runs the traveller write and the uploads, and only then says so and closes. Each file that lands leaves the list, so a failure leaves exactly the files still to send, and the editor keeps the stored booking so the retry updates it instead of creating another one, without a second linked cost or stay. All four editors (desktop booking and transport dialogs, both phone sheets) share this through useBookingFollowUp, an import review only moves on once an item's files are up, and desktop files picked on a saved booking wait for Update like on the phone. Closes #2883
179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
import { typeToCostCategory } from '@trek/shared';
|
|
|
|
import { isEffectivelyOffline } from '../../sync/networkMode';
|
|
import type { BudgetItem, Day, Reservation, TripFile } from '../../types';
|
|
import type { BookingExpenseRequest } from './BookingCostsSection.types';
|
|
import { importedPriceEntry } from './importedPrice';
|
|
|
|
/**
|
|
* What the transport and the booking forms share on both shells: the travellers
|
|
* picked for a booking (#1517) and the files picked before it was saved.
|
|
*/
|
|
|
|
/** The travellers a booking opens with. */
|
|
export function travelerIdsOf(reservation: Pick<Reservation, 'travelers'> | null | undefined): Set<number> {
|
|
return new Set((reservation?.travelers || []).map((tv) => tv.user_id));
|
|
}
|
|
|
|
/** The picked travellers with one more or one less. */
|
|
export function toggledTraveler(prev: Set<number>, id: number): Set<number> {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
}
|
|
|
|
/** Whether the picked travellers differ from the ones the booking had, so the save only writes a real change. */
|
|
export function travelersChanged(reservation: Pick<Reservation, 'travelers'> | null | undefined, picked: Set<number>) {
|
|
const original = (reservation?.travelers || []).map((tv) => tv.user_id);
|
|
const nextIds = [...picked];
|
|
return { changed: original.length !== nextIds.length || nextIds.some((id) => !original.includes(id)), nextIds };
|
|
}
|
|
|
|
/**
|
|
* Uploads the files picked in a booking form against the saved booking, one after
|
|
* the other, each described with the booking's title. Callers run it after both a
|
|
* create and an edit: the form only holds files the user just picked, so nothing is
|
|
* uploaded twice, and skipping the edit dropped them without a word (#2534).
|
|
* Without an upload handler (a dialog opened with no file support) there is
|
|
* nowhere to send them, so nothing is uploaded and the save carries on.
|
|
*
|
|
* Each file that lands is reported before the next one starts, and the first one
|
|
* that fails stops the rest, so the form can keep exactly the files still to send
|
|
* and a second save picks up where this one stopped (#2883).
|
|
*/
|
|
export async function uploadBookingFiles(
|
|
upload: ((fd: FormData) => Promise<unknown>) | undefined,
|
|
savedId: number,
|
|
files: File[],
|
|
description: string,
|
|
onUploaded?: (file: File) => void
|
|
): Promise<void> {
|
|
if (!upload) return;
|
|
for (const file of files) {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
fd.append('reservation_id', String(savedId));
|
|
fd.append('description', description);
|
|
await upload(fd);
|
|
onUploaded?.(file);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How a booking editor hands its save to the planner. The planner stores the
|
|
* booking, waits for `afterSave` (the travellers and files that hang on it) and
|
|
* only then reports success and closes the editor. When that part fails the
|
|
* editor stays open with the booking stored, and passes it back as `saved` on
|
|
* the next try, so the retry updates it instead of creating a second one.
|
|
*/
|
|
export interface BookingSaveSteps {
|
|
saved?: Reservation;
|
|
afterSave?: (saved: Reservation) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* The update a retry sends for a booking the first try already created: the cost
|
|
* that try linked is not created again, and a hotel keeps the stay it was given
|
|
* rather than gaining a second one beside it.
|
|
*/
|
|
export function retriedSave<T extends Record<string, unknown>>(
|
|
data: T,
|
|
stored: Pick<Reservation, 'accommodation_id'>
|
|
): T {
|
|
const { create_budget_entry: _linkedCost, ...rest } = data;
|
|
const update = rest as Record<string, unknown>;
|
|
if (update.type === 'hotel' && !update.accommodation_id && stored.accommodation_id) {
|
|
update.accommodation_id = stored.accommodation_id;
|
|
}
|
|
return update as T;
|
|
}
|
|
|
|
/**
|
|
* Whether saving now would have to upload picked files while TREK is offline. A
|
|
* file cannot wait in the offline queue the way the booking can, so the form asks
|
|
* to save without the new files instead of storing a booking that loses them.
|
|
*/
|
|
export function filesNeedConnection(files: File[], canUpload: boolean): boolean {
|
|
return canUpload && files.length > 0 && isEffectivelyOffline();
|
|
}
|
|
|
|
/**
|
|
* Which expense the user asked for while saving a booking: a new one or an edit of
|
|
* the linked one. The form saves itself first, so the request can name the saved id.
|
|
*/
|
|
export interface BookingExpenseIntent {
|
|
editItem?: BudgetItem;
|
|
create?: boolean;
|
|
}
|
|
|
|
type SavedBooking = Pick<Reservation, 'id'> &
|
|
Partial<Pick<Reservation, 'reservation_time' | 'day_id' | 'accommodation_start_day_id'>>;
|
|
|
|
/**
|
|
* The calendar day a saved booking falls on: its own date, else a hotel's check-in
|
|
* day, else the day it sits on. Nothing for a booking without any of them.
|
|
*/
|
|
function bookingDate(saved: SavedBooking, days: Pick<Day, 'id' | 'date'>[]): string | undefined {
|
|
const own = (saved.reservation_time || '').slice(0, 10);
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(own)) return own;
|
|
const dayId = saved.accommodation_start_day_id ?? saved.day_id;
|
|
return (dayId != null && days.find((d) => d.id === dayId)?.date) || undefined;
|
|
}
|
|
|
|
/**
|
|
* The expense editor request to open once a booking is saved, or null when the user
|
|
* asked for none or the save came back without a record. A new expense is dated on
|
|
* the booking's day rather than today (#2604).
|
|
*/
|
|
export function expenseRequestAfterSave(
|
|
intent: BookingExpenseIntent | null | undefined,
|
|
saved: SavedBooking | null | undefined,
|
|
booking: { title: string; type: string },
|
|
days: Pick<Day, 'id' | 'date'>[]
|
|
): BookingExpenseRequest | null {
|
|
if (!intent || !saved?.id) return null;
|
|
if (intent.editItem) return { editItem: intent.editItem };
|
|
const date = bookingDate(saved, days);
|
|
return {
|
|
prefill: {
|
|
reservationId: saved.id,
|
|
name: booking.title,
|
|
category: typeToCostCategory(booking.type),
|
|
...(date ? { date } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The cost an import review previews before the booking exists: the parsed price the
|
|
* save will link, so the preview and the save cannot name different currencies.
|
|
* Nothing for a saved booking or a booking that was not imported.
|
|
*/
|
|
export function pendingImportExpense(
|
|
reservation: unknown,
|
|
prefill: { metadata?: unknown } | null | undefined,
|
|
type: string
|
|
): { total_price: number; category: string; currency: string | null } | null {
|
|
const entry = !reservation && prefill ? importedPriceEntry(prefill.metadata, type) : null;
|
|
return entry ? { ...entry, currency: entry.currency ?? null } : null;
|
|
}
|
|
|
|
/**
|
|
* The trip files shown on a saved booking: the ones uploaded to it, the ones linked
|
|
* to it on the server and the ones linked in this dialog. Nothing before it is saved.
|
|
*/
|
|
export function attachedBookingFiles(
|
|
files: TripFile[],
|
|
reservationId: number | null | undefined,
|
|
linkedFileIds: number[]
|
|
): TripFile[] {
|
|
if (!reservationId) return [];
|
|
return files.filter(
|
|
(f) =>
|
|
f.reservation_id === reservationId ||
|
|
linkedFileIds.includes(f.id) ||
|
|
f.linked_reservation_ids?.includes(reservationId)
|
|
);
|
|
}
|