package-tours) adding support for pre-defining tours

This commit is contained in:
2026-02-19 16:39:58 -05:00
parent dba9aa0416
commit 18f9bbfbd7
23 changed files with 2167 additions and 64 deletions

View File

@@ -57,6 +57,11 @@
{ label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' }
];
interface PredefinedTour {
id: number;
name: string;
}
// Operator search
let operatorName = $state('');
let operatorQuery = $state('');
@@ -66,6 +71,9 @@
let operatorDebounce: ReturnType<typeof setTimeout>;
// Logo for the selected operator
let operatorLogoUrl = $state<string | null>(null);
// Predefined tours for the selected operator
let predefinedTours = $state<PredefinedTour[]>([]);
let selectedOperatorTourId = $state<number | null>(null);
function logoSlug(name: string): string {
return name
@@ -120,14 +128,27 @@
searchOperators(value);
}
async function loadToursForOperator(operatorId: number) {
try {
const res = await fetch(`${base}/api/operator-tours?operator_id=${operatorId}`);
predefinedTours = await res.json();
} catch {
predefinedTours = [];
}
}
function selectOperator(op: TourOperator) {
operatorName = op.name;
operatorQuery = op.name;
showOperatorDropdown = false;
checkOperatorLogo(op.name);
loadToursForOperator(op.id);
selectedOperatorTourId = null;
tourName = '';
}
// Core form state
let tourName = $state('');
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
let confirmationNumber = $state('');
let startDate = $state('');
@@ -154,6 +175,9 @@
operators = [];
showOperatorDropdown = false;
operatorLogoUrl = null;
predefinedTours = [];
selectedOperatorTourId = null;
tourName = '';
status = 'idea';
confirmationNumber = '';
startDate = '';
@@ -307,6 +331,45 @@
</div>
</div>
<!-- Tour name -->
<div class="flex flex-col gap-1.5">
<label for="tour_name" class="text-sm font-medium text-gray-700">
Tour name <span class="font-normal text-gray-400">(optional)</span>
</label>
{#if predefinedTours.length > 0}
<select
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"
onchange={(e) => {
const id = parseInt(e.currentTarget.value);
const selected = predefinedTours.find((pt) => pt.id === id);
if (selected) {
tourName = selected.name;
selectedOperatorTourId = selected.id;
} else {
tourName = '';
selectedOperatorTourId = null;
}
}}
>
<option value="">Choose a predefined tour...</option>
{#each predefinedTours as pt}
<option value={pt.id}>{pt.name}</option>
{/each}
</select>
{/if}
<input
id="tour_name"
name="tour_name"
type="text"
bind:value={tourName}
placeholder={predefinedTours.length > 0 ? 'Or type a custom name' : 'e.g. Peru'}
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"
/>
{#if selectedOperatorTourId}
<input type="hidden" name="operator_tour_id" value={selectedOperatorTourId} />
{/if}
</div>
<!-- Status -->
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-700">Status</span>

View File

@@ -66,6 +66,11 @@
{ label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' }
];
interface PredefinedTour {
id: number;
name: string;
}
// Operator search state
let operatorName = $state('');
let operatorQuery = $state('');
@@ -74,6 +79,8 @@
let showOperatorDropdown = $state(false);
let operatorDebounce: ReturnType<typeof setTimeout>;
let operatorLogoUrl = $state<string | null>(null);
// Predefined tours for the selected operator
let predefinedTours = $state<PredefinedTour[]>([]);
function logoSlug(name: string): string {
return name
@@ -128,14 +135,40 @@
searchOperators(value);
}
async function loadToursForOperator(operatorId: number) {
try {
const res = await fetch(`${base}/api/operator-tours?operator_id=${operatorId}`);
predefinedTours = await res.json();
} catch {
predefinedTours = [];
}
}
async function loadToursForOperatorByName(name: string) {
if (!name) {
predefinedTours = [];
return;
}
try {
const res = await fetch(
`${base}/api/operator-tours?operator_name=${encodeURIComponent(name)}`
);
predefinedTours = await res.json();
} catch {
predefinedTours = [];
}
}
function selectOperator(op: TourOperator) {
operatorName = op.name;
operatorQuery = op.name;
showOperatorDropdown = false;
checkOperatorLogo(op.name);
loadToursForOperator(op.id);
}
// Core form state
let tourName = $state('');
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
let confirmationNumber = $state('');
let startDate = $state('');
@@ -153,6 +186,7 @@
if (open && tour) {
operatorName = tour.operator_name;
operatorQuery = tour.operator_name;
tourName = tour.tour_name ?? '';
status = tour.planStatus;
confirmationNumber = tour.confirmation_number ?? '';
startDate = tour.start_date ?? '';
@@ -164,9 +198,10 @@
price = tour.price != null ? String(tour.price) : '';
currency = tour.currency;
selectedTravellers = [...tour.travellerIds];
// Load operators list and check for logo
// Load operators list, logo, and predefined tours
searchOperators('');
checkOperatorLogo(tour.operator_name);
loadToursForOperatorByName(tour.operator_name);
}
});
@@ -312,6 +347,35 @@
</div>
</div>
<!-- Tour name -->
<div class="flex flex-col gap-1.5">
<label for="edit_tour_name" class="text-sm font-medium text-gray-700">
Tour name <span class="font-normal text-gray-400">(optional)</span>
</label>
{#if predefinedTours.length > 0}
<select
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"
onchange={(e) => {
tourName = e.currentTarget.value;
}}
>
<option value="">Choose a predefined tour...</option>
{#each predefinedTours as pt}
<option value={pt.name} selected={pt.name === tourName}>{pt.name}</option>
{/each}
<option value="">— or enter manually below —</option>
</select>
{/if}
<input
id="edit_tour_name"
name="tour_name"
type="text"
bind:value={tourName}
placeholder={predefinedTours.length > 0 ? 'Or type a custom tour name' : 'e.g. Peru'}
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"
/>
</div>
<!-- Status -->
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-700">Status</span>

View File

@@ -1,23 +1,64 @@
<script lang="ts">
import { base } from '$app/paths';
import { enhance } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import type { Plan } from '$lib/server/plans.js';
import type { PackageTour, ChildPlanSummary } from '$lib/server/package-tours.js';
import type { PackageTour, TourDay, ChildPlanSummary } from '$lib/server/package-tours.js';
interface Props {
plan: Plan;
tour: PackageTour & {
travellerIds: string[];
childPlans: ChildPlanSummary[];
days: TourDay[];
ungroupedChildPlans: ChildPlanSummary[];
highlight_color: string | null;
};
onEdit?: () => void;
onDelete?: () => void;
onAddFlight?: () => void;
onAddLodging?: () => void;
onAddFlightToDay?: (dayPlanId: string) => void;
onAddLodgingToDay?: (dayPlanId: string) => void;
}
let { plan, tour, onEdit, onDelete, onAddFlight, onAddLodging }: Props = $props();
let {
plan,
tour,
onEdit,
onDelete,
onAddFlight,
onAddLodging,
onAddFlightToDay,
onAddLodgingToDay
}: Props = $props();
// Collapsible days — all expanded by default
let expandedDays = new SvelteSet(tour.days.map((d) => d.plan_id));
function toggleDay(planId: string) {
if (expandedDays.has(planId)) {
expandedDays.delete(planId);
} else {
expandedDays.add(planId);
}
}
// Per-day "Add to day" dropdown state
let openDayMenu = $state<string | null>(null);
let dayMenuOpenUp = $state(false);
function toggleDayMenu(e: MouseEvent, dayPlanId: string) {
if (openDayMenu === dayPlanId) {
openDayMenu = null;
return;
}
const btn = e.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
dayMenuOpenUp = rect.bottom + 96 > window.innerHeight;
openDayMenu = dayPlanId;
}
// Top-level "Add to tour" dropdown
let showAddMenu = $state(false);
let addMenuOpenUp = $state(false);
@@ -28,12 +69,33 @@
}
const btn = e.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
// Approximate height of the dropdown (2 items × ~40px + padding)
const dropdownHeight = 96;
addMenuOpenUp = rect.bottom + dropdownHeight > window.innerHeight;
showAddMenu = true;
}
// Inline add-day form
let showAddDayForm = $state(false);
let addDayTitle = $state('');
let addDayNotes = $state('');
// Per-day inline edit form
let editingDayPlanId = $state<string | null>(null);
let editDayTitle = $state('');
let editDayNotes = $state('');
function startEditDay(day: TourDay) {
editingDayPlanId = day.plan_id;
editDayTitle = day.title ?? '';
editDayNotes = day.notes ?? '';
}
function cancelEditDay() {
editingDayPlanId = null;
editDayTitle = '';
editDayNotes = '';
}
function formatDate(d: string | null): string {
if (!d) return '';
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
@@ -89,10 +151,10 @@
const hasStart = $derived(tour.start_date || tour.start_time);
const hasEnd = $derived(tour.end_date || tour.end_time);
const visibleChildren = $derived(tour.childPlans.slice(0, 3));
const extraChildren = $derived(Math.max(0, tour.childPlans.length - 3));
const visibleUngrouped = $derived(tour.ungroupedChildPlans.slice(0, 3));
const extraUngrouped = $derived(Math.max(0, tour.ungroupedChildPlans.length - 3));
// Operator logo + highlight colour
// Operator logo
function logoSlug(name: string): string {
return name
.toLowerCase()
@@ -158,7 +220,9 @@
{/if}
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="font-medium text-gray-900">{tour.operator_name}</p>
<p class="font-medium text-gray-900">
{tour.operator_name}{tour.tour_name ? `: ${tour.tour_name}` : ''}
</p>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[
plan.status
@@ -274,12 +338,380 @@
</div>
{/if}
<!-- Child plans -->
{#if tour.childPlans.length > 0}
<!-- Days -->
{#if tour.days.length > 0 || showAddDayForm}
<div class="border-t border-gray-100 pt-4">
{#each tour.days as day (day.plan_id)}
{@const isExpanded = expandedDays.has(day.plan_id)}
{@const isEditing = editingDayPlanId === day.plan_id}
<div class="mb-2 overflow-hidden rounded-lg border border-gray-100">
<!-- Day header -->
<div class="flex items-center gap-1 bg-gray-50 px-3 py-2">
<button
type="button"
onclick={() => toggleDay(day.plan_id)}
class="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
class="shrink-0 text-gray-400 transition-transform {isExpanded ? 'rotate-90' : ''}"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="truncate text-sm font-medium text-gray-800">
Day {day.day_number}{day.title ? ` ${day.title}` : ''}
</span>
</button>
<!-- Edit day button -->
{#if !isEditing}
<button
type="button"
onclick={() => startEditDay(day)}
class="shrink-0 rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
aria-label="Edit day"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/if}
<!-- Add to day dropdown -->
{#if onAddFlightToDay || onAddLodgingToDay}
<div class="relative shrink-0">
<button
type="button"
onclick={(e) => toggleDayMenu(e, day.plan_id)}
class="flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 hover:text-gray-700"
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add
</button>
{#if openDayMenu === day.plan_id}
<div
class="fixed inset-0 z-10"
role="button"
tabindex="-1"
onclick={() => (openDayMenu = null)}
onkeydown={(e) => e.key === 'Escape' && (openDayMenu = null)}
></div>
<div
class="absolute right-0 z-20 w-40 overflow-hidden rounded-lg border border-gray-200 bg-white py-1 shadow-lg {dayMenuOpenUp
? 'bottom-full mb-1'
: 'top-full mt-1'}"
>
{#if onAddFlightToDay}
<button
type="button"
onclick={() => {
openDayMenu = null;
onAddFlightToDay?.(day.plan_id);
}}
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="#F97316"
stroke-width="1.5"
>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.99-3.99A19.79 19.79 0 0 1 4.1 6.18 2 2 0 0 1 6.08 4h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L10.09 11a16 16 0 0 0 5.91 5.91l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 24 18z"
/>
</svg>
Flight
</button>
{/if}
{#if onAddLodgingToDay}
<button
type="button"
onclick={() => {
openDayMenu = null;
onAddLodgingToDay?.(day.plan_id);
}}
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="#A855F7"
stroke-width="1.5"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
Lodging
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Day body (expanded) -->
{#if isExpanded}
<div class="px-3 py-2.5">
<!-- Inline edit form -->
{#if isEditing}
<form
method="POST"
action="?/editTourDay"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') cancelEditDay();
};
}}
class="flex flex-col gap-2"
>
<input type="hidden" name="day_plan_id" value={day.plan_id} />
<input
name="title"
type="text"
bind:value={editDayTitle}
placeholder="Day title (optional)"
class="rounded-md border border-gray-300 px-2.5 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<textarea
name="notes"
bind:value={editDayNotes}
placeholder="Description (optional)"
rows="2"
class="resize-none rounded-md border border-gray-300 px-2.5 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Save
</button>
<button
type="button"
onclick={cancelEditDay}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
{:else}
<!-- Description -->
{#if day.notes}
<p class="mb-2 text-sm text-gray-600">{day.notes}</p>
{/if}
<!-- Child plan chips -->
{#if day.childPlans.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each day.childPlans as child}
{@const config = childTypeConfig[child.type] ?? {
label: child.type,
icon: ''
}}
<span
class="flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-2.5 py-1 text-xs text-gray-700"
>
{@html config.icon}
{child.title}
<span
class="ml-0.5 h-1.5 w-1.5 rounded-full {child.status === 'confirmed'
? 'bg-green-500'
: child.status === 'tentative'
? 'bg-yellow-400'
: 'bg-gray-300'}"
></span>
</span>
{/each}
</div>
{:else}
<p class="text-xs text-gray-400 italic">No plans yet</p>
{/if}
{/if}
</div>
{/if}
</div>
{/each}
<!-- Inline add-day form -->
{#if showAddDayForm}
<form
method="POST"
action="?/addTourDay"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') {
showAddDayForm = false;
addDayTitle = '';
addDayNotes = '';
}
};
}}
class="mt-1 flex flex-col gap-2 rounded-lg border border-blue-200 bg-blue-50/50 p-3"
>
<input type="hidden" name="tour_plan_id" value={plan.id} />
<input
name="title"
type="text"
bind:value={addDayTitle}
placeholder="Day title (optional, e.g. The Rainforest)"
class="rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<textarea
name="notes"
bind:value={addDayNotes}
placeholder="Description (optional)"
rows="2"
class="resize-none rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Add day
</button>
<button
type="button"
onclick={() => {
showAddDayForm = false;
addDayTitle = '';
addDayNotes = '';
}}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
{:else}
<button
type="button"
onclick={() => (showAddDayForm = true)}
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-gray-400 hover:bg-gray-50 hover:text-gray-600"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add day
</button>
{/if}
</div>
{:else}
<!-- No days yet — show add day button -->
<div class="border-t border-gray-100 pt-3">
{#if showAddDayForm}
<form
method="POST"
action="?/addTourDay"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') {
showAddDayForm = false;
addDayTitle = '';
addDayNotes = '';
}
};
}}
class="flex flex-col gap-2 rounded-lg border border-blue-200 bg-blue-50/50 p-3"
>
<input type="hidden" name="tour_plan_id" value={plan.id} />
<input
name="title"
type="text"
bind:value={addDayTitle}
placeholder="Day title (optional, e.g. The Rainforest)"
class="rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<textarea
name="notes"
bind:value={addDayNotes}
placeholder="Description (optional)"
rows="2"
class="resize-none rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Add day
</button>
<button
type="button"
onclick={() => {
showAddDayForm = false;
addDayTitle = '';
addDayNotes = '';
}}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
{:else}
<button
type="button"
onclick={() => (showAddDayForm = true)}
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-gray-400 hover:bg-gray-50 hover:text-gray-600"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add day
</button>
{/if}
</div>
{/if}
<!-- Ungrouped child plans -->
{#if tour.ungroupedChildPlans.length > 0}
<div class="border-t border-gray-100 pt-4">
<p class="mb-2 text-xs font-medium tracking-wide text-gray-400 uppercase">Includes</p>
<div class="flex flex-wrap gap-2">
{#each visibleChildren as child}
{#each visibleUngrouped as child}
{@const config = childTypeConfig[child.type] ?? { label: child.type, icon: '' }}
<span
class="flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-700"
@@ -295,18 +727,18 @@
></span>
</span>
{/each}
{#if extraChildren > 0}
{#if extraUngrouped > 0}
<span
class="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-500"
>
+{extraChildren} more
+{extraUngrouped} more
</span>
{/if}
</div>
</div>
{/if}
<!-- Add child plan button -->
<!-- Top-level "Add to tour" button (for ungrouped plans) -->
{#if onAddFlight || onAddLodging}
<div class="relative border-t border-gray-100 pt-3">
<button

View File

@@ -311,3 +311,110 @@ export function getTourOperatorByName(name: string): TourOperator | null {
]) ?? null
);
}
// --- Operator Tours ---
export interface OperatorTour {
id: number;
operator_id: number;
name: string;
created_at: string;
updated_at: string;
}
export function listToursForOperator(operatorId: number): OperatorTour[] {
return db.all<OperatorTour>('SELECT * FROM operator_tours WHERE operator_id = ? ORDER BY name', [
operatorId
]);
}
export function listToursForOperatorByName(operatorName: string): OperatorTour[] {
const operator = getTourOperatorByName(operatorName);
if (!operator) return [];
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()]);
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 deleteOperatorTour(id: number): void {
db.run('DELETE FROM operator_tours WHERE id = ?', [id]);
}
// --- Operator Tour Days ---
export interface OperatorTourDay {
id: number;
operator_tour_id: number;
day_number: number;
title: string | null;
notes: string | null;
position: number;
}
export function listDaysForOperatorTour(tourId: number): OperatorTourDay[] {
return db.all<OperatorTourDay>(
'SELECT * FROM operator_tour_days WHERE operator_tour_id = ? ORDER BY position ASC, day_number ASC',
[tourId]
);
}
export function createOperatorTourDay(
tourId: number,
title?: string,
notes?: string
): OperatorTourDay {
const count = db.get<{ count: number }>(
'SELECT COUNT(*) as count FROM operator_tour_days WHERE operator_tour_id = ?',
[tourId]
);
const dayNumber = (count?.count ?? 0) + 1;
const maxPos = db.get<{ pos: number }>(
'SELECT COALESCE(MAX(position), -1) + 1 as pos FROM operator_tour_days WHERE operator_tour_id = ?',
[tourId]
);
const position = maxPos?.pos ?? 0;
db.run(
`INSERT INTO operator_tour_days (operator_tour_id, day_number, title, notes, position)
VALUES (?, ?, ?, ?, ?)`,
[tourId, dayNumber, title?.trim() || null, notes?.trim() || null, position]
);
return db.get<OperatorTourDay>(
'SELECT * FROM operator_tour_days WHERE id = last_insert_rowid()'
)!;
}
export function updateOperatorTourDay(id: number, title?: string, notes?: string): void {
db.run(
`UPDATE operator_tour_days SET title = ?, notes = ?, updated_at = datetime('now') WHERE id = ?`,
[title?.trim() || null, notes?.trim() || null, id]
);
}
export function deleteOperatorTourDay(id: number): void {
const day = db.get<{ operator_tour_id: number }>(
'SELECT operator_tour_id FROM operator_tour_days WHERE id = ?',
[id]
);
db.run('DELETE FROM operator_tour_days WHERE id = ?', [id]);
if (day) {
// Re-number remaining days by position order
const remaining = db.all<{ id: number }>(
'SELECT id FROM operator_tour_days WHERE operator_tour_id = ? ORDER BY position ASC, id ASC',
[day.operator_tour_id]
);
remaining.forEach((row, i) => {
db.run('UPDATE operator_tour_days SET day_number = ? WHERE id = ?', [i + 1, row.id]);
});
}
}

View File

@@ -0,0 +1,21 @@
import { env } from '$env/dynamic/private';
import type { TourProvider, TourSearchResult } from './types.js';
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);
if (!res.ok) return [];
const data = await res.json();
return (data.results ?? []).map((r: { id: string; name: string }) => ({
id: r.id,
title: r.name
}));
}
};

View File

@@ -0,0 +1,10 @@
import type { TourProvider } from './types.js';
import { GAdventuresProvider } from './g-adventures.js';
const PROVIDERS: Record<string, TourProvider> = {
'g adventures': GAdventuresProvider
};
export function getProviderForOperator(operatorName: string): TourProvider | null {
return PROVIDERS[operatorName.toLowerCase()] ?? null;
}

View File

@@ -0,0 +1,13 @@
export interface TourSearchResult {
/** Provider-specific identifier for the tour */
id: string;
/** Display name of the tour */
title: string;
}
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[]>;
}

View File

@@ -255,6 +255,7 @@ export function runMigrations(db: Database): void {
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
operator_name TEXT NOT NULL,
tour_name TEXT,
confirmation_number TEXT,
start_date TEXT,
start_time TEXT,
@@ -268,6 +269,12 @@ export function runMigrations(db: Database): void {
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Migration: add tour_name to existing databases
try {
db.run(`ALTER TABLE package_tours ADD COLUMN tour_name TEXT`);
} catch {
/* already exists */
}
// Package tour travellers - links people to package tours
db.run(`
@@ -290,6 +297,31 @@ export function runMigrations(db: Database): void {
)
`);
// Predefined tours per tour operator (admin-managed)
db.run(`
CREATE TABLE IF NOT EXISTS operator_tours (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_id INTEGER NOT NULL REFERENCES tour_operators(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Template itinerary days for predefined operator tours
db.run(`
CREATE TABLE IF NOT EXISTS operator_tour_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_tour_id INTEGER NOT NULL REFERENCES operator_tours(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL DEFAULT 1,
title TEXT,
notes TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Countries table — editable list for admin (used by country selector)
db.run(`
CREATE TABLE IF NOT EXISTS countries (

View File

@@ -3,7 +3,13 @@ import { setupTestDb } from '../../tests/helpers.js';
import type { Database } from './db/types.js';
import { createTrip } from './trips.js';
import { createPerson } from './travellers.js';
import { createPackageTour, getPackageToursForTrip, updatePackageTour } from './package-tours.js';
import {
createPackageTour,
getPackageToursForTrip,
updatePackageTour,
createTourDay,
updateTourDay
} from './package-tours.js';
let db: Database;
beforeEach(() => {
@@ -34,6 +40,19 @@ describe('createPackageTour', () => {
expect(plan?.title).toBe('Viking');
});
it('sets tour_name and uses it in the plan title', () => {
const trip = makeTrip();
const tour = createPackageTour({
tripId: trip.id,
userId: 'u1',
operatorName: 'G Adventures',
tourName: 'Peru'
});
expect(tour.tour_name).toBe('Peru');
const plan = db.get<{ title: string }>('SELECT title FROM plans WHERE id = ?', [tour.plan_id]);
expect(plan?.title).toBe('G Adventures: Peru');
});
it('stores all optional fields', () => {
const trip = makeTrip();
const tour = createPackageTour({
@@ -78,7 +97,7 @@ describe('createPackageTour', () => {
});
describe('getPackageToursForTrip', () => {
it('returns tours with planStatus, travellerIds, and childPlans', () => {
it('returns tours with planStatus, travellerIds, empty days and ungrouped plans', () => {
const trip = makeTrip();
const person = createPerson('u1', 'Bob', 'B');
const tour = createPackageTour({
@@ -89,7 +108,7 @@ describe('getPackageToursForTrip', () => {
travellerIds: [person.id]
});
// Insert a child plan manually
// Insert an ungrouped child plan directly under the tour
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, parent_id, position)
VALUES ('child-1', ?, 'u1', 'lodging', 'confirmed', 'Tour Hotel', ?, 99)`,
@@ -100,9 +119,9 @@ describe('getPackageToursForTrip', () => {
expect(tours).toHaveLength(1);
expect(tours[0].planStatus).toBe('tentative');
expect(tours[0].travellerIds).toContain(person.id);
expect(tours[0].childPlans).toHaveLength(1);
expect(tours[0].childPlans[0].title).toBe('Tour Hotel');
expect(tours[0].childPlans[0].type).toBe('lodging');
expect(tours[0].days).toHaveLength(0);
expect(tours[0].ungroupedChildPlans).toHaveLength(1);
expect(tours[0].ungroupedChildPlans[0].title).toBe('Tour Hotel');
});
it('returns highlight_color from the tour_operators table', () => {
@@ -126,9 +145,51 @@ describe('getPackageToursForTrip', () => {
createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Globus' });
expect(getPackageToursForTrip(trip.id, 'u2')).toHaveLength(0);
});
it('returns days with child plans nested inside', () => {
const trip = makeTrip();
const tour = createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'G Adventures' });
const day = createTourDay({
tripId: trip.id,
userId: 'u1',
tourPlanId: tour.plan_id,
title: 'The Rainforest'
});
// Insert a child plan under the day
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, parent_id, position)
VALUES ('day-child-1', ?, 'u1', 'lodging', 'confirmed', 'Jungle Lodge', ?, 99)`,
[trip.id, day.plan_id]
);
const tours = getPackageToursForTrip(trip.id, 'u1');
expect(tours[0].days).toHaveLength(1);
expect(tours[0].days[0].day_number).toBe(1);
expect(tours[0].days[0].title).toBe('The Rainforest');
expect(tours[0].days[0].childPlans).toHaveLength(1);
expect(tours[0].days[0].childPlans[0].title).toBe('Jungle Lodge');
// Day itself does not appear in ungrouped
expect(tours[0].ungroupedChildPlans).toHaveLength(0);
});
});
describe('updatePackageTour', () => {
it('updates tour_name and plan title', () => {
const trip = makeTrip();
const tour = createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Contiki' });
updatePackageTour({
tourId: tour.id,
userId: 'u1',
operatorName: 'Contiki',
tourName: 'Europe'
});
const updated = getPackageToursForTrip(trip.id, 'u1')[0];
expect(updated.tour_name).toBe('Europe');
const plan = db.get<{ title: string }>('SELECT title FROM plans WHERE id = ?', [tour.plan_id]);
expect(plan?.title).toBe('Contiki: Europe');
});
it('updates both plan and package_tours rows', () => {
const trip = makeTrip();
const tour = createPackageTour({
@@ -181,3 +242,75 @@ describe('updatePackageTour', () => {
).toThrow();
});
});
describe('createTourDay', () => {
it('creates a day plan under the tour with auto day number title', () => {
const trip = makeTrip();
const tour = createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Viking' });
const day = createTourDay({ tripId: trip.id, userId: 'u1', tourPlanId: tour.plan_id });
expect(day.plan_id).toBeTruthy();
expect(day.day_number).toBe(1);
expect(day.title).toBe('Day 1');
expect(day.notes).toBeNull();
expect(day.childPlans).toHaveLength(0);
const row = db.get<{ type: string; parent_id: string }>(
'SELECT type, parent_id FROM plans WHERE id = ?',
[day.plan_id]
);
expect(row?.type).toBe('day');
expect(row?.parent_id).toBe(tour.plan_id);
});
it('uses the provided title', () => {
const trip = makeTrip();
const tour = createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Viking' });
const day = createTourDay({
tripId: trip.id,
userId: 'u1',
tourPlanId: tour.plan_id,
title: 'The Rainforest',
notes: 'A day in the Amazon jungle.'
});
expect(day.title).toBe('The Rainforest');
expect(day.notes).toBe('A day in the Amazon jungle.');
});
it('assigns sequential day numbers', () => {
const trip = makeTrip();
const tour = createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Viking' });
createTourDay({ tripId: trip.id, userId: 'u1', tourPlanId: tour.plan_id });
const day2 = createTourDay({ tripId: trip.id, userId: 'u1', tourPlanId: tour.plan_id });
expect(day2.day_number).toBe(2);
});
});
describe('updateTourDay', () => {
it('updates title and notes', () => {
const trip = makeTrip();
const tour = createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Viking' });
const day = createTourDay({ tripId: trip.id, userId: 'u1', tourPlanId: tour.plan_id });
updateTourDay({
dayPlanId: day.plan_id,
userId: 'u1',
title: 'Machu Picchu',
notes: 'The big day.'
});
const row = db.get<{ title: string; notes: string }>(
'SELECT title, notes FROM plans WHERE id = ?',
[day.plan_id]
);
expect(row?.title).toBe('Machu Picchu');
expect(row?.notes).toBe('The big day.');
});
it('throws when the user does not own the day', () => {
const trip = makeTrip();
const tour = createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Viking' });
const day = createTourDay({ tripId: trip.id, userId: 'u1', tourPlanId: tour.plan_id });
expect(() =>
updateTourDay({ dayPlanId: day.plan_id, userId: 'u2', title: 'Hijacked' })
).toThrow();
});
});

View File

@@ -1,11 +1,13 @@
import { db } from './db/index.js';
import { randomUUID } from 'crypto';
import type { PlanStatus } from './plans.js';
import { listDaysForOperatorTour } from './admin/data.js';
export interface PackageTour {
id: string;
plan_id: string;
operator_name: string;
tour_name: string | null;
confirmation_number: string | null;
start_date: string | null;
start_time: string | null;
@@ -27,11 +29,20 @@ export interface ChildPlanSummary {
start_date: string | null;
}
export interface TourDay {
plan_id: string;
day_number: number;
title: string | null;
notes: string | null;
childPlans: ChildPlanSummary[];
}
export interface CreatePackageTourInput {
tripId: string;
userId: string;
status?: PlanStatus;
operatorName: string;
tourName?: string;
confirmationNumber?: string;
startDate?: string;
startTime?: string;
@@ -49,6 +60,7 @@ export interface UpdatePackageTourInput {
userId: string;
status?: PlanStatus;
operatorName: string;
tourName?: string;
confirmationNumber?: string;
startDate?: string;
startTime?: string;
@@ -69,24 +81,26 @@ export function createPackageTour(input: CreatePackageTourInput): PackageTour {
);
const position = maxPos?.pos ?? 0;
const title = input.tourName ? `${input.operatorName}: ${input.tourName}` : input.operatorName;
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, position)
VALUES (?, ?, ?, 'tour', ?, ?, ?)`,
[planId, input.tripId, input.userId, input.status ?? 'idea', input.operatorName, position]
[planId, input.tripId, input.userId, input.status ?? 'idea', title, position]
);
const tourId = randomUUID();
db.run(
`INSERT INTO package_tours (
id, plan_id, operator_name, confirmation_number,
id, plan_id, operator_name, tour_name, confirmation_number,
start_date, start_time, start_timezone,
end_date, end_time, end_timezone,
price, currency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
tourId,
planId,
input.operatorName,
input.tourName ?? null,
input.confirmationNumber ?? null,
input.startDate ?? null,
input.startTime ?? null,
@@ -118,7 +132,8 @@ export function getPackageToursForTrip(
PackageTour & {
travellerIds: string[];
planStatus: PlanStatus;
childPlans: ChildPlanSummary[];
days: TourDay[];
ungroupedChildPlans: ChildPlanSummary[];
highlight_color: string | null;
}
> {
@@ -139,16 +154,46 @@ export function getPackageToursForTrip(
}>('SELECT person_id FROM package_tour_travellers WHERE package_tour_id = ?', [tour.id])
.map((r) => r.person_id);
const childPlans = db.all<ChildPlanSummary>(
`SELECT id, type, title, status, start_date
FROM plans
WHERE parent_id = ? AND user_id = ?
// Fetch day plans (type='day') directly under the tour, ordered by position
const dayRows = db.all<{ id: string; title: string; notes: string | null; position: number }>(
`SELECT id, title, notes, position FROM plans
WHERE parent_id = ? AND user_id = ? AND type = 'day'
ORDER BY position ASC, created_at ASC`,
[tour.plan_id, userId]
);
const days: TourDay[] = dayRows.map((day, i) => {
const childPlans = db.all<ChildPlanSummary>(
`SELECT id, type, title, status, start_date FROM plans
WHERE parent_id = ? AND user_id = ? AND type != 'day'
ORDER BY position ASC, created_at ASC`,
[day.id, userId]
);
return {
plan_id: day.id,
day_number: i + 1,
title: day.title || null,
notes: day.notes,
childPlans
};
});
// Ungrouped: direct children of the tour that are NOT days
const ungroupedChildPlans = db.all<ChildPlanSummary>(
`SELECT id, type, title, status, start_date FROM plans
WHERE parent_id = ? AND user_id = ? AND type != 'day'
ORDER BY position ASC, created_at ASC`,
[tour.plan_id, userId]
);
const { plan_status, ...rest } = tour;
return { ...rest, travellerIds, planStatus: plan_status as PlanStatus, childPlans };
return {
...rest,
travellerIds,
planStatus: plan_status as PlanStatus,
days,
ungroupedChildPlans
};
});
}
@@ -161,15 +206,16 @@ export function updatePackageTour(input: UpdatePackageTourInput): void {
);
if (!tour) throw new Error('Package tour not found or not authorized');
const title = input.tourName ? `${input.operatorName}: ${input.tourName}` : input.operatorName;
db.run(`UPDATE plans SET status = ?, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
input.status ?? 'idea',
input.operatorName,
title,
tour.plan_id
]);
db.run(
`UPDATE package_tours SET
operator_name = ?, confirmation_number = ?,
operator_name = ?, tour_name = ?, confirmation_number = ?,
start_date = ?, start_time = ?, start_timezone = ?,
end_date = ?, end_time = ?, end_timezone = ?,
price = ?, currency = ?,
@@ -177,6 +223,7 @@ export function updatePackageTour(input: UpdatePackageTourInput): void {
WHERE id = ?`,
[
input.operatorName,
input.tourName ?? null,
input.confirmationNumber ?? null,
input.startDate ?? null,
input.startTime ?? null,
@@ -200,3 +247,77 @@ export function updatePackageTour(input: UpdatePackageTourInput): void {
}
}
}
export function createTourDay(input: {
tripId: string;
userId: string;
tourPlanId: string;
title?: string;
notes?: string;
}): TourDay {
const planId = randomUUID();
const maxPos = 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;
// Count existing days to determine day number for the default title
const dayCount = db.get<{ count: number }>(
`SELECT COUNT(*) as count FROM plans WHERE parent_id = ? AND user_id = ? AND type = 'day'`,
[input.tourPlanId, input.userId]
);
const dayNumber = (dayCount?.count ?? 0) + 1;
const title = input.title?.trim() || `Day ${dayNumber}`;
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, notes, parent_id, position)
VALUES (?, ?, ?, 'day', 'confirmed', ?, ?, ?, ?)`,
[planId, input.tripId, input.userId, title, input.notes ?? null, input.tourPlanId, position]
);
return {
plan_id: planId,
day_number: dayNumber,
title,
notes: input.notes ?? null,
childPlans: []
};
}
export function updateTourDay(input: {
dayPlanId: string;
userId: string;
title?: string;
notes?: string;
}): void {
const existing = db.get<{ id: string }>(
`SELECT id FROM plans WHERE id = ? AND user_id = ? AND type = 'day'`,
[input.dayPlanId, input.userId]
);
if (!existing) throw new Error('Day not found or not authorized');
db.run(`UPDATE plans SET title = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
input.title?.trim() || null,
input.notes ?? null,
input.dayPlanId
]);
}
export function cloneTemplateDaysToTour(input: {
operatorTourId: number;
tripId: string;
userId: string;
tourPlanId: string;
}): void {
const days = listDaysForOperatorTour(input.operatorTourId);
for (const day of days) {
createTourDay({
tripId: input.tripId,
userId: input.userId,
tourPlanId: input.tourPlanId,
title: day.title ?? undefined,
notes: day.notes ?? undefined
});
}
}

View File

@@ -9,7 +9,8 @@ export type PlanType =
| 'restaurant'
| 'tour'
| 'packing'
| 'todo';
| 'todo'
| 'day';
export type PlanStatus = 'idea' | 'tentative' | 'confirmed';
@@ -106,9 +107,7 @@ export interface Country {
export function getCountries(query?: string): Country[] {
if (!query?.trim()) {
return db.all<Country>(
`SELECT name, country_code FROM countries ORDER BY name`
);
return db.all<Country>(`SELECT name, country_code FROM countries ORDER BY name`);
}
const pattern = `%${query.trim()}%`;
return db.all<Country>(