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

391 lines
13 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 Bookings</IonTitle>
<IonButtons slot="end">
<IonButton @click="openCreate">
<IonIcon slot="icon-only" :icon="addOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<!-- Filters -->
<div class="filter-row">
<IonSelect
v-model="filterBoatId"
placeholder="All boats"
interface="action-sheet"
class="boat-filter"
>
<IonSelectOption value="">All boats</IonSelectOption>
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">
{{ b.display_name || b.name }}
</IonSelectOption>
</IonSelect>
<IonInput v-model="filterDateFrom" type="date" class="date-filter" />
<IonInput v-model="filterDateTo" type="date" class="date-filter" />
</div>
<div v-if="loading" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<p v-else-if="filteredReservations.length === 0" class="empty-text">No bookings found.</p>
<IonCard v-else v-for="r in filteredReservations" :key="r.id" class="res-card">
<IonCardContent>
<div class="res-header">
<div class="res-main">
<div class="res-boat">{{ boatName(r.boat_id) }}</div>
<div class="res-member">{{ authStore.getUserNameById(r.user_id) }}</div>
<div class="res-time">
{{ formatDate(r.start_time) }}
{{ formatTime(r.start_time) }}{{ formatTime(r.end_time) }}
</div>
<div v-if="r.reason" class="res-reason">{{ r.reason }}</div>
</div>
<div class="res-right">
<IonBadge :color="statusColor(r.status)" class="status-badge">{{ r.status }}</IonBadge>
<div class="res-actions">
<IonButton fill="clear" @click="openEdit(r)">
<IonIcon slot="icon-only" :icon="pencilOutline" />
</IonButton>
<IonButton fill="clear" color="danger" @click="confirmDelete(r)">
<IonIcon slot="icon-only" :icon="trashOutline" />
</IonButton>
</div>
</div>
</div>
</IonCardContent>
</IonCard>
<!-- Create / Edit modal -->
<IonModal :is-open="showModal" @did-dismiss="closeModal">
<IonHeader>
<IonToolbar>
<IonTitle>{{ editing ? 'Edit Booking' : 'New Booking' }}</IonTitle>
<IonButtons slot="end">
<IonButton @click="closeModal">Cancel</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<IonList lines="full">
<IonItem>
<IonLabel position="stacked">Boat <span class="required">*</span></IonLabel>
<IonSelect v-model="form.boat_id" 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">Member <span class="required">*</span></IonLabel>
<IonSelect v-model="form.user_id" placeholder="Select member" interface="action-sheet">
<IonSelectOption v-for="m in members" :key="m.user_id" :value="m.user_id">
{{ m.first_name }} {{ m.last_name }} ({{ m.email }})
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Date <span class="required">*</span></IonLabel>
<IonInput v-model="form.date" type="date" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Start Time <span class="required">*</span></IonLabel>
<IonInput v-model="form.startTime" type="time" />
</IonItem>
<IonItem>
<IonLabel position="stacked">End Time <span class="required">*</span></IonLabel>
<IonInput v-model="form.endTime" type="time" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Status</IonLabel>
<IonSelect v-model="form.status" interface="action-sheet">
<IonSelectOption value="pending">Pending</IonSelectOption>
<IonSelectOption value="tentative">Tentative</IonSelectOption>
<IonSelectOption value="confirmed">Confirmed</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Reason</IonLabel>
<IonInput v-model="form.reason" placeholder="e.g. Day sail" />
</IonItem>
<IonItem>
<IonLabel position="stacked">Comment</IonLabel>
<IonTextarea v-model="form.comment" :rows="2" placeholder="Optional admin note" />
</IonItem>
</IonList>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!formValid || saving"
@click="saveReservation"
>
<IonSpinner v-if="saving" name="crescent" slot="start" />
{{ saving ? 'Saving…' : 'Save Booking' }}
</IonButton>
</IonContent>
</IonModal>
<IonAlert
:is-open="deleteAlert.show"
header="Delete Booking"
message="Delete this booking? This cannot be undone."
:buttons="[
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
{ text: 'Delete', role: 'destructive', handler: deleteReservation },
]"
@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, IonCardContent, IonBadge,
IonList, IonItem, IonLabel, IonInput, IonTextarea, IonSelect, IonSelectOption,
IonSpinner, IonModal, IonAlert, IonToast, onIonViewWillEnter,
} from '@ionic/vue'
import { addOutline, pencilOutline, trashOutline } from 'ionicons/icons'
import type { Database } from '~/types/supabase'
import { useAuthStore } from '~/stores/auth'
type Boat = Database['public']['Tables']['boats']['Row']
type Reservation = Database['public']['Tables']['reservations']['Row']
type Member = Database['public']['Tables']['members']['Row']
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const authStore = useAuthStore()
const loading = ref(true)
const saving = ref(false)
const showModal = ref(false)
const editing = ref<Reservation | null>(null)
const reservations = ref<Reservation[]>([])
const boats = ref<Boat[]>([])
const members = ref<Member[]>([])
const toast = reactive({ show: false, message: '', color: 'success' })
const deleteAlert = reactive({ show: false, id: '' })
// Filters — default to current week ± 4 weeks
const today = new Date()
const filterDateFrom = ref(toIso(new Date(today.getFullYear(), today.getMonth(), today.getDate() - 14)))
const filterDateTo = ref(toIso(new Date(today.getFullYear(), today.getMonth(), today.getDate() + 28)))
const filterBoatId = ref('')
const form = reactive({
boat_id: '',
user_id: '',
date: toIso(today),
startTime: '09:00',
endTime: '13:00',
status: 'confirmed' as 'pending' | 'tentative' | 'confirmed',
reason: '',
comment: '',
})
const formValid = computed(() =>
!!form.boat_id && !!form.user_id && !!form.date && !!form.startTime && !!form.endTime
)
const filteredReservations = computed(() => {
return reservations.value.filter(r => {
if (filterBoatId.value && r.boat_id !== filterBoatId.value) return false
return true
})
})
async function loadAll() {
await Promise.all([fetchBoats(), fetchMembers()])
await fetchReservations()
}
const user = useSupabaseUser()
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
onIonViewWillEnter(() => { if (user.value) loadAll() })
watch([filterDateFrom, filterDateTo], () => { if (user.value) fetchReservations() })
async function fetchBoats() {
const { data } = await supabase.from('boats').select('*').order('name')
boats.value = data ?? []
}
async function fetchMembers() {
const { data } = await supabase
.from('members')
.select('*')
.order('last_name')
members.value = data ?? []
}
async function fetchReservations() {
loading.value = true
const from = filterDateFrom.value + 'T00:00:00Z'
const to = filterDateTo.value + 'T23:59:59Z'
const { data } = await supabase
.from('reservations')
.select('*')
.gte('start_time', from)
.lte('start_time', to)
.order('start_time', { ascending: true })
reservations.value = data ?? []
loading.value = false
}
function openCreate() {
editing.value = null
form.boat_id = ''
form.user_id = ''
form.date = toIso(today)
form.startTime = '09:00'
form.endTime = '13:00'
form.status = 'confirmed'
form.reason = ''
form.comment = ''
showModal.value = true
}
function openEdit(r: Reservation) {
editing.value = r
form.boat_id = r.boat_id
form.user_id = r.user_id
form.date = r.start_time.slice(0, 10)
form.startTime = new Date(r.start_time).toTimeString().slice(0, 5)
form.endTime = new Date(r.end_time).toTimeString().slice(0, 5)
form.status = r.status as 'pending' | 'tentative' | 'confirmed'
form.reason = r.reason
form.comment = r.comment
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function saveReservation() {
saving.value = true
const payload = {
boat_id: form.boat_id,
user_id: form.user_id,
start_time: new Date(`${form.date}T${form.startTime}:00`).toISOString(),
end_time: new Date(`${form.date}T${form.endTime}:00`).toISOString(),
status: form.status,
reason: form.reason,
comment: form.comment,
}
let error
if (editing.value) {
;({ error } = await supabase.from('reservations').update(payload).eq('id', editing.value.id))
} else {
;({ error } = await supabase.from('reservations').insert(payload))
}
saving.value = false
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
closeModal()
await fetchReservations()
showToast(editing.value ? 'Booking updated' : 'Booking created', 'success')
}
function confirmDelete(r: Reservation) {
deleteAlert.id = r.id
deleteAlert.show = true
}
async function deleteReservation() {
deleteAlert.show = false
const { error } = await supabase.from('reservations').delete().eq('id', deleteAlert.id)
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
await fetchReservations()
showToast('Booking deleted', 'success')
}
function boatName(id: string) {
const b = boats.value.find(b => b.id === id)
return b ? (b.display_name || b.name) : id
}
function statusColor(status: string) {
return status === 'confirmed' ? 'success' : status === 'tentative' ? 'primary' : 'warning'
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' })
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function toIso(d: Date) { return d.toISOString().slice(0, 10) }
function showToast(message: string, color: string) {
toast.message = message
toast.color = color
toast.show = true
}
</script>
<style scoped>
.filter-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: center;
}
.boat-filter { flex: 1; min-width: 120px; }
.date-filter { flex: 1; min-width: 130px; }
.res-card { margin: 0 0 0.6rem; }
.res-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.res-main { flex: 1; min-width: 0; }
.res-boat { font-weight: 600; font-size: 0.95rem; }
.res-member { font-size: 0.85rem; color: var(--ion-color-medium); }
.res-time { font-size: 0.85rem; margin: 0.15rem 0; }
.res-reason { font-size: 0.8rem; color: var(--ion-color-medium); font-style: italic; }
.res-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
}
.status-badge { font-size: 0.7rem; }
.res-actions { display: flex; }
.required { color: var(--ion-color-danger); }
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
</style>