package-tours) adding support for pre-defining tours
This commit is contained in:
@@ -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
|
||||
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 {
|
||||
@@ -65,12 +68,22 @@
|
||||
const wd = wideLogo(name);
|
||||
try {
|
||||
const r = await fetch(sq, { method: 'HEAD' });
|
||||
if (r.ok) { logoCache = { ...logoCache, [name]: 'square' }; return; }
|
||||
} catch { /* ignore */ }
|
||||
if (r.ok) {
|
||||
logoCache = { ...logoCache, [name]: 'square' };
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const r = await fetch(wd, { method: 'HEAD' });
|
||||
if (r.ok) { logoCache = { ...logoCache, [name]: 'wide' }; return; }
|
||||
} catch { /* ignore */ }
|
||||
if (r.ok) {
|
||||
logoCache = { ...logoCache, [name]: 'wide' };
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
logoCache = { ...logoCache, [name]: 'none' };
|
||||
}
|
||||
|
||||
@@ -170,7 +183,10 @@
|
||||
|
||||
async function submitAdd() {
|
||||
error = '';
|
||||
if (!newName.trim()) { error = 'Operator name is required'; return; }
|
||||
if (!newName.trim()) {
|
||||
error = 'Operator name is required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/tour-operators`, {
|
||||
method: 'POST',
|
||||
@@ -196,7 +212,10 @@
|
||||
async function submitEdit() {
|
||||
if (!editing) return;
|
||||
error = '';
|
||||
if (!newName.trim()) { error = 'Operator name is required'; return; }
|
||||
if (!newName.trim()) {
|
||||
error = 'Operator name is required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/tour-operators`, {
|
||||
method: 'PATCH',
|
||||
@@ -289,7 +308,9 @@
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_auto_auto]">
|
||||
<!-- Operator name with dropdown -->
|
||||
<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">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
{#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}
|
||||
<li>
|
||||
<button
|
||||
@@ -316,10 +339,19 @@
|
||||
class="h-5 w-5 shrink-0 object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-5 w-5 shrink-0 rounded bg-amber-100 flex items-center justify-center">
|
||||
<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"/>
|
||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||
<div
|
||||
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" />
|
||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -365,7 +397,9 @@
|
||||
<!-- Logo preview -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<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'}
|
||||
<img src={getLogoUrl(newName)} alt={newName} class="h-8 w-12 object-contain" />
|
||||
{:else}
|
||||
@@ -397,11 +431,19 @@
|
||||
<p class="py-8 text-center text-sm text-gray-500">Loading...</p>
|
||||
{:else if items.length === 0}
|
||||
<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">
|
||||
<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
|
||||
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 tour operators yet. Add one to get started.</p>
|
||||
</div>
|
||||
@@ -417,15 +459,24 @@
|
||||
>
|
||||
<!-- Logo + name -->
|
||||
<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}
|
||||
<img src={logoUrl} alt={item.name} class="h-8 w-8 object-contain" />
|
||||
{:else}
|
||||
<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"/>
|
||||
<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
|
||||
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" />
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -454,7 +505,9 @@
|
||||
></div>
|
||||
<span class="font-mono text-xs text-gray-500">{borderColor}</span>
|
||||
{#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
|
||||
</span>
|
||||
{:else if logoType === 'none'}
|
||||
@@ -472,6 +525,12 @@
|
||||
>
|
||||
Edit
|
||||
</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
|
||||
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"
|
||||
|
||||
@@ -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 {
|
||||
createPackageTour,
|
||||
updatePackageTour,
|
||||
getPackageToursForTrip
|
||||
getPackageToursForTrip,
|
||||
createTourDay,
|
||||
updateTourDay,
|
||||
cloneTemplateDaysToTour
|
||||
} from '$lib/server/package-tours.js';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
@@ -552,11 +555,12 @@ export const actions: Actions = {
|
||||
const travellerIds = data.getAll('traveller_ids[]') as string[];
|
||||
|
||||
try {
|
||||
createPackageTour({
|
||||
const newTour = createPackageTour({
|
||||
tripId: trip.id,
|
||||
userId,
|
||||
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
|
||||
operatorName,
|
||||
tourName: str('tour_name'),
|
||||
confirmationNumber: str('confirmation_number'),
|
||||
startDate: str('start_date'),
|
||||
startTime: str('start_time'),
|
||||
@@ -568,6 +572,18 @@ export const actions: Actions = {
|
||||
currency: str('currency') ?? 'USD',
|
||||
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 };
|
||||
} catch (err) {
|
||||
return fail(400, { error: err instanceof Error ? err.message : 'Failed to create tour' });
|
||||
@@ -603,6 +619,7 @@ export const actions: Actions = {
|
||||
userId,
|
||||
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
|
||||
operatorName,
|
||||
tourName: str('tour_name'),
|
||||
confirmationNumber: str('confirmation_number'),
|
||||
startDate: str('start_date'),
|
||||
startTime: str('start_time'),
|
||||
@@ -618,5 +635,48 @@ export const actions: Actions = {
|
||||
} catch (err) {
|
||||
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;
|
||||
showAddLodging = true;
|
||||
}}
|
||||
onAddFlightToDay={(dayPlanId) => {
|
||||
addingChildToPlanId = dayPlanId;
|
||||
showAddFlight = true;
|
||||
}}
|
||||
onAddLodgingToDay={(dayPlanId) => {
|
||||
addingChildToPlanId = dayPlanId;
|
||||
showAddLodging = true;
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user