tour-operators) sharing add/edit lodging across admin and user facing side
This commit is contained in:
@@ -19,12 +19,27 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
people: Person[];
|
people?: Person[];
|
||||||
tripTravellerIds: string[];
|
tripTravellerIds?: string[];
|
||||||
parentPlanId?: string;
|
parentPlanId?: string;
|
||||||
|
/** Template context (tour itinerary): add lodging to a day, post to addDayPlan */
|
||||||
|
variant?: 'trip' | 'template';
|
||||||
|
dayId?: number;
|
||||||
|
formAction?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open, onclose, people = [], tripTravellerIds = [], parentPlanId }: Props = $props();
|
let {
|
||||||
|
open,
|
||||||
|
onclose,
|
||||||
|
people = [],
|
||||||
|
tripTravellerIds = [],
|
||||||
|
parentPlanId,
|
||||||
|
variant = 'trip',
|
||||||
|
dayId: templateDayId,
|
||||||
|
formAction = '?/addLodging'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isTemplate = $derived(variant === 'template');
|
||||||
|
|
||||||
const TIMEZONES = [
|
const TIMEZONES = [
|
||||||
{ label: 'UTC (UTC+0)', value: 'UTC' },
|
{ label: 'UTC (UTC+0)', value: 'UTC' },
|
||||||
@@ -163,6 +178,7 @@
|
|||||||
let price = $state('');
|
let price = $state('');
|
||||||
let currency = $state('USD');
|
let currency = $state('USD');
|
||||||
let selectedGuests = $state<string[]>([]);
|
let selectedGuests = $state<string[]>([]);
|
||||||
|
let notes = $state('');
|
||||||
|
|
||||||
function toggleGuest(personId: string) {
|
function toggleGuest(personId: string) {
|
||||||
if (selectedGuests.includes(personId)) {
|
if (selectedGuests.includes(personId)) {
|
||||||
@@ -200,6 +216,7 @@
|
|||||||
price = '';
|
price = '';
|
||||||
currency = 'USD';
|
currency = 'USD';
|
||||||
selectedGuests = [];
|
selectedGuests = [];
|
||||||
|
notes = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@@ -229,7 +246,9 @@
|
|||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
<h2 class="text-base font-semibold text-gray-900">Add lodging</h2>
|
<h2 class="text-base font-semibold text-gray-900">
|
||||||
|
{isTemplate ? 'Add lodging (template)' : 'Add lodging'}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onclick={handleClose}
|
onclick={handleClose}
|
||||||
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
@@ -252,7 +271,7 @@
|
|||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/addLodging"
|
action={formAction}
|
||||||
class="flex flex-1 flex-col overflow-y-auto"
|
class="flex flex-1 flex-col overflow-y-auto"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return ({ result, update }) => {
|
return ({ result, update }) => {
|
||||||
@@ -261,6 +280,10 @@
|
|||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{#if isTemplate && templateDayId != null}
|
||||||
|
<input type="hidden" name="dayId" value={templateDayId} />
|
||||||
|
<input type="hidden" name="type" value="lodging" />
|
||||||
|
{/if}
|
||||||
{#if parentPlanId}
|
{#if parentPlanId}
|
||||||
<input type="hidden" name="parent_plan_id" value={parentPlanId} />
|
<input type="hidden" name="parent_plan_id" value={parentPlanId} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -296,7 +319,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status (trip only) -->
|
||||||
|
{#if !isTemplate}
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-gray-700">Status</span>
|
<span class="text-sm font-medium text-gray-700">Status</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -326,7 +350,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Check-in / Check-out -->
|
<!-- Check-in / Check-out (trip only) -->
|
||||||
<div class="grid grid-cols-2 gap-6">
|
<div class="grid grid-cols-2 gap-6">
|
||||||
<!-- Check-in -->
|
<!-- Check-in -->
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -407,9 +431,10 @@
|
|||||||
<option value={tz.value}>{tz.label}</option>
|
<option value={tz.value}>{tz.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Address -->
|
<!-- Address -->
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -575,7 +600,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Booking details -->
|
<!-- Notes (template only) -->
|
||||||
|
{#if isTemplate}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="lodging_notes" class="text-sm font-medium text-gray-700">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="lodging_notes"
|
||||||
|
name="notes"
|
||||||
|
bind:value={notes}
|
||||||
|
rows="3"
|
||||||
|
placeholder="Additional details..."
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Booking details (trip only) -->
|
||||||
|
{#if !isTemplate}
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<label for="confirmation_number" class="text-sm font-medium text-gray-700">
|
<label for="confirmation_number" class="text-sm font-medium text-gray-700">
|
||||||
@@ -646,9 +687,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Guests -->
|
<!-- Guests (trip only) -->
|
||||||
{#if people.length > 0 && tripTravellerIds.length > 0}
|
{#if !isTemplate && people.length > 0 && tripTravellerIds.length > 0}
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-gray-700">Guests</span>
|
<span class="text-sm font-medium text-gray-700">Guests</span>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type { Person } from '$lib/server/travellers.js';
|
import type { Person } from '$lib/server/travellers.js';
|
||||||
import type { Lodging } from '$lib/server/lodgings.js';
|
import type { Lodging } from '$lib/server/lodgings.js';
|
||||||
import type { PlanStatus } from '$lib/server/plans.js';
|
import type { PlanStatus } from '$lib/server/plans.js';
|
||||||
|
import type { TemplateLodging } from './LodgingCard.svelte';
|
||||||
|
|
||||||
interface City {
|
interface City {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,12 +22,30 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
lodging: (Lodging & { guestIds: string[]; planStatus: PlanStatus }) | null;
|
/** Trip context */
|
||||||
people: Person[];
|
lodging?: (Lodging & { guestIds: string[]; planStatus: PlanStatus }) | null;
|
||||||
tripTravellerIds: string[];
|
people?: Person[];
|
||||||
|
tripTravellerIds?: string[];
|
||||||
|
/** Template context (tour itinerary): use same modal, post to editDayPlan */
|
||||||
|
variant?: 'trip' | 'template';
|
||||||
|
templatePlan?: TemplateLodging | null;
|
||||||
|
formAction?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open, onclose, lodging, people = [], tripTravellerIds = [] }: Props = $props();
|
let {
|
||||||
|
open,
|
||||||
|
onclose,
|
||||||
|
lodging = null,
|
||||||
|
people = [],
|
||||||
|
tripTravellerIds = [],
|
||||||
|
variant = 'trip',
|
||||||
|
templatePlan = null,
|
||||||
|
formAction = '?/editLodging'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isTemplate = $derived(variant === 'template');
|
||||||
|
const source = $derived(isTemplate ? templatePlan : lodging);
|
||||||
|
const showModal = $derived(open && (lodging != null || (isTemplate && templatePlan != null)));
|
||||||
|
|
||||||
const TIMEZONES = [
|
const TIMEZONES = [
|
||||||
{ label: 'UTC (UTC+0)', value: 'UTC' },
|
{ label: 'UTC (UTC+0)', value: 'UTC' },
|
||||||
@@ -165,10 +184,22 @@
|
|||||||
let price = $state('');
|
let price = $state('');
|
||||||
let currency = $state('USD');
|
let currency = $state('USD');
|
||||||
let selectedGuests = $state<string[]>([]);
|
let selectedGuests = $state<string[]>([]);
|
||||||
|
let notes = $state('');
|
||||||
|
|
||||||
// Pre-populate when the modal opens with a lodging
|
// Pre-populate when the modal opens (trip lodging or template)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && lodging) {
|
if (!open || !source) return;
|
||||||
|
if (isTemplate && templatePlan) {
|
||||||
|
name = templatePlan.title;
|
||||||
|
chain = templatePlan.chain ?? '';
|
||||||
|
addressLine1 = templatePlan.address_line1 ?? '';
|
||||||
|
addressLine2 = templatePlan.address_line2 ?? '';
|
||||||
|
cityName = templatePlan.city_name ?? '';
|
||||||
|
country = templatePlan.country ?? '';
|
||||||
|
countryCode = templatePlan.country_code ?? '';
|
||||||
|
postalCode = templatePlan.postal_code ?? '';
|
||||||
|
notes = templatePlan.notes ?? '';
|
||||||
|
} else if (lodging) {
|
||||||
name = lodging.name;
|
name = lodging.name;
|
||||||
chain = lodging.chain ?? '';
|
chain = lodging.chain ?? '';
|
||||||
status = lodging.planStatus;
|
status = lodging.planStatus;
|
||||||
@@ -208,7 +239,7 @@
|
|||||||
let canSubmit = $derived(name.trim().length > 0);
|
let canSubmit = $derived(name.trim().length > 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if open && lodging}
|
{#if showModal}
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-40 bg-black/30"
|
class="fixed inset-0 z-40 bg-black/30"
|
||||||
@@ -227,7 +258,9 @@
|
|||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
<h2 class="text-base font-semibold text-gray-900">Edit lodging</h2>
|
<h2 class="text-base font-semibold text-gray-900">
|
||||||
|
{isTemplate ? 'Edit lodging (template)' : 'Edit lodging'}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onclick={handleClose}
|
onclick={handleClose}
|
||||||
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
@@ -250,7 +283,7 @@
|
|||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/editLodging"
|
action={formAction}
|
||||||
class="flex flex-1 flex-col overflow-y-auto"
|
class="flex flex-1 flex-col overflow-y-auto"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return ({ result, update }) => {
|
return ({ result, update }) => {
|
||||||
@@ -259,7 +292,11 @@
|
|||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="lodging_id" value={lodging.id} />
|
{#if isTemplate && templatePlan}
|
||||||
|
<input type="hidden" name="id" value={templatePlan.id} />
|
||||||
|
{:else if lodging}
|
||||||
|
<input type="hidden" name="lodging_id" value={lodging.id} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 px-6 py-6">
|
<div class="flex flex-1 flex-col gap-6 px-6 py-6">
|
||||||
<!-- Property details -->
|
<!-- Property details -->
|
||||||
@@ -293,7 +330,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status (trip only) -->
|
||||||
|
{#if !isTemplate}
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-gray-700">Status</span>
|
<span class="text-sm font-medium text-gray-700">Status</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -322,8 +360,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Check-in / Check-out -->
|
<!-- Check-in / Check-out (trip only) -->
|
||||||
|
{#if !isTemplate}
|
||||||
<div class="grid grid-cols-2 gap-6">
|
<div class="grid grid-cols-2 gap-6">
|
||||||
<!-- Check-in -->
|
<!-- Check-in -->
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -409,6 +449,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Address -->
|
<!-- Address -->
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -576,7 +617,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Booking details -->
|
<!-- Notes (template only) -->
|
||||||
|
{#if isTemplate}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="edit_notes" class="text-sm font-medium text-gray-700">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="edit_notes"
|
||||||
|
name="notes"
|
||||||
|
bind:value={notes}
|
||||||
|
rows="3"
|
||||||
|
placeholder="Additional details..."
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Booking details (trip only) -->
|
||||||
|
{#if !isTemplate}
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<label for="edit_confirmation_number" class="text-sm font-medium text-gray-700">
|
<label for="edit_confirmation_number" class="text-sm font-medium text-gray-700">
|
||||||
@@ -604,7 +661,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contact + Cost -->
|
<!-- Contact + Cost (trip only) -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<label for="edit_phone" class="text-sm font-medium text-gray-700">Phone</label>
|
<label for="edit_phone" class="text-sm font-medium text-gray-700">Phone</label>
|
||||||
@@ -647,9 +704,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Guests -->
|
<!-- Guests (trip only) -->
|
||||||
{#if people.length > 0 && tripTravellerIds.length > 0}
|
{#if !isTemplate && people.length > 0 && tripTravellerIds.length > 0}
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-gray-700">Guests</span>
|
<span class="text-sm font-medium text-gray-700">Guests</span>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -2,14 +2,36 @@
|
|||||||
import type { Plan } from '$lib/server/plans.js';
|
import type { Plan } from '$lib/server/plans.js';
|
||||||
import type { Lodging } from '$lib/server/lodgings.js';
|
import type { Lodging } from '$lib/server/lodgings.js';
|
||||||
|
|
||||||
|
/** Template lodging (operator tour day plan type=lodging) — same display shape as trip lodging */
|
||||||
|
export interface TemplateLodging {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
notes?: string | null;
|
||||||
|
chain?: string | null;
|
||||||
|
address_line1?: string | null;
|
||||||
|
address_line2?: string | null;
|
||||||
|
city_name?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
country_code?: string | null;
|
||||||
|
postal_code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plan: Plan;
|
/** Trip context: plan + lodging */
|
||||||
lodging: Lodging & { guestIds: string[] };
|
plan?: Plan;
|
||||||
|
lodging?: Lodging & { guestIds: string[] };
|
||||||
|
/** Template context: operator tour day plan (lodging) */
|
||||||
|
templatePlan?: TemplateLodging;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { plan, lodging, onEdit, onDelete }: Props = $props();
|
let { plan, lodging, templatePlan, onEdit, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
const isTemplate = $derived(!!templatePlan);
|
||||||
|
const displayName = $derived((lodging?.name ?? templatePlan?.title) || '');
|
||||||
|
const displayChain = $derived(lodging?.chain ?? templatePlan?.chain ?? '');
|
||||||
|
const displayStatus = $derived(plan?.status);
|
||||||
|
|
||||||
function formatDate(d: string | null): string {
|
function formatDate(d: string | null): string {
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
@@ -39,26 +61,29 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasAddress = $derived(
|
const hasAddress = $derived(
|
||||||
lodging.address_line1 ||
|
(lodging ?? templatePlan) &&
|
||||||
lodging.address_line2 ||
|
((lodging?.address_line1 ?? templatePlan?.address_line1) ||
|
||||||
lodging.city_name ||
|
(lodging?.address_line2 ?? templatePlan?.address_line2) ||
|
||||||
lodging.country ||
|
(lodging?.city_name ?? templatePlan?.city_name) ||
|
||||||
lodging.postal_code
|
(lodging?.country ?? templatePlan?.country) ||
|
||||||
|
(lodging?.postal_code ?? templatePlan?.postal_code))
|
||||||
);
|
);
|
||||||
|
|
||||||
const addressParts = $derived(
|
const addressParts = $derived(
|
||||||
[
|
[
|
||||||
lodging.address_line1,
|
lodging?.address_line1 ?? templatePlan?.address_line1,
|
||||||
lodging.address_line2,
|
lodging?.address_line2 ?? templatePlan?.address_line2,
|
||||||
[lodging.city_name, lodging.postal_code].filter(Boolean).join(' '),
|
[(lodging?.city_name ?? templatePlan?.city_name), lodging?.postal_code ?? templatePlan?.postal_code]
|
||||||
lodging.country
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
lodging?.country ?? templatePlan?.country
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasCheckIn = $derived(lodging.check_in_date || lodging.check_in_time);
|
const hasCheckIn = $derived(lodging ? lodging.check_in_date || lodging.check_in_time : false);
|
||||||
const hasCheckOut = $derived(lodging.check_out_date || lodging.check_out_time);
|
const hasCheckOut = $derived(lodging ? lodging.check_out_date || lodging.check_out_time : false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
@@ -82,17 +107,25 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="font-medium text-gray-900">{lodging.name}</p>
|
<p class="font-medium text-gray-900">{displayName}</p>
|
||||||
<span
|
{#if displayStatus && !isTemplate}
|
||||||
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[
|
<span
|
||||||
plan.status
|
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[
|
||||||
].class}"
|
displayStatus
|
||||||
>
|
].class}"
|
||||||
{statusConfig[plan.status].label}
|
>
|
||||||
</span>
|
{statusConfig[displayStatus].label}
|
||||||
|
</span>
|
||||||
|
{:else if isTemplate}
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600"
|
||||||
|
>
|
||||||
|
Template
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if lodging.chain}
|
{#if displayChain}
|
||||||
<p class="mt-0.5 text-sm text-gray-500">{lodging.chain}</p>
|
<p class="mt-0.5 text-sm text-gray-500">{displayChain}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,8 +175,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Check-in / Check-out -->
|
<!-- Check-in / Check-out (trip only) -->
|
||||||
{#if hasCheckIn || hasCheckOut}
|
{#if lodging && (hasCheckIn || hasCheckOut)}
|
||||||
<div class="grid grid-cols-2 gap-4 border-t border-gray-100 py-4">
|
<div class="grid grid-cols-2 gap-4 border-t border-gray-100 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-medium uppercase tracking-wide text-gray-400">Check-in</p>
|
<p class="text-xs font-medium uppercase tracking-wide text-gray-400">Check-in</p>
|
||||||
@@ -198,8 +231,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Confirmation + Price -->
|
<!-- Notes (template only) -->
|
||||||
{#if lodging.confirmation_number || (lodging.price != null && lodging.price > 0)}
|
{#if isTemplate && templatePlan?.notes}
|
||||||
|
<div class="border-t border-gray-100 py-4">
|
||||||
|
<p class="text-sm text-gray-700 whitespace-pre-wrap">{templatePlan.notes}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Confirmation + Price (trip only) -->
|
||||||
|
{#if lodging && (lodging.confirmation_number || (lodging.price != null && lodging.price > 0))}
|
||||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 border-t border-gray-100 pt-4 text-xs text-gray-500">
|
<div class="flex flex-wrap gap-x-3 gap-y-0.5 border-t border-gray-100 pt-4 text-xs text-gray-500">
|
||||||
{#if lodging.confirmation_number}
|
{#if lodging.confirmation_number}
|
||||||
<span>Confirmation: {lodging.confirmation_number}</span>
|
<span>Confirmation: {lodging.confirmation_number}</span>
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ export interface OperatorTour {
|
|||||||
id: number;
|
id: number;
|
||||||
operator_id: number;
|
operator_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
description: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -334,16 +335,24 @@ export function listToursForOperatorByName(operatorName: string): OperatorTour[]
|
|||||||
return listToursForOperator(operator.id);
|
return listToursForOperator(operator.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOperatorTour(operatorId: number, name: string): OperatorTour {
|
export function createOperatorTour(
|
||||||
db.run('INSERT INTO operator_tours (operator_id, name) VALUES (?, ?)', [operatorId, name.trim()]);
|
operatorId: number,
|
||||||
|
name: string,
|
||||||
|
description?: string | null
|
||||||
|
): OperatorTour {
|
||||||
|
db.run('INSERT INTO operator_tours (operator_id, name, description) VALUES (?, ?, ?)', [
|
||||||
|
operatorId,
|
||||||
|
name.trim(),
|
||||||
|
description?.trim() || null
|
||||||
|
]);
|
||||||
return db.get<OperatorTour>('SELECT * FROM operator_tours WHERE id = last_insert_rowid()')!;
|
return db.get<OperatorTour>('SELECT * FROM operator_tours WHERE id = last_insert_rowid()')!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateOperatorTour(id: number, name: string): void {
|
export function updateOperatorTour(id: number, name: string, description?: string | null): void {
|
||||||
db.run(`UPDATE operator_tours SET name = ?, updated_at = datetime('now') WHERE id = ?`, [
|
db.run(
|
||||||
name.trim(),
|
`UPDATE operator_tours SET name = ?, description = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||||
id
|
[name.trim(), description?.trim() || null, id]
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteOperatorTour(id: number): void {
|
export function deleteOperatorTour(id: number): void {
|
||||||
@@ -418,3 +427,133 @@ export function deleteOperatorTourDay(id: number): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Operator Tour Day Plans (transport / lodging templates per day) ---
|
||||||
|
|
||||||
|
export type OperatorTourDayPlanType = 'transport' | 'lodging';
|
||||||
|
|
||||||
|
export interface OperatorTourDayPlan {
|
||||||
|
id: number;
|
||||||
|
operator_tour_day_id: number;
|
||||||
|
type: OperatorTourDayPlanType;
|
||||||
|
title: string;
|
||||||
|
notes: string | null;
|
||||||
|
position: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
// Lodging-only (when type === 'lodging'), aligned with trip lodgings
|
||||||
|
chain?: string | null;
|
||||||
|
address_line1?: string | null;
|
||||||
|
address_line2?: string | null;
|
||||||
|
city_name?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
country_code?: string | null;
|
||||||
|
postal_code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPlansForOperatorTour(tourId: number): OperatorTourDayPlan[] {
|
||||||
|
return db.all<OperatorTourDayPlan>(
|
||||||
|
`SELECT p.* FROM operator_tour_day_plans p
|
||||||
|
JOIN operator_tour_days d ON d.id = p.operator_tour_day_id
|
||||||
|
WHERE d.operator_tour_id = ?
|
||||||
|
ORDER BY d.position ASC, d.id ASC, p.position ASC, p.id ASC`,
|
||||||
|
[tourId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOperatorTourDayPlan(
|
||||||
|
dayId: number,
|
||||||
|
type: OperatorTourDayPlanType,
|
||||||
|
title: string,
|
||||||
|
notes?: string | null,
|
||||||
|
lodgingFields?: {
|
||||||
|
chain?: string | null;
|
||||||
|
address_line1?: string | null;
|
||||||
|
address_line2?: string | null;
|
||||||
|
city_name?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
country_code?: string | null;
|
||||||
|
postal_code?: string | null;
|
||||||
|
}
|
||||||
|
): OperatorTourDayPlan {
|
||||||
|
const maxPos = db.get<{ pos: number }>(
|
||||||
|
'SELECT COALESCE(MAX(position), -1) + 1 as pos FROM operator_tour_day_plans WHERE operator_tour_day_id = ?',
|
||||||
|
[dayId]
|
||||||
|
);
|
||||||
|
const position = maxPos?.pos ?? 0;
|
||||||
|
|
||||||
|
const l = lodgingFields;
|
||||||
|
const cols =
|
||||||
|
type === 'lodging' && l
|
||||||
|
? 'operator_tour_day_id, type, title, notes, position, chain, address_line1, address_line2, city_name, country, country_code, postal_code'
|
||||||
|
: 'operator_tour_day_id, type, title, notes, position';
|
||||||
|
const vals =
|
||||||
|
type === 'lodging' && l
|
||||||
|
? [
|
||||||
|
dayId,
|
||||||
|
type,
|
||||||
|
title.trim(),
|
||||||
|
notes?.trim() || null,
|
||||||
|
position,
|
||||||
|
l.chain?.trim() || null,
|
||||||
|
l.address_line1?.trim() || null,
|
||||||
|
l.address_line2?.trim() || null,
|
||||||
|
l.city_name?.trim() || null,
|
||||||
|
l.country?.trim() || null,
|
||||||
|
l.country_code?.trim() || null,
|
||||||
|
l.postal_code?.trim() || null
|
||||||
|
]
|
||||||
|
: [dayId, type, title.trim(), notes?.trim() || null, position];
|
||||||
|
const placeholders = vals.map(() => '?').join(', ');
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO operator_tour_day_plans (${cols}) VALUES (${placeholders})`,
|
||||||
|
vals
|
||||||
|
);
|
||||||
|
return db.get<OperatorTourDayPlan>(
|
||||||
|
'SELECT * FROM operator_tour_day_plans WHERE id = last_insert_rowid()'
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOperatorTourDayPlan(
|
||||||
|
id: number,
|
||||||
|
updates: {
|
||||||
|
title?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
chain?: string | null;
|
||||||
|
address_line1?: string | null;
|
||||||
|
address_line2?: string | null;
|
||||||
|
city_name?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
country_code?: string | null;
|
||||||
|
postal_code?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const values: (string | null | number)[] = [];
|
||||||
|
const optional = (key: string, v: string | null | undefined) => {
|
||||||
|
if (v !== undefined) {
|
||||||
|
setClauses.push(`${key} = ?`);
|
||||||
|
values.push(v === null ? null : (typeof v === 'string' ? v.trim() : v));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
optional('title', updates.title);
|
||||||
|
optional('notes', updates.notes ?? undefined);
|
||||||
|
optional('chain', updates.chain ?? undefined);
|
||||||
|
optional('address_line1', updates.address_line1 ?? undefined);
|
||||||
|
optional('address_line2', updates.address_line2 ?? undefined);
|
||||||
|
optional('city_name', updates.city_name ?? undefined);
|
||||||
|
optional('country', updates.country ?? undefined);
|
||||||
|
optional('country_code', updates.country_code ?? undefined);
|
||||||
|
optional('postal_code', updates.postal_code ?? undefined);
|
||||||
|
if (setClauses.length === 0) return;
|
||||||
|
setClauses.push("updated_at = datetime('now')");
|
||||||
|
values.push(id);
|
||||||
|
db.run(
|
||||||
|
`UPDATE operator_tour_day_plans SET ${setClauses.join(', ')} WHERE id = ?`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOperatorTourDayPlan(id: number): void {
|
||||||
|
db.run('DELETE FROM operator_tour_day_plans WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,159 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import type { TourProvider, TourSearchResult } from './types.js';
|
import type {
|
||||||
|
TourDetail,
|
||||||
|
TourDay,
|
||||||
|
TourDayPlan,
|
||||||
|
TourDayPlanLodgingFields,
|
||||||
|
TourProvider,
|
||||||
|
TourSearchResult
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const BASE = 'https://rest.gadventures.com';
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
return { 'X-Application-Key': env.GADVENTURES_API_KEY };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw API shapes for itinerary days and components
|
||||||
|
interface GAdventureComponent {
|
||||||
|
type?: string;
|
||||||
|
summary?: string;
|
||||||
|
accommodation_dossier?: { id: string; href: string; name: string };
|
||||||
|
transport_dossier?: { id: string; href: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GAdventureItineraryDay {
|
||||||
|
day: number;
|
||||||
|
label?: string;
|
||||||
|
summary?: string;
|
||||||
|
components?: GAdventureComponent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accommodation dossier detail response (from following accommodation_dossier href)
|
||||||
|
interface GAdventureAccommodationDossier {
|
||||||
|
name?: string;
|
||||||
|
address?: {
|
||||||
|
address_line_1?: string;
|
||||||
|
address_line_2?: string;
|
||||||
|
address_line_3?: string;
|
||||||
|
postal_code?: string;
|
||||||
|
city?: { name?: string };
|
||||||
|
country?: { name?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAccommodationDetails(href: string): Promise<GAdventureAccommodationDossier | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(href, { headers: authHeaders() });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lodgingFieldsFromDossier(dossier: GAdventureAccommodationDossier): TourDayPlanLodgingFields | undefined {
|
||||||
|
const addr = dossier.address;
|
||||||
|
if (!addr) return undefined;
|
||||||
|
const address_line1 = addr.address_line_1?.trim() || null;
|
||||||
|
const address_line2 = addr.address_line_2?.trim() || null;
|
||||||
|
const city_name = addr.city?.name?.trim() || null;
|
||||||
|
const country = addr.country?.name?.trim() || null;
|
||||||
|
const country_code = addr.country?.id?.trim() || null;
|
||||||
|
const postal_code = addr.postal_code?.trim() || null;
|
||||||
|
if (
|
||||||
|
!address_line1 &&
|
||||||
|
!address_line2 &&
|
||||||
|
!city_name &&
|
||||||
|
!country &&
|
||||||
|
!country_code &&
|
||||||
|
!postal_code
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
address_line1: address_line1 ?? undefined,
|
||||||
|
address_line2: address_line2 ?? undefined,
|
||||||
|
city_name: city_name ?? undefined,
|
||||||
|
country: country ?? undefined,
|
||||||
|
country_code: country_code ?? undefined,
|
||||||
|
postal_code: postal_code ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dayPlansFromComponents(components: GAdventureComponent[]): Promise<TourDayPlan[]> {
|
||||||
|
const plans: TourDayPlan[] = [];
|
||||||
|
for (const c of components ?? []) {
|
||||||
|
const type = c.type?.toUpperCase();
|
||||||
|
if (type === 'TRANSPORT' && c.transport_dossier) {
|
||||||
|
plans.push({
|
||||||
|
type: 'transport',
|
||||||
|
title: c.transport_dossier.name?.trim() || c.summary?.trim() || 'Transport',
|
||||||
|
notes: c.summary?.trim() || null
|
||||||
|
});
|
||||||
|
} else if (type === 'ACCOMMODATION' && c.accommodation_dossier) {
|
||||||
|
const dossier = c.accommodation_dossier;
|
||||||
|
let title = dossier.name?.trim() || c.summary?.trim() || 'Accommodation';
|
||||||
|
const notes: string | null = c.summary?.trim() || null;
|
||||||
|
let lodgingFields: TourDayPlanLodgingFields | undefined;
|
||||||
|
if (dossier.href) {
|
||||||
|
const details = await fetchAccommodationDetails(dossier.href);
|
||||||
|
if (details?.name?.trim()) title = details.name.trim();
|
||||||
|
if (details) lodgingFields = lodgingFieldsFromDossier(details);
|
||||||
|
}
|
||||||
|
plans.push({ type: 'lodging', title, notes: notes ?? undefined, lodgingFields });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
|
|
||||||
export const GAdventuresProvider: TourProvider = {
|
export const GAdventuresProvider: TourProvider = {
|
||||||
name: 'G Adventures',
|
name: 'G Adventures',
|
||||||
|
|
||||||
search: async (query: string): Promise<TourSearchResult[]> => {
|
search: async (query: string): Promise<TourSearchResult[]> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (query.trim()) params.set('name', query.trim());
|
if (query.trim()) params.set('name', query.trim());
|
||||||
const url = `https://rest.gadventures.com/tour_dossiers?${params}`;
|
const res = await fetch(`${BASE}/tour_dossiers?${params}`, { headers: authHeaders() });
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: { 'X-Application-Key': env.GADVENTURES_API_KEY }
|
|
||||||
});
|
|
||||||
console.dir(res);
|
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return (data.results ?? []).map((r: { id: string; name: string }) => ({
|
return (data.results ?? []).map((r: { id: string; name: string }) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.name
|
title: r.name
|
||||||
}));
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
getDetail: async (id: string): Promise<TourDetail | null> => {
|
||||||
|
// 1. Fetch the tour dossier
|
||||||
|
const dossierRes = await fetch(`${BASE}/tour_dossiers/${id}`, { headers: authHeaders() });
|
||||||
|
if (!dossierRes.ok) return null;
|
||||||
|
const dossier = await dossierRes.json();
|
||||||
|
|
||||||
|
const title: string = dossier.name ?? '';
|
||||||
|
const description: string = dossier.description ?? '';
|
||||||
|
|
||||||
|
// 2. Resolve itinerary — use the first structured itinerary's href
|
||||||
|
const itineraryHref: string | undefined = dossier.structured_itineraries?.[0]?.href;
|
||||||
|
if (!itineraryHref) {
|
||||||
|
return { id, title, description, days: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const itinRes = await fetch(itineraryHref, { headers: authHeaders() });
|
||||||
|
if (!itinRes.ok) return { id, title, description, days: [] };
|
||||||
|
const itinerary = await itinRes.json();
|
||||||
|
|
||||||
|
const rawDays: GAdventureItineraryDay[] = itinerary.days ?? [];
|
||||||
|
const days: TourDay[] = [];
|
||||||
|
|
||||||
|
for (const d of rawDays) {
|
||||||
|
const dayPlans = await dayPlansFromComponents(d.components ?? []);
|
||||||
|
days.push({
|
||||||
|
dayNumber: d.day,
|
||||||
|
title: d.label ?? '',
|
||||||
|
description: d.summary ?? '',
|
||||||
|
dayPlans: dayPlans.length > 0 ? dayPlans : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, title, description, days };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,9 +5,53 @@ export interface TourSearchResult {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lodging-specific fields (from provider accommodation dossier) */
|
||||||
|
export interface TourDayPlanLodgingFields {
|
||||||
|
chain?: string | null;
|
||||||
|
address_line1?: string | null;
|
||||||
|
address_line2?: string | null;
|
||||||
|
city_name?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
country_code?: string | null;
|
||||||
|
postal_code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A transport or lodging plan attached to a day (from provider components) */
|
||||||
|
export interface TourDayPlan {
|
||||||
|
type: 'transport' | 'lodging';
|
||||||
|
title: string;
|
||||||
|
notes?: string | null;
|
||||||
|
/** When type is 'lodging', structured address/location from provider */
|
||||||
|
lodgingFields?: TourDayPlanLodgingFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourDay {
|
||||||
|
/** 1-based day number */
|
||||||
|
dayNumber: number;
|
||||||
|
/** Short title / label for the day */
|
||||||
|
title: string;
|
||||||
|
/** Longer description / summary for the day */
|
||||||
|
description: string;
|
||||||
|
/** Optional transport/lodging items parsed from day components */
|
||||||
|
dayPlans?: TourDayPlan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourDetail {
|
||||||
|
/** Provider-specific identifier */
|
||||||
|
id: string;
|
||||||
|
/** Display name of the tour */
|
||||||
|
title: string;
|
||||||
|
/** Overview description of the tour */
|
||||||
|
description: string;
|
||||||
|
/** Ordered list of days in the itinerary */
|
||||||
|
days: TourDay[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TourProvider {
|
export interface TourProvider {
|
||||||
/** Human-readable provider name shown in the import drawer */
|
/** Human-readable provider name shown in the import drawer */
|
||||||
name: string;
|
name: string;
|
||||||
/** Search tours by query string. Empty query returns a default set of results. */
|
/** Search tours by query string. Empty query returns a default set of results. */
|
||||||
search(query: string): Promise<TourSearchResult[]>;
|
search(query: string): Promise<TourSearchResult[]>;
|
||||||
|
/** Fetch full details (description + itinerary days) for a specific tour by provider ID */
|
||||||
|
getDetail(id: string): Promise<TourDetail | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,11 +303,19 @@ export function runMigrations(db: Database): void {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
operator_id INTEGER NOT NULL REFERENCES tour_operators(id) ON DELETE CASCADE,
|
operator_id INTEGER NOT NULL REFERENCES tour_operators(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: add description to existing operator_tours tables
|
||||||
|
try {
|
||||||
|
db.run(`ALTER TABLE operator_tours ADD COLUMN description TEXT`);
|
||||||
|
} catch {
|
||||||
|
/* already exists */
|
||||||
|
}
|
||||||
|
|
||||||
// Template itinerary days for predefined operator tours
|
// Template itinerary days for predefined operator tours
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS operator_tour_days (
|
CREATE TABLE IF NOT EXISTS operator_tour_days (
|
||||||
@@ -322,6 +330,37 @@ export function runMigrations(db: Database): void {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Template plans (transport/lodging) attached to operator tour days
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS operator_tour_day_plans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
operator_tour_day_id INTEGER NOT NULL REFERENCES operator_tour_days(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('transport', 'lodging')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Lodging-specific fields for operator_tour_day_plans (type=lodging) — same shape as trip lodgings
|
||||||
|
for (const col of [
|
||||||
|
'chain TEXT',
|
||||||
|
'address_line1 TEXT',
|
||||||
|
'address_line2 TEXT',
|
||||||
|
'city_name TEXT',
|
||||||
|
'country TEXT',
|
||||||
|
'country_code TEXT',
|
||||||
|
'postal_code TEXT'
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
db.run(`ALTER TABLE operator_tour_day_plans ADD COLUMN ${col}`);
|
||||||
|
} catch {
|
||||||
|
/* already exists */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Countries table — editable list for admin (used by country selector)
|
// Countries table — editable list for admin (used by country selector)
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS countries (
|
CREATE TABLE IF NOT EXISTS countries (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { db } from './db/index.js';
|
import { db } from './db/index.js';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { PlanStatus } from './plans.js';
|
import type { PlanStatus } from './plans.js';
|
||||||
import { listDaysForOperatorTour } from './admin/data.js';
|
import { createPlan } from './plans.js';
|
||||||
|
import { listDaysForOperatorTour, listPlansForOperatorTour } from './admin/data.js';
|
||||||
|
|
||||||
export interface PackageTour {
|
export interface PackageTour {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -311,13 +312,26 @@ export function cloneTemplateDaysToTour(input: {
|
|||||||
tourPlanId: string;
|
tourPlanId: string;
|
||||||
}): void {
|
}): void {
|
||||||
const days = listDaysForOperatorTour(input.operatorTourId);
|
const days = listDaysForOperatorTour(input.operatorTourId);
|
||||||
|
const allDayPlans = listPlansForOperatorTour(input.operatorTourId);
|
||||||
for (const day of days) {
|
for (const day of days) {
|
||||||
createTourDay({
|
const newDay = createTourDay({
|
||||||
tripId: input.tripId,
|
tripId: input.tripId,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
tourPlanId: input.tourPlanId,
|
tourPlanId: input.tourPlanId,
|
||||||
title: day.title ?? undefined,
|
title: day.title ?? undefined,
|
||||||
notes: day.notes ?? undefined
|
notes: day.notes ?? undefined
|
||||||
});
|
});
|
||||||
|
const dayPlans = allDayPlans.filter((p) => p.operator_tour_day_id === day.id);
|
||||||
|
for (const dp of dayPlans) {
|
||||||
|
createPlan({
|
||||||
|
tripId: input.tripId,
|
||||||
|
userId: input.userId,
|
||||||
|
type: dp.type,
|
||||||
|
title: dp.title,
|
||||||
|
notes: dp.notes ?? undefined,
|
||||||
|
parentId: newDay.plan_id,
|
||||||
|
status: 'idea'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,47 @@ export function createDestination(input: CreateDestinationInput): Plan {
|
|||||||
return db.get<Plan>('SELECT * FROM plans WHERE id = ?', [id])!;
|
return db.get<Plan>('SELECT * FROM plans WHERE id = ?', [id])!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreatePlanInput {
|
||||||
|
tripId: string;
|
||||||
|
userId: string;
|
||||||
|
type: PlanType;
|
||||||
|
title: string;
|
||||||
|
notes?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
status?: PlanStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlan(input: CreatePlanInput): Plan {
|
||||||
|
const id = randomUUID();
|
||||||
|
const maxPos = input.parentId
|
||||||
|
? db.get<{ pos: number }>(
|
||||||
|
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE parent_id = ? AND user_id = ?`,
|
||||||
|
[input.parentId, input.userId]
|
||||||
|
)
|
||||||
|
: db.get<{ pos: number }>(
|
||||||
|
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`,
|
||||||
|
[input.tripId, input.userId]
|
||||||
|
);
|
||||||
|
const position = maxPos?.pos ?? 0;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO plans (id, trip_id, user_id, type, status, title, notes, parent_id, position)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
input.tripId,
|
||||||
|
input.userId,
|
||||||
|
input.type,
|
||||||
|
input.status ?? 'idea',
|
||||||
|
input.title,
|
||||||
|
input.notes ?? null,
|
||||||
|
input.parentId ?? null,
|
||||||
|
position
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return db.get<Plan>('SELECT * FROM plans WHERE id = ?', [id])!;
|
||||||
|
}
|
||||||
|
|
||||||
export function getPlansForTrip(tripId: string, userId: string): Plan[] {
|
export function getPlansForTrip(tripId: string, userId: string): Plan[] {
|
||||||
return db.all<Plan>(
|
return db.all<Plan>(
|
||||||
`SELECT * FROM plans WHERE trip_id = ? AND user_id = ? ORDER BY position ASC, created_at ASC`,
|
`SELECT * FROM plans WHERE trip_id = ? AND user_id = ? ORDER BY position ASC, created_at ASC`,
|
||||||
|
|||||||
@@ -83,12 +83,54 @@ export const actions: Actions = {
|
|||||||
const operatorId = parseInt(event.params.operatorId);
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
||||||
|
|
||||||
|
const operators = data.listTourOperators();
|
||||||
|
const operator = operators.find((o) => o.id === operatorId);
|
||||||
|
if (!operator) return fail(404, { error: 'Operator not found' });
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const name = (formData.get('name') as string)?.trim();
|
const name = (formData.get('name') as string)?.trim();
|
||||||
|
const providerId = (formData.get('providerId') as string)?.trim();
|
||||||
if (!name) return fail(400, { error: 'Tour name is required' });
|
if (!name) return fail(400, { error: 'Tour name is required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data.createOperatorTour(operatorId, name);
|
// Try to fetch full detail from the provider
|
||||||
|
const provider = getProviderForOperator(operator.name);
|
||||||
|
let description: string | null = null;
|
||||||
|
let days: {
|
||||||
|
dayNumber: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
dayPlans?: { type: 'transport' | 'lodging'; title: string; notes?: string | null }[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
if (provider && providerId) {
|
||||||
|
const detail = await provider.getDetail(providerId);
|
||||||
|
if (detail) {
|
||||||
|
description = detail.description || null;
|
||||||
|
days = detail.days;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tour = data.createOperatorTour(operatorId, name, description);
|
||||||
|
|
||||||
|
// Import itinerary days and any transport/lodging day plans from provider components
|
||||||
|
for (const day of days) {
|
||||||
|
const createdDay = data.createOperatorTourDay(
|
||||||
|
tour.id,
|
||||||
|
day.title || undefined,
|
||||||
|
day.description || undefined
|
||||||
|
);
|
||||||
|
for (const plan of day.dayPlans ?? []) {
|
||||||
|
data.createOperatorTourDayPlan(
|
||||||
|
createdDay.id,
|
||||||
|
plan.type,
|
||||||
|
plan.title,
|
||||||
|
plan.notes ?? undefined,
|
||||||
|
plan.type === 'lodging' && plan.lodgingFields ? plan.lodgingFields : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(400, { error: err instanceof Error ? err.message : 'Failed to import tour' });
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to import tour' });
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
let importQuery = $state('');
|
let importQuery = $state('');
|
||||||
let importResults = $state<{ id: string; title: string }[]>([]);
|
let importResults = $state<{ id: string; title: string }[]>([]);
|
||||||
let importLoading = $state(false);
|
let importLoading = $state(false);
|
||||||
|
let importSelectedId = $state('');
|
||||||
let importSelectedTitle = $state('');
|
let importSelectedTitle = $state('');
|
||||||
let importError = $state('');
|
let importError = $state('');
|
||||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
importDrawerOpen = true;
|
importDrawerOpen = true;
|
||||||
importQuery = '';
|
importQuery = '';
|
||||||
importResults = [];
|
importResults = [];
|
||||||
|
importSelectedId = '';
|
||||||
importSelectedTitle = '';
|
importSelectedTitle = '';
|
||||||
importError = '';
|
importError = '';
|
||||||
// Load initial results
|
// Load initial results
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
importDrawerOpen = false;
|
importDrawerOpen = false;
|
||||||
importQuery = '';
|
importQuery = '';
|
||||||
importResults = [];
|
importResults = [];
|
||||||
|
importSelectedId = '';
|
||||||
importSelectedTitle = '';
|
importSelectedTitle = '';
|
||||||
importError = '';
|
importError = '';
|
||||||
}
|
}
|
||||||
@@ -78,7 +81,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectImportResult(title: string) {
|
function selectImportResult(id: string, title: string) {
|
||||||
|
importSelectedId = id;
|
||||||
importSelectedTitle = title;
|
importSelectedTitle = title;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -108,7 +112,14 @@
|
|||||||
onclick={openImportDrawer}
|
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"
|
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">
|
<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" />
|
<polyline points="8 17 12 21 16 17" />
|
||||||
<line x1="12" y1="3" x2="12" y2="21" />
|
<line x1="12" y1="3" x2="12" y2="21" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -117,7 +128,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => { showAddForm = true; addName = ''; addError = ''; }}
|
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"
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Add tour
|
Add tour
|
||||||
@@ -129,7 +144,15 @@
|
|||||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
{#if data.tours.length === 0 && !showAddForm}
|
{#if data.tours.length === 0 && !showAddForm}
|
||||||
<div class="p-10 text-center">
|
<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">
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="mx-auto mb-3 text-gray-300"
|
||||||
|
>
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" />
|
<rect x="2" y="7" width="20" height="14" rx="2" />
|
||||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
||||||
<line x1="12" y1="12" x2="12" y2="16" />
|
<line x1="12" y1="12" x2="12" y2="16" />
|
||||||
@@ -141,8 +164,14 @@
|
|||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium tracking-wide text-gray-500 uppercase">Tour name</th>
|
<th
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium tracking-wide text-gray-500 uppercase">Actions</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-100">
|
<tbody class="divide-y divide-gray-100">
|
||||||
@@ -168,10 +197,21 @@
|
|||||||
type="text"
|
type="text"
|
||||||
bind:value={editName}
|
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"
|
class="min-w-0 flex-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }}
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') cancelEdit();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<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
|
||||||
<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>
|
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>
|
</form>
|
||||||
{#if editError}<p class="mt-1.5 text-xs text-red-600">{editError}</p>{/if}
|
{#if editError}<p class="mt-1.5 text-xs text-red-600">{editError}</p>{/if}
|
||||||
</td>
|
</td>
|
||||||
@@ -182,22 +222,30 @@
|
|||||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
||||||
<a
|
<a
|
||||||
href="{base}/admin/tour-operators/{data.operator.id}/tours/{tour.id}"
|
href="{base}/admin/tour-operators/{data.operator.id}/tours/{tour.id}"
|
||||||
class="hover:text-blue-700 hover:underline"
|
class="hover:text-blue-700 hover:underline">{tour.name}</a
|
||||||
>{tour.name}</a>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<a
|
<a
|
||||||
href="{base}/admin/tour-operators/{data.operator.id}/tours/{tour.id}"
|
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"
|
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>
|
>Itinerary →</a
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => startEdit(tour)}
|
onclick={() => startEdit(tour)}
|
||||||
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||||
title="Edit name"
|
title="Edit name"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -214,11 +262,20 @@
|
|||||||
<input type="hidden" name="id" value={tour.id} />
|
<input type="hidden" name="id" value={tour.id} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
onclick={(e) => { if (!confirm(`Delete "${tour.name}"?`)) e.preventDefault(); }}
|
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"
|
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
|
||||||
title="Delete tour"
|
title="Delete tour"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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" />
|
<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="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
<path d="M10 11v6M14 11v6" />
|
<path d="M10 11v6M14 11v6" />
|
||||||
@@ -257,10 +314,28 @@
|
|||||||
bind:value={addName}
|
bind:value={addName}
|
||||||
placeholder="Tour name"
|
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"
|
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 = ''; } }}
|
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
|
||||||
<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>
|
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>
|
</form>
|
||||||
{#if addError}<p class="mt-1.5 text-xs text-red-600">{addError}</p>{/if}
|
{#if addError}<p class="mt-1.5 text-xs text-red-600">{addError}</p>{/if}
|
||||||
</td>
|
</td>
|
||||||
@@ -301,7 +376,14 @@
|
|||||||
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -311,7 +393,13 @@
|
|||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="border-b border-gray-100 px-6 py-4">
|
<div class="border-b border-gray-100 px-6 py-4">
|
||||||
<div class="relative">
|
<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">
|
<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" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -325,7 +413,14 @@
|
|||||||
{#if importLoading}
|
{#if importLoading}
|
||||||
<div class="absolute top-2.5 right-3">
|
<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">
|
<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>
|
<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>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,12 +440,21 @@
|
|||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => selectImportResult(result.title)}
|
onclick={() => selectImportResult(result.id, 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' : ''}"
|
class="flex w-full items-center justify-between px-6 py-3.5 text-left hover:bg-gray-50 {importSelectedId ===
|
||||||
|
result.id
|
||||||
|
? 'bg-blue-50'
|
||||||
|
: ''}"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-gray-900">{result.title}</span>
|
<span class="text-sm text-gray-900">{result.title}</span>
|
||||||
{#if importSelectedTitle === result.title}
|
{#if importSelectedId === result.id}
|
||||||
<svg class="h-4 w-4 shrink-0 text-blue-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<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" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -385,6 +489,7 @@
|
|||||||
class="flex gap-2"
|
class="flex gap-2"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="name" value={importSelectedTitle} />
|
<input type="hidden" name="name" value={importSelectedTitle} />
|
||||||
|
<input type="hidden" name="providerId" value={importSelectedId} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!importSelectedTitle}
|
disabled={!importSelectedTitle}
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
if (!tour) error(404, 'Tour not found');
|
if (!tour) error(404, 'Tour not found');
|
||||||
|
|
||||||
const days = data.listDaysForOperatorTour(tourId);
|
const days = data.listDaysForOperatorTour(tourId);
|
||||||
|
const dayPlans = data.listPlansForOperatorTour(tourId);
|
||||||
|
|
||||||
return { operator, tour, days };
|
return { operator, tour, days, dayPlans };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -74,5 +75,90 @@ export const actions: Actions = {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(400, { error: err instanceof Error ? err.message : 'Failed to delete day' });
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to delete day' });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addDayPlan: async (event) => {
|
||||||
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const dayId = parseInt(formData.get('dayId') as string);
|
||||||
|
const type = (formData.get('type') as string)?.trim();
|
||||||
|
const get = (k: string) => (formData.get(k) as string)?.trim() || undefined;
|
||||||
|
const title = get('name') || get('title');
|
||||||
|
|
||||||
|
if (isNaN(dayId)) return fail(400, { error: 'Invalid day ID' });
|
||||||
|
if (type !== 'transport' && type !== 'lodging') return fail(400, { error: 'Invalid plan type' });
|
||||||
|
if (!title) return fail(400, { error: 'Name is required' });
|
||||||
|
|
||||||
|
const notes = get('notes') ?? undefined;
|
||||||
|
const lodgingFields =
|
||||||
|
type === 'lodging'
|
||||||
|
? {
|
||||||
|
chain: get('chain') ?? null,
|
||||||
|
address_line1: get('address_line1') ?? null,
|
||||||
|
address_line2: get('address_line2') ?? null,
|
||||||
|
city_name: get('city_name') ?? null,
|
||||||
|
country: get('country') ?? null,
|
||||||
|
country_code: get('country_code') ?? null,
|
||||||
|
postal_code: get('postal_code') ?? null
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.createOperatorTourDayPlan(
|
||||||
|
dayId,
|
||||||
|
type as 'transport' | 'lodging',
|
||||||
|
title,
|
||||||
|
notes,
|
||||||
|
lodgingFields
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to add plan' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
editDayPlan: 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 plan ID' });
|
||||||
|
|
||||||
|
const title = (formData.get('name') as string)?.trim() || (formData.get('title') as string)?.trim();
|
||||||
|
if (!title) return fail(400, { error: 'Name is required' });
|
||||||
|
|
||||||
|
const get = (k: string) => (formData.get(k) as string)?.trim() || undefined;
|
||||||
|
try {
|
||||||
|
data.updateOperatorTourDayPlan(id, {
|
||||||
|
title,
|
||||||
|
notes: get('notes') ?? null,
|
||||||
|
chain: get('chain') ?? null,
|
||||||
|
address_line1: get('address_line1') ?? null,
|
||||||
|
address_line2: get('address_line2') ?? null,
|
||||||
|
city_name: get('city_name') ?? null,
|
||||||
|
country: get('country') ?? null,
|
||||||
|
country_code: get('country_code') ?? null,
|
||||||
|
postal_code: get('postal_code') ?? null
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to update plan' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDayPlan: 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 plan ID' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.deleteOperatorTourDayPlan(id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to delete plan' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
|
import LodgingCard from '$lib/components/LodgingCard.svelte';
|
||||||
|
import EditLodgingModal from '$lib/components/EditLodgingModal.svelte';
|
||||||
|
import AddLodgingModal from '$lib/components/AddLodgingModal.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -13,6 +16,37 @@
|
|||||||
let addTitle = $state('');
|
let addTitle = $state('');
|
||||||
let addNotes = $state('');
|
let addNotes = $state('');
|
||||||
|
|
||||||
|
// Day plans (transport/lodging) per day
|
||||||
|
let plansByDay = $derived.by(() => {
|
||||||
|
const map: Record<number, (typeof data.dayPlans)[number][]> = {};
|
||||||
|
for (const d of data.days) map[d.id] = [];
|
||||||
|
for (const p of data.dayPlans ?? []) {
|
||||||
|
if (map[p.operator_tour_day_id]) map[p.operator_tour_day_id].push(p);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add day plan: transport inline form; lodging uses AddLodgingModal
|
||||||
|
let addPlanFor = $state<{ dayId: number; type: 'transport' | 'lodging' } | null>(null);
|
||||||
|
let addPlanTitle = $state('');
|
||||||
|
let addPlanNotes = $state('');
|
||||||
|
|
||||||
|
// Edit: transport inline; lodging uses EditLodgingModal
|
||||||
|
let editingPlanId = $state<number | null>(null);
|
||||||
|
let editPlanTitle = $state('');
|
||||||
|
let editPlanNotes = $state('');
|
||||||
|
let editingLodgingPlan = $state<(typeof data.dayPlans)[number] | null>(null);
|
||||||
|
let addLodgingDayId = $state<number | null>(null);
|
||||||
|
|
||||||
|
// Delete day plan form (for LodgingCard onDelete)
|
||||||
|
let deletePlanFormRef = $state<HTMLFormElement | null>(null);
|
||||||
|
let planToDeleteId = $state<number | null>(null);
|
||||||
|
function submitDeletePlan(planId: number) {
|
||||||
|
if (!confirm('Remove this lodging from the day?')) return;
|
||||||
|
planToDeleteId = planId;
|
||||||
|
setTimeout(() => deletePlanFormRef?.requestSubmit(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
function startEdit(day: { id: number; title: string | null; notes: string | null }) {
|
function startEdit(day: { id: number; title: string | null; notes: string | null }) {
|
||||||
editingDayId = day.id;
|
editingDayId = day.id;
|
||||||
editTitle = day.title ?? '';
|
editTitle = day.title ?? '';
|
||||||
@@ -24,8 +58,75 @@
|
|||||||
editTitle = '';
|
editTitle = '';
|
||||||
editNotes = '';
|
editNotes = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAddPlan(dayId: number, type: 'transport' | 'lodging') {
|
||||||
|
addPlanFor = { dayId, type };
|
||||||
|
addPlanTitle = '';
|
||||||
|
addPlanNotes = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAddPlan() {
|
||||||
|
addPlanFor = null;
|
||||||
|
addPlanTitle = '';
|
||||||
|
addPlanNotes = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditPlan(plan: { id: number; title: string; notes: string | null; type: string }) {
|
||||||
|
if (plan.type === 'lodging') {
|
||||||
|
editingLodgingPlan = plan as (typeof data.dayPlans)[number];
|
||||||
|
} else {
|
||||||
|
editingPlanId = plan.id;
|
||||||
|
editPlanTitle = plan.title;
|
||||||
|
editPlanNotes = plan.notes ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditPlan() {
|
||||||
|
editingPlanId = null;
|
||||||
|
editPlanTitle = '';
|
||||||
|
editPlanNotes = '';
|
||||||
|
editingLodgingPlan = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddLodging(dayId: number) {
|
||||||
|
addLodgingDayId = dayId;
|
||||||
|
addPlanFor = null;
|
||||||
|
addPlanTitle = '';
|
||||||
|
addPlanNotes = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddLodging() {
|
||||||
|
addLodgingDayId = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteDayPlan"
|
||||||
|
bind:this={deletePlanFormRef}
|
||||||
|
use:enhance={() => ({ update }) => update()}
|
||||||
|
class="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={planToDeleteId ?? ''} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<EditLodgingModal
|
||||||
|
open={editingLodgingPlan != null}
|
||||||
|
onclose={() => (editingLodgingPlan = null)}
|
||||||
|
variant="template"
|
||||||
|
templatePlan={editingLodgingPlan}
|
||||||
|
formAction="?/editDayPlan"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddLodgingModal
|
||||||
|
open={addLodgingDayId != null}
|
||||||
|
onclose={closeAddLodging}
|
||||||
|
variant="template"
|
||||||
|
dayId={addLodgingDayId ?? 0}
|
||||||
|
formAction="?/addDayPlan"
|
||||||
|
/>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.tour.name} Itinerary — Admin — Trips</title>
|
<title>{data.tour.name} Itinerary — Admin — Trips</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
@@ -35,7 +136,9 @@
|
|||||||
<nav class="mb-6 flex items-center gap-2 text-sm text-gray-500">
|
<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>
|
<a href="{base}/admin/tour-operators" class="hover:text-gray-700">Tour Operators</a>
|
||||||
<span>›</span>
|
<span>›</span>
|
||||||
<span class="text-gray-700">{data.operator.name}</span>
|
<a href="{base}/admin/tour-operators/{data.operator.id}" class="text-gray-700 hover:text-gray-900"
|
||||||
|
>{data.operator.name}</a
|
||||||
|
>
|
||||||
<span>›</span>
|
<span>›</span>
|
||||||
<span class="font-medium text-gray-900">{data.tour.name} — Itinerary</span>
|
<span class="font-medium text-gray-900">{data.tour.name} — Itinerary</span>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -51,6 +154,7 @@
|
|||||||
<!-- Days list -->
|
<!-- Days list -->
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{#each data.days as day (day.id)}
|
{#each data.days as day (day.id)}
|
||||||
|
{@const dayPlanList = plansByDay[day.id] ?? []}
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
{#if editingDayId === day.id}
|
{#if editingDayId === day.id}
|
||||||
<!-- Inline edit form -->
|
<!-- Inline edit form -->
|
||||||
@@ -177,6 +281,185 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Attached plans (transport / lodging) for this day -->
|
||||||
|
<div class="mt-3 border-t border-gray-100 pt-3">
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-gray-400">
|
||||||
|
Transport & lodging
|
||||||
|
</p>
|
||||||
|
{#each dayPlanList as plan (plan.id)}
|
||||||
|
{#if plan.type === 'lodging'}
|
||||||
|
<!-- Lodging: same card + modal as trip page -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<LodgingCard
|
||||||
|
templatePlan={plan}
|
||||||
|
onEdit={() => startEditPlan(plan)}
|
||||||
|
onDelete={() => submitDeletePlan(plan.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Transport: inline edit -->
|
||||||
|
{#if editingPlanId === plan.id}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/editDayPlan"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') cancelEditPlan();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mb-2 rounded-md border border-gray-200 bg-gray-50/50 p-3"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={plan.id} />
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={editPlanTitle}
|
||||||
|
placeholder="Title"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
bind:value={editPlanNotes}
|
||||||
|
placeholder="Notes (optional)"
|
||||||
|
rows="2"
|
||||||
|
class="resize-none rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelEditPlan}
|
||||||
|
class="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="mb-2 flex items-start justify-between gap-2 rounded-md border border-gray-100 bg-gray-50/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<span class="mr-2 inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-sky-100 text-sky-800">
|
||||||
|
Transport
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-gray-900">{plan.title}</span>
|
||||||
|
{#if plan.notes}
|
||||||
|
<p class="mt-0.5 text-sm text-gray-500">{plan.notes}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => startEditPlan(plan)}
|
||||||
|
class="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<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="?/deleteDayPlan" use:enhance={() => ({ update }) => update()}>
|
||||||
|
<input type="hidden" name="id" value={plan.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (!confirm('Remove this plan from the day?')) e.preventDefault();
|
||||||
|
}}
|
||||||
|
class="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-red-600"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if addPlanFor?.dayId === day.id && addPlanFor?.type === 'transport'}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addDayPlan"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') cancelAddPlan();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mb-2 rounded-md border border-dashed border-gray-200 bg-gray-50/30 p-3"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="dayId" value={day.id} />
|
||||||
|
<input type="hidden" name="type" value="transport" />
|
||||||
|
<p class="mb-2 text-xs font-medium text-gray-500">Add transport</p>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
bind:value={addPlanTitle}
|
||||||
|
placeholder="e.g. Flight to Rome"
|
||||||
|
required
|
||||||
|
class="rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
bind:value={addPlanNotes}
|
||||||
|
placeholder="Notes (optional)"
|
||||||
|
rows="2"
|
||||||
|
class="resize-none rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelAddPlan}
|
||||||
|
class="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => openAddPlan(day.id, 'transport')}
|
||||||
|
class="rounded border border-dashed border-gray-200 px-2 py-1.5 text-xs text-gray-500 hover:border-sky-300 hover:bg-sky-50/50 hover:text-sky-700"
|
||||||
|
>
|
||||||
|
+ Transport
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => openAddLodging(day.id)}
|
||||||
|
class="rounded border border-dashed border-gray-200 px-2 py-1.5 text-xs text-gray-500 hover:border-amber-300 hover:bg-amber-50/50 hover:text-amber-700"
|
||||||
|
>
|
||||||
|
+ Lodging
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user