mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
64ceecefb3
Every booking and stay write went straight to the API, so offline a new
booking failed with "Network Error" and was lost, and a status tick, the
travellers or a moved transport snapped back. Bookings (add, edit, status,
travellers, delete, day order) and stays (add, edit, delete) now go through
reservationRepo and accommodationRepo and the mutation queue like places and
costs: a temporary row offline, held behind an older queued write online,
since neither carries a version token. Desktop, the phone sheets, the day
plan, the road trip nights and the PDF all write and read through them.
The replay reads the booking from `reservation` only, so the travellers
answer ({ travelers, reservation }) lands on the booking it names. A booking
or its stay that names a place or stay created offline is sent with the real
id once that one has synced, inside create_accommodation too, so an imported
hotel reviewed offline can be linked to a place made for it.
What only works online says so instead of failing: an AirTrail import, a
booking file import, the AI retry and an attachment on a saved booking show
that they need a connection, and a save with newly picked files is stopped
with a hint, since an upload cannot wait in the queue.
Refs #2601
144 lines
5.5 KiB
TypeScript
144 lines
5.5 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.
|
|
*/
|
|
export async function uploadBookingFiles(
|
|
upload: ((fd: FormData) => Promise<unknown>) | undefined,
|
|
savedId: number,
|
|
files: File[],
|
|
description: string
|
|
): 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export 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)
|
|
);
|
|
}
|