feat: Enhance reservation functionality
This commit is contained in:
@@ -187,7 +187,7 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonBadge,
|
||||
IonList, IonItem, IonLabel, IonInput, IonToggle, IonSpinner,
|
||||
IonModal, IonAlert, IonToast,
|
||||
IonModal, IonAlert, IonToast, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { addOutline, pencilOutline, trashOutline, boatOutline, cameraOutline } from 'ionicons/icons'
|
||||
import type { Database } from '~/types/supabase'
|
||||
@@ -224,7 +224,9 @@ const form = reactive({
|
||||
img_src: null as string | null,
|
||||
})
|
||||
|
||||
onMounted(fetchBoats)
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) fetchBoats() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) fetchBoats() })
|
||||
|
||||
async function fetchBoats() {
|
||||
loading.value = true
|
||||
|
||||
@@ -155,7 +155,7 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonInput,
|
||||
IonSpinner, IonToast,
|
||||
IonSpinner, IonToast, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { saveOutline, trashOutline, addOutline } from 'ionicons/icons'
|
||||
import type { Database } from '~/types/supabase'
|
||||
@@ -184,10 +184,14 @@ const local = reactive<Record<ConfigKey, number>>({
|
||||
|
||||
const original = reactive<Record<ConfigKey, number>>({ ...local })
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadAll() {
|
||||
await Promise.all([fetchConfig(), fetchHolidays()])
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) loadAll() })
|
||||
|
||||
async function fetchConfig() {
|
||||
const { data } = await supabase
|
||||
|
||||
@@ -193,6 +193,7 @@ import {
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonToggle,
|
||||
IonSelect, IonSelectOption, IonSpinner, IonModal, IonInput, IonToast, IonCheckbox,
|
||||
onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import {
|
||||
addOutline, timeOutline, trashOutline, constructOutline, calendarOutline,
|
||||
@@ -252,12 +253,16 @@ const dateOptions = computed(() => {
|
||||
return out
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadAll() {
|
||||
await Promise.all([fetchBoats(), fetchTemplates()])
|
||||
await fetchSlots()
|
||||
})
|
||||
}
|
||||
|
||||
watch(selectedDate, fetchSlots)
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) loadAll() })
|
||||
|
||||
watch(selectedDate, () => { if (user.value) fetchSlots() })
|
||||
|
||||
async function fetchBoats() {
|
||||
const { data } = await supabase.from('boats').select('*').order('name')
|
||||
|
||||
@@ -161,7 +161,7 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardContent, IonBadge,
|
||||
IonList, IonItem, IonLabel, IonInput, IonTextarea, IonSelect, IonSelectOption,
|
||||
IonSpinner, IonModal, IonAlert, IonToast,
|
||||
IonSpinner, IonModal, IonAlert, IonToast, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { addOutline, pencilOutline, trashOutline } from 'ionicons/icons'
|
||||
import type { Database } from '~/types/supabase'
|
||||
@@ -215,12 +215,16 @@ const filteredReservations = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadAll() {
|
||||
await Promise.all([fetchBoats(), fetchMembers()])
|
||||
await fetchReservations()
|
||||
})
|
||||
}
|
||||
|
||||
watch([filterDateFrom, filterDateTo], fetchReservations)
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) loadAll() })
|
||||
|
||||
watch([filterDateFrom, filterDateTo], () => { if (user.value) fetchReservations() })
|
||||
|
||||
async function fetchBoats() {
|
||||
const { data } = await supabase.from('boats').select('*').order('name')
|
||||
|
||||
@@ -128,7 +128,7 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||
IonCardContent, IonList, IonItem, IonLabel, IonInput, IonSpinner,
|
||||
IonModal, IonAlert, IonToast,
|
||||
IonModal, IonAlert, IonToast, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { addOutline, pencilOutline, trashOutline, closeOutline } from 'ionicons/icons'
|
||||
import type { Database, TimeTuple } from '~/types/supabase'
|
||||
@@ -153,7 +153,9 @@ const form = reactive<{ name: string; tuples: [string, string][] }>({
|
||||
tuples: [['08:00', '12:00']],
|
||||
})
|
||||
|
||||
onMounted(fetchTemplates)
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) fetchTemplates() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) fetchTemplates() })
|
||||
|
||||
async function fetchTemplates() {
|
||||
loading.value = true
|
||||
|
||||
@@ -35,7 +35,13 @@
|
||||
No upcoming reservations.
|
||||
</p>
|
||||
<IonList v-else lines="full">
|
||||
<IonItem v-for="r in upcomingReservations" :key="r.id">
|
||||
<IonItem
|
||||
v-for="r in upcomingReservations"
|
||||
:key="r.id"
|
||||
button
|
||||
detail
|
||||
@click="openActionSheet(r)"
|
||||
>
|
||||
<IonLabel>
|
||||
<h3>{{ boatName(r) }}</h3>
|
||||
<p>{{ formatDateRange(r.start_time, r.end_time) }}</p>
|
||||
@@ -71,9 +77,9 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
||||
IonButtons, IonMenuButton, IonButton, IonCard, IonCardHeader,
|
||||
IonCardTitle, IonCardContent, IonList, IonItem, IonLabel,
|
||||
IonBadge, IonSpinner, IonIcon,
|
||||
IonBadge, IonSpinner, IonIcon, actionSheetController, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { addCircleOutline } from 'ionicons/icons'
|
||||
import { addCircleOutline, createOutline, trashOutline } from 'ionicons/icons'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import type { Database, ReservationStatus } from '~/types/supabase'
|
||||
|
||||
@@ -86,6 +92,7 @@ definePageMeta({ layout: false })
|
||||
const user = useSupabaseUser()
|
||||
const auth = useAuthStore()
|
||||
const supabase = useSupabaseClient<Database>()
|
||||
const router = useRouter()
|
||||
|
||||
const today = new Date()
|
||||
const loadingReservations = ref(true)
|
||||
@@ -99,13 +106,47 @@ async function fetchReservations() {
|
||||
.select('*, boats(name, display_name)')
|
||||
.eq('user_id', user.value.id)
|
||||
.gte('start_time', new Date().toISOString())
|
||||
.neq('status', 'cancelled')
|
||||
.order('start_time', { ascending: true })
|
||||
.limit(3)
|
||||
upcomingReservations.value = (data as ReservationWithBoat[]) ?? []
|
||||
loadingReservations.value = false
|
||||
}
|
||||
|
||||
// Initial load and re-fetch on every page visit (Ionic caches the page, so
|
||||
// watch(user) alone won't re-run when navigating back from create/edit).
|
||||
watch(user, (val) => { if (val) fetchReservations() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) fetchReservations() })
|
||||
|
||||
async function openActionSheet(r: ReservationWithBoat) {
|
||||
const isFuture = new Date(r.start_time) > new Date()
|
||||
const sheet = await actionSheetController.create({
|
||||
header: `${boatName(r)} — ${formatDateRange(r.start_time, r.end_time)}`,
|
||||
buttons: [
|
||||
...(isFuture ? [
|
||||
{
|
||||
text: 'Edit',
|
||||
icon: createOutline,
|
||||
handler: () => void router.push(`/reservations/edit/${r.id}`),
|
||||
},
|
||||
{
|
||||
text: 'Cancel Reservation',
|
||||
role: 'destructive',
|
||||
icon: trashOutline,
|
||||
handler: () => void cancelReservation(r.id),
|
||||
},
|
||||
] : []),
|
||||
{ text: 'Dismiss', role: 'cancel' },
|
||||
],
|
||||
})
|
||||
await sheet.present()
|
||||
}
|
||||
|
||||
async function cancelReservation(id: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (supabase as any).from('reservations').update({ status: 'cancelled' }).eq('id', id)
|
||||
upcomingReservations.value = upcomingReservations.value.filter(r => r.id !== id)
|
||||
}
|
||||
|
||||
function boatName(r: ReservationWithBoat) {
|
||||
return r.boats?.display_name || r.boats?.name || 'Unknown boat'
|
||||
@@ -122,9 +163,10 @@ function formatDateRange(start: string, end: string) {
|
||||
|
||||
function statusColor(status: ReservationStatus): string {
|
||||
const colors: Record<ReservationStatus, string> = {
|
||||
pending: 'warning',
|
||||
pending: 'warning',
|
||||
tentative: 'medium',
|
||||
confirmed: 'success',
|
||||
cancelled: 'danger',
|
||||
}
|
||||
return colors[status]
|
||||
}
|
||||
|
||||
@@ -88,6 +88,15 @@
|
||||
</IonCard>
|
||||
|
||||
<IonList lines="full" class="ion-margin-top">
|
||||
<IonItem v-if="auth.isAdmin">
|
||||
<IonLabel position="stacked">Member</IonLabel>
|
||||
<IonSelect v-model="form.targetUserId" placeholder="Self (admin)" interface="action-sheet">
|
||||
<IonSelectOption value="">Self (admin)</IonSelectOption>
|
||||
<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">
|
||||
@@ -133,6 +142,7 @@ import {
|
||||
} from '@ionic/vue'
|
||||
import { timeOutline, warningOutline } from 'ionicons/icons'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import type { Member } from '~/stores/auth'
|
||||
import { toDateToronto } from '~/utils/toronto'
|
||||
import type { Database } from '~/types/supabase'
|
||||
import { useBookingDraft } from '~/composables/useBookingDraft'
|
||||
@@ -158,9 +168,10 @@ const submitting = ref(false)
|
||||
const selectedDate = ref(todayIso())
|
||||
const selectedBoat = ref<Boat | null>(null)
|
||||
const selectedSlot = ref<Interval | null>(null)
|
||||
const members = ref<Member[]>([])
|
||||
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||
|
||||
const form = reactive({ reason: '', comment: '' })
|
||||
const form = reactive({ reason: '', comment: '', targetUserId: '' })
|
||||
const reasonOptions = ['Open Sail', 'Private Sail', 'Racing', 'Training', 'Other']
|
||||
|
||||
// 14-day date strip starting today
|
||||
@@ -210,6 +221,11 @@ onIonViewWillEnter(async () => {
|
||||
selectedBoat.value = null
|
||||
selectedSlot.value = null
|
||||
selectedDate.value = todayIso()
|
||||
form.targetUserId = ''
|
||||
if (auth.isAdmin) {
|
||||
const { data } = await supabase.from('members').select('*').order('last_name')
|
||||
members.value = data ?? []
|
||||
}
|
||||
await loadSlots()
|
||||
})
|
||||
|
||||
@@ -299,11 +315,12 @@ async function submitReservation() {
|
||||
|
||||
const response = await supabase.functions.invoke('create-reservation', {
|
||||
body: {
|
||||
boat_id: selectedBoat.value.id,
|
||||
start_time: selectedSlot.value.start_time,
|
||||
end_time: selectedSlot.value.end_time,
|
||||
reason: form.reason,
|
||||
comment: form.comment,
|
||||
boat_id: selectedBoat.value.id,
|
||||
start_time: selectedSlot.value.start_time,
|
||||
end_time: selectedSlot.value.end_time,
|
||||
reason: form.reason,
|
||||
comment: form.comment,
|
||||
...(auth.isAdmin && form.targetUserId ? { target_user_id: form.targetUserId } : {}),
|
||||
},
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
211
app/pages/reservations/edit/[id].vue
Normal file
211
app/pages/reservations/edit/[id].vue
Normal 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>
|
||||
@@ -53,18 +53,19 @@
|
||||
v-for="slot in daySlots(boat.id, focusDate)"
|
||||
:key="slot.id"
|
||||
class="slot-block"
|
||||
:class="[slotClass(slot), slot.type === 'available' ? 'slot-tappable' : '']"
|
||||
:class="[slotClass(slot), (slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'slot-tappable' : '']"
|
||||
:title="slotTitle(slot)"
|
||||
:role="slot.type === 'available' ? 'button' : undefined"
|
||||
:tabindex="slot.type === 'available' ? 0 : undefined"
|
||||
@click="slot.type === 'available' && bookSlot(slot)"
|
||||
@keydown.enter="slot.type === 'available' && bookSlot(slot)"
|
||||
:role="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'button' : undefined"
|
||||
:tabindex="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 0 : undefined"
|
||||
@click="handleSlotClick(slot)"
|
||||
@keydown.enter="handleSlotClick(slot)"
|
||||
>
|
||||
<div class="slot-main-row">
|
||||
<span class="slot-time">{{ fmtTime(slot.startTime) }}–{{ fmtTime(slot.endTime) }}</span>
|
||||
<IonIcon v-if="slot.type === 'available'" :icon="chevronForwardOutline" class="slot-chevron" />
|
||||
</div>
|
||||
<span class="slot-status">{{ slotLabel(slot) }}</span>
|
||||
<span class="slot-member">{{ slot.memberName ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,13 +108,16 @@
|
||||
v-for="slot in daySlots(boat.id, date)"
|
||||
:key="slot.id"
|
||||
class="slot-block slot-sm"
|
||||
:class="slotClass(slot)"
|
||||
:class="[slotClass(slot), (slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'slot-tappable' : '']"
|
||||
:title="slotTitle(slot)"
|
||||
:role="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'button' : undefined"
|
||||
:tabindex="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 0 : undefined"
|
||||
@click="handleSlotClick(slot)"
|
||||
@keydown.enter="handleSlotClick(slot)"
|
||||
>
|
||||
<span class="slot-time-sm">{{ fmtTime(slot.startTime) }}</span>
|
||||
<span class="slot-sep">–</span>
|
||||
<span class="slot-time-sm">{{ fmtTime(slot.endTime) }}</span>
|
||||
<span class="slot-time-sm">{{ fmtTime(slot.startTime) }}-{{ fmtTime(slot.endTime) }}</span>
|
||||
<span class="slot-status-sm">{{ slotLabelShort(slot) }}</span>
|
||||
<span class="slot-member-sm">{{ slot.memberName ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -138,13 +142,17 @@
|
||||
import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
||||
IonButtons, IonMenuButton, IonButton, IonIcon, IonBadge, IonSpinner,
|
||||
actionSheetController, alertController, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import {
|
||||
chevronBackOutline, chevronForwardOutline, todayOutline,
|
||||
createOutline, trashOutline,
|
||||
} from 'ionicons/icons'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
import type { Database, ReservationStatus } from '~/types/supabase'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import {
|
||||
todayToronto, toDateToronto, addDays,
|
||||
fmtTime, fmtDateLong, fmtDayHeader, weekDates, utcRange,
|
||||
@@ -164,15 +172,19 @@ interface SlotBlock {
|
||||
endTime: string
|
||||
type: 'available' | 'booked'
|
||||
status: ReservationStatus | null
|
||||
isOwn: boolean
|
||||
memberName: string | null
|
||||
}
|
||||
|
||||
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = useSupabaseClient() as any
|
||||
const user = useSupabaseUser()
|
||||
const cache = useAppCache()
|
||||
const { isOnline } = useOfflineStatus()
|
||||
const { set: setDraft } = useBookingDraft()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Responsive ──────────────────────────────────────────────
|
||||
const isMobile = ref(true)
|
||||
@@ -200,10 +212,11 @@ function navNext() {
|
||||
function goToday() { focusDate.value = todayToronto() }
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────
|
||||
const loading = ref(true)
|
||||
const boats = ref<Boat[]>([])
|
||||
const intervals = ref<Interval[]>([])
|
||||
const slotViews = ref<SlotView[]>([])
|
||||
const loading = ref(true)
|
||||
const boats = ref<Boat[]>([])
|
||||
const intervals = ref<Interval[]>([])
|
||||
const slotViews = ref<SlotView[]>([])
|
||||
const myReservationIds = ref(new Set<string>())
|
||||
|
||||
async function fetchBoats() {
|
||||
if (!isOnline.value) {
|
||||
@@ -231,26 +244,31 @@ async function fetchSchedule() {
|
||||
}
|
||||
|
||||
const { from, to } = utcRange(dates[0]!, dates[dates.length - 1]!)
|
||||
const [intRes, slotRes] = await Promise.all([
|
||||
const [intRes, slotRes, myRes] = await Promise.all([
|
||||
supabase.from('intervals').select('*').gte('start_time', from).lte('start_time', to),
|
||||
supabase.from('reservation_slots').select('*').gte('start_time', from).lte('start_time', to),
|
||||
supabase.from('reservations').select('id').eq('user_id', user.value?.id).gte('start_time', from).lte('start_time', to).neq('status', 'cancelled'),
|
||||
])
|
||||
|
||||
intervals.value = intRes.data ?? []
|
||||
slotViews.value = slotRes.data ?? []
|
||||
myReservationIds.value = new Set((myRes.data ?? []).map((r: { id: string }) => r.id))
|
||||
loading.value = false
|
||||
|
||||
if (intRes.data) cache.set(`intervals:${wk}`, intRes.data)
|
||||
if (slotRes.data) cache.set(`slots:${wk}`, slotRes.data)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadAll() {
|
||||
await fetchBoats()
|
||||
await fetchSchedule()
|
||||
})
|
||||
}
|
||||
|
||||
// Re-fetch when the visible date range changes
|
||||
watch(visibleDates, fetchSchedule)
|
||||
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) loadAll() })
|
||||
|
||||
// Re-fetch when the visible date range changes (user-driven navigation)
|
||||
watch(visibleDates, () => { if (user.value) fetchSchedule() })
|
||||
|
||||
// ── Slot computation ─────────────────────────────────────────
|
||||
function daySlots(boatId: string, dateIso: string): SlotBlock[] {
|
||||
@@ -273,8 +291,10 @@ function daySlots(boatId: string, dateIso: string): SlotBlock[] {
|
||||
boatId,
|
||||
startTime: interval.start_time,
|
||||
endTime: interval.end_time,
|
||||
type: 'booked',
|
||||
status: res.status,
|
||||
type: 'booked' as const,
|
||||
status: res.status,
|
||||
isOwn: myReservationIds.value.has(res.id),
|
||||
memberName: res.member_name ?? null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -282,8 +302,10 @@ function daySlots(boatId: string, dateIso: string): SlotBlock[] {
|
||||
boatId,
|
||||
startTime: interval.start_time,
|
||||
endTime: interval.end_time,
|
||||
type: 'available',
|
||||
type: 'available' as const,
|
||||
status: null,
|
||||
isOwn: false,
|
||||
memberName: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -314,6 +336,60 @@ function bookSlot(slot: SlotBlock) {
|
||||
router.push('/reservations/create')
|
||||
}
|
||||
|
||||
function handleSlotClick(slot: SlotBlock) {
|
||||
if (slot.type === 'available') {
|
||||
bookSlot(slot)
|
||||
} else if (slot.isOwn || authStore.isAdmin) {
|
||||
openReservationSheet(slot)
|
||||
}
|
||||
}
|
||||
|
||||
async function openReservationSheet(slot: SlotBlock) {
|
||||
const isFuture = new Date(slot.startTime) > new Date()
|
||||
const memberName = slot.memberName ?? ''
|
||||
const header = memberName
|
||||
? `${memberName} · ${fmtTime(slot.startTime)}–${fmtTime(slot.endTime)}`
|
||||
: `${fmtTime(slot.startTime)}–${fmtTime(slot.endTime)}`
|
||||
const sheet = await actionSheetController.create({
|
||||
header,
|
||||
buttons: [
|
||||
...(isFuture ? [
|
||||
{
|
||||
text: 'Edit',
|
||||
icon: createOutline,
|
||||
handler: () => void router.push(`/reservations/edit/${slot.id}`),
|
||||
},
|
||||
{
|
||||
text: 'Cancel Reservation',
|
||||
role: 'destructive',
|
||||
icon: trashOutline,
|
||||
handler: () => void confirmCancelSlot(slot.id),
|
||||
},
|
||||
] : []),
|
||||
{ text: 'Dismiss', role: 'cancel' },
|
||||
],
|
||||
})
|
||||
await sheet.present()
|
||||
}
|
||||
|
||||
async function confirmCancelSlot(id: string) {
|
||||
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 cancelSlot(id) },
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async function cancelSlot(id: string) {
|
||||
await supabase.from('reservations').update({ status: 'cancelled' }).eq('id', id)
|
||||
slotViews.value = slotViews.value.filter(s => s.id !== id)
|
||||
myReservationIds.value.delete(id)
|
||||
}
|
||||
|
||||
function slotTitle(slot: SlotBlock): string {
|
||||
return `${fmtTime(slot.startTime)}–${fmtTime(slot.endTime)} · ${slotLabel(slot)}`
|
||||
}
|
||||
@@ -360,7 +436,9 @@ function slotTitle(slot: SlotBlock): string {
|
||||
}
|
||||
.slot-time { font-variant-numeric: tabular-nums; font-size: 0.9rem; }
|
||||
.slot-status { font-size: 0.75rem; opacity: 0.85; }
|
||||
.slot-member { font-size: 0.72rem; opacity: 0.8; font-style: italic; min-height: 1em; }
|
||||
.slot-chevron { font-size: 1rem; opacity: 0.8; flex-shrink: 0; }
|
||||
.slot-member-sm { font-size: 0.62rem; opacity: 0.8; font-style: italic; }
|
||||
|
||||
.slot-tappable {
|
||||
cursor: pointer;
|
||||
@@ -475,16 +553,17 @@ function slotTitle(slot: SlotBlock): string {
|
||||
|
||||
/* Compact slot variant for desktop cells */
|
||||
.slot-sm {
|
||||
padding: 0.2rem 0.4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.05rem;
|
||||
border-radius: 0.3rem;
|
||||
min-height: 0;
|
||||
}
|
||||
.slot-time-sm { font-variant-numeric: tabular-nums; font-size: 0.72rem; line-height: 1.2; }
|
||||
.slot-sep { display: none; }
|
||||
.slot-status-sm { font-size: 0.65rem; opacity: 0.85; }
|
||||
.slot-time-sm { font-variant-numeric: tabular-nums; font-size: 0.72rem; line-height: 1.3; font-weight: 600; }
|
||||
.slot-status-sm { font-size: 0.65rem; opacity: 0.85; line-height: 1.3; }
|
||||
.slot-member-sm { font-size: 0.65rem; opacity: 0.8; font-style: italic; line-height: 1.3; min-height: 0.9em; }
|
||||
|
||||
/* ── Legend ─────────────────────────────────────────────────── */
|
||||
.legend {
|
||||
|
||||
Reference in New Issue
Block a user