package-tours) adding support for pre-defining tours
This commit is contained in:
@@ -17,3 +17,4 @@ ADMIN_USER_IDS=
|
|||||||
# SQLite (default): file:trips.db
|
# SQLite (default): file:trips.db
|
||||||
# Postgres (future): postgresql://user:password@host:5432/trips
|
# Postgres (future): postgresql://user:password@host:5432/trips
|
||||||
DATABASE_URL=file:trips.db
|
DATABASE_URL=file:trips.db
|
||||||
|
GADVENTURES_API_KEY=
|
||||||
|
|||||||
@@ -57,6 +57,11 @@
|
|||||||
{ label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' }
|
{ label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface PredefinedTour {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Operator search
|
// Operator search
|
||||||
let operatorName = $state('');
|
let operatorName = $state('');
|
||||||
let operatorQuery = $state('');
|
let operatorQuery = $state('');
|
||||||
@@ -66,6 +71,9 @@
|
|||||||
let operatorDebounce: ReturnType<typeof setTimeout>;
|
let operatorDebounce: ReturnType<typeof setTimeout>;
|
||||||
// Logo for the selected operator
|
// Logo for the selected operator
|
||||||
let operatorLogoUrl = $state<string | null>(null);
|
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 {
|
function logoSlug(name: string): string {
|
||||||
return name
|
return name
|
||||||
@@ -120,14 +128,27 @@
|
|||||||
searchOperators(value);
|
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) {
|
function selectOperator(op: TourOperator) {
|
||||||
operatorName = op.name;
|
operatorName = op.name;
|
||||||
operatorQuery = op.name;
|
operatorQuery = op.name;
|
||||||
showOperatorDropdown = false;
|
showOperatorDropdown = false;
|
||||||
checkOperatorLogo(op.name);
|
checkOperatorLogo(op.name);
|
||||||
|
loadToursForOperator(op.id);
|
||||||
|
selectedOperatorTourId = null;
|
||||||
|
tourName = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core form state
|
// Core form state
|
||||||
|
let tourName = $state('');
|
||||||
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
|
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
|
||||||
let confirmationNumber = $state('');
|
let confirmationNumber = $state('');
|
||||||
let startDate = $state('');
|
let startDate = $state('');
|
||||||
@@ -154,6 +175,9 @@
|
|||||||
operators = [];
|
operators = [];
|
||||||
showOperatorDropdown = false;
|
showOperatorDropdown = false;
|
||||||
operatorLogoUrl = null;
|
operatorLogoUrl = null;
|
||||||
|
predefinedTours = [];
|
||||||
|
selectedOperatorTourId = null;
|
||||||
|
tourName = '';
|
||||||
status = 'idea';
|
status = 'idea';
|
||||||
confirmationNumber = '';
|
confirmationNumber = '';
|
||||||
startDate = '';
|
startDate = '';
|
||||||
@@ -307,6 +331,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Status -->
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-gray-700">Status</span>
|
<span class="text-sm font-medium text-gray-700">Status</span>
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
{ label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' }
|
{ label: 'Honolulu (HST, UTC-10)', value: 'Pacific/Honolulu' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface PredefinedTour {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Operator search state
|
// Operator search state
|
||||||
let operatorName = $state('');
|
let operatorName = $state('');
|
||||||
let operatorQuery = $state('');
|
let operatorQuery = $state('');
|
||||||
@@ -74,6 +79,8 @@
|
|||||||
let showOperatorDropdown = $state(false);
|
let showOperatorDropdown = $state(false);
|
||||||
let operatorDebounce: ReturnType<typeof setTimeout>;
|
let operatorDebounce: ReturnType<typeof setTimeout>;
|
||||||
let operatorLogoUrl = $state<string | null>(null);
|
let operatorLogoUrl = $state<string | null>(null);
|
||||||
|
// Predefined tours for the selected operator
|
||||||
|
let predefinedTours = $state<PredefinedTour[]>([]);
|
||||||
|
|
||||||
function logoSlug(name: string): string {
|
function logoSlug(name: string): string {
|
||||||
return name
|
return name
|
||||||
@@ -128,14 +135,40 @@
|
|||||||
searchOperators(value);
|
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) {
|
function selectOperator(op: TourOperator) {
|
||||||
operatorName = op.name;
|
operatorName = op.name;
|
||||||
operatorQuery = op.name;
|
operatorQuery = op.name;
|
||||||
showOperatorDropdown = false;
|
showOperatorDropdown = false;
|
||||||
checkOperatorLogo(op.name);
|
checkOperatorLogo(op.name);
|
||||||
|
loadToursForOperator(op.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core form state
|
// Core form state
|
||||||
|
let tourName = $state('');
|
||||||
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
|
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
|
||||||
let confirmationNumber = $state('');
|
let confirmationNumber = $state('');
|
||||||
let startDate = $state('');
|
let startDate = $state('');
|
||||||
@@ -153,6 +186,7 @@
|
|||||||
if (open && tour) {
|
if (open && tour) {
|
||||||
operatorName = tour.operator_name;
|
operatorName = tour.operator_name;
|
||||||
operatorQuery = tour.operator_name;
|
operatorQuery = tour.operator_name;
|
||||||
|
tourName = tour.tour_name ?? '';
|
||||||
status = tour.planStatus;
|
status = tour.planStatus;
|
||||||
confirmationNumber = tour.confirmation_number ?? '';
|
confirmationNumber = tour.confirmation_number ?? '';
|
||||||
startDate = tour.start_date ?? '';
|
startDate = tour.start_date ?? '';
|
||||||
@@ -164,9 +198,10 @@
|
|||||||
price = tour.price != null ? String(tour.price) : '';
|
price = tour.price != null ? String(tour.price) : '';
|
||||||
currency = tour.currency;
|
currency = tour.currency;
|
||||||
selectedTravellers = [...tour.travellerIds];
|
selectedTravellers = [...tour.travellerIds];
|
||||||
// Load operators list and check for logo
|
// Load operators list, logo, and predefined tours
|
||||||
searchOperators('');
|
searchOperators('');
|
||||||
checkOperatorLogo(tour.operator_name);
|
checkOperatorLogo(tour.operator_name);
|
||||||
|
loadToursForOperatorByName(tour.operator_name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -312,6 +347,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Status -->
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-gray-700">Status</span>
|
<span class="text-sm font-medium text-gray-700">Status</span>
|
||||||
|
|||||||
@@ -1,23 +1,64 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
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 { 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 {
|
interface Props {
|
||||||
plan: Plan;
|
plan: Plan;
|
||||||
tour: PackageTour & {
|
tour: PackageTour & {
|
||||||
travellerIds: string[];
|
travellerIds: string[];
|
||||||
childPlans: ChildPlanSummary[];
|
days: TourDay[];
|
||||||
|
ungroupedChildPlans: ChildPlanSummary[];
|
||||||
highlight_color: string | null;
|
highlight_color: string | null;
|
||||||
};
|
};
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onAddFlight?: () => void;
|
onAddFlight?: () => void;
|
||||||
onAddLodging?: () => 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 showAddMenu = $state(false);
|
||||||
let addMenuOpenUp = $state(false);
|
let addMenuOpenUp = $state(false);
|
||||||
|
|
||||||
@@ -28,12 +69,33 @@
|
|||||||
}
|
}
|
||||||
const btn = e.currentTarget as HTMLElement;
|
const btn = e.currentTarget as HTMLElement;
|
||||||
const rect = btn.getBoundingClientRect();
|
const rect = btn.getBoundingClientRect();
|
||||||
// Approximate height of the dropdown (2 items × ~40px + padding)
|
|
||||||
const dropdownHeight = 96;
|
const dropdownHeight = 96;
|
||||||
addMenuOpenUp = rect.bottom + dropdownHeight > window.innerHeight;
|
addMenuOpenUp = rect.bottom + dropdownHeight > window.innerHeight;
|
||||||
showAddMenu = true;
|
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 {
|
function formatDate(d: string | null): string {
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
|
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
|
||||||
@@ -89,10 +151,10 @@
|
|||||||
|
|
||||||
const hasStart = $derived(tour.start_date || tour.start_time);
|
const hasStart = $derived(tour.start_date || tour.start_time);
|
||||||
const hasEnd = $derived(tour.end_date || tour.end_time);
|
const hasEnd = $derived(tour.end_date || tour.end_time);
|
||||||
const visibleChildren = $derived(tour.childPlans.slice(0, 3));
|
const visibleUngrouped = $derived(tour.ungroupedChildPlans.slice(0, 3));
|
||||||
const extraChildren = $derived(Math.max(0, tour.childPlans.length - 3));
|
const extraUngrouped = $derived(Math.max(0, tour.ungroupedChildPlans.length - 3));
|
||||||
|
|
||||||
// Operator logo + highlight colour
|
// Operator logo
|
||||||
function logoSlug(name: string): string {
|
function logoSlug(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -158,7 +220,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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
|
<span
|
||||||
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[
|
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[
|
||||||
plan.status
|
plan.status
|
||||||
@@ -274,12 +338,380 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Child plans -->
|
<!-- Days -->
|
||||||
{#if tour.childPlans.length > 0}
|
{#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">
|
<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>
|
<p class="mb-2 text-xs font-medium tracking-wide text-gray-400 uppercase">Includes</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each visibleChildren as child}
|
{#each visibleUngrouped as child}
|
||||||
{@const config = childTypeConfig[child.type] ?? { label: child.type, icon: '' }}
|
{@const config = childTypeConfig[child.type] ?? { label: child.type, icon: '' }}
|
||||||
<span
|
<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"
|
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>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
{#if extraChildren > 0}
|
{#if extraUngrouped > 0}
|
||||||
<span
|
<span
|
||||||
class="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-500"
|
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>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Add child plan button -->
|
<!-- Top-level "Add to tour" button (for ungrouped plans) -->
|
||||||
{#if onAddFlight || onAddLodging}
|
{#if onAddFlight || onAddLodging}
|
||||||
<div class="relative border-t border-gray-100 pt-3">
|
<div class="relative border-t border-gray-100 pt-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -311,3 +311,110 @@ export function getTourOperatorByName(name: string): TourOperator | null {
|
|||||||
]) ?? 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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
21
src/lib/server/admin/providers/g-adventures.ts
Normal file
21
src/lib/server/admin/providers/g-adventures.ts
Normal 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
10
src/lib/server/admin/providers/index.ts
Normal file
10
src/lib/server/admin/providers/index.ts
Normal 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;
|
||||||
|
}
|
||||||
13
src/lib/server/admin/providers/types.ts
Normal file
13
src/lib/server/admin/providers/types.ts
Normal 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[]>;
|
||||||
|
}
|
||||||
@@ -255,6 +255,7 @@ export function runMigrations(db: Database): void {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
|
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
|
||||||
operator_name TEXT NOT NULL,
|
operator_name TEXT NOT NULL,
|
||||||
|
tour_name TEXT,
|
||||||
confirmation_number TEXT,
|
confirmation_number TEXT,
|
||||||
start_date TEXT,
|
start_date TEXT,
|
||||||
start_time TEXT,
|
start_time TEXT,
|
||||||
@@ -268,6 +269,12 @@ export function runMigrations(db: Database): void {
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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
|
// Package tour travellers - links people to package tours
|
||||||
db.run(`
|
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)
|
// Countries table — editable list for admin (used by country selector)
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS countries (
|
CREATE TABLE IF NOT EXISTS countries (
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { setupTestDb } from '../../tests/helpers.js';
|
|||||||
import type { Database } from './db/types.js';
|
import type { Database } from './db/types.js';
|
||||||
import { createTrip } from './trips.js';
|
import { createTrip } from './trips.js';
|
||||||
import { createPerson } from './travellers.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;
|
let db: Database;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -34,6 +40,19 @@ describe('createPackageTour', () => {
|
|||||||
expect(plan?.title).toBe('Viking');
|
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', () => {
|
it('stores all optional fields', () => {
|
||||||
const trip = makeTrip();
|
const trip = makeTrip();
|
||||||
const tour = createPackageTour({
|
const tour = createPackageTour({
|
||||||
@@ -78,7 +97,7 @@ describe('createPackageTour', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getPackageToursForTrip', () => {
|
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 trip = makeTrip();
|
||||||
const person = createPerson('u1', 'Bob', 'B');
|
const person = createPerson('u1', 'Bob', 'B');
|
||||||
const tour = createPackageTour({
|
const tour = createPackageTour({
|
||||||
@@ -89,7 +108,7 @@ describe('getPackageToursForTrip', () => {
|
|||||||
travellerIds: [person.id]
|
travellerIds: [person.id]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert a child plan manually
|
// Insert an ungrouped child plan directly under the tour
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO plans (id, trip_id, user_id, type, status, title, parent_id, position)
|
`INSERT INTO plans (id, trip_id, user_id, type, status, title, parent_id, position)
|
||||||
VALUES ('child-1', ?, 'u1', 'lodging', 'confirmed', 'Tour Hotel', ?, 99)`,
|
VALUES ('child-1', ?, 'u1', 'lodging', 'confirmed', 'Tour Hotel', ?, 99)`,
|
||||||
@@ -100,9 +119,9 @@ describe('getPackageToursForTrip', () => {
|
|||||||
expect(tours).toHaveLength(1);
|
expect(tours).toHaveLength(1);
|
||||||
expect(tours[0].planStatus).toBe('tentative');
|
expect(tours[0].planStatus).toBe('tentative');
|
||||||
expect(tours[0].travellerIds).toContain(person.id);
|
expect(tours[0].travellerIds).toContain(person.id);
|
||||||
expect(tours[0].childPlans).toHaveLength(1);
|
expect(tours[0].days).toHaveLength(0);
|
||||||
expect(tours[0].childPlans[0].title).toBe('Tour Hotel');
|
expect(tours[0].ungroupedChildPlans).toHaveLength(1);
|
||||||
expect(tours[0].childPlans[0].type).toBe('lodging');
|
expect(tours[0].ungroupedChildPlans[0].title).toBe('Tour Hotel');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns highlight_color from the tour_operators table', () => {
|
it('returns highlight_color from the tour_operators table', () => {
|
||||||
@@ -126,9 +145,51 @@ describe('getPackageToursForTrip', () => {
|
|||||||
createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Globus' });
|
createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Globus' });
|
||||||
expect(getPackageToursForTrip(trip.id, 'u2')).toHaveLength(0);
|
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', () => {
|
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', () => {
|
it('updates both plan and package_tours rows', () => {
|
||||||
const trip = makeTrip();
|
const trip = makeTrip();
|
||||||
const tour = createPackageTour({
|
const tour = createPackageTour({
|
||||||
@@ -181,3 +242,75 @@ describe('updatePackageTour', () => {
|
|||||||
).toThrow();
|
).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { db } from './db/index.js';
|
import { db } from './db/index.js';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { PlanStatus } from './plans.js';
|
import type { PlanStatus } from './plans.js';
|
||||||
|
import { listDaysForOperatorTour } from './admin/data.js';
|
||||||
|
|
||||||
export interface PackageTour {
|
export interface PackageTour {
|
||||||
id: string;
|
id: string;
|
||||||
plan_id: string;
|
plan_id: string;
|
||||||
operator_name: string;
|
operator_name: string;
|
||||||
|
tour_name: string | null;
|
||||||
confirmation_number: string | null;
|
confirmation_number: string | null;
|
||||||
start_date: string | null;
|
start_date: string | null;
|
||||||
start_time: string | null;
|
start_time: string | null;
|
||||||
@@ -27,11 +29,20 @@ export interface ChildPlanSummary {
|
|||||||
start_date: string | null;
|
start_date: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TourDay {
|
||||||
|
plan_id: string;
|
||||||
|
day_number: number;
|
||||||
|
title: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
childPlans: ChildPlanSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreatePackageTourInput {
|
export interface CreatePackageTourInput {
|
||||||
tripId: string;
|
tripId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
status?: PlanStatus;
|
status?: PlanStatus;
|
||||||
operatorName: string;
|
operatorName: string;
|
||||||
|
tourName?: string;
|
||||||
confirmationNumber?: string;
|
confirmationNumber?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
@@ -49,6 +60,7 @@ export interface UpdatePackageTourInput {
|
|||||||
userId: string;
|
userId: string;
|
||||||
status?: PlanStatus;
|
status?: PlanStatus;
|
||||||
operatorName: string;
|
operatorName: string;
|
||||||
|
tourName?: string;
|
||||||
confirmationNumber?: string;
|
confirmationNumber?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
@@ -69,24 +81,26 @@ export function createPackageTour(input: CreatePackageTourInput): PackageTour {
|
|||||||
);
|
);
|
||||||
const position = maxPos?.pos ?? 0;
|
const position = maxPos?.pos ?? 0;
|
||||||
|
|
||||||
|
const title = input.tourName ? `${input.operatorName}: ${input.tourName}` : input.operatorName;
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO plans (id, trip_id, user_id, type, status, title, position)
|
`INSERT INTO plans (id, trip_id, user_id, type, status, title, position)
|
||||||
VALUES (?, ?, ?, 'tour', ?, ?, ?)`,
|
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();
|
const tourId = randomUUID();
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO package_tours (
|
`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,
|
start_date, start_time, start_timezone,
|
||||||
end_date, end_time, end_timezone,
|
end_date, end_time, end_timezone,
|
||||||
price, currency
|
price, currency
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
tourId,
|
tourId,
|
||||||
planId,
|
planId,
|
||||||
input.operatorName,
|
input.operatorName,
|
||||||
|
input.tourName ?? null,
|
||||||
input.confirmationNumber ?? null,
|
input.confirmationNumber ?? null,
|
||||||
input.startDate ?? null,
|
input.startDate ?? null,
|
||||||
input.startTime ?? null,
|
input.startTime ?? null,
|
||||||
@@ -118,7 +132,8 @@ export function getPackageToursForTrip(
|
|||||||
PackageTour & {
|
PackageTour & {
|
||||||
travellerIds: string[];
|
travellerIds: string[];
|
||||||
planStatus: PlanStatus;
|
planStatus: PlanStatus;
|
||||||
childPlans: ChildPlanSummary[];
|
days: TourDay[];
|
||||||
|
ungroupedChildPlans: ChildPlanSummary[];
|
||||||
highlight_color: string | null;
|
highlight_color: string | null;
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
@@ -139,16 +154,46 @@ export function getPackageToursForTrip(
|
|||||||
}>('SELECT person_id FROM package_tour_travellers WHERE package_tour_id = ?', [tour.id])
|
}>('SELECT person_id FROM package_tour_travellers WHERE package_tour_id = ?', [tour.id])
|
||||||
.map((r) => r.person_id);
|
.map((r) => r.person_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>(
|
const childPlans = db.all<ChildPlanSummary>(
|
||||||
`SELECT id, type, title, status, start_date
|
`SELECT id, type, title, status, start_date FROM plans
|
||||||
FROM plans
|
WHERE parent_id = ? AND user_id = ? AND type != 'day'
|
||||||
WHERE parent_id = ? AND user_id = ?
|
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`,
|
ORDER BY position ASC, created_at ASC`,
|
||||||
[tour.plan_id, userId]
|
[tour.plan_id, userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { plan_status, ...rest } = tour;
|
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');
|
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 = ?`, [
|
db.run(`UPDATE plans SET status = ?, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
|
||||||
input.status ?? 'idea',
|
input.status ?? 'idea',
|
||||||
input.operatorName,
|
title,
|
||||||
tour.plan_id
|
tour.plan_id
|
||||||
]);
|
]);
|
||||||
|
|
||||||
db.run(
|
db.run(
|
||||||
`UPDATE package_tours SET
|
`UPDATE package_tours SET
|
||||||
operator_name = ?, confirmation_number = ?,
|
operator_name = ?, tour_name = ?, confirmation_number = ?,
|
||||||
start_date = ?, start_time = ?, start_timezone = ?,
|
start_date = ?, start_time = ?, start_timezone = ?,
|
||||||
end_date = ?, end_time = ?, end_timezone = ?,
|
end_date = ?, end_time = ?, end_timezone = ?,
|
||||||
price = ?, currency = ?,
|
price = ?, currency = ?,
|
||||||
@@ -177,6 +223,7 @@ export function updatePackageTour(input: UpdatePackageTourInput): void {
|
|||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[
|
[
|
||||||
input.operatorName,
|
input.operatorName,
|
||||||
|
input.tourName ?? null,
|
||||||
input.confirmationNumber ?? null,
|
input.confirmationNumber ?? null,
|
||||||
input.startDate ?? null,
|
input.startDate ?? null,
|
||||||
input.startTime ?? 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export type PlanType =
|
|||||||
| 'restaurant'
|
| 'restaurant'
|
||||||
| 'tour'
|
| 'tour'
|
||||||
| 'packing'
|
| 'packing'
|
||||||
| 'todo';
|
| 'todo'
|
||||||
|
| 'day';
|
||||||
|
|
||||||
export type PlanStatus = 'idea' | 'tentative' | 'confirmed';
|
export type PlanStatus = 'idea' | 'tentative' | 'confirmed';
|
||||||
|
|
||||||
@@ -106,9 +107,7 @@ export interface Country {
|
|||||||
|
|
||||||
export function getCountries(query?: string): Country[] {
|
export function getCountries(query?: string): Country[] {
|
||||||
if (!query?.trim()) {
|
if (!query?.trim()) {
|
||||||
return db.all<Country>(
|
return db.all<Country>(`SELECT name, country_code FROM countries ORDER BY name`);
|
||||||
`SELECT name, country_code FROM countries ORDER BY name`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const pattern = `%${query.trim()}%`;
|
const pattern = `%${query.trim()}%`;
|
||||||
return db.all<Country>(
|
return db.all<Country>(
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { requireAdmin } from '$lib/server/admin/auth.js';
|
||||||
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
const tourId = parseInt(event.url.searchParams.get('operator_tour_id') ?? '');
|
||||||
|
if (isNaN(tourId)) return json({ error: 'operator_tour_id required' }, { status: 400 });
|
||||||
|
return json(data.listDaysForOperatorTour(tourId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
const body = await event.request.json();
|
||||||
|
const { operator_tour_id, title, notes } = body as {
|
||||||
|
operator_tour_id?: number;
|
||||||
|
title?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
if (operator_tour_id == null) {
|
||||||
|
return json({ error: 'operator_tour_id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return json(data.createOperatorTourDay(operator_tour_id, title, notes));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
const body = await event.request.json();
|
||||||
|
const { id, title, notes } = body as { id?: number; title?: string; notes?: string };
|
||||||
|
if (id == null) return json({ error: 'id is required' }, { status: 400 });
|
||||||
|
data.updateOperatorTourDay(id, title, notes);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
|
data.deleteOperatorTourDay(id);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
40
src/routes/(protected)/admin/api/operator-tours/+server.ts
Normal file
40
src/routes/(protected)/admin/api/operator-tours/+server.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { requireAdmin } from '$lib/server/admin/auth.js';
|
||||||
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
||||||
|
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
||||||
|
return json(data.listToursForOperator(operatorId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
const body = await event.request.json();
|
||||||
|
const { operator_id, name } = body as { operator_id?: number; name?: string };
|
||||||
|
if (operator_id == null || !name?.trim()) {
|
||||||
|
return json({ error: 'operator_id and name are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return json(data.createOperatorTour(operator_id, name));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
const body = await event.request.json();
|
||||||
|
const { id, name } = body as { id?: number; name?: string };
|
||||||
|
if (id == null || !name?.trim()) {
|
||||||
|
return json({ error: 'id and name are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
data.updateOperatorTour(id, name);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
|
data.deleteOperatorTour(id);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
23
src/routes/(protected)/admin/api/provider-search/+server.ts
Normal file
23
src/routes/(protected)/admin/api/provider-search/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { requireAdmin } from '$lib/server/admin/auth.js';
|
||||||
|
import { listTourOperators } from '$lib/server/admin/data.js';
|
||||||
|
import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
||||||
|
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
||||||
|
|
||||||
|
const operators = listTourOperators();
|
||||||
|
const operator = operators.find((o) => o.id === operatorId);
|
||||||
|
if (!operator) return json({ error: 'Operator not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const provider = getProviderForOperator(operator.name);
|
||||||
|
if (!provider) return json({ error: 'No provider for this operator' }, { status: 404 });
|
||||||
|
|
||||||
|
const q = event.url.searchParams.get('q') ?? '';
|
||||||
|
const results = await provider.search(q);
|
||||||
|
return json(results);
|
||||||
|
};
|
||||||
@@ -44,7 +44,10 @@
|
|||||||
|
|
||||||
// Derive logo filename slug from operator name
|
// Derive logo filename slug from operator name
|
||||||
function logoSlug(name: string): string {
|
function logoSlug(name: string): string {
|
||||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_|_$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function squareLogo(name: string): string {
|
function squareLogo(name: string): string {
|
||||||
@@ -65,12 +68,22 @@
|
|||||||
const wd = wideLogo(name);
|
const wd = wideLogo(name);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(sq, { method: 'HEAD' });
|
const r = await fetch(sq, { method: 'HEAD' });
|
||||||
if (r.ok) { logoCache = { ...logoCache, [name]: 'square' }; return; }
|
if (r.ok) {
|
||||||
} catch { /* ignore */ }
|
logoCache = { ...logoCache, [name]: 'square' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const r = await fetch(wd, { method: 'HEAD' });
|
const r = await fetch(wd, { method: 'HEAD' });
|
||||||
if (r.ok) { logoCache = { ...logoCache, [name]: 'wide' }; return; }
|
if (r.ok) {
|
||||||
} catch { /* ignore */ }
|
logoCache = { ...logoCache, [name]: 'wide' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
logoCache = { ...logoCache, [name]: 'none' };
|
logoCache = { ...logoCache, [name]: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +183,10 @@
|
|||||||
|
|
||||||
async function submitAdd() {
|
async function submitAdd() {
|
||||||
error = '';
|
error = '';
|
||||||
if (!newName.trim()) { error = 'Operator name is required'; return; }
|
if (!newName.trim()) {
|
||||||
|
error = 'Operator name is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/admin/api/tour-operators`, {
|
const res = await fetch(`${base}/admin/api/tour-operators`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -196,7 +212,10 @@
|
|||||||
async function submitEdit() {
|
async function submitEdit() {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
error = '';
|
error = '';
|
||||||
if (!newName.trim()) { error = 'Operator name is required'; return; }
|
if (!newName.trim()) {
|
||||||
|
error = 'Operator name is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/admin/api/tour-operators`, {
|
const res = await fetch(`${base}/admin/api/tour-operators`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -289,7 +308,9 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_auto_auto]">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_auto_auto]">
|
||||||
<!-- Operator name with dropdown -->
|
<!-- Operator name with dropdown -->
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<label class="text-xs font-medium text-gray-500">Operator name <span class="text-red-500">*</span></label>
|
<label class="text-xs font-medium text-gray-500"
|
||||||
|
>Operator name <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -301,7 +322,9 @@
|
|||||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{#if showNameDropdown && filteredOperators.length > 0}
|
{#if showNameDropdown && filteredOperators.length > 0}
|
||||||
<ul class="absolute z-10 mt-1 max-h-52 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg">
|
<ul
|
||||||
|
class="absolute z-10 mt-1 max-h-52 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
|
||||||
|
>
|
||||||
{#each filteredOperators as op}
|
{#each filteredOperators as op}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -316,8 +339,17 @@
|
|||||||
class="h-5 w-5 shrink-0 object-contain"
|
class="h-5 w-5 shrink-0 object-contain"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-5 w-5 shrink-0 rounded bg-amber-100 flex items-center justify-center">
|
<div
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="2">
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-amber-100"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#F59E0B"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" />
|
<rect x="2" y="7" width="20" height="14" rx="2" />
|
||||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -365,7 +397,9 @@
|
|||||||
<!-- Logo preview -->
|
<!-- Logo preview -->
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-xs font-medium text-gray-500">Logo preview</span>
|
<span class="text-xs font-medium text-gray-500">Logo preview</span>
|
||||||
<div class="flex h-[38px] w-16 items-center justify-center rounded-md border border-gray-200 bg-white">
|
<div
|
||||||
|
class="flex h-[38px] w-16 items-center justify-center rounded-md border border-gray-200 bg-white"
|
||||||
|
>
|
||||||
{#if newName && logoCache[newName] && logoCache[newName] !== 'none'}
|
{#if newName && logoCache[newName] && logoCache[newName] !== 'none'}
|
||||||
<img src={getLogoUrl(newName)} alt={newName} class="h-8 w-12 object-contain" />
|
<img src={getLogoUrl(newName)} alt={newName} class="h-8 w-12 object-contain" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -397,7 +431,15 @@
|
|||||||
<p class="py-8 text-center text-sm text-gray-500">Loading...</p>
|
<p class="py-8 text-center text-sm text-gray-500">Loading...</p>
|
||||||
{:else if items.length === 0}
|
{:else if items.length === 0}
|
||||||
<div class="rounded-lg border border-dashed border-gray-300 p-10 text-center">
|
<div class="rounded-lg border border-dashed border-gray-300 p-10 text-center">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="mx-auto mb-3 text-gray-300">
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="mx-auto mb-3 text-gray-300"
|
||||||
|
>
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" />
|
<rect x="2" y="7" width="20" height="14" rx="2" />
|
||||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
||||||
<line x1="12" y1="12" x2="12" y2="16" />
|
<line x1="12" y1="12" x2="12" y2="16" />
|
||||||
@@ -417,11 +459,20 @@
|
|||||||
>
|
>
|
||||||
<!-- Logo + name -->
|
<!-- Logo + name -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-gray-100 bg-gray-50">
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-gray-100 bg-gray-50"
|
||||||
|
>
|
||||||
{#if logoType && logoType !== 'none' && logoUrl}
|
{#if logoType && logoType !== 'none' && logoUrl}
|
||||||
<img src={logoUrl} alt={item.name} class="h-8 w-8 object-contain" />
|
<img src={logoUrl} alt={item.name} class="h-8 w-8 object-contain" />
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{borderColor}" stroke-width="1.5">
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={borderColor}
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" />
|
<rect x="2" y="7" width="20" height="14" rx="2" />
|
||||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
||||||
<line x1="12" y1="12" x2="12" y2="16" />
|
<line x1="12" y1="12" x2="12" y2="16" />
|
||||||
@@ -454,7 +505,9 @@
|
|||||||
></div>
|
></div>
|
||||||
<span class="font-mono text-xs text-gray-500">{borderColor}</span>
|
<span class="font-mono text-xs text-gray-500">{borderColor}</span>
|
||||||
{#if logoType === 'square' || logoType === 'wide'}
|
{#if logoType === 'square' || logoType === 'wide'}
|
||||||
<span class="ml-auto rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">
|
<span
|
||||||
|
class="ml-auto rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700"
|
||||||
|
>
|
||||||
{logoType} logo
|
{logoType} logo
|
||||||
</span>
|
</span>
|
||||||
{:else if logoType === 'none'}
|
{:else if logoType === 'none'}
|
||||||
@@ -472,6 +525,12 @@
|
|||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
<a
|
||||||
|
href="{base}/admin/tour-operators/{item.id}"
|
||||||
|
class="flex-1 rounded-md border border-gray-200 px-3 py-1.5 text-center text-xs font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Tours
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
onclick={() => remove(item.id, item.name)}
|
onclick={() => remove(item.id, item.name)}
|
||||||
class="rounded-md border border-red-100 px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 hover:text-red-700"
|
class="rounded-md border border-red-100 px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 hover:text-red-700"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { error, fail } from '@sveltejs/kit';
|
||||||
|
import { requireAdmin } from '$lib/server/admin/auth.js';
|
||||||
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
|
if (isNaN(operatorId)) error(404, 'Not found');
|
||||||
|
|
||||||
|
const operators = data.listTourOperators();
|
||||||
|
const operator = operators.find((o) => o.id === operatorId);
|
||||||
|
if (!operator) error(404, 'Operator not found');
|
||||||
|
|
||||||
|
const tours = data.listToursForOperator(operatorId);
|
||||||
|
const provider = getProviderForOperator(operator.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operator,
|
||||||
|
tours,
|
||||||
|
hasProvider: provider !== null,
|
||||||
|
providerName: provider?.name ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addTour: async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
|
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const name = (formData.get('name') as string)?.trim();
|
||||||
|
if (!name) return fail(400, { error: 'Tour name is required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.createOperatorTour(operatorId, name);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to add tour' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
editTour: async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const id = parseInt(formData.get('id') as string);
|
||||||
|
const name = (formData.get('name') as string)?.trim();
|
||||||
|
|
||||||
|
if (isNaN(id)) return fail(400, { error: 'Invalid tour ID' });
|
||||||
|
if (!name) return fail(400, { error: 'Tour name is required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.updateOperatorTour(id, name);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update tour' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTour: async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const id = parseInt(formData.get('id') as string);
|
||||||
|
if (isNaN(id)) return fail(400, { error: 'Invalid tour ID' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.deleteOperatorTour(id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to delete tour' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
importTour: async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
|
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const name = (formData.get('name') as string)?.trim();
|
||||||
|
if (!name) return fail(400, { error: 'Tour name is required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.createOperatorTour(operatorId, name);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to import tour' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// Add tour form
|
||||||
|
let showAddForm = $state(false);
|
||||||
|
let addName = $state('');
|
||||||
|
let addError = $state('');
|
||||||
|
|
||||||
|
// Inline edit state
|
||||||
|
let editingId = $state<number | null>(null);
|
||||||
|
let editName = $state('');
|
||||||
|
let editError = $state('');
|
||||||
|
|
||||||
|
function startEdit(tour: { id: number; name: string }) {
|
||||||
|
editingId = tour.id;
|
||||||
|
editName = tour.name;
|
||||||
|
editError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editName = '';
|
||||||
|
editError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import drawer
|
||||||
|
let importDrawerOpen = $state(false);
|
||||||
|
let importQuery = $state('');
|
||||||
|
let importResults = $state<{ id: string; title: string }[]>([]);
|
||||||
|
let importLoading = $state(false);
|
||||||
|
let importSelectedTitle = $state('');
|
||||||
|
let importError = $state('');
|
||||||
|
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function openImportDrawer() {
|
||||||
|
importDrawerOpen = true;
|
||||||
|
importQuery = '';
|
||||||
|
importResults = [];
|
||||||
|
importSelectedTitle = '';
|
||||||
|
importError = '';
|
||||||
|
// Load initial results
|
||||||
|
searchProvider('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImportDrawer() {
|
||||||
|
importDrawerOpen = false;
|
||||||
|
importQuery = '';
|
||||||
|
importResults = [];
|
||||||
|
importSelectedTitle = '';
|
||||||
|
importError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchInput(value: string) {
|
||||||
|
importQuery = value;
|
||||||
|
importSelectedTitle = '';
|
||||||
|
clearTimeout(searchDebounce);
|
||||||
|
searchDebounce = setTimeout(() => searchProvider(value), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchProvider(q: string) {
|
||||||
|
importLoading = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ operator_id: String(data.operator.id), q });
|
||||||
|
const res = await fetch(`${base}/admin/api/provider-search?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
importResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importResults = await res.json();
|
||||||
|
} catch {
|
||||||
|
importResults = [];
|
||||||
|
} finally {
|
||||||
|
importLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectImportResult(title: string) {
|
||||||
|
importSelectedTitle = title;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.operator.name} — Tours — Admin — Trips</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-3xl">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="mb-6 flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<a href="{base}/admin/tour-operators" class="hover:text-gray-700">Tour Operators</a>
|
||||||
|
<span>›</span>
|
||||||
|
<span class="font-medium text-gray-900">{data.operator.name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page header -->
|
||||||
|
<div class="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{data.operator.name}</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Predefined tours for this operator.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
{#if data.hasProvider}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="8 17 12 21 16 17" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { showAddForm = true; addName = ''; addError = ''; }}
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Add tour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tours table -->
|
||||||
|
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
{#if data.tours.length === 0 && !showAddForm}
|
||||||
|
<div class="p-10 text-center">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="mx-auto mb-3 text-gray-300">
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2" />
|
||||||
|
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
||||||
|
<line x1="12" y1="12" x2="12" y2="16" />
|
||||||
|
<line x1="10" y1="14" x2="14" y2="14" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-gray-500">No tours yet. Add one or import from a provider.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium tracking-wide text-gray-500 uppercase">Tour name</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium tracking-wide text-gray-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{#each data.tours as tour (tour.id)}
|
||||||
|
{#if editingId === tour.id}
|
||||||
|
<!-- Inline edit row -->
|
||||||
|
<tr class="bg-blue-50/40">
|
||||||
|
<td class="px-4 py-3" colspan="2">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/editTour"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') cancelEdit();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={tour.id} />
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
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(); }}
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Save</button>
|
||||||
|
<button type="button" onclick={cancelEdit} class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">Cancel</button>
|
||||||
|
</form>
|
||||||
|
{#if editError}<p class="mt-1.5 text-xs text-red-600">{editError}</p>{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<!-- Display row -->
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
||||||
|
<a
|
||||||
|
href="{base}/admin/tour-operators/{data.operator.id}/tours/{tour.id}"
|
||||||
|
class="hover:text-blue-700 hover:underline"
|
||||||
|
>{tour.name}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<a
|
||||||
|
href="{base}/admin/tour-operators/{data.operator.id}/tours/{tour.id}"
|
||||||
|
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-blue-600 hover:bg-blue-50 hover:text-blue-800"
|
||||||
|
>Itinerary →</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => startEdit(tour)}
|
||||||
|
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
title="Edit name"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" 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>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteTour"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
if (result.type === 'success') update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={tour.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onclick={(e) => { if (!confirm(`Delete "${tour.name}"?`)) e.preventDefault(); }}
|
||||||
|
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
|
||||||
|
title="Delete tour"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Add tour inline form -->
|
||||||
|
{#if showAddForm}
|
||||||
|
<tr class="bg-green-50/30">
|
||||||
|
<td class="px-4 py-3" colspan="2">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addTour"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') {
|
||||||
|
showAddForm = false;
|
||||||
|
addName = '';
|
||||||
|
addError = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={addName}
|
||||||
|
placeholder="Tour name"
|
||||||
|
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') { showAddForm = false; addName = ''; } }}
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Add</button>
|
||||||
|
<button type="button" onclick={() => { showAddForm = false; addName = ''; addError = ''; }} class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">Cancel</button>
|
||||||
|
</form>
|
||||||
|
{#if addError}<p class="mt-1.5 text-xs text-red-600">{addError}</p>{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import drawer -->
|
||||||
|
{#if importDrawerOpen}
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 bg-black/20"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={closeImportDrawer}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && closeImportDrawer()}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Drawer panel -->
|
||||||
|
<div
|
||||||
|
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Import tours"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold text-gray-900">Import tours</h2>
|
||||||
|
<p class="text-xs text-gray-500">from {data.providerName}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={closeImportDrawer}
|
||||||
|
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="border-b border-gray-100 px-6 py-4">
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="absolute top-2.5 left-3 h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={importQuery}
|
||||||
|
oninput={(e) => onSearchInput(e.currentTarget.value)}
|
||||||
|
placeholder="Search tours..."
|
||||||
|
class="w-full rounded-md border border-gray-300 py-2 pr-3 pl-9 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{#if importLoading}
|
||||||
|
<div class="absolute top-2.5 right-3">
|
||||||
|
<svg class="h-4 w-4 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#if importResults.length === 0 && !importLoading}
|
||||||
|
<p class="px-6 py-8 text-center text-sm text-gray-400">
|
||||||
|
{importQuery.trim() ? 'No results found.' : 'Type to search for tours.'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y divide-gray-100">
|
||||||
|
{#each importResults as result (result.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectImportResult(result.title)}
|
||||||
|
class="flex w-full items-center justify-between px-6 py-3.5 text-left hover:bg-gray-50 {importSelectedTitle === result.title ? 'bg-blue-50' : ''}"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-900">{result.title}</span>
|
||||||
|
{#if importSelectedTitle === result.title}
|
||||||
|
<svg class="h-4 w-4 shrink-0 text-blue-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer with import action -->
|
||||||
|
<div class="border-t border-gray-200 px-6 py-4">
|
||||||
|
{#if importError}
|
||||||
|
<p class="mb-3 text-sm text-red-600">{importError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if importSelectedTitle}
|
||||||
|
<p class="mb-3 text-sm text-gray-700">
|
||||||
|
Import: <span class="font-medium">"{importSelectedTitle}"</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/importTour"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') {
|
||||||
|
closeImportDrawer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex gap-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="name" value={importSelectedTitle} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!importSelectedTitle}
|
||||||
|
class="flex-1 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Import tour
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeImportDrawer}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { error, fail } from '@sveltejs/kit';
|
||||||
|
import { requireAdmin } from '$lib/server/admin/auth.js';
|
||||||
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
|
const tourId = parseInt(event.params.tourId);
|
||||||
|
|
||||||
|
if (isNaN(operatorId) || isNaN(tourId)) error(404, 'Not found');
|
||||||
|
|
||||||
|
const operators = data.listTourOperators();
|
||||||
|
const operator = operators.find((o) => o.id === operatorId);
|
||||||
|
if (!operator) error(404, 'Operator not found');
|
||||||
|
|
||||||
|
const tours = data.listToursForOperator(operatorId);
|
||||||
|
const tour = tours.find((t) => t.id === tourId);
|
||||||
|
if (!tour) error(404, 'Tour not found');
|
||||||
|
|
||||||
|
const days = data.listDaysForOperatorTour(tourId);
|
||||||
|
|
||||||
|
return { operator, tour, days };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addDay: async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const tourId = parseInt(event.params.tourId);
|
||||||
|
if (isNaN(tourId)) return fail(400, { error: 'Invalid tour ID' });
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const title = (formData.get('title') as string)?.trim() || undefined;
|
||||||
|
const notes = (formData.get('notes') as string)?.trim() || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.createOperatorTourDay(tourId, title, notes);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to add day' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
editDay: async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const id = parseInt(formData.get('id') as string);
|
||||||
|
if (isNaN(id)) return fail(400, { error: 'Invalid day ID' });
|
||||||
|
|
||||||
|
const title = (formData.get('title') as string)?.trim() || undefined;
|
||||||
|
const notes = (formData.get('notes') as string)?.trim() || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.updateOperatorTourDay(id, title, notes);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update day' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDay: async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const id = parseInt(formData.get('id') as string);
|
||||||
|
if (isNaN(id)) return fail(400, { error: 'Invalid day ID' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.deleteOperatorTourDay(id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to delete day' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let editingDayId = $state<number | null>(null);
|
||||||
|
let editTitle = $state('');
|
||||||
|
let editNotes = $state('');
|
||||||
|
|
||||||
|
let showAddForm = $state(false);
|
||||||
|
let addTitle = $state('');
|
||||||
|
let addNotes = $state('');
|
||||||
|
|
||||||
|
function startEdit(day: { id: number; title: string | null; notes: string | null }) {
|
||||||
|
editingDayId = day.id;
|
||||||
|
editTitle = day.title ?? '';
|
||||||
|
editNotes = day.notes ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingDayId = null;
|
||||||
|
editTitle = '';
|
||||||
|
editNotes = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.tour.name} Itinerary — Admin — Trips</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="mb-6 flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<a href="{base}/admin/tour-operators" class="hover:text-gray-700">Tour Operators</a>
|
||||||
|
<span>›</span>
|
||||||
|
<span class="text-gray-700">{data.operator.name}</span>
|
||||||
|
<span>›</span>
|
||||||
|
<span class="font-medium text-gray-900">{data.tour.name} — Itinerary</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{data.tour.name}</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Template itinerary for <span class="font-medium">{data.operator.name}</span>. Days are
|
||||||
|
automatically copied to a user's trip when they select this tour.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Days list -->
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each data.days as day (day.id)}
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
{#if editingDayId === day.id}
|
||||||
|
<!-- Inline edit form -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/editDay"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') cancelEdit();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={day.id} />
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500">Day title</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
bind:value={editTitle}
|
||||||
|
placeholder="e.g. Amsterdam"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500"
|
||||||
|
>Notes <span class="font-normal text-gray-400">(optional)</span></label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
bind:value={editNotes}
|
||||||
|
placeholder="What happens on this day..."
|
||||||
|
rows="3"
|
||||||
|
class="resize-none rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- Display mode -->
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-sm font-semibold text-gray-600"
|
||||||
|
>
|
||||||
|
{day.day_number}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-medium text-gray-900">
|
||||||
|
{#if day.title}{day.title}{:else}<span class="text-gray-400 italic"
|
||||||
|
>Untitled day</span
|
||||||
|
>{/if}
|
||||||
|
</p>
|
||||||
|
{#if day.notes}
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{day.notes}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => startEdit(day)}
|
||||||
|
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
title="Edit day"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
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>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteDay"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
if (result.type === 'success') update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={day.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (!confirm(`Delete Day ${day.day_number}?`)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
|
||||||
|
title="Delete day"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
{#if data.days.length === 0 && !showAddForm}
|
||||||
|
<div class="rounded-lg border border-dashed border-gray-300 p-10 text-center">
|
||||||
|
<p class="text-sm text-gray-500">No days yet. Add the first day to build the itinerary.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add day -->
|
||||||
|
{#if showAddForm}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addDay"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') {
|
||||||
|
showAddForm = false;
|
||||||
|
addTitle = '';
|
||||||
|
addNotes = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="rounded-lg border border-blue-200 bg-blue-50/50 p-4"
|
||||||
|
>
|
||||||
|
<p class="mb-3 text-sm font-medium text-gray-700">
|
||||||
|
Day {data.days.length + 1}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500">Day title</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
bind:value={addTitle}
|
||||||
|
placeholder="e.g. Amsterdam"
|
||||||
|
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500"
|
||||||
|
>Notes <span class="font-normal text-gray-400">(optional)</span></label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
bind:value={addNotes}
|
||||||
|
placeholder="What happens on this day..."
|
||||||
|
rows="3"
|
||||||
|
class="resize-none rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Add day
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
showAddForm = false;
|
||||||
|
addTitle = '';
|
||||||
|
addNotes = '';
|
||||||
|
}}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showAddForm = true)}
|
||||||
|
class="flex items-center gap-2 rounded-lg border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-600 hover:border-blue-300 hover:bg-blue-50/50 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Add day
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
20
src/routes/(protected)/api/operator-tours/+server.ts
Normal file
20
src/routes/(protected)/api/operator-tours/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { listToursForOperator, listToursForOperatorByName } from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = ({ url }) => {
|
||||||
|
const operatorIdParam = url.searchParams.get('operator_id');
|
||||||
|
const operatorName = url.searchParams.get('operator_name');
|
||||||
|
|
||||||
|
if (operatorIdParam != null) {
|
||||||
|
const operatorId = parseInt(operatorIdParam);
|
||||||
|
if (isNaN(operatorId)) return json({ error: 'operator_id must be a number' }, { status: 400 });
|
||||||
|
return json(listToursForOperator(operatorId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operatorName) {
|
||||||
|
return json(listToursForOperatorByName(operatorName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ error: 'operator_id or operator_name required' }, { status: 400 });
|
||||||
|
};
|
||||||
@@ -12,7 +12,10 @@ import { createLodging, updateLodging, getLodgingsForTrip } from '$lib/server/lo
|
|||||||
import {
|
import {
|
||||||
createPackageTour,
|
createPackageTour,
|
||||||
updatePackageTour,
|
updatePackageTour,
|
||||||
getPackageToursForTrip
|
getPackageToursForTrip,
|
||||||
|
createTourDay,
|
||||||
|
updateTourDay,
|
||||||
|
cloneTemplateDaysToTour
|
||||||
} from '$lib/server/package-tours.js';
|
} from '$lib/server/package-tours.js';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
@@ -552,11 +555,12 @@ export const actions: Actions = {
|
|||||||
const travellerIds = data.getAll('traveller_ids[]') as string[];
|
const travellerIds = data.getAll('traveller_ids[]') as string[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
createPackageTour({
|
const newTour = createPackageTour({
|
||||||
tripId: trip.id,
|
tripId: trip.id,
|
||||||
userId,
|
userId,
|
||||||
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
|
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
|
||||||
operatorName,
|
operatorName,
|
||||||
|
tourName: str('tour_name'),
|
||||||
confirmationNumber: str('confirmation_number'),
|
confirmationNumber: str('confirmation_number'),
|
||||||
startDate: str('start_date'),
|
startDate: str('start_date'),
|
||||||
startTime: str('start_time'),
|
startTime: str('start_time'),
|
||||||
@@ -568,6 +572,18 @@ export const actions: Actions = {
|
|||||||
currency: str('currency') ?? 'USD',
|
currency: str('currency') ?? 'USD',
|
||||||
travellerIds: travellerIds.length > 0 ? travellerIds : undefined
|
travellerIds: travellerIds.length > 0 ? travellerIds : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const operatorTourIdRaw = data.get('operator_tour_id') as string;
|
||||||
|
const operatorTourId = operatorTourIdRaw ? parseInt(operatorTourIdRaw) : NaN;
|
||||||
|
if (!isNaN(operatorTourId)) {
|
||||||
|
cloneTemplateDaysToTour({
|
||||||
|
operatorTourId,
|
||||||
|
tripId: trip.id,
|
||||||
|
userId,
|
||||||
|
tourPlanId: newTour.plan_id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(400, { error: err instanceof Error ? err.message : 'Failed to create tour' });
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to create tour' });
|
||||||
@@ -603,6 +619,7 @@ export const actions: Actions = {
|
|||||||
userId,
|
userId,
|
||||||
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
|
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
|
||||||
operatorName,
|
operatorName,
|
||||||
|
tourName: str('tour_name'),
|
||||||
confirmationNumber: str('confirmation_number'),
|
confirmationNumber: str('confirmation_number'),
|
||||||
startDate: str('start_date'),
|
startDate: str('start_date'),
|
||||||
startTime: str('start_time'),
|
startTime: str('start_time'),
|
||||||
@@ -618,5 +635,48 @@ export const actions: Actions = {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update tour' });
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update tour' });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addTourDay: async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
const trip = getTripById(event.params.id, userId);
|
||||||
|
if (!trip) return fail(404, { error: 'Trip not found' });
|
||||||
|
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const tourPlanId = (data.get('tour_plan_id') as string)?.trim();
|
||||||
|
if (!tourPlanId) return fail(400, { error: 'Tour plan ID is required' });
|
||||||
|
|
||||||
|
const title = (data.get('title') as string)?.trim() || undefined;
|
||||||
|
const notes = (data.get('notes') as string)?.trim() || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
createTourDay({ tripId: trip.id, userId, tourPlanId, title, notes });
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to add day' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
editTourDay: async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const dayPlanId = (data.get('day_plan_id') as string)?.trim();
|
||||||
|
if (!dayPlanId) return fail(400, { error: 'Day plan ID is required' });
|
||||||
|
|
||||||
|
const title = (data.get('title') as string)?.trim() || undefined;
|
||||||
|
const notes = (data.get('notes') as string)?.trim() || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateTourDay({ dayPlanId, userId, title, notes });
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update day' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -616,6 +616,14 @@
|
|||||||
addingChildToPlanId = plan.id;
|
addingChildToPlanId = plan.id;
|
||||||
showAddLodging = true;
|
showAddLodging = true;
|
||||||
}}
|
}}
|
||||||
|
onAddFlightToDay={(dayPlanId) => {
|
||||||
|
addingChildToPlanId = dayPlanId;
|
||||||
|
showAddFlight = true;
|
||||||
|
}}
|
||||||
|
onAddLodgingToDay={(dayPlanId) => {
|
||||||
|
addingChildToPlanId = dayPlanId;
|
||||||
|
showAddLodging = true;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user