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

263 lines
7.9 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>Interval Templates</IonTitle>
<IonButtons slot="end">
<IonButton @click="openCreate">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<p v-else-if="templates.length === 0" class="empty-text">No templates yet. Create one to get started.</p>
<IonCard v-else v-for="t in templates" :key="t.id" class="template-card">
<IonCardHeader>
<div class="template-header">
<IonCardTitle>{{ t.name }}</IonCardTitle>
<div class="header-actions">
<IonButton fill="clear" @click="openEdit(t)">
<IonIcon slot="icon-only" :icon="pencilOutline" />
</IonButton>
<IonButton fill="clear" color="danger" @click="confirmDelete(t)">
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</div>
</div>
</IonCardHeader>
<IonCardContent>
<div class="time-tuple-list">
<span v-for="(tuple, i) in (t.time_tuples as TimeTuple[])" :key="i" class="time-tuple-chip">
{{ tuple[0] }}{{ tuple[1] }}
</span>
</div>
</IonCardContent>
</IonCard>
<!-- Create/Edit modal -->
<IonModal :is-open="showModal" @did-dismiss="closeModal">
<IonHeader>
<IonToolbar>
<IonTitle>{{ editing ? 'Edit Template' : 'New Template' }}</IonTitle>
<IonButtons slot="end">
<IonButton @click="closeModal">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Template Name</IonLabel>
<IonInput v-model="form.name" placeholder="e.g. Weekday Standard" />
</IonItem>
</IonList>
<h4 class="section-title ion-margin-top">Time Slots</h4>
<div
v-for="(tuple, i) in form.tuples"
:key="i"
class="tuple-row"
>
<IonInput
class="time-input"
type="time"
:value="tuple[0]"
@ion-input="tuple[0] = ($event.detail.value as string)"
/>
<span class="tuple-dash"></span>
<IonInput
class="time-input"
type="time"
:value="tuple[1]"
@ion-input="tuple[1] = ($event.detail.value as string)"
/>
<IonButton fill="clear" color="danger" @click="removeTuple(i)">
<IonIcon slot="icon-only" :icon="closeOutline" />
</IonButton>
</div>
<IonButton expand="block" fill="outline" class="ion-margin-top" @click="addTuple">
<IonIcon slot="start" :icon="addOutline" />
Add Time Slot
</IonButton>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!form.name || form.tuples.length === 0 || saving"
@click="saveTemplate"
>
<IonSpinner v-if="saving" name="crescent" slot="start" />
Save Template
</IonButton>
</IonContent>
</IonModal>
<IonAlert
:is-open="deleteAlert.show"
header="Delete Template"
:message="`Delete '${deleteAlert.name}'? This cannot be undone.`"
:buttons="[
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
{ text: 'Delete', role: 'destructive', handler: () => deleteTemplate() },
]"
@did-dismiss="deleteAlert.show = false"
/>
<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,
IonCardContent, IonList, IonItem, IonLabel, IonInput, IonSpinner,
IonModal, IonAlert, IonToast,
} from '@ionic/vue'
import { addOutline, pencilOutline, trashOutline, closeOutline } from 'ionicons/icons'
import type { Database, TimeTuple } from '~/types/supabase'
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(true)
const saving = ref(false)
const showModal = ref(false)
const editing = ref<Template | null>(null)
const templates = ref<Template[]>([])
const toast = reactive({ show: false, message: '', color: 'success' })
const deleteAlert = reactive({ show: false, id: '', name: '' })
const form = reactive<{ name: string; tuples: [string, string][] }>({
name: '',
tuples: [['08:00', '12:00']],
})
onMounted(fetchTemplates)
async function fetchTemplates() {
loading.value = true
const { data } = await supabase.from('interval_templates').select('*').order('name')
templates.value = data ?? []
loading.value = false
}
function openCreate() {
editing.value = null
form.name = ''
form.tuples = [['08:00', '12:00']]
showModal.value = true
}
function openEdit(t: Template) {
editing.value = t
form.name = t.name
form.tuples = (t.time_tuples as TimeTuple[]).map(tuple => [tuple[0], tuple[1]])
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
function addTuple() {
form.tuples.push(['13:00', '17:00'])
}
function removeTuple(i: number) {
form.tuples.splice(i, 1)
}
async function saveTemplate() {
saving.value = true
const payload = {
name: form.name,
time_tuples: form.tuples as TimeTuple[],
}
let error
if (editing.value) {
;({ error } = await supabase.from('interval_templates').update(payload).eq('id', editing.value.id))
} else {
;({ error } = await supabase.from('interval_templates').insert(payload))
}
saving.value = false
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
closeModal()
await fetchTemplates()
showToast(editing.value ? 'Template updated' : 'Template created', 'success')
}
function confirmDelete(t: Template) {
deleteAlert.id = t.id
deleteAlert.name = t.name
deleteAlert.show = true
}
async function deleteTemplate() {
const { error } = await supabase.from('interval_templates').delete().eq('id', deleteAlert.id)
deleteAlert.show = false
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
await fetchTemplates()
showToast('Template deleted', 'success')
}
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
</script>
<style scoped>
.template-card { margin: 0 0 1rem; }
.template-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions { display: flex; }
.time-tuple-list { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.time-tuple-chip {
padding: 0.3rem 0.75rem;
border-radius: 1rem;
background: var(--ion-color-light);
border: 1px solid var(--ion-color-light-shade);
font-size: 0.85rem;
font-weight: 500;
}
.section-title { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.5rem; }
.tuple-row {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.time-input { flex: 1; }
.tuple-dash { font-weight: 600; }
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
</style>