From cf2fd658f118228b92c1adebc2319e38f7c653ea Mon Sep 17 00:00:00 2001 From: Shaun Campbell Date: Thu, 19 Feb 2026 18:09:20 -0500 Subject: [PATCH] tour-operators) sharing add/edit lodging across admin and user facing side --- src/lib/components/AddLodgingModal.svelte | 64 +++- src/lib/components/EditLodgingModal.svelte | 90 +++++- src/lib/components/LodgingCard.svelte | 96 ++++-- src/lib/server/admin/data.ts | 153 +++++++++- .../server/admin/providers/g-adventures.ts | 150 ++++++++- src/lib/server/admin/providers/types.ts | 44 +++ src/lib/server/db/migrations.ts | 39 +++ src/lib/server/package-tours.ts | 18 +- src/lib/server/plans.ts | 41 +++ .../[operatorId]/+page.server.ts | 44 ++- .../tour-operators/[operatorId]/+page.svelte | 155 ++++++++-- .../tours/[tourId]/+page.server.ts | 88 +++++- .../[operatorId]/tours/[tourId]/+page.svelte | 285 +++++++++++++++++- 13 files changed, 1169 insertions(+), 98 deletions(-) diff --git a/src/lib/components/AddLodgingModal.svelte b/src/lib/components/AddLodgingModal.svelte index 1cb29b8..881270e 100644 --- a/src/lib/components/AddLodgingModal.svelte +++ b/src/lib/components/AddLodgingModal.svelte @@ -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([]); + 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 @@ >
-

Add lodging

+

+ {isTemplate ? 'Add lodging (template)' : 'Add lodging'} +

