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

@@ -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 {