Files
oysqn.app/app/pages/admin/config.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

279 lines
8.7 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>Booking Rules</IonTitle>
<IonButtons slot="end">
<IonButton :disabled="!dirty || saving" @click="saveAll">
<IonSpinner v-if="saving" name="crescent" slot="icon-only" />
<IonIcon v-else slot="icon-only" :icon="saveOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<template v-else>
<IonCard>
<IonCardHeader>
<IonCardTitle>Weekly Limits</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<IonList lines="full">
<IonItem>
<IonLabel>
<h3>Max sessions per week</h3>
<p>Pre-booked sessions allowed per member per ISO week (MonSun)</p>
</IonLabel>
<IonInput
slot="end"
type="number"
:value="local.max_sessions_per_week"
class="config-input"
:min="1"
:max="14"
@ion-input="set('max_sessions_per_week', $event.detail.value)"
/>
</IonItem>
</IonList>
</IonCardContent>
</IonCard>
<IonCard>
<IonCardHeader>
<IonCardTitle>Weekend / Holiday Limits</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<IonList lines="full">
<IonItem>
<IonLabel>
<h3>Max weekend sessions per period</h3>
<p>Max Sat/Sun/holiday pre-bookings per alternating period</p>
</IonLabel>
<IonInput
slot="end"
type="number"
:value="local.max_weekend_sessions_per_period"
class="config-input"
:min="1"
:max="10"
@ion-input="set('max_weekend_sessions_per_period', $event.detail.value)"
/>
</IonItem>
<IonItem>
<IonLabel>
<h3>Weekend period length (weeks)</h3>
<p>Number of weeks in each alternating period (default: 2 = every other weekend)</p>
</IonLabel>
<IonInput
slot="end"
type="number"
:value="local.weekend_period_weeks"
class="config-input"
:min="1"
:max="8"
@ion-input="set('weekend_period_weeks', $event.detail.value)"
/>
</IonItem>
</IonList>
</IonCardContent>
</IonCard>
<IonCard>
<IonCardHeader>
<IonCardTitle>Open Session Window</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<IonList lines="full">
<IonItem>
<IonLabel>
<h3>Advance hours for open sessions</h3>
<p>Pre-booking limits are waived for sessions starting within this many hours</p>
</IonLabel>
<IonInput
slot="end"
type="number"
:value="local.open_session_advance_hours"
class="config-input"
:min="1"
:max="72"
@ion-input="set('open_session_advance_hours', $event.detail.value)"
/>
</IonItem>
</IonList>
</IonCardContent>
</IonCard>
<IonCard>
<IonCardHeader>
<IonCardTitle>Holidays</IonCardTitle>
<IonCardSubtitle>Counted as weekend days for booking limits</IonCardSubtitle>
</IonCardHeader>
<IonCardContent>
<IonList lines="full">
<IonItem v-for="h in holidays" :key="h.date">
<IonLabel>
<h3>{{ h.name }}</h3>
<p>{{ h.date }}</p>
</IonLabel>
<IonButton slot="end" fill="clear" color="danger" @click="deleteHoliday(h.date)">
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</IonItem>
</IonList>
<div class="add-holiday-row">
<IonInput v-model="newHoliday.date" type="date" placeholder="Date" class="holiday-date-input" />
<IonInput v-model="newHoliday.name" placeholder="Name (e.g. Canada Day)" class="holiday-name-input" />
<IonButton @click="addHoliday" :disabled="!newHoliday.date || !newHoliday.name">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</div>
</IonCardContent>
</IonCard>
</template>
<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, IonInput,
IonSpinner, IonToast,
} from '@ionic/vue'
import { saveOutline, trashOutline, addOutline } from 'ionicons/icons'
import type { Database } from '~/types/supabase'
type ConfigKey = 'max_sessions_per_week' | 'max_weekend_sessions_per_period' | 'weekend_period_weeks' | 'open_session_advance_hours'
type Holiday = { date: string; name: string }
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const loading = ref(true)
const saving = ref(false)
const dirty = ref(false)
const toast = reactive({ show: false, message: '', color: 'success' })
const holidays = ref<Holiday[]>([])
const newHoliday = reactive({ date: '', name: '' })
const local = reactive<Record<ConfigKey, number>>({
max_sessions_per_week: 2,
max_weekend_sessions_per_period: 1,
weekend_period_weeks: 2,
open_session_advance_hours: 24,
})
const original = reactive<Record<ConfigKey, number>>({ ...local })
onMounted(async () => {
await Promise.all([fetchConfig(), fetchHolidays()])
loading.value = false
})
async function fetchConfig() {
const { data } = await supabase
.from('booking_config')
.select('key, value')
for (const row of (data ?? []) as { key: string; value: unknown }[]) {
const k = row.key as ConfigKey
if (k in local) {
local[k] = Number(row.value)
original[k] = Number(row.value)
}
}
}
async function fetchHolidays() {
const { data } = await supabase
.from('holidays')
.select('date, name')
.order('date', { ascending: true })
holidays.value = (data ?? []) as Holiday[]
}
function set(key: ConfigKey, value: string | number | null | undefined) {
const n = Number(value)
if (!isNaN(n) && n > 0) {
local[key] = n
dirty.value = true
}
}
async function saveAll() {
saving.value = true
const upserts = (Object.keys(local) as ConfigKey[]).map(k => ({ key: k, value: local[k] }))
const { error } = await supabase
.from('booking_config')
.upsert(upserts, { onConflict: 'key' })
saving.value = false
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
Object.assign(original, local)
dirty.value = false
showToast('Booking rules saved', 'success')
}
async function addHoliday() {
const { error } = await supabase
.from('holidays')
.insert({ date: newHoliday.date, name: newHoliday.name })
if (error) { showToast('Failed: ' + error.message, 'danger'); return }
newHoliday.date = ''
newHoliday.name = ''
await fetchHolidays()
showToast('Holiday added', 'success')
}
async function deleteHoliday(date: string) {
const { error } = await supabase
.from('holidays')
.delete()
.eq('date', date)
if (error) { showToast('Failed: ' + error.message, 'danger'); return }
await fetchHolidays()
showToast('Holiday removed', 'success')
}
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
</script>
<style scoped>
.config-input {
text-align: right;
max-width: 80px;
}
.add-holiday-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
}
.holiday-date-input { flex: 0 0 160px; }
.holiday-name-input { flex: 1; }
</style>