391 lines
13 KiB
Vue
391 lines
13 KiB
Vue
<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>
|