- + + {#if !isTemplate}
Status
@@ -326,7 +350,7 @@
- +
@@ -407,9 +431,10 @@ {/each} -
+ + {/if}
@@ -575,7 +600,23 @@
- + + {#if isTemplate} +
+ + +
+ {/if} + + + {#if !isTemplate}
+ {/if} - - {#if people.length > 0 && tripTravellerIds.length > 0} + + {#if !isTemplate && people.length > 0 && tripTravellerIds.length > 0}
Guests
diff --git a/src/lib/components/EditLodgingModal.svelte b/src/lib/components/EditLodgingModal.svelte index 6e242e9..2f9deed 100644 --- a/src/lib/components/EditLodgingModal.svelte +++ b/src/lib/components/EditLodgingModal.svelte @@ -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([]); + 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); -{#if open && lodging} +{#if showModal}
-

Edit lodging

+

+ {isTemplate ? 'Edit lodging (template)' : 'Edit lodging'} +

- + + {#if !isTemplate}
Status
@@ -322,8 +360,10 @@ {/each}
+ {/if} - + + {#if !isTemplate}
@@ -409,6 +449,7 @@
+ {/if}
@@ -576,7 +617,23 @@
- + + {#if isTemplate} +
+ + +
+ {/if} + + + {#if !isTemplate}
- +
@@ -647,9 +704,10 @@
+ {/if} - - {#if people.length > 0 && tripTravellerIds.length > 0} + + {#if !isTemplate && people.length > 0 && tripTravellerIds.length > 0}
Guests
diff --git a/src/lib/components/LodgingCard.svelte b/src/lib/components/LodgingCard.svelte index af52afc..36edca8 100644 --- a/src/lib/components/LodgingCard.svelte +++ b/src/lib/components/LodgingCard.svelte @@ -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);
@@ -82,17 +107,25 @@
-

{lodging.name}

- - {statusConfig[plan.status].label} - +

{displayName}

+ {#if displayStatus && !isTemplate} + + {statusConfig[displayStatus].label} + + {:else if isTemplate} + + Template + + {/if}
- {#if lodging.chain} -

{lodging.chain}

+ {#if displayChain} +

{displayChain}

{/if}
@@ -142,8 +175,8 @@
- - {#if hasCheckIn || hasCheckOut} + + {#if lodging && (hasCheckIn || hasCheckOut)}

Check-in

@@ -198,8 +231,15 @@
{/if} - - {#if lodging.confirmation_number || (lodging.price != null && lodging.price > 0)} + + {#if isTemplate && templatePlan?.notes} +
+

{templatePlan.notes}

+
+ {/if} + + + {#if lodging && (lodging.confirmation_number || (lodging.price != null && lodging.price > 0))}
{#if lodging.confirmation_number} Confirmation: {lodging.confirmation_number} diff --git a/src/lib/server/admin/data.ts b/src/lib/server/admin/data.ts index dedfeb3..fcd868f 100644 --- a/src/lib/server/admin/data.ts +++ b/src/lib/server/admin/data.ts @@ -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('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( + `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( + '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]); +} diff --git a/src/lib/server/admin/providers/g-adventures.ts b/src/lib/server/admin/providers/g-adventures.ts index 433ff98..29ece7f 100644 --- a/src/lib/server/admin/providers/g-adventures.ts +++ b/src/lib/server/admin/providers/g-adventures.ts @@ -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 { + 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 { + 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 => { 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 => { + // 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 }; } }; diff --git a/src/lib/server/admin/providers/types.ts b/src/lib/server/admin/providers/types.ts index 296af26..d212e0a 100644 --- a/src/lib/server/admin/providers/types.ts +++ b/src/lib/server/admin/providers/types.ts @@ -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; + /** Fetch full details (description + itinerary days) for a specific tour by provider ID */ + getDetail(id: string): Promise; } diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 2cb70b5..5d9418f 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -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 ( diff --git a/src/lib/server/package-tours.ts b/src/lib/server/package-tours.ts index d927264..b29d378 100644 --- a/src/lib/server/package-tours.ts +++ b/src/lib/server/package-tours.ts @@ -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' + }); + } } } diff --git a/src/lib/server/plans.ts b/src/lib/server/plans.ts index b221f0e..5c88705 100644 --- a/src/lib/server/plans.ts +++ b/src/lib/server/plans.ts @@ -85,6 +85,47 @@ export function createDestination(input: CreateDestinationInput): Plan { return db.get('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('SELECT * FROM plans WHERE id = ?', [id])!; +} + export function getPlansForTrip(tripId: string, userId: string): Plan[] { return db.all( `SELECT * FROM plans WHERE trip_id = ? AND user_id = ? ORDER BY position ASC, created_at ASC`, diff --git a/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.server.ts b/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.server.ts index 3877fba..4c844b1 100644 --- a/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.server.ts +++ b/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.server.ts @@ -83,12 +83,54 @@ export const actions: Actions = { const operatorId = parseInt(event.params.operatorId); if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' }); + const operators = data.listTourOperators(); + const operator = operators.find((o) => o.id === operatorId); + if (!operator) return fail(404, { error: 'Operator not found' }); + const formData = await event.request.formData(); const name = (formData.get('name') as string)?.trim(); + const providerId = (formData.get('providerId') as string)?.trim(); if (!name) return fail(400, { error: 'Tour name is required' }); try { - data.createOperatorTour(operatorId, name); + // Try to fetch full detail from the provider + const provider = getProviderForOperator(operator.name); + let description: string | null = null; + let days: { + dayNumber: number; + title: string; + description: string; + dayPlans?: { type: 'transport' | 'lodging'; title: string; notes?: string | null }[]; + }[] = []; + + if (provider && providerId) { + const detail = await provider.getDetail(providerId); + if (detail) { + description = detail.description || null; + days = detail.days; + } + } + + const tour = data.createOperatorTour(operatorId, name, description); + + // Import itinerary days and any transport/lodging day plans from provider components + for (const day of days) { + const createdDay = data.createOperatorTourDay( + tour.id, + day.title || undefined, + day.description || undefined + ); + for (const plan of day.dayPlans ?? []) { + data.createOperatorTourDayPlan( + createdDay.id, + plan.type, + plan.title, + plan.notes ?? undefined, + plan.type === 'lodging' && plan.lodgingFields ? plan.lodgingFields : undefined + ); + } + } + return { success: true }; } catch (err) { return fail(400, { error: err instanceof Error ? err.message : 'Failed to import tour' }); diff --git a/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.svelte b/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.svelte index 7f15aa7..64a0f6e 100644 --- a/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.svelte +++ b/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.svelte @@ -32,6 +32,7 @@ let importQuery = $state(''); let importResults = $state<{ id: string; title: string }[]>([]); let importLoading = $state(false); + let importSelectedId = $state(''); let importSelectedTitle = $state(''); let importError = $state(''); let searchDebounce: ReturnType; @@ -40,6 +41,7 @@ importDrawerOpen = true; importQuery = ''; importResults = []; + importSelectedId = ''; importSelectedTitle = ''; importError = ''; // Load initial results @@ -50,6 +52,7 @@ importDrawerOpen = false; importQuery = ''; importResults = []; + importSelectedId = ''; importSelectedTitle = ''; importError = ''; } @@ -78,7 +81,8 @@ } } - function selectImportResult(title: string) { + function selectImportResult(id: string, title: string) { + importSelectedId = id; importSelectedTitle = title; } @@ -108,7 +112,14 @@ onclick={openImportDrawer} class="flex items-center gap-2 rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" > - + @@ -117,7 +128,11 @@ {/if}
{#if data.tours.length === 0 && !showAddForm}
- + @@ -141,8 +164,14 @@ - - + + @@ -168,10 +197,21 @@ type="text" bind:value={editName} class="min-w-0 flex-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" - onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }} + onkeydown={(e) => { + if (e.key === 'Escape') cancelEdit(); + }} /> - - + + {#if editError}

{editError}

{/if} @@ -182,22 +222,30 @@ @@ -301,7 +376,14 @@ class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600" aria-label="Close" > - + @@ -311,7 +393,13 @@
- + @@ -325,7 +413,14 @@ {#if importLoading}
- +
@@ -345,12 +440,21 @@
  • + +
  • +
    + + {:else} +
    +
    + + Transport + + {plan.title} + {#if plan.notes} +

    {plan.notes}

    + {/if} +
    +
    + +
    ({ update }) => update()}> + + + +
    +
    + {/if} + {/if} + {/each} + + {#if addPlanFor?.dayId === day.id && addPlanFor?.type === 'transport'} + { + return ({ result, update }) => { + update(); + if (result.type === 'success') cancelAddPlan(); + }; + }} + class="mb-2 rounded-md border border-dashed border-gray-200 bg-gray-50/30 p-3" + > + + +

    Add transport

    +
    + + +
    + + +
    +
    + + {:else} +
    + + +
    + {/if} + {/each}
    Tour nameActionsTour nameActions
    {tour.name} + class="hover:text-blue-700 hover:underline">{tour.name}
    Itinerary → + >Itinerary →