Files
oysqn.app/app/pages/admin/intervals.vue
Patrick Toal 108c042921 fix(edge-fn): replace getClaims with adminClient.auth.getUser(token)
fix(edge-fn): use user.id instead of claims.sub; fixes 500s and false cert_required
fix(migrations): drop broad reservations SELECT policy; add reservation_slots view with security_invoker=false
fix(tests): correct weekSlot() keys from start/end to start_time/end_time
fix(tests): spread overlap test slots across separate ISO weeks
fix(tests): update e2e assertion to match actual authenticated home text
fix(app): hide IonMenu before user is authenticated
feat(dx): add test:all script running unit, integration, and e2e in sequence
docs(claude-md): document SELinux fix, Edge Function auth pattern, security_invoker behaviour
2026-04-20 14:32:37 -04:00

352 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Manage Slots</IonTitle>
<IonButtons slot="end">
<IonButton @click="showAddSlot = true">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<!-- Date selector -->
<h3 class="section-title">Date</h3>
<div class="date-strip">
<button
v-for="d in dateOptions"
:key="d.iso"
class="date-chip"
:class="{ active: selectedDate === d.iso }"
@click="selectedDate = d.iso"
>
<span class="day-name">{{ d.dayName }}</span>
<span class="day-num">{{ d.dayNum }}</span>
<span class="month">{{ d.month }}</span>
</button>
</div>
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<template v-else>
<IonCard v-for="boat in boats" :key="boat.id" class="boat-card">
<IonCardHeader>
<div class="boat-header">
<div>
<IonCardTitle>{{ boat.display_name || boat.name }}</IonCardTitle>
<IonCardSubtitle v-if="boat.class">{{ boat.class }}</IonCardSubtitle>
</div>
<IonToggle
:checked="boat.booking_available"
:enable-on-off-labels="true"
color="success"
@ion-change="toggleBoatAvailability(boat, $event.detail.checked)"
/>
</div>
</IonCardHeader>
<IonCardContent>
<p v-if="!boat.booking_available" class="out-of-service">
<IonIcon :icon="constructOutline" /> Out of service
</p>
<div class="apply-template-row">
<IonSelect
placeholder="Apply template..."
interface="action-sheet"
@ion-change="applyTemplate(boat.id, $event.detail.value)"
>
<IonSelectOption v-for="t in templates" :key="t.id" :value="t.id">
{{ t.name }}
</IonSelectOption>
</IonSelect>
</div>
<p v-if="slotsByBoat[boat.id]?.length === 0" class="empty-text">No slots for this date.</p>
<IonList v-else lines="full">
<IonItem v-for="slot in slotsByBoat[boat.id]" :key="slot.id">
<IonIcon slot="start" :icon="timeOutline" />
<IonLabel>{{ formatTime(slot.start_time) }} {{ formatTime(slot.end_time) }}</IonLabel>
<IonButton
slot="end"
fill="clear"
color="danger"
@click="deleteSlot(slot.id)"
>
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</IonItem>
</IonList>
</IonCardContent>
</IonCard>
</template>
<!-- Add slot modal -->
<IonModal :is-open="showAddSlot" @did-dismiss="showAddSlot = false">
<IonHeader>
<IonToolbar>
<IonTitle>Add Slot</IonTitle>
<IonButtons slot="end">
<IonButton @click="showAddSlot = false">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Boat</IonLabel>
<IonSelect v-model="newSlot.boatId" placeholder="Select boat" interface="action-sheet">
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">
{{ b.display_name || b.name }}
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Start Time</IonLabel>
<IonInput v-model="newSlot.startTime" type="time" />
</IonItem>
<IonItem>
<IonLabel position="stacked">End Time</IonLabel>
<IonInput v-model="newSlot.endTime" type="time" />
</IonItem>
</IonList>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!newSlot.boatId || !newSlot.startTime || !newSlot.endTime || addingSlot"
@click="addSlot"
>
<IonSpinner v-if="addingSlot" name="crescent" slot="start" />
Add Slot
</IonButton>
</IonContent>
</IonModal>
<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,
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonToggle,
IonSelect, IonSelectOption, IonSpinner, IonModal, IonInput, IonToast,
} from '@ionic/vue'
import {
addOutline, timeOutline, trashOutline, constructOutline,
} from 'ionicons/icons'
import type { Database, TimeTuple } from '~/types/supabase'
type Boat = Database['public']['Tables']['boats']['Row']
type Interval = Database['public']['Tables']['intervals']['Row']
type Template = Database['public']['Tables']['interval_templates']['Row']
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const loading = ref(false)
const addingSlot = ref(false)
const showAddSlot = ref(false)
const selectedDate = ref(todayIso())
const boats = ref<Boat[]>([])
const templates = ref<Template[]>([])
const slotsByBoat = ref<Record<string, Interval[]>>({})
const toast = reactive({ show: false, message: '', color: 'success' })
const newSlot = reactive({ boatId: '', startTime: '08:00', endTime: '12:00' })
const dateOptions = computed(() => {
const out = []
const base = new Date()
for (let i = 0; i < 14; i++) {
const d = new Date(base)
d.setDate(base.getDate() + i)
out.push({
iso: toIso(d),
dayName: d.toLocaleDateString('en-CA', { weekday: 'short' }),
dayNum: d.getDate(),
month: d.toLocaleDateString('en-CA', { month: 'short' }),
})
}
return out
})
onMounted(async () => {
await Promise.all([fetchBoats(), fetchTemplates()])
await fetchSlots()
})
watch(selectedDate, fetchSlots)
async function fetchBoats() {
const { data } = await supabase.from('boats').select('*').order('name')
boats.value = data ?? []
}
async function fetchTemplates() {
const { data } = await supabase.from('interval_templates').select('*').order('name')
templates.value = data ?? []
}
async function fetchSlots() {
loading.value = true
const dayStart = selectedDate.value + 'T00:00:00Z'
const dayEnd = selectedDate.value + 'T23:59:59Z'
const { data } = await supabase
.from('intervals')
.select('*')
.gte('start_time', dayStart)
.lte('start_time', dayEnd)
.order('start_time', { ascending: true })
const grouped: Record<string, Interval[]> = {}
for (const boat of boats.value) grouped[boat.id] = []
for (const slot of (data ?? []) as Interval[]) {
if (!grouped[slot.boat_id]) grouped[slot.boat_id] = []
grouped[slot.boat_id]!.push(slot)
}
slotsByBoat.value = grouped
loading.value = false
}
async function addSlot() {
addingSlot.value = true
const start = new Date(`${selectedDate.value}T${newSlot.startTime}:00`)
const end = new Date(`${selectedDate.value}T${newSlot.endTime}:00`)
const { error } = await supabase.from('intervals').insert({
boat_id: newSlot.boatId,
start_time: start.toISOString(),
end_time: end.toISOString(),
})
addingSlot.value = false
if (error) {
showToast('Failed to add slot: ' + error.message, 'danger')
return
}
showAddSlot.value = false
newSlot.boatId = ''
await fetchSlots()
showToast('Slot added', 'success')
}
async function deleteSlot(id: string) {
const { error } = await supabase.from('intervals').delete().eq('id', id)
if (error) { showToast('Failed to delete: ' + error.message, 'danger'); return }
await fetchSlots()
showToast('Slot removed', 'success')
}
async function toggleBoatAvailability(boat: Boat, available: boolean) {
const { error } = await supabase
.from('boats')
.update({ booking_available: available })
.eq('id', boat.id)
if (error) { showToast('Update failed: ' + error.message, 'danger'); return }
boat.booking_available = available
showToast(available ? 'Boat back in service' : 'Boat out of service', available ? 'success' : 'warning')
}
async function applyTemplate(boatId: string, templateId: string) {
const template = templates.value.find(t => t.id === templateId)
if (!template) return
const inserts = (template.time_tuples as TimeTuple[]).map(([start, end]) => ({
boat_id: boatId,
start_time: new Date(`${selectedDate.value}T${start}:00`).toISOString(),
end_time: new Date(`${selectedDate.value}T${end}:00`).toISOString(),
}))
const { error } = await supabase.from('intervals').insert(inserts)
if (error) { showToast('Failed to apply template: ' + error.message, 'danger'); return }
await fetchSlots()
showToast(`Template "${template.name}" applied`, 'success')
}
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
function todayIso() { return toIso(new Date()) }
function toIso(d: Date) { return d.toISOString().slice(0, 10) }
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false })
}
</script>
<style scoped>
.section-title { font-size: 1rem; font-weight: 600; margin: 0 0 0.5rem; }
.date-strip {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
margin-bottom: 1rem;
}
.date-strip::-webkit-scrollbar { display: none; }
.date-chip {
display: flex;
flex-direction: column;
align-items: center;
min-width: 52px;
padding: 0.4rem 0.6rem;
border: 1px solid var(--ion-color-light-shade);
border-radius: 0.5rem;
background: var(--ion-color-light);
cursor: pointer;
font-size: 0.75rem;
gap: 2px;
flex-shrink: 0;
}
.date-chip.active {
background: var(--ion-color-primary);
color: white;
border-color: var(--ion-color-primary);
}
.day-name { font-weight: 600; text-transform: uppercase; font-size: 0.65rem; }
.day-num { font-size: 1.1rem; font-weight: 700; }
.month { font-size: 0.65rem; }
.boat-card { margin: 0 0 1rem; }
.boat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.out-of-service {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--ion-color-danger);
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.apply-template-row { margin-bottom: 0.75rem; }
.empty-text { color: var(--ion-color-medium); font-size: 0.9rem; }
</style>