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
263 lines
7.9 KiB
Vue
263 lines
7.9 KiB
Vue
<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>
|