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

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

View File

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

View File

@@ -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')

View File

@@ -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')

View File

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

View File

@@ -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]
}

View File

@@ -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}` },
})

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>

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 {