trips) adding package-tours option
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { base } from '$app/paths';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
@@ -7,5 +8,12 @@ export const load: LayoutServerLoad = async (event) => {
|
||||
if (!session?.user) {
|
||||
redirect(303, `${base}/login`);
|
||||
}
|
||||
return { session };
|
||||
|
||||
const adminIds = (env.ADMIN_USER_IDS ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const isAdmin = adminIds.length > 0 && session.user.id && adminIds.includes(session.user.id);
|
||||
|
||||
return { session, isAdmin };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { base } from '$app/paths';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import NavMenu from '$lib/components/NavMenu.svelte';
|
||||
import AdminNavMenu from '$lib/components/AdminNavMenu.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
const isAdminRoute = $derived(
|
||||
$page.url.pathname === `${base}/admin` || $page.url.pathname.startsWith(`${base}/admin/`)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<Sidebar userName={data.session.user?.name ?? data.session.user?.email} />
|
||||
<NavMenu />
|
||||
<Sidebar
|
||||
userName={data.session.user?.name ?? data.session.user?.email}
|
||||
isAdmin={data.isAdmin}
|
||||
isAdminRoute={isAdminRoute}
|
||||
/>
|
||||
{#if isAdminRoute}
|
||||
<AdminNavMenu />
|
||||
{:else}
|
||||
<NavMenu />
|
||||
{/if}
|
||||
<main class="flex-1 overflow-auto bg-white p-8">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
11
src/routes/(protected)/admin/+layout.server.ts
Normal file
11
src/routes/(protected)/admin/+layout.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { base } from '$app/paths';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
const parentData = await event.parent();
|
||||
if (!parentData?.isAdmin) {
|
||||
redirect(303, `${base}/dashboard`);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
7
src/routes/(protected)/admin/+page.server.ts
Normal file
7
src/routes/(protected)/admin/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { base } from '$app/paths';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
redirect(303, `${base}/admin/general`);
|
||||
};
|
||||
1
src/routes/(protected)/admin/+page.svelte
Normal file
1
src/routes/(protected)/admin/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<!-- Redirects to /admin/general via +page.server.ts load -->
|
||||
316
src/routes/(protected)/admin/airlines/+page.svelte
Normal file
316
src/routes/(protected)/admin/airlines/+page.svelte
Normal file
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
|
||||
interface Airline {
|
||||
id: number;
|
||||
iata_code: string | null;
|
||||
icao_code: string | null;
|
||||
name: string;
|
||||
country: string | null;
|
||||
country_code: string | null;
|
||||
}
|
||||
|
||||
let items = $state<Airline[]>([]);
|
||||
let loading = $state(true);
|
||||
let search = $state('');
|
||||
let adding = $state(false);
|
||||
let editing = $state<Airline | null>(null);
|
||||
let newName = $state('');
|
||||
let newCountry = $state('');
|
||||
let newCode = $state('');
|
||||
let newIata = $state('');
|
||||
let newIcao = $state('');
|
||||
let error = $state('');
|
||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = search.trim() ? `?q=${encodeURIComponent(search.trim())}` : '';
|
||||
const res = await fetch(`${base}/admin/api/airlines${params}`);
|
||||
items = await res.json();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
search;
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(loadItems, 200);
|
||||
});
|
||||
|
||||
function startAdd() {
|
||||
adding = true;
|
||||
editing = null;
|
||||
newName = '';
|
||||
newCountry = '';
|
||||
newCode = '';
|
||||
newIata = '';
|
||||
newIcao = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function startEdit(item: Airline) {
|
||||
editing = item;
|
||||
adding = false;
|
||||
newName = item.name;
|
||||
newCountry = item.country ?? '';
|
||||
newCode = item.country_code ?? '';
|
||||
newIata = item.iata_code ?? '';
|
||||
newIcao = item.icao_code ?? '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
adding = false;
|
||||
editing = null;
|
||||
newName = '';
|
||||
newCountry = '';
|
||||
newCode = '';
|
||||
newIata = '';
|
||||
newIcao = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
async function submitAdd() {
|
||||
error = '';
|
||||
if (!newName.trim()) {
|
||||
error = 'Name is required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/airlines`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
country: newCountry.trim() || null,
|
||||
country_code: newCode.trim() || null,
|
||||
iata_code: newIata.trim() || null,
|
||||
icao_code: newIcao.trim() || null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
error = data.error ?? 'Failed to add';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editing) return;
|
||||
error = '';
|
||||
if (!newName.trim()) {
|
||||
error = 'Name is required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/airlines`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editing.id,
|
||||
name: newName.trim(),
|
||||
country: newCountry.trim() || null,
|
||||
country_code: newCode.trim() || null,
|
||||
iata_code: newIata.trim() || null,
|
||||
icao_code: newIcao.trim() || null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
error = data.error ?? 'Failed to update';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update';
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this airline?')) return;
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/airlines?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
alert(data.error ?? 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to delete');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Airlines — Admin — Trips</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Airlines</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Manage the list of airlines used for flight planning.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Search airlines..."
|
||||
class="w-64 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"
|
||||
/>
|
||||
<button
|
||||
onclick={startAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add airline
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline form -->
|
||||
{#if adding || editing}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-700">
|
||||
{editing ? 'Edit airline' : 'Add airline'}
|
||||
</h3>
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div class="flex flex-col gap-1.5 lg:col-span-2">
|
||||
<label class="text-xs font-medium text-gray-500"
|
||||
>Name <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="Singapore Airlines"
|
||||
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">IATA</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newIata}
|
||||
placeholder="SQ"
|
||||
maxlength="2"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase 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">ICAO</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newIcao}
|
||||
placeholder="SIA"
|
||||
maxlength="3"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase 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">Country</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCountry}
|
||||
placeholder="Singapore"
|
||||
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>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
onclick={editing ? submitEdit : submitAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{editing ? 'Save changes' : 'Add airline'}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelForm}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Table -->
|
||||
<div
|
||||
class="max-h-[calc(100vh-320px)] overflow-x-auto overflow-y-auto rounded-lg border border-gray-200"
|
||||
>
|
||||
{#if loading}
|
||||
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="p-8 text-center text-sm text-gray-500">No airlines found.</p>
|
||||
{:else}
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="sticky top-0 bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>IATA</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Name</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Country</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each items as item (item.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
{#if item.iata_code}
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700"
|
||||
>{item.iata_code}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-sm text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{item.name}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{item.country ?? '—'}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
onclick={() => startEdit(item)}
|
||||
class="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<span class="mx-2 text-gray-300">·</span>
|
||||
<button
|
||||
onclick={() => remove(item.id)}
|
||||
class="text-sm text-red-500 hover:text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
342
src/routes/(protected)/admin/airports/+page.svelte
Normal file
342
src/routes/(protected)/admin/airports/+page.svelte
Normal file
@@ -0,0 +1,342 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
|
||||
interface Airport {
|
||||
id: number;
|
||||
iata_code: string | null;
|
||||
icao_code: string | null;
|
||||
name: string;
|
||||
city: string | null;
|
||||
country: string;
|
||||
country_code: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
timezone: string | null;
|
||||
}
|
||||
|
||||
let items = $state<Airport[]>([]);
|
||||
let loading = $state(true);
|
||||
let search = $state('');
|
||||
let adding = $state(false);
|
||||
let editing = $state<Airport | null>(null);
|
||||
let newName = $state('');
|
||||
let newCountry = $state('');
|
||||
let newCode = $state('');
|
||||
let newIata = $state('');
|
||||
let newIcao = $state('');
|
||||
let newCity = $state('');
|
||||
let error = $state('');
|
||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = search.trim() ? `?q=${encodeURIComponent(search.trim())}` : '';
|
||||
const res = await fetch(`${base}/admin/api/airports${params}`);
|
||||
items = await res.json();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
search;
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(loadItems, 200);
|
||||
});
|
||||
|
||||
function startAdd() {
|
||||
adding = true;
|
||||
editing = null;
|
||||
newName = '';
|
||||
newCountry = '';
|
||||
newCode = '';
|
||||
newIata = '';
|
||||
newIcao = '';
|
||||
newCity = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function startEdit(item: Airport) {
|
||||
editing = item;
|
||||
adding = false;
|
||||
newName = item.name;
|
||||
newCountry = item.country;
|
||||
newCode = item.country_code;
|
||||
newIata = item.iata_code ?? '';
|
||||
newIcao = item.icao_code ?? '';
|
||||
newCity = item.city ?? '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
adding = false;
|
||||
editing = null;
|
||||
newName = '';
|
||||
newCountry = '';
|
||||
newCode = '';
|
||||
newIata = '';
|
||||
newIcao = '';
|
||||
newCity = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
async function submitAdd() {
|
||||
error = '';
|
||||
if (!newName.trim() || !newCountry.trim() || !newCode.trim()) {
|
||||
error = 'Name, country and country code are required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/airports`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
country: newCountry.trim(),
|
||||
country_code: newCode.trim().toUpperCase(),
|
||||
iata_code: newIata.trim() || null,
|
||||
icao_code: newIcao.trim() || null,
|
||||
city: newCity.trim() || null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
error = data.error ?? 'Failed to add';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editing) return;
|
||||
error = '';
|
||||
if (!newName.trim() || !newCountry.trim() || !newCode.trim()) {
|
||||
error = 'Name, country and country code are required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/airports`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editing.id,
|
||||
name: newName.trim(),
|
||||
country: newCountry.trim(),
|
||||
country_code: newCode.trim().toUpperCase(),
|
||||
iata_code: newIata.trim() || null,
|
||||
icao_code: newIcao.trim() || null,
|
||||
city: newCity.trim() || null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
error = data.error ?? 'Failed to update';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update';
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this airport?')) return;
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/airports?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
alert(data.error ?? 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to delete');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Airports — Admin — Trips</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Airports</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Manage the list of airports used for flight planning.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Search airports..."
|
||||
class="w-64 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"
|
||||
/>
|
||||
<button
|
||||
onclick={startAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add airport
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline form -->
|
||||
{#if adding || editing}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-700">
|
||||
{editing ? 'Edit airport' : 'Add airport'}
|
||||
</h3>
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div class="flex flex-col gap-1.5 lg:col-span-2">
|
||||
<label class="text-xs font-medium text-gray-500"
|
||||
>Name <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="Changi Airport"
|
||||
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">IATA</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newIata}
|
||||
placeholder="SIN"
|
||||
maxlength="3"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase 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">ICAO</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newIcao}
|
||||
placeholder="WSSS"
|
||||
maxlength="4"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase 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">City</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCity}
|
||||
placeholder="Singapore"
|
||||
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"
|
||||
>Country <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCountry}
|
||||
placeholder="Singapore"
|
||||
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>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
onclick={editing ? submitEdit : submitAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{editing ? 'Save changes' : 'Add airport'}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelForm}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Table -->
|
||||
<div
|
||||
class="max-h-[calc(100vh-320px)] overflow-x-auto overflow-y-auto rounded-lg border border-gray-200"
|
||||
>
|
||||
{#if loading}
|
||||
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="p-8 text-center text-sm text-gray-500">No airports found.</p>
|
||||
{:else}
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="sticky top-0 bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>IATA</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Name</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>City</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Country</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each items as item (item.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
{#if item.iata_code}
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700"
|
||||
>{item.iata_code}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-sm text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{item.name}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{item.city ?? '—'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{item.country}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
onclick={() => startEdit(item)}
|
||||
class="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<span class="mx-2 text-gray-300">·</span>
|
||||
<button
|
||||
onclick={() => remove(item.id)}
|
||||
class="text-sm text-red-500 hover:text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
63
src/routes/(protected)/admin/api/airlines/+server.ts
Normal file
63
src/routes/(protected)/admin/api/airlines/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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 q = event.url.searchParams.get('q') ?? undefined;
|
||||
return json(data.listAirlines(q));
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const { name, country, country_code, iata_code, icao_code } = body as {
|
||||
name?: string;
|
||||
country?: string | null;
|
||||
country_code?: string | null;
|
||||
iata_code?: string | null;
|
||||
icao_code?: string | null;
|
||||
};
|
||||
if (!name?.trim()) return json({ error: 'name required' }, { status: 400 });
|
||||
return json(
|
||||
data.createAirline(
|
||||
name,
|
||||
country ?? null,
|
||||
country_code ?? null,
|
||||
iata_code ?? null,
|
||||
icao_code ?? null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const { id, name, country, country_code, iata_code, icao_code } = body as {
|
||||
id?: number;
|
||||
name?: string;
|
||||
country?: string | null;
|
||||
country_code?: string | null;
|
||||
iata_code?: string | null;
|
||||
icao_code?: string | null;
|
||||
};
|
||||
if (id == null || !name?.trim()) return json({ error: 'id and name required' }, { status: 400 });
|
||||
data.updateAirline(
|
||||
id,
|
||||
name,
|
||||
country ?? null,
|
||||
country_code ?? null,
|
||||
iata_code ?? null,
|
||||
icao_code ?? null
|
||||
);
|
||||
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.deleteAirline(id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
95
src/routes/(protected)/admin/api/airports/+server.ts
Normal file
95
src/routes/(protected)/admin/api/airports/+server.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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 q = event.url.searchParams.get('q') ?? undefined;
|
||||
return json(data.listAirports(q));
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const { name, country, country_code, iata_code, icao_code, city, latitude, longitude, timezone } =
|
||||
body as {
|
||||
name?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
iata_code?: string | null;
|
||||
icao_code?: string | null;
|
||||
city?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
timezone?: string | null;
|
||||
};
|
||||
if (!name?.trim() || !country?.trim() || !country_code?.trim()) {
|
||||
return json({ error: 'name, country and country_code required' }, { status: 400 });
|
||||
}
|
||||
return json(
|
||||
data.createAirport(
|
||||
name,
|
||||
country,
|
||||
country_code,
|
||||
iata_code ?? null,
|
||||
icao_code ?? null,
|
||||
city ?? null,
|
||||
latitude ?? null,
|
||||
longitude ?? null,
|
||||
timezone ?? null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
country,
|
||||
country_code,
|
||||
iata_code,
|
||||
icao_code,
|
||||
city,
|
||||
latitude,
|
||||
longitude,
|
||||
timezone
|
||||
} = body as {
|
||||
id?: number;
|
||||
name?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
iata_code?: string | null;
|
||||
icao_code?: string | null;
|
||||
city?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
timezone?: string | null;
|
||||
};
|
||||
if (id == null || !name?.trim() || !country?.trim() || !country_code?.trim()) {
|
||||
return json({ error: 'id, name, country and country_code required' }, { status: 400 });
|
||||
}
|
||||
data.updateAirport(
|
||||
id,
|
||||
name,
|
||||
country,
|
||||
country_code,
|
||||
iata_code ?? null,
|
||||
icao_code ?? null,
|
||||
city ?? null,
|
||||
latitude ?? null,
|
||||
longitude ?? null,
|
||||
timezone ?? null
|
||||
);
|
||||
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.deleteAirport(id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
51
src/routes/(protected)/admin/api/cities/+server.ts
Normal file
51
src/routes/(protected)/admin/api/cities/+server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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 q = event.url.searchParams.get('q') ?? undefined;
|
||||
const countryCode = event.url.searchParams.get('country_code') ?? undefined;
|
||||
return json(data.listCities(q, countryCode));
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const { name, country, country_code, population } = body as {
|
||||
name?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
population?: number | null;
|
||||
};
|
||||
if (!name?.trim() || !country?.trim() || !country_code?.trim()) {
|
||||
return json({ error: 'name, country and country_code required' }, { status: 400 });
|
||||
}
|
||||
return json(data.createCity(name, country, country_code, population ?? null));
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const { id, name, country, country_code, population } = body as {
|
||||
id?: number;
|
||||
name?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
population?: number | null;
|
||||
};
|
||||
if (id == null || !name?.trim() || !country?.trim() || !country_code?.trim()) {
|
||||
return json({ error: 'id, name, country and country_code required' }, { status: 400 });
|
||||
}
|
||||
data.updateCity(id, name, country, country_code, population ?? null);
|
||||
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.deleteCity(id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
39
src/routes/(protected)/admin/api/countries/+server.ts
Normal file
39
src/routes/(protected)/admin/api/countries/+server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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 q = event.url.searchParams.get('q') ?? undefined;
|
||||
return json(data.listCountries(q));
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const { name, country_code } = body as { name?: string; country_code?: string };
|
||||
if (!name?.trim() || !country_code?.trim()) {
|
||||
return json({ error: 'name and country_code required' }, { status: 400 });
|
||||
}
|
||||
return json(data.createCountry(name, country_code));
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const { id, name, country_code } = body as { id?: number; name?: string; country_code?: string };
|
||||
if (id == null || !name?.trim() || !country_code?.trim()) {
|
||||
return json({ error: 'id, name and country_code required' }, { status: 400 });
|
||||
}
|
||||
data.updateCountry(id, name, country_code);
|
||||
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.deleteCountry(id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
48
src/routes/(protected)/admin/api/tour-operators/+server.ts
Normal file
48
src/routes/(protected)/admin/api/tour-operators/+server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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 q = event.url.searchParams.get('q') ?? undefined;
|
||||
return json(data.listTourOperators(q));
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const { name, website, highlight_color } = body as {
|
||||
name?: string;
|
||||
website?: string | null;
|
||||
highlight_color?: string | null;
|
||||
};
|
||||
if (!name?.trim()) {
|
||||
return json({ error: 'name is required' }, { status: 400 });
|
||||
}
|
||||
return json(data.createTourOperator(name, website, highlight_color));
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireAdmin((await event.locals.auth())?.user?.id);
|
||||
const body = await event.request.json();
|
||||
const { id, name, website, highlight_color } = body as {
|
||||
id?: number;
|
||||
name?: string;
|
||||
website?: string | null;
|
||||
highlight_color?: string | null;
|
||||
};
|
||||
if (id == null || !name?.trim()) {
|
||||
return json({ error: 'id and name are required' }, { status: 400 });
|
||||
}
|
||||
data.updateTourOperator(id, name, website, highlight_color);
|
||||
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.deleteTourOperator(id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
317
src/routes/(protected)/admin/cities/+page.svelte
Normal file
317
src/routes/(protected)/admin/cities/+page.svelte
Normal file
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
|
||||
interface City {
|
||||
id: number;
|
||||
name: string;
|
||||
country: string;
|
||||
country_code: string;
|
||||
population: number | null;
|
||||
}
|
||||
|
||||
let items = $state<City[]>([]);
|
||||
let loading = $state(true);
|
||||
let search = $state('');
|
||||
let adding = $state(false);
|
||||
let editing = $state<City | null>(null);
|
||||
let newName = $state('');
|
||||
let newCountry = $state('');
|
||||
let newCode = $state('');
|
||||
let newPopulation = $state('');
|
||||
let error = $state('');
|
||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) params.set('q', search.trim());
|
||||
const res = await fetch(`${base}/admin/api/cities?${params}`);
|
||||
items = await res.json();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
search;
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(loadItems, 200);
|
||||
});
|
||||
|
||||
function startAdd() {
|
||||
adding = true;
|
||||
editing = null;
|
||||
newName = '';
|
||||
newCountry = '';
|
||||
newCode = '';
|
||||
newPopulation = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function startEdit(item: City) {
|
||||
editing = item;
|
||||
adding = false;
|
||||
newName = item.name;
|
||||
newCountry = item.country;
|
||||
newCode = item.country_code;
|
||||
newPopulation = item.population != null ? String(item.population) : '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
adding = false;
|
||||
editing = null;
|
||||
newName = '';
|
||||
newCountry = '';
|
||||
newCode = '';
|
||||
newPopulation = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
async function submitAdd() {
|
||||
error = '';
|
||||
if (!newName.trim() || !newCountry.trim() || !newCode.trim()) {
|
||||
error = 'Name, country and country code are required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/cities`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
country: newCountry.trim(),
|
||||
country_code: newCode.trim().toUpperCase(),
|
||||
population: newPopulation.trim() ? parseInt(newPopulation) : null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
error = data.error ?? 'Failed to add';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editing) return;
|
||||
error = '';
|
||||
if (!newName.trim() || !newCountry.trim() || !newCode.trim()) {
|
||||
error = 'Name, country and country code are required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/cities`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editing.id,
|
||||
name: newName.trim(),
|
||||
country: newCountry.trim(),
|
||||
country_code: newCode.trim().toUpperCase(),
|
||||
population: newPopulation.trim() ? parseInt(newPopulation) : null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
error = data.error ?? 'Failed to update';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update';
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this city?')) return;
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/cities?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
alert(data.error ?? 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to delete');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Cities — Admin — Trips</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Cities</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Manage the list of cities used across the app.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Search cities..."
|
||||
class="w-64 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"
|
||||
/>
|
||||
<button
|
||||
onclick={startAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add city
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline form -->
|
||||
{#if adding || editing}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-700">
|
||||
{editing ? 'Edit city' : 'Add city'}
|
||||
</h3>
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-500"
|
||||
>Name <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="Singapore"
|
||||
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"
|
||||
>Country <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCountry}
|
||||
placeholder="Singapore"
|
||||
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"
|
||||
>Country code <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCode}
|
||||
placeholder="SG"
|
||||
maxlength="2"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm uppercase 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">Population</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={newPopulation}
|
||||
placeholder="Optional"
|
||||
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>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
onclick={editing ? submitEdit : submitAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{editing ? 'Save changes' : 'Add city'}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelForm}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Table -->
|
||||
<div
|
||||
class="max-h-[calc(100vh-320px)] overflow-x-auto overflow-y-auto rounded-lg border border-gray-200"
|
||||
>
|
||||
{#if loading}
|
||||
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="p-8 text-center text-sm text-gray-500">No cities found.</p>
|
||||
{:else}
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="sticky top-0 bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Name</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Country</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Code</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Population</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each items as item (item.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{item.name}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{item.country}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700"
|
||||
>{item.country_code}</span
|
||||
>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">
|
||||
{item.population != null ? item.population.toLocaleString() : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
onclick={() => startEdit(item)}
|
||||
class="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<span class="mx-2 text-gray-300">·</span>
|
||||
<button
|
||||
onclick={() => remove(item.id)}
|
||||
class="text-sm text-red-500 hover:text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
264
src/routes/(protected)/admin/countries/+page.svelte
Normal file
264
src/routes/(protected)/admin/countries/+page.svelte
Normal file
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
|
||||
interface Country {
|
||||
id: number;
|
||||
name: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
let items = $state<Country[]>([]);
|
||||
let loading = $state(true);
|
||||
let search = $state('');
|
||||
let adding = $state(false);
|
||||
let editing = $state<Country | null>(null);
|
||||
let newName = $state('');
|
||||
let newCode = $state('');
|
||||
let error = $state('');
|
||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = search.trim() ? `?q=${encodeURIComponent(search.trim())}` : '';
|
||||
const res = await fetch(`${base}/admin/api/countries${params}`);
|
||||
items = await res.json();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
search;
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(loadItems, 200);
|
||||
});
|
||||
|
||||
function startAdd() {
|
||||
adding = true;
|
||||
editing = null;
|
||||
newName = '';
|
||||
newCode = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function startEdit(item: Country) {
|
||||
editing = item;
|
||||
adding = false;
|
||||
newName = item.name;
|
||||
newCode = item.country_code;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
adding = false;
|
||||
editing = null;
|
||||
newName = '';
|
||||
newCode = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
async function submitAdd() {
|
||||
error = '';
|
||||
if (!newName.trim() || !newCode.trim()) {
|
||||
error = 'Name and country code are required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/countries`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName.trim(), country_code: newCode.trim().toUpperCase() })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
error = data.error ?? 'Failed to add';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editing) return;
|
||||
error = '';
|
||||
if (!newName.trim() || !newCode.trim()) {
|
||||
error = 'Name and country code are required';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/countries`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editing.id,
|
||||
name: newName.trim(),
|
||||
country_code: newCode.trim().toUpperCase()
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
error = data.error ?? 'Failed to update';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update';
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this country?')) return;
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/countries?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
alert(data.error ?? 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to delete');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Countries — Admin — Trips</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Countries</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Manage the list of countries used across the app.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Search countries..."
|
||||
class="w-64 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"
|
||||
/>
|
||||
<button
|
||||
onclick={startAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add country
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline form -->
|
||||
{#if adding || editing}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-700">
|
||||
{editing ? 'Edit country' : 'Add country'}
|
||||
</h3>
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-[1fr_auto_auto] items-end gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-500"
|
||||
>Name <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="United States"
|
||||
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"
|
||||
>Code <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCode}
|
||||
placeholder="US"
|
||||
maxlength="2"
|
||||
class="w-20 rounded-md border border-gray-300 px-3 py-2 text-sm uppercase focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={editing ? submitEdit : submitAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{editing ? 'Save' : 'Add'}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelForm}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200">
|
||||
{#if loading}
|
||||
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="p-8 text-center text-sm text-gray-500">No countries found.</p>
|
||||
{: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-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Name</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Code</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each items as item (item.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{item.name}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700"
|
||||
>{item.country_code}</span
|
||||
>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
onclick={() => startEdit(item)}
|
||||
class="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<span class="mx-2 text-gray-300">·</span>
|
||||
<button
|
||||
onclick={() => remove(item.id)}
|
||||
class="text-sm text-red-500 hover:text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
16
src/routes/(protected)/admin/general/+page.svelte
Normal file
16
src/routes/(protected)/admin/general/+page.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<svelte:head>
|
||||
<title>General — Admin — Trips</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">General</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">General administration settings.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-sm text-gray-500">
|
||||
Administration settings will go here.
|
||||
</div>
|
||||
</div>
|
||||
487
src/routes/(protected)/admin/tour-operators/+page.svelte
Normal file
487
src/routes/(protected)/admin/tour-operators/+page.svelte
Normal file
@@ -0,0 +1,487 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
|
||||
interface TourOperator {
|
||||
id: number;
|
||||
name: string;
|
||||
website: string | null;
|
||||
highlight_color: string | null;
|
||||
}
|
||||
|
||||
// Known operators for autocomplete — same list used in the add modal
|
||||
const KNOWN_OPERATORS = [
|
||||
'AAT Kings',
|
||||
'Abercrombie & Kent',
|
||||
'Avalon Waterways',
|
||||
'Brendan Vacations',
|
||||
'Contiki',
|
||||
'Cox & Kings',
|
||||
'Crystal Cruises',
|
||||
'Exodus Travels',
|
||||
'G Adventures',
|
||||
'Gate 1 Travel',
|
||||
'Globus',
|
||||
'Insight Vacations',
|
||||
'Intrepid Travel',
|
||||
'Monograms',
|
||||
'Newmarket Holidays',
|
||||
'Oceania Cruises',
|
||||
'On The Go Tours',
|
||||
'Overseas Adventure Travel',
|
||||
'Regent Seven Seas',
|
||||
'Road Scholar',
|
||||
'Rocky Mountaineer',
|
||||
'Scenic',
|
||||
'Seabourn',
|
||||
'Silversea',
|
||||
'Tauck',
|
||||
'Trafalgar',
|
||||
'TUI',
|
||||
'Uniworld Boutique River Cruises',
|
||||
'Viking',
|
||||
'Wendy Wu Tours'
|
||||
];
|
||||
|
||||
// Derive logo filename slug from operator name
|
||||
function logoSlug(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||
}
|
||||
|
||||
function squareLogo(name: string): string {
|
||||
return `${base}/package-tour-logos/${logoSlug(name)}_square.svg`;
|
||||
}
|
||||
|
||||
function wideLogo(name: string): string {
|
||||
return `${base}/package-tour-logos/${logoSlug(name)}_wide.svg`;
|
||||
}
|
||||
|
||||
// Track which logo URLs actually exist (checked on render)
|
||||
let logoCache = $state<Record<string, 'square' | 'wide' | 'none'>>({});
|
||||
|
||||
async function checkLogo(name: string) {
|
||||
if (name in logoCache) return;
|
||||
// Try square first, then wide
|
||||
const sq = squareLogo(name);
|
||||
const wd = wideLogo(name);
|
||||
try {
|
||||
const r = await fetch(sq, { method: 'HEAD' });
|
||||
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 */ }
|
||||
logoCache = { ...logoCache, [name]: 'none' };
|
||||
}
|
||||
|
||||
function getLogoUrl(name: string): string | null {
|
||||
const t = logoCache[name];
|
||||
if (t === 'square') return squareLogo(name);
|
||||
if (t === 'wide') return wideLogo(name);
|
||||
return null;
|
||||
}
|
||||
|
||||
let items = $state<TourOperator[]>([]);
|
||||
let loading = $state(true);
|
||||
let search = $state('');
|
||||
let adding = $state(false);
|
||||
let editing = $state<TourOperator | null>(null);
|
||||
let error = $state('');
|
||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
// Form fields
|
||||
let newName = $state('');
|
||||
let newWebsite = $state('');
|
||||
let newColor = $state('#F59E0B');
|
||||
|
||||
// Operator name search dropdown
|
||||
let nameQuery = $state('');
|
||||
let showNameDropdown = $state(false);
|
||||
let filteredOperators = $derived(
|
||||
nameQuery.trim().length > 0
|
||||
? KNOWN_OPERATORS.filter((op) => op.toLowerCase().includes(nameQuery.toLowerCase()))
|
||||
: KNOWN_OPERATORS
|
||||
);
|
||||
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = search.trim() ? `?q=${encodeURIComponent(search.trim())}` : '';
|
||||
const res = await fetch(`${base}/admin/api/tour-operators${params}`);
|
||||
items = await res.json();
|
||||
// Check logos for all loaded items
|
||||
for (const item of items) {
|
||||
checkLogo(item.name);
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
search;
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(loadItems, 200);
|
||||
});
|
||||
|
||||
function startAdd() {
|
||||
adding = true;
|
||||
editing = null;
|
||||
newName = '';
|
||||
nameQuery = '';
|
||||
newWebsite = '';
|
||||
newColor = '#F59E0B';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function startEdit(item: TourOperator) {
|
||||
editing = item;
|
||||
adding = false;
|
||||
newName = item.name;
|
||||
nameQuery = item.name;
|
||||
newWebsite = item.website ?? '';
|
||||
newColor = item.highlight_color ?? '#F59E0B';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
adding = false;
|
||||
editing = null;
|
||||
newName = '';
|
||||
nameQuery = '';
|
||||
newWebsite = '';
|
||||
newColor = '#F59E0B';
|
||||
error = '';
|
||||
showNameDropdown = false;
|
||||
}
|
||||
|
||||
function selectOperator(name: string) {
|
||||
newName = name;
|
||||
nameQuery = name;
|
||||
showNameDropdown = false;
|
||||
checkLogo(name);
|
||||
}
|
||||
|
||||
function onNameInput(value: string) {
|
||||
newName = value;
|
||||
nameQuery = value;
|
||||
showNameDropdown = true;
|
||||
}
|
||||
|
||||
async function submitAdd() {
|
||||
error = '';
|
||||
if (!newName.trim()) { error = 'Operator name is required'; return; }
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/tour-operators`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
website: newWebsite.trim() || null,
|
||||
highlight_color: newColor || null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
error = d.error ?? 'Failed to add';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editing) return;
|
||||
error = '';
|
||||
if (!newName.trim()) { error = 'Operator name is required'; return; }
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/tour-operators`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editing.id,
|
||||
name: newName.trim(),
|
||||
website: newWebsite.trim() || null,
|
||||
highlight_color: newColor || null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
error = d.error ?? 'Failed to update';
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update';
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number, name: string) {
|
||||
if (!confirm(`Delete "${name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`${base}/admin/api/tour-operators?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
alert(d.error ?? 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
cancelForm();
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to delete');
|
||||
}
|
||||
}
|
||||
|
||||
// Preview logo for the name currently being typed in the form
|
||||
let previewLogoChecked = $state(false);
|
||||
$effect(() => {
|
||||
if (newName && !previewLogoChecked) {
|
||||
checkLogo(newName);
|
||||
previewLogoChecked = true;
|
||||
}
|
||||
if (!newName) previewLogoChecked = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tour Operators — Admin — Trips</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Tour Operators</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Manage known tour operators, their websites, and highlight colours for plan cards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Search operators..."
|
||||
class="w-64 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"
|
||||
/>
|
||||
<button
|
||||
onclick={startAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add operator
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline form -->
|
||||
{#if adding || editing}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-700">
|
||||
{editing ? 'Edit operator' : 'Add operator'}
|
||||
</h3>
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={nameQuery}
|
||||
oninput={(e) => onNameInput(e.currentTarget.value)}
|
||||
onfocus={() => (showNameDropdown = true)}
|
||||
onblur={() => setTimeout(() => (showNameDropdown = false), 150)}
|
||||
placeholder="Search or type operator name"
|
||||
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">
|
||||
{#each filteredOperators as op}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectOperator(op)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
{#if logoCache[op] && logoCache[op] !== 'none'}
|
||||
<img
|
||||
src={getLogoUrl(op)}
|
||||
alt={op}
|
||||
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"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{op}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-500">Website</label>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={newWebsite}
|
||||
placeholder="https://www.example.com"
|
||||
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>
|
||||
|
||||
<!-- Colour picker -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-gray-500">Highlight colour</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
bind:value={newColor}
|
||||
class="h-[38px] w-12 cursor-pointer rounded-md border border-gray-300 p-0.5"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newColor}
|
||||
placeholder="#F59E0B"
|
||||
maxlength="7"
|
||||
class="w-24 rounded-md border border-gray-300 px-3 py-2 font-mono text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
{#if newName && logoCache[newName] && logoCache[newName] !== 'none'}
|
||||
<img src={getLogoUrl(newName)} alt={newName} class="h-8 w-12 object-contain" />
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400">None</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
onclick={editing ? submitEdit : submitAdd}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{editing ? 'Save changes' : 'Add operator'}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelForm}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Operator cards -->
|
||||
{#if loading}
|
||||
<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>
|
||||
<p class="text-sm text-gray-500">No tour operators yet. Add one to get started.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each items as item (item.id)}
|
||||
{@const logoType = logoCache[item.name]}
|
||||
{@const logoUrl = getLogoUrl(item.name)}
|
||||
{@const borderColor = item.highlight_color ?? '#E5E7EB'}
|
||||
<div
|
||||
class="group relative flex flex-col gap-3 rounded-xl border-2 bg-white p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
style="border-color: {borderColor}"
|
||||
>
|
||||
<!-- 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">
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-gray-900">{item.name}</p>
|
||||
{#if item.website}
|
||||
<a
|
||||
href={item.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{item.website.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
{:else}
|
||||
<p class="text-xs text-gray-400">No website</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colour swatch -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-4 w-4 rounded-full border border-gray-200"
|
||||
style="background-color: {borderColor}"
|
||||
></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">
|
||||
{logoType} logo
|
||||
</span>
|
||||
{:else if logoType === 'none'}
|
||||
<span class="ml-auto rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||
No logo
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 border-t border-gray-100 pt-3">
|
||||
<button
|
||||
onclick={() => startEdit(item)}
|
||||
class="flex-1 rounded-md border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
8
src/routes/(protected)/api/tour-operators/+server.ts
Normal file
8
src/routes/(protected)/api/tour-operators/+server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listTourOperators } from '$lib/server/admin/data.js';
|
||||
|
||||
export const GET: RequestHandler = ({ url }) => {
|
||||
const q = url.searchParams.get('q') ?? undefined;
|
||||
return json(listTourOperators(q));
|
||||
};
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
} from '$lib/server/travellers.js';
|
||||
import { createFlight, updateFlight, getFlightBookingsForTrip } from '$lib/server/flights.js';
|
||||
import { createLodging, updateLodging, getLodgingsForTrip } from '$lib/server/lodgings.js';
|
||||
import {
|
||||
createPackageTour,
|
||||
updatePackageTour,
|
||||
getPackageToursForTrip
|
||||
} from '$lib/server/package-tours.js';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
@@ -24,6 +29,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
const people = getPeopleForUser(userId);
|
||||
const flightBookings = getFlightBookingsForTrip(trip.id, userId);
|
||||
const lodgings = getLodgingsForTrip(trip.id, userId);
|
||||
const packageTours = getPackageToursForTrip(trip.id, userId);
|
||||
|
||||
return {
|
||||
trip,
|
||||
@@ -33,7 +39,8 @@ export const load: PageServerLoad = async (event) => {
|
||||
travellerCount: travellers.length,
|
||||
people,
|
||||
flightBookings,
|
||||
lodgings
|
||||
lodgings,
|
||||
packageTours
|
||||
};
|
||||
};
|
||||
|
||||
@@ -184,6 +191,7 @@ export const actions: Actions = {
|
||||
if (!trip) return fail(404, { error: 'Trip not found' });
|
||||
|
||||
const data = await event.request.formData();
|
||||
const parentPlanId = (data.get('parent_plan_id') as string)?.trim() || undefined;
|
||||
const confirmationNumber = (data.get('confirmation_number') as string)?.trim() || undefined;
|
||||
const priceRaw = (data.get('price') as string)?.trim();
|
||||
const price = priceRaw ? parseFloat(priceRaw) : undefined;
|
||||
@@ -282,6 +290,7 @@ export const actions: Actions = {
|
||||
createFlight({
|
||||
tripId: trip.id,
|
||||
userId,
|
||||
parentId: parentPlanId,
|
||||
confirmationNumber,
|
||||
price,
|
||||
currency,
|
||||
@@ -430,6 +439,7 @@ export const actions: Actions = {
|
||||
const v = str(key);
|
||||
return v ? parseFloat(v) : undefined;
|
||||
};
|
||||
const parentPlanId = str('parent_plan_id');
|
||||
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
|
||||
const guestIds = data.getAll('guest_ids[]') as string[];
|
||||
|
||||
@@ -437,6 +447,7 @@ export const actions: Actions = {
|
||||
createLodging({
|
||||
tripId: trip.id,
|
||||
userId,
|
||||
parentId: parentPlanId,
|
||||
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
|
||||
name,
|
||||
chain: str('chain'),
|
||||
@@ -518,5 +529,94 @@ export const actions: Actions = {
|
||||
} catch (err) {
|
||||
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update lodging' });
|
||||
}
|
||||
},
|
||||
|
||||
addPackageTour: 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 operatorName = (data.get('operator_name') as string)?.trim();
|
||||
if (!operatorName) return fail(400, { error: 'Operator name is required' });
|
||||
|
||||
const str = (key: string) => (data.get(key) as string)?.trim() || undefined;
|
||||
const num = (key: string) => {
|
||||
const v = str(key);
|
||||
return v ? parseFloat(v) : undefined;
|
||||
};
|
||||
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
|
||||
const travellerIds = data.getAll('traveller_ids[]') as string[];
|
||||
|
||||
try {
|
||||
createPackageTour({
|
||||
tripId: trip.id,
|
||||
userId,
|
||||
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
|
||||
operatorName,
|
||||
confirmationNumber: str('confirmation_number'),
|
||||
startDate: str('start_date'),
|
||||
startTime: str('start_time'),
|
||||
startTimezone: str('start_timezone'),
|
||||
endDate: str('end_date'),
|
||||
endTime: str('end_time'),
|
||||
endTimezone: str('end_timezone'),
|
||||
price: num('price'),
|
||||
currency: str('currency') ?? 'USD',
|
||||
travellerIds: travellerIds.length > 0 ? travellerIds : undefined
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return fail(400, { error: err instanceof Error ? err.message : 'Failed to create tour' });
|
||||
}
|
||||
},
|
||||
|
||||
editPackageTour: 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 tourId = (data.get('tour_id') as string)?.trim();
|
||||
if (!tourId) return fail(400, { error: 'Tour ID is required' });
|
||||
|
||||
const operatorName = (data.get('operator_name') as string)?.trim();
|
||||
if (!operatorName) return fail(400, { error: 'Operator name is required' });
|
||||
|
||||
const str = (key: string) => (data.get(key) as string)?.trim() || undefined;
|
||||
const num = (key: string) => {
|
||||
const v = str(key);
|
||||
return v ? parseFloat(v) : undefined;
|
||||
};
|
||||
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
|
||||
const travellerIds = data.getAll('traveller_ids[]') as string[];
|
||||
|
||||
try {
|
||||
updatePackageTour({
|
||||
tourId,
|
||||
userId,
|
||||
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea',
|
||||
operatorName,
|
||||
confirmationNumber: str('confirmation_number'),
|
||||
startDate: str('start_date'),
|
||||
startTime: str('start_time'),
|
||||
startTimezone: str('start_timezone'),
|
||||
endDate: str('end_date'),
|
||||
endTime: str('end_time'),
|
||||
endTimezone: str('end_timezone'),
|
||||
price: num('price'),
|
||||
currency: str('currency') ?? 'USD',
|
||||
travellerIds: travellerIds.length > 0 ? travellerIds : undefined
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update tour' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import EditFlightModal from '$lib/components/EditFlightModal.svelte';
|
||||
import AddLodgingModal from '$lib/components/AddLodgingModal.svelte';
|
||||
import EditLodgingModal from '$lib/components/EditLodgingModal.svelte';
|
||||
import AddPackageTourModal from '$lib/components/AddPackageTourModal.svelte';
|
||||
import EditPackageTourModal from '$lib/components/EditPackageTourModal.svelte';
|
||||
import PackageTourCard from '$lib/components/PackageTourCard.svelte';
|
||||
import PlanCard from '$lib/components/PlanCard.svelte';
|
||||
import FlightCard from '$lib/components/FlightCard.svelte';
|
||||
import LodgingCard from '$lib/components/LodgingCard.svelte';
|
||||
@@ -22,17 +25,21 @@
|
||||
let tripTravellerIds = $derived(travellers.map((t) => t.id));
|
||||
let flightBookings = $derived(data.flightBookings ?? []);
|
||||
let lodgings = $derived(data.lodgings ?? []);
|
||||
let packageTours = $derived(data.packageTours ?? []);
|
||||
let editing = $state(false);
|
||||
let showAddDestination = $state(false);
|
||||
let showAddTraveller = $state(false);
|
||||
let showAddFlight = $state(false);
|
||||
let showAddLodging = $state(false);
|
||||
let showAddPackageTour = $state(false);
|
||||
let showAddMenu = $state(false);
|
||||
let editingFlight = $state<(typeof flightBookings)[0] | null>(null);
|
||||
let editingFlightPlan = $derived(
|
||||
editingFlight ? (plans.find((p) => p.id === editingFlight!.plan_id) ?? null) : null
|
||||
);
|
||||
let editingLodging = $state<(typeof lodgings)[0] | null>(null);
|
||||
let editingTour = $state<(typeof packageTours)[0] | null>(null);
|
||||
let addingChildToPlanId = $state<string | null>(null);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
@@ -77,6 +84,7 @@
|
||||
label: 'Package Tours',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" 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>`,
|
||||
onclick: () => {
|
||||
showAddPackageTour = true;
|
||||
showAddMenu = false;
|
||||
}
|
||||
},
|
||||
@@ -119,9 +127,13 @@
|
||||
/>
|
||||
<AddFlightModal
|
||||
open={showAddFlight}
|
||||
onclose={() => (showAddFlight = false)}
|
||||
onclose={() => {
|
||||
showAddFlight = false;
|
||||
addingChildToPlanId = null;
|
||||
}}
|
||||
{people}
|
||||
{tripTravellerIds}
|
||||
parentPlanId={addingChildToPlanId ?? undefined}
|
||||
/>
|
||||
<EditFlightModal
|
||||
open={!!editingFlight}
|
||||
@@ -133,9 +145,13 @@
|
||||
/>
|
||||
<AddLodgingModal
|
||||
open={showAddLodging}
|
||||
onclose={() => (showAddLodging = false)}
|
||||
onclose={() => {
|
||||
showAddLodging = false;
|
||||
addingChildToPlanId = null;
|
||||
}}
|
||||
{people}
|
||||
{tripTravellerIds}
|
||||
parentPlanId={addingChildToPlanId ?? undefined}
|
||||
/>
|
||||
<EditLodgingModal
|
||||
open={!!editingLodging}
|
||||
@@ -144,6 +160,19 @@
|
||||
{people}
|
||||
{tripTravellerIds}
|
||||
/>
|
||||
<AddPackageTourModal
|
||||
open={showAddPackageTour}
|
||||
onclose={() => (showAddPackageTour = false)}
|
||||
{people}
|
||||
{tripTravellerIds}
|
||||
/>
|
||||
<EditPackageTourModal
|
||||
open={!!editingTour}
|
||||
tour={editingTour}
|
||||
onclose={() => (editingTour = null)}
|
||||
{people}
|
||||
{tripTravellerIds}
|
||||
/>
|
||||
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<!-- Header -->
|
||||
@@ -546,6 +575,54 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Package Tours section -->
|
||||
{#if packageTours.length > 0}
|
||||
<div class="mt-8">
|
||||
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
|
||||
Package Tours
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each packageTours as tour (tour.id)}
|
||||
{@const plan = plans.find((p) => p.id === tour.plan_id)}
|
||||
{#if plan}
|
||||
{@const formId = `remove-plan-${plan.id}`}
|
||||
{@const submitForm = () => {
|
||||
const f = document.getElementById(formId) as HTMLFormElement;
|
||||
f?.requestSubmit();
|
||||
}}
|
||||
<form
|
||||
id={formId}
|
||||
method="POST"
|
||||
action="?/removePlan"
|
||||
use:enhance={() => {
|
||||
return ({ update }) => {
|
||||
update();
|
||||
};
|
||||
}}
|
||||
class="contents"
|
||||
>
|
||||
<input type="hidden" name="plan_id" value={plan.id} />
|
||||
<PackageTourCard
|
||||
{plan}
|
||||
{tour}
|
||||
onEdit={() => (editingTour = tour)}
|
||||
onDelete={submitForm}
|
||||
onAddFlight={() => {
|
||||
addingChildToPlanId = plan.id;
|
||||
showAddFlight = true;
|
||||
}}
|
||||
onAddLodging={() => {
|
||||
addingChildToPlanId = plan.id;
|
||||
showAddLodging = true;
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user