feat: Enhance reservation functionality

This commit is contained in:
2026-04-22 10:23:22 -04:00
parent 7f1e82acc2
commit 534d66c774
25 changed files with 1236 additions and 91 deletions

View File

@@ -0,0 +1,211 @@
<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonBackButton default-href="/" />
</IonButtons>
<IonTitle>Edit Reservation</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<template v-else-if="reservation">
<IonCard>
<IonCardHeader>
<IonCardTitle>{{ boatName }}</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<div class="time-row">
<IonIcon :icon="timeOutline" />
<span>{{ formatDateRange(reservation.start_time, reservation.end_time) }}</span>
</div>
</IonCardContent>
</IonCard>
<IonList lines="full" class="ion-margin-top">
<IonItem v-if="authStore.isAdmin">
<IonLabel position="stacked">Member</IonLabel>
<IonSelect v-model="form.user_id" interface="action-sheet">
<IonSelectOption v-for="m in members" :key="m.user_id" :value="m.user_id">
{{ m.first_name }} {{ m.last_name }}
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Reason</IonLabel>
<IonSelect v-model="form.reason" placeholder="Select reason" interface="action-sheet">
<IonSelectOption v-for="r in reasonOptions" :key="r" :value="r">{{ r }}</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Additional Comments (optional)</IonLabel>
<IonTextarea v-model="form.comment" :rows="3" placeholder="Any notes..." />
</IonItem>
</IonList>
<div class="form-actions">
<IonButton fill="outline" @click="router.back()">Cancel</IonButton>
<IonButton :disabled="!form.reason || saving" @click="save">
<IonSpinner v-if="saving" name="crescent" slot="start" />
Save Changes
</IonButton>
</div>
<IonButton
v-if="isFuture"
expand="block"
fill="outline"
color="danger"
class="ion-margin-top"
:disabled="saving"
@click="confirmCancel"
>
<IonIcon slot="start" :icon="trashOutline" />
Cancel Reservation
</IonButton>
</template>
<p v-else class="empty-text">Reservation not found.</p>
<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,
IonBackButton, IonCard, IonCardHeader, IonCardTitle, IonCardContent,
IonList, IonItem, IonLabel, IonSelect, IonSelectOption, IonTextarea,
IonButton, IonSpinner, IonIcon, IonToast, alertController, onIonViewWillEnter,
} from '@ionic/vue'
import { useRoute, useRouter } from 'vue-router'
import { timeOutline, trashOutline } from 'ionicons/icons'
import type { Database } from '~/types/supabase'
import { useAuthStore } from '~/stores/auth'
type ReservationWithBoat = Database['public']['Tables']['reservations']['Row'] & {
boats: { name: string; display_name: string | null } | null
}
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 user = useSupabaseUser()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id as string)
const loading = ref(true)
const saving = ref(false)
const reservation = ref<ReservationWithBoat | null>(null)
const members = ref<Member[]>([])
const toast = reactive({ show: false, message: '', color: 'success' })
const form = reactive({ reason: '', comment: '', user_id: '' })
const reasonOptions = ['Open Sail', 'Private Sail', 'Racing', 'Training', 'Other']
const boatName = computed(() =>
reservation.value?.boats?.display_name || reservation.value?.boats?.name || 'Unknown boat'
)
const isFuture = computed(() =>
reservation.value ? new Date(reservation.value.start_time) > new Date() : false
)
async function loadReservation() {
const reservationId = route.params.id as string
if (!user.value || !reservationId) return
loading.value = true
const [{ data }, { data: memberData }] = await Promise.all([
supabase.from('reservations').select('*, boats(name, display_name)').eq('id', reservationId).single(),
authStore.isAdmin
? supabase.from('members').select('*').order('last_name')
: Promise.resolve({ data: [] }),
])
reservation.value = data ?? null
members.value = memberData ?? []
if (data) {
form.reason = data.reason ?? ''
form.comment = data.comment ?? ''
form.user_id = data.user_id ?? ''
}
loading.value = false
}
watch(user, (val) => { if (val) loadReservation() }, { immediate: true })
onIonViewWillEnter(() => { if (user.value) loadReservation() })
async function save() {
saving.value = true
const payload: Record<string, string> = { reason: form.reason, comment: form.comment }
if (authStore.isAdmin && form.user_id) payload.user_id = form.user_id
const { error } = await supabase
.from('reservations')
.update(payload)
.eq('id', id.value)
saving.value = false
if (error) {
toast.message = 'Failed to save changes.'
toast.color = 'danger'
toast.show = true
return
}
toast.message = 'Reservation updated.'
toast.color = 'success'
toast.show = true
setTimeout(() => router.back(), 1500)
}
async function confirmCancel() {
const alert = await alertController.create({
header: 'Cancel Reservation',
message: 'Are you sure you want to cancel this reservation?',
buttons: [
{ text: 'Keep it', role: 'cancel' },
{
text: 'Cancel Reservation',
role: 'destructive',
handler: () => void cancelReservation(),
},
],
})
await alert.present()
}
async function cancelReservation() {
saving.value = true
await supabase.from('reservations').update({ status: 'cancelled' }).eq('id', id.value)
saving.value = false
router.back()
}
function formatDateRange(start: string, end: string) {
const s = new Date(start)
const e = new Date(end)
const date = s.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' })
const startTime = s.toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })
const endTime = e.toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })
return `${date} · ${startTime}${endTime}`
}
</script>
<style scoped>
.time-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.95rem; }
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
</style>