feat: Schedule bookings

This commit is contained in:
2026-04-21 16:12:04 -04:00
parent 108c042921
commit 5b4955f07e
9 changed files with 1636 additions and 4 deletions

499
app/pages/admin/boat.vue Normal file
View File

@@ -0,0 +1,499 @@
<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Manage Boats</IonTitle>
<IonButtons slot="end">
<IonButton @click="openCreate">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<p v-else-if="boats.length === 0" class="empty-text">No boats yet. Add one to get started.</p>
<IonCard v-else v-for="boat in boats" :key="boat.id" class="boat-card">
<div class="boat-card-inner">
<div class="boat-thumb-wrap">
<img
v-if="boat.img_src"
:src="boatImage.thumbnail(boat.img_src)"
class="boat-thumb"
alt=""
/>
<div v-else class="boat-thumb-placeholder">
<IonIcon :icon="boatOutline" />
</div>
</div>
<div class="boat-info">
<div class="boat-name">{{ boat.display_name || boat.name }}</div>
<div class="boat-meta">
<span v-if="boat.class">{{ boat.class }}</span>
<span v-if="boat.year">{{ boat.year }}</span>
<span>{{ boat.max_passengers }} pax</span>
</div>
<div class="cert-chips" v-if="boat.required_certs.length">
<span v-for="c in boat.required_certs" :key="c" class="cert-chip">{{ c }}</span>
</div>
<IonBadge :color="boat.booking_available ? 'success' : 'medium'" class="avail-badge">
{{ boat.booking_available ? 'Available' : 'Out of service' }}
</IonBadge>
</div>
<div class="boat-actions">
<IonButton fill="clear" @click="openEdit(boat)">
<IonIcon slot="icon-only" :icon="pencilOutline" />
</IonButton>
<IonButton fill="clear" color="danger" @click="confirmDelete(boat)">
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</div>
</div>
</IonCard>
<!-- Create / Edit modal -->
<IonModal :is-open="showModal" @did-dismiss="closeModal">
<IonHeader>
<IonToolbar>
<IonTitle>{{ editing ? 'Edit Boat' : 'New Boat' }}</IonTitle>
<IonButtons slot="end">
<IonButton @click="closeModal">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<!-- Image upload section -->
<div class="image-section">
<div class="image-preview-wrap" @click="triggerFileInput">
<img v-if="imagePreviewUrl" :src="imagePreviewUrl" class="image-preview" alt="Boat preview" />
<div v-else-if="form.img_src" class="image-preview-wrap">
<img :src="boatImage.medium(form.img_src)" class="image-preview" alt="Current boat image" />
</div>
<div v-else class="image-placeholder">
<IonIcon :icon="cameraOutline" class="camera-icon" />
<span>Tap to upload photo</span>
</div>
<div v-if="imagePreviewUrl || form.img_src" class="image-overlay">
<IonIcon :icon="cameraOutline" />
</div>
</div>
<input
ref="fileInputRef"
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden-file-input"
@change="onFileSelected"
/>
<p v-if="pendingFile" class="image-hint">{{ pendingFile.name }} will upload on save</p>
</div>
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Name <span class="required">*</span></IonLabel>
<IonInput v-model="form.name" placeholder="e.g. J27-1" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Display Name</IonLabel>
<IonInput v-model="form.display_name" placeholder="e.g. Blue Heron" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Class</IonLabel>
<IonInput v-model="form.class" placeholder="e.g. J/27" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Year</IonLabel>
<IonInput v-model.number="form.year" type="number" placeholder="e.g. 1988" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Max Passengers</IonLabel>
<IonInput v-model.number="form.max_passengers" type="number" :min="1" :max="20" />
</IonItem>
<IonItem>
<IonLabel>
<h3>Available for Booking</h3>
</IonLabel>
<IonToggle v-model="form.booking_available" slot="end" color="success" />
</IonItem>
</IonList>
<!-- Required certifications -->
<h4 class="section-title ion-margin-top">Required Certifications</h4>
<div class="cert-edit-area">
<div class="cert-chips-edit" v-if="form.required_certs.length">
<span v-for="(c, i) in form.required_certs" :key="i" class="cert-chip-edit">
{{ c }}
<button class="cert-remove" @click="removeCert(i)">×</button>
</span>
</div>
<p v-else class="empty-text-sm">No certifications required.</p>
<div class="cert-add-row">
<IonInput
v-model="newCert"
placeholder="Add cert code (e.g. j27)"
class="cert-input"
@keyup.enter="addCert"
/>
<IonButton @click="addCert" :disabled="!newCert.trim()" fill="outline">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</div>
</div>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!form.name.trim() || saving"
@click="saveBoat"
>
<IonSpinner v-if="saving" name="crescent" slot="start" />
{{ saving ? 'Saving…' : 'Save Boat' }}
</IonButton>
</IonContent>
</IonModal>
<IonAlert
:is-open="deleteAlert.show"
header="Delete Boat"
:message="`Delete '${deleteAlert.name}'? All slots and reservations for this boat will also be deleted.`"
:buttons="[
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
{ text: 'Delete', role: 'destructive', handler: deleteBoat },
]"
@did-dismiss="deleteAlert.show = false"
/>
<IonToast
v-model:is-open="toast.show"
:message="toast.message"
:color="toast.color"
:duration="2500"
position="bottom"
/>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
IonMenuButton, IonButton, IonIcon, IonCard, IonBadge,
IonList, IonItem, IonLabel, IonInput, IonToggle, IonSpinner,
IonModal, IonAlert, IonToast,
} from '@ionic/vue'
import { addOutline, pencilOutline, trashOutline, boatOutline, cameraOutline } from 'ionicons/icons'
import type { Database } from '~/types/supabase'
type Boat = Database['public']['Tables']['boats']['Row']
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const boatImage = useBoatImage()
const loading = ref(true)
const saving = ref(false)
const showModal = ref(false)
const editing = ref<Boat | null>(null)
const boats = ref<Boat[]>([])
const toast = reactive({ show: false, message: '', color: 'success' })
const deleteAlert = reactive({ show: false, id: '', name: '' })
const fileInputRef = ref<HTMLInputElement | null>(null)
const pendingFile = ref<File | null>(null)
const imagePreviewUrl = ref<string>('')
const newCert = ref('')
const form = reactive({
name: '',
display_name: '',
class: '',
year: null as number | null,
max_passengers: 6,
booking_available: true,
required_certs: [] as string[],
img_src: null as string | null,
})
onMounted(fetchBoats)
async function fetchBoats() {
loading.value = true
const { data } = await supabase.from('boats').select('*').order('name')
boats.value = data ?? []
loading.value = false
}
function openCreate() {
editing.value = null
resetForm()
showModal.value = true
}
function openEdit(boat: Boat) {
editing.value = boat
form.name = boat.name
form.display_name = boat.display_name ?? ''
form.class = boat.class ?? ''
form.year = boat.year ?? null
form.max_passengers = boat.max_passengers
form.booking_available = boat.booking_available
form.required_certs = [...boat.required_certs]
form.img_src = boat.img_src
pendingFile.value = null
imagePreviewUrl.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
resetForm()
}
function resetForm() {
form.name = ''
form.display_name = ''
form.class = ''
form.year = null
form.max_passengers = 6
form.booking_available = true
form.required_certs = []
form.img_src = null
pendingFile.value = null
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
imagePreviewUrl.value = ''
newCert.value = ''
}
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
pendingFile.value = file
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
imagePreviewUrl.value = URL.createObjectURL(file)
}
function addCert() {
const c = newCert.value.trim().toLowerCase()
if (c && !form.required_certs.includes(c)) {
form.required_certs.push(c)
}
newCert.value = ''
}
function removeCert(index: number) {
form.required_certs.splice(index, 1)
}
async function saveBoat() {
saving.value = true
const payload = {
name: form.name.trim(),
display_name: form.display_name.trim() || null,
class: form.class.trim() || null,
year: form.year || null,
max_passengers: form.max_passengers,
booking_available: form.booking_available,
required_certs: form.required_certs,
}
let boatId = editing.value?.id
let error
if (editing.value) {
;({ error } = await supabase.from('boats').update(payload).eq('id', boatId))
} else {
const { data, error: insertError } = await supabase
.from('boats').insert(payload).select('id').single()
error = insertError
boatId = data?.id
}
if (error) {
saving.value = false
showToast('Save failed: ' + error.message, 'danger')
return
}
// Upload image if a new file was selected
if (pendingFile.value && boatId) {
const ext = pendingFile.value.name.split('.').pop() ?? 'jpg'
const path = `boats/${boatId}/original.${ext}`
const { error: uploadError } = await supabase.storage
.from('boat-images')
.upload(path, pendingFile.value, { upsert: true, contentType: pendingFile.value.type })
if (uploadError) {
saving.value = false
showToast('Boat saved but image upload failed: ' + uploadError.message, 'warning')
await fetchBoats()
closeModal()
return
}
await supabase.from('boats').update({ img_src: path }).eq('id', boatId)
}
saving.value = false
closeModal()
await fetchBoats()
showToast(editing.value ? 'Boat updated' : 'Boat created', 'success')
}
function confirmDelete(boat: Boat) {
deleteAlert.id = boat.id
deleteAlert.name = boat.display_name || boat.name
deleteAlert.show = true
}
async function deleteBoat() {
deleteAlert.show = false
const { error } = await supabase.from('boats').delete().eq('id', deleteAlert.id)
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
await fetchBoats()
showToast('Boat deleted', 'success')
}
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
</script>
<style scoped>
.boat-card { margin: 0 0 0.75rem; }
.boat-card-inner {
display: flex;
align-items: flex-start;
padding: 0.75rem;
gap: 0.75rem;
}
.boat-thumb-wrap { flex-shrink: 0; }
.boat-thumb {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 0.5rem;
}
.boat-thumb-placeholder {
width: 72px;
height: 72px;
border-radius: 0.5rem;
background: var(--ion-color-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: var(--ion-color-medium);
}
.boat-info { flex: 1; min-width: 0; }
.boat-name { font-weight: 600; font-size: 1rem; }
.boat-meta {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--ion-color-medium);
margin: 0.2rem 0;
}
.cert-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.3rem 0; }
.cert-chip {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
background: var(--ion-color-primary-tint);
color: var(--ion-color-primary-shade);
font-weight: 500;
}
.avail-badge { font-size: 0.7rem; }
.boat-actions { display: flex; flex-direction: column; flex-shrink: 0; }
/* Modal image section */
.image-section { margin-bottom: 1rem; }
.image-preview-wrap {
position: relative;
width: 100%;
height: 200px;
border-radius: 0.75rem;
overflow: hidden;
background: var(--ion-color-light);
cursor: pointer;
}
.image-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 0.5rem;
color: var(--ion-color-medium);
font-size: 0.9rem;
}
.camera-icon { font-size: 2.5rem; }
.image-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
opacity: 0;
transition: opacity 0.2s;
}
.image-preview-wrap:hover .image-overlay { opacity: 1; }
.hidden-file-input { display: none; }
.image-hint { font-size: 0.78rem; color: var(--ion-color-medium); margin: 0.25rem 0 0; }
.required { color: var(--ion-color-danger); }
.section-title { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.5rem; }
/* Cert editor */
.cert-edit-area { margin-bottom: 0.75rem; }
.cert-chips-edit { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
.cert-chip-edit {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.6rem;
border-radius: 1rem;
background: var(--ion-color-primary-tint);
color: var(--ion-color-primary-shade);
font-size: 0.82rem;
font-weight: 500;
}
.cert-remove {
background: none;
border: none;
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 1rem;
color: var(--ion-color-primary-shade);
}
.cert-add-row { display: flex; gap: 0.5rem; align-items: center; }
.cert-input { flex: 1; }
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
.empty-text-sm { color: var(--ion-color-medium); font-size: 0.85rem; margin: 0.25rem 0; }
</style>

View File

@@ -7,6 +7,9 @@
</IonButtons>
<IonTitle>Manage Slots</IonTitle>
<IonButtons slot="end">
<IonButton @click="showWeekApply = true">
<IonIcon slot="icon-only" :icon="calendarOutline" />
</IonButton>
<IonButton @click="showAddSlot = true">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
@@ -127,6 +130,52 @@
</IonContent>
</IonModal>
<!-- Apply to Week modal -->
<IonModal :is-open="showWeekApply" @did-dismiss="showWeekApply = false">
<IonHeader>
<IonToolbar>
<IonTitle>Apply Template to Week</IonTitle>
<IonButtons slot="end">
<IonButton @click="showWeekApply = false">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Template</IonLabel>
<IonSelect v-model="weekApply.templateId" placeholder="Select template" interface="action-sheet">
<IonSelectOption v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Boat</IonLabel>
<IonSelect v-model="weekApply.boatId" placeholder="Select boat" interface="action-sheet">
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">{{ b.display_name || b.name }}</IonSelectOption>
</IonSelect>
</IonItem>
</IonList>
<h4 class="section-title ion-margin-top">Days to apply</h4>
<IonList lines="none">
<IonItem v-for="(day, i) in weekDays" :key="day.iso">
<IonCheckbox v-model="weekApply.days[i]" slot="start" />
<IonLabel>{{ day.label }}</IonLabel>
</IonItem>
</IonList>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!weekApply.templateId || !weekApply.boatId || !weekApply.days.some(Boolean) || weekApply.applying"
@click="applyTemplateToWeek"
>
<IonSpinner v-if="weekApply.applying" name="crescent" slot="start" />
Apply to Selected Days
</IonButton>
</IonContent>
</IonModal>
<IonToast
v-model:is-open="toast.show"
:message="toast.message"
@@ -143,10 +192,10 @@ import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonToggle,
IonSelect, IonSelectOption, IonSpinner, IonModal, IonInput, IonToast,
IonSelect, IonSelectOption, IonSpinner, IonModal, IonInput, IonToast, IonCheckbox,
} from '@ionic/vue'
import {
addOutline, timeOutline, trashOutline, constructOutline,
addOutline, timeOutline, trashOutline, constructOutline, calendarOutline,
} from 'ionicons/icons'
import type { Database, TimeTuple } from '~/types/supabase'
@@ -162,6 +211,23 @@ const supabase = useSupabaseClient() as any
const loading = ref(false)
const addingSlot = ref(false)
const showAddSlot = ref(false)
const showWeekApply = ref(false)
const weekApply = reactive({ templateId: '', boatId: '', applying: false, days: [] as boolean[] })
watch(showWeekApply, (open) => {
if (open) weekApply.days = Array(7).fill(true)
})
const weekDays = computed(() => {
const base = new Date(selectedDate.value + 'T12:00:00')
const dow = base.getDay() // 0=Sun
const monday = new Date(base)
monday.setDate(base.getDate() - ((dow + 6) % 7))
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
return { iso: d.toISOString().slice(0, 10), label: d.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' }) }
})
})
const selectedDate = ref(todayIso())
const boats = ref<Boat[]>([])
const templates = ref<Template[]>([])
@@ -280,6 +346,28 @@ async function applyTemplate(boatId: string, templateId: string) {
showToast(`Template "${template.name}" applied`, 'success')
}
async function applyTemplateToWeek() {
const template = templates.value.find(t => t.id === weekApply.templateId)
if (!template) return
weekApply.applying = true
const selectedDays = weekDays.value.filter((_, i) => weekApply.days[i])
const inserts = selectedDays.flatMap(day =>
(template.time_tuples as TimeTuple[]).map(([start, end]) => ({
boat_id: weekApply.boatId,
start_time: new Date(`${day.iso}T${start}:00`).toISOString(),
end_time: new Date(`${day.iso}T${end}:00`).toISOString(),
}))
)
const { error } = await supabase.from('intervals').insert(inserts)
weekApply.applying = false
if (error) { showToast('Failed to apply: ' + error.message, 'danger'); return }
showWeekApply.value = false
await fetchSlots()
showToast(`Template applied to ${selectedDays.length} day(s)`, 'success')
}
function showToast(message: string, color: string) {
toast.message = message
toast.color = color

View File

@@ -0,0 +1,386 @@
<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Manage Bookings</IonTitle>
<IonButtons slot="end">
<IonButton @click="openCreate">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<!-- Filters -->
<div class="filter-row">
<IonSelect
v-model="filterBoatId"
placeholder="All boats"
interface="action-sheet"
class="boat-filter"
>
<IonSelectOption value="">All boats</IonSelectOption>
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">
{{ b.display_name || b.name }}
</IonSelectOption>
</IonSelect>
<IonInput v-model="filterDateFrom" type="date" class="date-filter" />
<IonInput v-model="filterDateTo" type="date" class="date-filter" />
</div>
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<p v-else-if="filteredReservations.length === 0" class="empty-text">No bookings found.</p>
<IonCard v-else v-for="r in filteredReservations" :key="r.id" class="res-card">
<IonCardContent>
<div class="res-header">
<div class="res-main">
<div class="res-boat">{{ boatName(r.boat_id) }}</div>
<div class="res-member">{{ authStore.getUserNameById(r.user_id) }}</div>
<div class="res-time">
{{ formatDate(r.start_time) }}
{{ formatTime(r.start_time) }}{{ formatTime(r.end_time) }}
</div>
<div v-if="r.reason" class="res-reason">{{ r.reason }}</div>
</div>
<div class="res-right">
<IonBadge :color="statusColor(r.status)" class="status-badge">{{ r.status }}</IonBadge>
<div class="res-actions">
<IonButton fill="clear" @click="openEdit(r)">
<IonIcon slot="icon-only" :icon="pencilOutline" />
</IonButton>
<IonButton fill="clear" color="danger" @click="confirmDelete(r)">
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</div>
</div>
</div>
</IonCardContent>
</IonCard>
<!-- Create / Edit modal -->
<IonModal :is-open="showModal" @did-dismiss="closeModal">
<IonHeader>
<IonToolbar>
<IonTitle>{{ editing ? 'Edit Booking' : 'New Booking' }}</IonTitle>
<IonButtons slot="end">
<IonButton @click="closeModal">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Boat <span class="required">*</span></IonLabel>
<IonSelect v-model="form.boat_id" placeholder="Select boat" interface="action-sheet">
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">
{{ b.display_name || b.name }}
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Member <span class="required">*</span></IonLabel>
<IonSelect v-model="form.user_id" placeholder="Select member" interface="action-sheet">
<IonSelectOption v-for="m in members" :key="m.user_id" :value="m.user_id">
{{ m.first_name }} {{ m.last_name }} ({{ m.email }})
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Date <span class="required">*</span></IonLabel>
<IonInput v-model="form.date" type="date" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Start Time <span class="required">*</span></IonLabel>
<IonInput v-model="form.startTime" type="time" />
</IonItem>
<IonItem>
<IonLabel position="stacked">End Time <span class="required">*</span></IonLabel>
<IonInput v-model="form.endTime" type="time" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Status</IonLabel>
<IonSelect v-model="form.status" interface="action-sheet">
<IonSelectOption value="pending">Pending</IonSelectOption>
<IonSelectOption value="tentative">Tentative</IonSelectOption>
<IonSelectOption value="confirmed">Confirmed</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Reason</IonLabel>
<IonInput v-model="form.reason" placeholder="e.g. Day sail" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Comment</IonLabel>
<IonTextarea v-model="form.comment" :rows="2" placeholder="Optional admin note" />
</IonItem>
</IonList>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!formValid || saving"
@click="saveReservation"
>
<IonSpinner v-if="saving" name="crescent" slot="start" />
{{ saving ? 'Saving…' : 'Save Booking' }}
</IonButton>
</IonContent>
</IonModal>
<IonAlert
:is-open="deleteAlert.show"
header="Delete Booking"
message="Delete this booking? This cannot be undone."
:buttons="[
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
{ text: 'Delete', role: 'destructive', handler: deleteReservation },
]"
@did-dismiss="deleteAlert.show = false"
/>
<IonToast
v-model:is-open="toast.show"
:message="toast.message"
:color="toast.color"
:duration="2500"
position="bottom"
/>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
IonMenuButton, IonButton, IonIcon, IonCard, IonCardContent, IonBadge,
IonList, IonItem, IonLabel, IonInput, IonTextarea, IonSelect, IonSelectOption,
IonSpinner, IonModal, IonAlert, IonToast,
} from '@ionic/vue'
import { addOutline, pencilOutline, trashOutline } from 'ionicons/icons'
import type { Database } from '~/types/supabase'
import { useAuthStore } from '~/stores/auth'
type Boat = Database['public']['Tables']['boats']['Row']
type Reservation = Database['public']['Tables']['reservations']['Row']
type Member = Database['public']['Tables']['members']['Row']
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const authStore = useAuthStore()
const loading = ref(true)
const saving = ref(false)
const showModal = ref(false)
const editing = ref<Reservation | null>(null)
const reservations = ref<Reservation[]>([])
const boats = ref<Boat[]>([])
const members = ref<Member[]>([])
const toast = reactive({ show: false, message: '', color: 'success' })
const deleteAlert = reactive({ show: false, id: '' })
// Filters — default to current week ± 4 weeks
const today = new Date()
const filterDateFrom = ref(toIso(new Date(today.getFullYear(), today.getMonth(), today.getDate() - 14)))
const filterDateTo = ref(toIso(new Date(today.getFullYear(), today.getMonth(), today.getDate() + 28)))
const filterBoatId = ref('')
const form = reactive({
boat_id: '',
user_id: '',
date: toIso(today),
startTime: '09:00',
endTime: '13:00',
status: 'confirmed' as 'pending' | 'tentative' | 'confirmed',
reason: '',
comment: '',
})
const formValid = computed(() =>
!!form.boat_id && !!form.user_id && !!form.date && !!form.startTime && !!form.endTime
)
const filteredReservations = computed(() => {
return reservations.value.filter(r => {
if (filterBoatId.value && r.boat_id !== filterBoatId.value) return false
return true
})
})
onMounted(async () => {
await Promise.all([fetchBoats(), fetchMembers()])
await fetchReservations()
})
watch([filterDateFrom, filterDateTo], fetchReservations)
async function fetchBoats() {
const { data } = await supabase.from('boats').select('*').order('name')
boats.value = data ?? []
}
async function fetchMembers() {
const { data } = await supabase
.from('members')
.select('*')
.order('last_name')
members.value = data ?? []
}
async function fetchReservations() {
loading.value = true
const from = filterDateFrom.value + 'T00:00:00Z'
const to = filterDateTo.value + 'T23:59:59Z'
const { data } = await supabase
.from('reservations')
.select('*')
.gte('start_time', from)
.lte('start_time', to)
.order('start_time', { ascending: true })
reservations.value = data ?? []
loading.value = false
}
function openCreate() {
editing.value = null
form.boat_id = ''
form.user_id = ''
form.date = toIso(today)
form.startTime = '09:00'
form.endTime = '13:00'
form.status = 'confirmed'
form.reason = ''
form.comment = ''
showModal.value = true
}
function openEdit(r: Reservation) {
editing.value = r
form.boat_id = r.boat_id
form.user_id = r.user_id
form.date = r.start_time.slice(0, 10)
form.startTime = new Date(r.start_time).toTimeString().slice(0, 5)
form.endTime = new Date(r.end_time).toTimeString().slice(0, 5)
form.status = r.status as 'pending' | 'tentative' | 'confirmed'
form.reason = r.reason
form.comment = r.comment
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function saveReservation() {
saving.value = true
const payload = {
boat_id: form.boat_id,
user_id: form.user_id,
start_time: new Date(`${form.date}T${form.startTime}:00`).toISOString(),
end_time: new Date(`${form.date}T${form.endTime}:00`).toISOString(),
status: form.status,
reason: form.reason,
comment: form.comment,
}
let error
if (editing.value) {
;({ error } = await supabase.from('reservations').update(payload).eq('id', editing.value.id))
} else {
;({ error } = await supabase.from('reservations').insert(payload))
}
saving.value = false
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
closeModal()
await fetchReservations()
showToast(editing.value ? 'Booking updated' : 'Booking created', 'success')
}
function confirmDelete(r: Reservation) {
deleteAlert.id = r.id
deleteAlert.show = true
}
async function deleteReservation() {
deleteAlert.show = false
const { error } = await supabase.from('reservations').delete().eq('id', deleteAlert.id)
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
await fetchReservations()
showToast('Booking deleted', 'success')
}
function boatName(id: string) {
const b = boats.value.find(b => b.id === id)
return b ? (b.display_name || b.name) : id
}
function statusColor(status: string) {
return status === 'confirmed' ? 'success' : status === 'tentative' ? 'primary' : 'warning'
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' })
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function toIso(d: Date) { return d.toISOString().slice(0, 10) }
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
</script>
<style scoped>
.filter-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: center;
}
.boat-filter { flex: 1; min-width: 120px; }
.date-filter { flex: 1; min-width: 130px; }
.res-card { margin: 0 0 0.6rem; }
.res-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.res-main { flex: 1; min-width: 0; }
.res-boat { font-weight: 600; font-size: 0.95rem; }
.res-member { font-size: 0.85rem; color: var(--ion-color-medium); }
.res-time { font-size: 0.85rem; margin: 0.15rem 0; }
.res-reason { font-size: 0.8rem; color: var(--ion-color-medium); font-style: italic; }
.res-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
}
.status-badge { font-size: 0.7rem; }
.res-actions { display: flex; }
.required { color: var(--ion-color-danger); }
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
</style>