440 lines
15 KiB
Vue
440 lines
15 KiB
Vue
<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,
|
||
} 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
|
||
})
|
||
|
||
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')
|
||
}
|
||
|
||
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>
|