feat: Enhance reservation functionality
This commit is contained in:
@@ -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