Files
oysqn.app/app/pages/admin/intervals.vue

445 lines
15 KiB
Vue
Raw Permalink 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="showWeekApply = true">
<IonIcon slot="icon-only" :icon="calendarOutline" />
</IonButton>
<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>
<!-- Apply to Week modal -->
<IonModal :is-open="showWeekApply" @did-dismiss="showWeekApply = false">
<IonHeader>
<IonToolbar>
<IonTitle>Apply Template to Week</IonTitle>
<IonButtons slot="end">
<IonButton @click="showWeekApply = false">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Template</IonLabel>
<IonSelect v-model="weekApply.templateId" placeholder="Select template" interface="action-sheet">
<IonSelectOption v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Boat</IonLabel>
<IonSelect v-model="weekApply.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>
</IonList>
<h4 class="section-title ion-margin-top">Days to apply</h4>
<IonList lines="none">
<IonItem v-for="(day, i) in weekDays" :key="day.iso">
<IonCheckbox v-model="weekApply.days[i]" slot="start" />
<IonLabel>{{ day.label }}</IonLabel>
</IonItem>
</IonList>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!weekApply.templateId || !weekApply.boatId || !weekApply.days.some(Boolean) || weekApply.applying"
@click="applyTemplateToWeek"
>
<IonSpinner v-if="weekApply.applying" name="crescent" slot="start" />
Apply to Selected Days
</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, IonCheckbox,
onIonViewWillEnter,
} from '@ionic/vue'
import {
addOutline, timeOutline, trashOutline, constructOutline, calendarOutline,
} 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 showWeekApply = ref(false)
const weekApply = reactive({ templateId: '', boatId: '', applying: false, days: [] as boolean[] })
watch(showWeekApply, (open) => {
if (open) weekApply.days = Array(7).fill(true)
})
const weekDays = computed(() => {
const base = new Date(selectedDate.value + 'T12:00:00')
const dow = base.getDay() // 0=Sun
const monday = new Date(base)
monday.setDate(base.getDate() - ((dow + 6) % 7))
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
return { iso: d.toISOString().slice(0, 10), label: d.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' }) }
})
})
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
})
async function loadAll() {
await Promise.all([fetchBoats(), fetchTemplates()])
await fetchSlots()
}
const user = useSupabaseUser()
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
onIonViewWillEnter(() => { if (user.value) loadAll() })
watch(selectedDate, () => { if (user.value) 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')
}
async function applyTemplateToWeek() {
const template = templates.value.find(t => t.id === weekApply.templateId)
if (!template) return
weekApply.applying = true
const selectedDays = weekDays.value.filter((_, i) => weekApply.days[i])
const inserts = selectedDays.flatMap(day =>
(template.time_tuples as TimeTuple[]).map(([start, end]) => ({
boat_id: weekApply.boatId,
start_time: new Date(`${day.iso}T${start}:00`).toISOString(),
end_time: new Date(`${day.iso}T${end}:00`).toISOString(),
}))
)
const { error } = await supabase.from('intervals').insert(inserts)
weekApply.applying = false
if (error) { showToast('Failed to apply: ' + error.message, 'danger'); return }
showWeekApply.value = false
await fetchSlots()
showToast(`Template applied to ${selectedDays.length} day(s)`, '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>