tour-operators) sharing add/edit lodging across admin and user facing side
This commit is contained in:
@@ -83,12 +83,54 @@ export const actions: Actions = {
|
||||
const operatorId = parseInt(event.params.operatorId);
|
||||
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 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' });
|
||||
|
||||
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 };
|
||||
} catch (err) {
|
||||
return fail(400, { error: err instanceof Error ? err.message : 'Failed to import tour' });
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
let importQuery = $state('');
|
||||
let importResults = $state<{ id: string; title: string }[]>([]);
|
||||
let importLoading = $state(false);
|
||||
let importSelectedId = $state('');
|
||||
let importSelectedTitle = $state('');
|
||||
let importError = $state('');
|
||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||
@@ -40,6 +41,7 @@
|
||||
importDrawerOpen = true;
|
||||
importQuery = '';
|
||||
importResults = [];
|
||||
importSelectedId = '';
|
||||
importSelectedTitle = '';
|
||||
importError = '';
|
||||
// Load initial results
|
||||
@@ -50,6 +52,7 @@
|
||||
importDrawerOpen = false;
|
||||
importQuery = '';
|
||||
importResults = [];
|
||||
importSelectedId = '';
|
||||
importSelectedTitle = '';
|
||||
importError = '';
|
||||
}
|
||||
@@ -78,7 +81,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function selectImportResult(title: string) {
|
||||
function selectImportResult(id: string, title: string) {
|
||||
importSelectedId = id;
|
||||
importSelectedTitle = title;
|
||||
}
|
||||
</script>
|
||||
@@ -108,7 +112,14 @@
|
||||
onclick={openImportDrawer}
|
||||
class="flex items-center gap-2 rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="8 17 12 21 16 17" />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
@@ -117,7 +128,11 @@
|
||||
{/if}
|
||||
<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"
|
||||
>
|
||||
Add tour
|
||||
@@ -129,7 +144,15 @@
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
{#if data.tours.length === 0 && !showAddForm}
|
||||
<div class="p-10 text-center">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="mx-auto mb-3 text-gray-300">
|
||||
<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" />
|
||||
@@ -141,8 +164,14 @@
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium tracking-wide text-gray-500 uppercase">Tour name</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium tracking-wide text-gray-500 uppercase">Actions</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium tracking-wide text-gray-500 uppercase"
|
||||
>Tour name</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-medium tracking-wide text-gray-500 uppercase"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@@ -168,10 +197,21 @@
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
class="min-w-0 flex-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') cancelEdit();
|
||||
}}
|
||||
/>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Save</button>
|
||||
<button type="button" onclick={cancelEdit} class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>Save</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEdit}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>Cancel</button
|
||||
>
|
||||
</form>
|
||||
{#if editError}<p class="mt-1.5 text-xs text-red-600">{editError}</p>{/if}
|
||||
</td>
|
||||
@@ -182,22 +222,30 @@
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
||||
<a
|
||||
href="{base}/admin/tour-operators/{data.operator.id}/tours/{tour.id}"
|
||||
class="hover:text-blue-700 hover:underline"
|
||||
>{tour.name}</a>
|
||||
class="hover:text-blue-700 hover:underline">{tour.name}</a
|
||||
>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a
|
||||
href="{base}/admin/tour-operators/{data.operator.id}/tours/{tour.id}"
|
||||
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-blue-600 hover:bg-blue-50 hover:text-blue-800"
|
||||
>Itinerary →</a>
|
||||
>Itinerary →</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startEdit(tour)}
|
||||
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
title="Edit name"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
@@ -214,11 +262,20 @@
|
||||
<input type="hidden" name="id" value={tour.id} />
|
||||
<button
|
||||
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"
|
||||
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" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
@@ -257,10 +314,28 @@
|
||||
bind:value={addName}
|
||||
placeholder="Tour name"
|
||||
class="min-w-0 flex-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') { showAddForm = false; addName = ''; } }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
showAddForm = false;
|
||||
addName = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Add</button>
|
||||
<button type="button" onclick={() => { showAddForm = false; addName = ''; addError = ''; }} class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>Add</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showAddForm = false;
|
||||
addName = '';
|
||||
addError = '';
|
||||
}}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>Cancel</button
|
||||
>
|
||||
</form>
|
||||
{#if addError}<p class="mt-1.5 text-xs text-red-600">{addError}</p>{/if}
|
||||
</td>
|
||||
@@ -301,7 +376,14 @@
|
||||
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
@@ -311,7 +393,13 @@
|
||||
<!-- Search -->
|
||||
<div class="border-b border-gray-100 px-6 py-4">
|
||||
<div class="relative">
|
||||
<svg class="absolute top-2.5 left-3 h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
class="absolute top-2.5 left-3 h-4 w-4 text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
@@ -325,7 +413,14 @@
|
||||
{#if importLoading}
|
||||
<div class="absolute top-2.5 right-3">
|
||||
<svg class="h-4 w-4 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -345,12 +440,21 @@
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectImportResult(result.title)}
|
||||
class="flex w-full items-center justify-between px-6 py-3.5 text-left hover:bg-gray-50 {importSelectedTitle === result.title ? 'bg-blue-50' : ''}"
|
||||
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 {importSelectedId ===
|
||||
result.id
|
||||
? 'bg-blue-50'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm text-gray-900">{result.title}</span>
|
||||
{#if importSelectedTitle === result.title}
|
||||
<svg class="h-4 w-4 shrink-0 text-blue-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
{#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"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -385,6 +489,7 @@
|
||||
class="flex gap-2"
|
||||
>
|
||||
<input type="hidden" name="name" value={importSelectedTitle} />
|
||||
<input type="hidden" name="providerId" value={importSelectedId} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!importSelectedTitle}
|
||||
|
||||
@@ -20,8 +20,9 @@ export const load: PageServerLoad = async (event) => {
|
||||
if (!tour) error(404, 'Tour not found');
|
||||
|
||||
const days = data.listDaysForOperatorTour(tourId);
|
||||
const dayPlans = data.listPlansForOperatorTour(tourId);
|
||||
|
||||
return { operator, tour, days };
|
||||
return { operator, tour, days, dayPlans };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -74,5 +75,90 @@ export const actions: Actions = {
|
||||
} catch (err) {
|
||||
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">
|
||||
import { enhance } from '$app/forms';
|
||||
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';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -13,6 +16,37 @@
|
||||
let addTitle = $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 }) {
|
||||
editingDayId = day.id;
|
||||
editTitle = day.title ?? '';
|
||||
@@ -24,8 +58,75 @@
|
||||
editTitle = '';
|
||||
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>
|
||||
|
||||
<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>
|
||||
<title>{data.tour.name} Itinerary — Admin — Trips</title>
|
||||
</svelte:head>
|
||||
@@ -35,7 +136,9 @@
|
||||
<nav class="mb-6 flex items-center gap-2 text-sm text-gray-500">
|
||||
<a href="{base}/admin/tour-operators" class="hover:text-gray-700">Tour Operators</a>
|
||||
<span>›</span>
|
||||
<span class="text-gray-700">{data.operator.name}</span>
|
||||
<a href="{base}/admin/tour-operators/{data.operator.id}" class="text-gray-700 hover:text-gray-900"
|
||||
>{data.operator.name}</a
|
||||
>
|
||||
<span>›</span>
|
||||
<span class="font-medium text-gray-900">{data.tour.name} — Itinerary</span>
|
||||
</nav>
|
||||
@@ -51,6 +154,7 @@
|
||||
<!-- Days list -->
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each data.days as day (day.id)}
|
||||
{@const dayPlanList = plansByDay[day.id] ?? []}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
{#if editingDayId === day.id}
|
||||
<!-- Inline edit form -->
|
||||
@@ -177,6 +281,185 @@
|
||||
</div>
|
||||
</div>
|
||||
{/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>
|
||||
{/each}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user