tour-operators) sharing add/edit lodging across admin and user facing side

This commit is contained in:
2026-02-19 18:09:20 -05:00
parent 18f9bbfbd7
commit cf2fd658f1
13 changed files with 1169 additions and 98 deletions

View File

@@ -19,12 +19,27 @@
interface Props {
open: boolean;
onclose: () => void;
people: Person[];
tripTravellerIds: string[];
people?: Person[];
tripTravellerIds?: string[];
parentPlanId?: string;
/** Template context (tour itinerary): add lodging to a day, post to addDayPlan */
variant?: 'trip' | 'template';
dayId?: number;
formAction?: string;
}
let { open, onclose, people = [], tripTravellerIds = [], parentPlanId }: Props = $props();
let {
open,
onclose,
people = [],
tripTravellerIds = [],
parentPlanId,
variant = 'trip',
dayId: templateDayId,
formAction = '?/addLodging'
}: Props = $props();
const isTemplate = $derived(variant === 'template');
const TIMEZONES = [
{ label: 'UTC (UTC+0)', value: 'UTC' },
@@ -163,6 +178,7 @@
let price = $state('');
let currency = $state('USD');
let selectedGuests = $state<string[]>([]);
let notes = $state('');
function toggleGuest(personId: string) {
if (selectedGuests.includes(personId)) {
@@ -200,6 +216,7 @@
price = '';
currency = 'USD';
selectedGuests = [];
notes = '';
}
function handleClose() {
@@ -229,7 +246,9 @@
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<h2 class="text-base font-semibold text-gray-900">Add lodging</h2>
<h2 class="text-base font-semibold text-gray-900">
{isTemplate ? 'Add lodging (template)' : 'Add lodging'}
</h2>
<button
onclick={handleClose}
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@@ -252,7 +271,7 @@
<!-- Form -->
<form
method="POST"
action="?/addLodging"
action={formAction}
class="flex flex-1 flex-col overflow-y-auto"
use:enhance={() => {
return ({ result, update }) => {
@@ -261,6 +280,10 @@
};
}}
>
{#if isTemplate && templateDayId != null}
<input type="hidden" name="dayId" value={templateDayId} />
<input type="hidden" name="type" value="lodging" />
{/if}
{#if parentPlanId}
<input type="hidden" name="parent_plan_id" value={parentPlanId} />
{/if}
@@ -296,7 +319,8 @@
</div>
</div>
<!-- Status -->
<!-- Status (trip only) -->
{#if !isTemplate}
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-700">Status</span>
<div class="flex gap-2">
@@ -326,7 +350,7 @@
</div>
</div>
<!-- Check-in / Check-out -->
<!-- Check-in / Check-out (trip only) -->
<div class="grid grid-cols-2 gap-6">
<!-- Check-in -->
<div class="flex flex-col gap-3">
@@ -407,9 +431,10 @@
<option value={tz.value}>{tz.label}</option>
{/each}
</select>
</div>
</div>
</div>
</div>
{/if}
<!-- Address -->
<div class="flex flex-col gap-3">
@@ -575,7 +600,23 @@
</div>
</div>
<!-- Booking details -->
<!-- Notes (template only) -->
{#if isTemplate}
<div class="flex flex-col gap-1.5">
<label for="lodging_notes" class="text-sm font-medium text-gray-700">Notes</label>
<textarea
id="lodging_notes"
name="notes"
bind:value={notes}
rows="3"
placeholder="Additional details..."
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
</div>
{/if}
<!-- Booking details (trip only) -->
{#if !isTemplate}
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label for="confirmation_number" class="text-sm font-medium text-gray-700">
@@ -646,9 +687,10 @@
</div>
</div>
</div>
{/if}
<!-- Guests -->
{#if people.length > 0 && tripTravellerIds.length > 0}
<!-- Guests (trip only) -->
{#if !isTemplate && people.length > 0 && tripTravellerIds.length > 0}
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-700">Guests</span>
<div class="flex flex-wrap gap-2">

View File

@@ -4,6 +4,7 @@
import type { Person } from '$lib/server/travellers.js';
import type { Lodging } from '$lib/server/lodgings.js';
import type { PlanStatus } from '$lib/server/plans.js';
import type { TemplateLodging } from './LodgingCard.svelte';
interface City {
id: number;
@@ -21,12 +22,30 @@
interface Props {
open: boolean;
onclose: () => void;
lodging: (Lodging & { guestIds: string[]; planStatus: PlanStatus }) | null;
people: Person[];
tripTravellerIds: string[];
/** Trip context */
lodging?: (Lodging & { guestIds: string[]; planStatus: PlanStatus }) | null;
people?: Person[];
tripTravellerIds?: string[];
/** Template context (tour itinerary): use same modal, post to editDayPlan */
variant?: 'trip' | 'template';
templatePlan?: TemplateLodging | null;
formAction?: string;
}
let { open, onclose, lodging, people = [], tripTravellerIds = [] }: Props = $props();
let {
open,
onclose,
lodging = null,
people = [],
tripTravellerIds = [],
variant = 'trip',
templatePlan = null,
formAction = '?/editLodging'
}: Props = $props();
const isTemplate = $derived(variant === 'template');
const source = $derived(isTemplate ? templatePlan : lodging);
const showModal = $derived(open && (lodging != null || (isTemplate && templatePlan != null)));
const TIMEZONES = [
{ label: 'UTC (UTC+0)', value: 'UTC' },
@@ -165,10 +184,22 @@
let price = $state('');
let currency = $state('USD');
let selectedGuests = $state<string[]>([]);
let notes = $state('');
// Pre-populate when the modal opens with a lodging
// Pre-populate when the modal opens (trip lodging or template)
$effect(() => {
if (open && lodging) {
if (!open || !source) return;
if (isTemplate && templatePlan) {
name = templatePlan.title;
chain = templatePlan.chain ?? '';
addressLine1 = templatePlan.address_line1 ?? '';
addressLine2 = templatePlan.address_line2 ?? '';
cityName = templatePlan.city_name ?? '';
country = templatePlan.country ?? '';
countryCode = templatePlan.country_code ?? '';
postalCode = templatePlan.postal_code ?? '';
notes = templatePlan.notes ?? '';
} else if (lodging) {
name = lodging.name;
chain = lodging.chain ?? '';
status = lodging.planStatus;
@@ -208,7 +239,7 @@
let canSubmit = $derived(name.trim().length > 0);
</script>
{#if open && lodging}
{#if showModal}
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/30"
@@ -227,7 +258,9 @@
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<h2 class="text-base font-semibold text-gray-900">Edit lodging</h2>
<h2 class="text-base font-semibold text-gray-900">
{isTemplate ? 'Edit lodging (template)' : 'Edit lodging'}
</h2>
<button
onclick={handleClose}
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@@ -250,7 +283,7 @@
<!-- Form -->
<form
method="POST"
action="?/editLodging"
action={formAction}
class="flex flex-1 flex-col overflow-y-auto"
use:enhance={() => {
return ({ result, update }) => {
@@ -259,7 +292,11 @@
};
}}
>
<input type="hidden" name="lodging_id" value={lodging.id} />
{#if isTemplate && templatePlan}
<input type="hidden" name="id" value={templatePlan.id} />
{:else if lodging}
<input type="hidden" name="lodging_id" value={lodging.id} />
{/if}
<div class="flex flex-1 flex-col gap-6 px-6 py-6">
<!-- Property details -->
@@ -293,7 +330,8 @@
</div>
</div>
<!-- Status -->
<!-- Status (trip only) -->
{#if !isTemplate}
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-700">Status</span>
<div class="flex gap-2">
@@ -322,8 +360,10 @@
{/each}
</div>
</div>
{/if}
<!-- Check-in / Check-out -->
<!-- Check-in / Check-out (trip only) -->
{#if !isTemplate}
<div class="grid grid-cols-2 gap-6">
<!-- Check-in -->
<div class="flex flex-col gap-3">
@@ -409,6 +449,7 @@
</div>
</div>
</div>
{/if}
<!-- Address -->
<div class="flex flex-col gap-3">
@@ -576,7 +617,23 @@
</div>
</div>
<!-- Booking details -->
<!-- Notes (template only) -->
{#if isTemplate}
<div class="flex flex-col gap-1.5">
<label for="edit_notes" class="text-sm font-medium text-gray-700">Notes</label>
<textarea
id="edit_notes"
name="notes"
bind:value={notes}
rows="3"
placeholder="Additional details..."
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
</div>
{/if}
<!-- Booking details (trip only) -->
{#if !isTemplate}
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label for="edit_confirmation_number" class="text-sm font-medium text-gray-700">
@@ -604,7 +661,7 @@
</div>
</div>
<!-- Contact + Cost -->
<!-- Contact + Cost (trip only) -->
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label for="edit_phone" class="text-sm font-medium text-gray-700">Phone</label>
@@ -647,9 +704,10 @@
</div>
</div>
</div>
{/if}
<!-- Guests -->
{#if people.length > 0 && tripTravellerIds.length > 0}
<!-- Guests (trip only) -->
{#if !isTemplate && people.length > 0 && tripTravellerIds.length > 0}
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-700">Guests</span>
<div class="flex flex-wrap gap-2">

View File

@@ -2,14 +2,36 @@
import type { Plan } from '$lib/server/plans.js';
import type { Lodging } from '$lib/server/lodgings.js';
/** Template lodging (operator tour day plan type=lodging) — same display shape as trip lodging */
export interface TemplateLodging {
id: number;
title: string;
notes?: string | null;
chain?: string | null;
address_line1?: string | null;
address_line2?: string | null;
city_name?: string | null;
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
}
interface Props {
plan: Plan;
lodging: Lodging & { guestIds: string[] };
/** Trip context: plan + lodging */
plan?: Plan;
lodging?: Lodging & { guestIds: string[] };
/** Template context: operator tour day plan (lodging) */
templatePlan?: TemplateLodging;
onEdit?: () => void;
onDelete?: () => void;
}
let { plan, lodging, onEdit, onDelete }: Props = $props();
let { plan, lodging, templatePlan, onEdit, onDelete }: Props = $props();
const isTemplate = $derived(!!templatePlan);
const displayName = $derived((lodging?.name ?? templatePlan?.title) || '');
const displayChain = $derived(lodging?.chain ?? templatePlan?.chain ?? '');
const displayStatus = $derived(plan?.status);
function formatDate(d: string | null): string {
if (!d) return '';
@@ -39,26 +61,29 @@
};
const hasAddress = $derived(
lodging.address_line1 ||
lodging.address_line2 ||
lodging.city_name ||
lodging.country ||
lodging.postal_code
(lodging ?? templatePlan) &&
((lodging?.address_line1 ?? templatePlan?.address_line1) ||
(lodging?.address_line2 ?? templatePlan?.address_line2) ||
(lodging?.city_name ?? templatePlan?.city_name) ||
(lodging?.country ?? templatePlan?.country) ||
(lodging?.postal_code ?? templatePlan?.postal_code))
);
const addressParts = $derived(
[
lodging.address_line1,
lodging.address_line2,
[lodging.city_name, lodging.postal_code].filter(Boolean).join(' '),
lodging.country
lodging?.address_line1 ?? templatePlan?.address_line1,
lodging?.address_line2 ?? templatePlan?.address_line2,
[(lodging?.city_name ?? templatePlan?.city_name), lodging?.postal_code ?? templatePlan?.postal_code]
.filter(Boolean)
.join(' '),
lodging?.country ?? templatePlan?.country
]
.filter(Boolean)
.join(', ')
);
const hasCheckIn = $derived(lodging.check_in_date || lodging.check_in_time);
const hasCheckOut = $derived(lodging.check_out_date || lodging.check_out_time);
const hasCheckIn = $derived(lodging ? lodging.check_in_date || lodging.check_in_time : false);
const hasCheckOut = $derived(lodging ? lodging.check_out_date || lodging.check_out_time : false);
</script>
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
@@ -82,17 +107,25 @@
</svg>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="font-medium text-gray-900">{lodging.name}</p>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[
plan.status
].class}"
>
{statusConfig[plan.status].label}
</span>
<p class="font-medium text-gray-900">{displayName}</p>
{#if displayStatus && !isTemplate}
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[
displayStatus
].class}"
>
{statusConfig[displayStatus].label}
</span>
{:else if isTemplate}
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600"
>
Template
</span>
{/if}
</div>
{#if lodging.chain}
<p class="mt-0.5 text-sm text-gray-500">{lodging.chain}</p>
{#if displayChain}
<p class="mt-0.5 text-sm text-gray-500">{displayChain}</p>
{/if}
</div>
</div>
@@ -142,8 +175,8 @@
</div>
</div>
<!-- Check-in / Check-out -->
{#if hasCheckIn || hasCheckOut}
<!-- Check-in / Check-out (trip only) -->
{#if lodging && (hasCheckIn || hasCheckOut)}
<div class="grid grid-cols-2 gap-4 border-t border-gray-100 py-4">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-gray-400">Check-in</p>
@@ -198,8 +231,15 @@
</div>
{/if}
<!-- Confirmation + Price -->
{#if lodging.confirmation_number || (lodging.price != null && lodging.price > 0)}
<!-- Notes (template only) -->
{#if isTemplate && templatePlan?.notes}
<div class="border-t border-gray-100 py-4">
<p class="text-sm text-gray-700 whitespace-pre-wrap">{templatePlan.notes}</p>
</div>
{/if}
<!-- Confirmation + Price (trip only) -->
{#if lodging && (lodging.confirmation_number || (lodging.price != null && lodging.price > 0))}
<div class="flex flex-wrap gap-x-3 gap-y-0.5 border-t border-gray-100 pt-4 text-xs text-gray-500">
{#if lodging.confirmation_number}
<span>Confirmation: {lodging.confirmation_number}</span>

View File

@@ -318,6 +318,7 @@ export interface OperatorTour {
id: number;
operator_id: number;
name: string;
description: string | null;
created_at: string;
updated_at: string;
}
@@ -334,16 +335,24 @@ export function listToursForOperatorByName(operatorName: string): OperatorTour[]
return listToursForOperator(operator.id);
}
export function createOperatorTour(operatorId: number, name: string): OperatorTour {
db.run('INSERT INTO operator_tours (operator_id, name) VALUES (?, ?)', [operatorId, name.trim()]);
export function createOperatorTour(
operatorId: number,
name: string,
description?: string | null
): OperatorTour {
db.run('INSERT INTO operator_tours (operator_id, name, description) VALUES (?, ?, ?)', [
operatorId,
name.trim(),
description?.trim() || null
]);
return db.get<OperatorTour>('SELECT * FROM operator_tours WHERE id = last_insert_rowid()')!;
}
export function updateOperatorTour(id: number, name: string): void {
db.run(`UPDATE operator_tours SET name = ?, updated_at = datetime('now') WHERE id = ?`, [
name.trim(),
id
]);
export function updateOperatorTour(id: number, name: string, description?: string | null): void {
db.run(
`UPDATE operator_tours SET name = ?, description = ?, updated_at = datetime('now') WHERE id = ?`,
[name.trim(), description?.trim() || null, id]
);
}
export function deleteOperatorTour(id: number): void {
@@ -418,3 +427,133 @@ export function deleteOperatorTourDay(id: number): void {
});
}
}
// --- Operator Tour Day Plans (transport / lodging templates per day) ---
export type OperatorTourDayPlanType = 'transport' | 'lodging';
export interface OperatorTourDayPlan {
id: number;
operator_tour_day_id: number;
type: OperatorTourDayPlanType;
title: string;
notes: string | null;
position: number;
created_at: string;
updated_at: string;
// Lodging-only (when type === 'lodging'), aligned with trip lodgings
chain?: string | null;
address_line1?: string | null;
address_line2?: string | null;
city_name?: string | null;
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
}
export function listPlansForOperatorTour(tourId: number): OperatorTourDayPlan[] {
return db.all<OperatorTourDayPlan>(
`SELECT p.* FROM operator_tour_day_plans p
JOIN operator_tour_days d ON d.id = p.operator_tour_day_id
WHERE d.operator_tour_id = ?
ORDER BY d.position ASC, d.id ASC, p.position ASC, p.id ASC`,
[tourId]
);
}
export function createOperatorTourDayPlan(
dayId: number,
type: OperatorTourDayPlanType,
title: string,
notes?: string | null,
lodgingFields?: {
chain?: string | null;
address_line1?: string | null;
address_line2?: string | null;
city_name?: string | null;
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
}
): OperatorTourDayPlan {
const maxPos = db.get<{ pos: number }>(
'SELECT COALESCE(MAX(position), -1) + 1 as pos FROM operator_tour_day_plans WHERE operator_tour_day_id = ?',
[dayId]
);
const position = maxPos?.pos ?? 0;
const l = lodgingFields;
const cols =
type === 'lodging' && l
? 'operator_tour_day_id, type, title, notes, position, chain, address_line1, address_line2, city_name, country, country_code, postal_code'
: 'operator_tour_day_id, type, title, notes, position';
const vals =
type === 'lodging' && l
? [
dayId,
type,
title.trim(),
notes?.trim() || null,
position,
l.chain?.trim() || null,
l.address_line1?.trim() || null,
l.address_line2?.trim() || null,
l.city_name?.trim() || null,
l.country?.trim() || null,
l.country_code?.trim() || null,
l.postal_code?.trim() || null
]
: [dayId, type, title.trim(), notes?.trim() || null, position];
const placeholders = vals.map(() => '?').join(', ');
db.run(
`INSERT INTO operator_tour_day_plans (${cols}) VALUES (${placeholders})`,
vals
);
return db.get<OperatorTourDayPlan>(
'SELECT * FROM operator_tour_day_plans WHERE id = last_insert_rowid()'
)!;
}
export function updateOperatorTourDayPlan(
id: number,
updates: {
title?: string;
notes?: string | null;
chain?: string | null;
address_line1?: string | null;
address_line2?: string | null;
city_name?: string | null;
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
}
): void {
const setClauses: string[] = [];
const values: (string | null | number)[] = [];
const optional = (key: string, v: string | null | undefined) => {
if (v !== undefined) {
setClauses.push(`${key} = ?`);
values.push(v === null ? null : (typeof v === 'string' ? v.trim() : v));
}
};
optional('title', updates.title);
optional('notes', updates.notes ?? undefined);
optional('chain', updates.chain ?? undefined);
optional('address_line1', updates.address_line1 ?? undefined);
optional('address_line2', updates.address_line2 ?? undefined);
optional('city_name', updates.city_name ?? undefined);
optional('country', updates.country ?? undefined);
optional('country_code', updates.country_code ?? undefined);
optional('postal_code', updates.postal_code ?? undefined);
if (setClauses.length === 0) return;
setClauses.push("updated_at = datetime('now')");
values.push(id);
db.run(
`UPDATE operator_tour_day_plans SET ${setClauses.join(', ')} WHERE id = ?`,
values
);
}
export function deleteOperatorTourDayPlan(id: number): void {
db.run('DELETE FROM operator_tour_day_plans WHERE id = ?', [id]);
}

View File

@@ -1,21 +1,159 @@
import { env } from '$env/dynamic/private';
import type { TourProvider, TourSearchResult } from './types.js';
import type {
TourDetail,
TourDay,
TourDayPlan,
TourDayPlanLodgingFields,
TourProvider,
TourSearchResult
} from './types.js';
const BASE = 'https://rest.gadventures.com';
function authHeaders() {
return { 'X-Application-Key': env.GADVENTURES_API_KEY };
}
// Raw API shapes for itinerary days and components
interface GAdventureComponent {
type?: string;
summary?: string;
accommodation_dossier?: { id: string; href: string; name: string };
transport_dossier?: { id: string; href: string; name: string };
}
interface GAdventureItineraryDay {
day: number;
label?: string;
summary?: string;
components?: GAdventureComponent[];
}
// Accommodation dossier detail response (from following accommodation_dossier href)
interface GAdventureAccommodationDossier {
name?: string;
address?: {
address_line_1?: string;
address_line_2?: string;
address_line_3?: string;
postal_code?: string;
city?: { name?: string };
country?: { name?: string };
};
}
async function fetchAccommodationDetails(href: string): Promise<GAdventureAccommodationDossier | null> {
try {
const res = await fetch(href, { headers: authHeaders() });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
function lodgingFieldsFromDossier(dossier: GAdventureAccommodationDossier): TourDayPlanLodgingFields | undefined {
const addr = dossier.address;
if (!addr) return undefined;
const address_line1 = addr.address_line_1?.trim() || null;
const address_line2 = addr.address_line_2?.trim() || null;
const city_name = addr.city?.name?.trim() || null;
const country = addr.country?.name?.trim() || null;
const country_code = addr.country?.id?.trim() || null;
const postal_code = addr.postal_code?.trim() || null;
if (
!address_line1 &&
!address_line2 &&
!city_name &&
!country &&
!country_code &&
!postal_code
) {
return undefined;
}
return {
address_line1: address_line1 ?? undefined,
address_line2: address_line2 ?? undefined,
city_name: city_name ?? undefined,
country: country ?? undefined,
country_code: country_code ?? undefined,
postal_code: postal_code ?? undefined
};
}
async function dayPlansFromComponents(components: GAdventureComponent[]): Promise<TourDayPlan[]> {
const plans: TourDayPlan[] = [];
for (const c of components ?? []) {
const type = c.type?.toUpperCase();
if (type === 'TRANSPORT' && c.transport_dossier) {
plans.push({
type: 'transport',
title: c.transport_dossier.name?.trim() || c.summary?.trim() || 'Transport',
notes: c.summary?.trim() || null
});
} else if (type === 'ACCOMMODATION' && c.accommodation_dossier) {
const dossier = c.accommodation_dossier;
let title = dossier.name?.trim() || c.summary?.trim() || 'Accommodation';
const notes: string | null = c.summary?.trim() || null;
let lodgingFields: TourDayPlanLodgingFields | undefined;
if (dossier.href) {
const details = await fetchAccommodationDetails(dossier.href);
if (details?.name?.trim()) title = details.name.trim();
if (details) lodgingFields = lodgingFieldsFromDossier(details);
}
plans.push({ type: 'lodging', title, notes: notes ?? undefined, lodgingFields });
}
}
return plans;
}
export const GAdventuresProvider: TourProvider = {
name: 'G Adventures',
search: async (query: string): Promise<TourSearchResult[]> => {
const params = new URLSearchParams();
if (query.trim()) params.set('name', query.trim());
const url = `https://rest.gadventures.com/tour_dossiers?${params}`;
const res = await fetch(url, {
headers: { 'X-Application-Key': env.GADVENTURES_API_KEY }
});
console.dir(res);
const res = await fetch(`${BASE}/tour_dossiers?${params}`, { headers: authHeaders() });
if (!res.ok) return [];
const data = await res.json();
return (data.results ?? []).map((r: { id: string; name: string }) => ({
id: r.id,
title: r.name
}));
},
getDetail: async (id: string): Promise<TourDetail | null> => {
// 1. Fetch the tour dossier
const dossierRes = await fetch(`${BASE}/tour_dossiers/${id}`, { headers: authHeaders() });
if (!dossierRes.ok) return null;
const dossier = await dossierRes.json();
const title: string = dossier.name ?? '';
const description: string = dossier.description ?? '';
// 2. Resolve itinerary — use the first structured itinerary's href
const itineraryHref: string | undefined = dossier.structured_itineraries?.[0]?.href;
if (!itineraryHref) {
return { id, title, description, days: [] };
}
const itinRes = await fetch(itineraryHref, { headers: authHeaders() });
if (!itinRes.ok) return { id, title, description, days: [] };
const itinerary = await itinRes.json();
const rawDays: GAdventureItineraryDay[] = itinerary.days ?? [];
const days: TourDay[] = [];
for (const d of rawDays) {
const dayPlans = await dayPlansFromComponents(d.components ?? []);
days.push({
dayNumber: d.day,
title: d.label ?? '',
description: d.summary ?? '',
dayPlans: dayPlans.length > 0 ? dayPlans : undefined
});
}
return { id, title, description, days };
}
};

View File

@@ -5,9 +5,53 @@ export interface TourSearchResult {
title: string;
}
/** Lodging-specific fields (from provider accommodation dossier) */
export interface TourDayPlanLodgingFields {
chain?: string | null;
address_line1?: string | null;
address_line2?: string | null;
city_name?: string | null;
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
}
/** A transport or lodging plan attached to a day (from provider components) */
export interface TourDayPlan {
type: 'transport' | 'lodging';
title: string;
notes?: string | null;
/** When type is 'lodging', structured address/location from provider */
lodgingFields?: TourDayPlanLodgingFields;
}
export interface TourDay {
/** 1-based day number */
dayNumber: number;
/** Short title / label for the day */
title: string;
/** Longer description / summary for the day */
description: string;
/** Optional transport/lodging items parsed from day components */
dayPlans?: TourDayPlan[];
}
export interface TourDetail {
/** Provider-specific identifier */
id: string;
/** Display name of the tour */
title: string;
/** Overview description of the tour */
description: string;
/** Ordered list of days in the itinerary */
days: TourDay[];
}
export interface TourProvider {
/** Human-readable provider name shown in the import drawer */
name: string;
/** Search tours by query string. Empty query returns a default set of results. */
search(query: string): Promise<TourSearchResult[]>;
/** Fetch full details (description + itinerary days) for a specific tour by provider ID */
getDetail(id: string): Promise<TourDetail | null>;
}

View File

@@ -303,11 +303,19 @@ export function runMigrations(db: Database): void {
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_id INTEGER NOT NULL REFERENCES tour_operators(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Migration: add description to existing operator_tours tables
try {
db.run(`ALTER TABLE operator_tours ADD COLUMN description TEXT`);
} catch {
/* already exists */
}
// Template itinerary days for predefined operator tours
db.run(`
CREATE TABLE IF NOT EXISTS operator_tour_days (
@@ -322,6 +330,37 @@ export function runMigrations(db: Database): void {
)
`);
// Template plans (transport/lodging) attached to operator tour days
db.run(`
CREATE TABLE IF NOT EXISTS operator_tour_day_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_tour_day_id INTEGER NOT NULL REFERENCES operator_tour_days(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('transport', 'lodging')),
title TEXT NOT NULL,
notes TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Lodging-specific fields for operator_tour_day_plans (type=lodging) — same shape as trip lodgings
for (const col of [
'chain TEXT',
'address_line1 TEXT',
'address_line2 TEXT',
'city_name TEXT',
'country TEXT',
'country_code TEXT',
'postal_code TEXT'
]) {
try {
db.run(`ALTER TABLE operator_tour_day_plans ADD COLUMN ${col}`);
} catch {
/* already exists */
}
}
// Countries table — editable list for admin (used by country selector)
db.run(`
CREATE TABLE IF NOT EXISTS countries (

View File

@@ -1,7 +1,8 @@
import { db } from './db/index.js';
import { randomUUID } from 'crypto';
import type { PlanStatus } from './plans.js';
import { listDaysForOperatorTour } from './admin/data.js';
import { createPlan } from './plans.js';
import { listDaysForOperatorTour, listPlansForOperatorTour } from './admin/data.js';
export interface PackageTour {
id: string;
@@ -311,13 +312,26 @@ export function cloneTemplateDaysToTour(input: {
tourPlanId: string;
}): void {
const days = listDaysForOperatorTour(input.operatorTourId);
const allDayPlans = listPlansForOperatorTour(input.operatorTourId);
for (const day of days) {
createTourDay({
const newDay = createTourDay({
tripId: input.tripId,
userId: input.userId,
tourPlanId: input.tourPlanId,
title: day.title ?? undefined,
notes: day.notes ?? undefined
});
const dayPlans = allDayPlans.filter((p) => p.operator_tour_day_id === day.id);
for (const dp of dayPlans) {
createPlan({
tripId: input.tripId,
userId: input.userId,
type: dp.type,
title: dp.title,
notes: dp.notes ?? undefined,
parentId: newDay.plan_id,
status: 'idea'
});
}
}
}

View File

@@ -85,6 +85,47 @@ export function createDestination(input: CreateDestinationInput): Plan {
return db.get<Plan>('SELECT * FROM plans WHERE id = ?', [id])!;
}
export interface CreatePlanInput {
tripId: string;
userId: string;
type: PlanType;
title: string;
notes?: string | null;
parentId?: string | null;
status?: PlanStatus;
}
export function createPlan(input: CreatePlanInput): Plan {
const id = randomUUID();
const maxPos = input.parentId
? db.get<{ pos: number }>(
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE parent_id = ? AND user_id = ?`,
[input.parentId, input.userId]
)
: db.get<{ pos: number }>(
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`,
[input.tripId, input.userId]
);
const position = maxPos?.pos ?? 0;
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, notes, parent_id, position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
input.tripId,
input.userId,
input.type,
input.status ?? 'idea',
input.title,
input.notes ?? null,
input.parentId ?? null,
position
]
);
return db.get<Plan>('SELECT * FROM plans WHERE id = ?', [id])!;
}
export function getPlansForTrip(tripId: string, userId: string): Plan[] {
return db.all<Plan>(
`SELECT * FROM plans WHERE trip_id = ? AND user_id = ? ORDER BY position ASC, created_at ASC`,