feat: Schedule bookings
This commit is contained in:
@@ -44,6 +44,10 @@
|
|||||||
<IonIcon slot="start" :icon="layersOutline" />
|
<IonIcon slot="start" :icon="layersOutline" />
|
||||||
<IonLabel>Templates</IonLabel>
|
<IonLabel>Templates</IonLabel>
|
||||||
</IonItem>
|
</IonItem>
|
||||||
|
<IonItem button router-link="/admin/reservations" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="bookmarkOutline" />
|
||||||
|
<IonLabel>Manage Bookings</IonLabel>
|
||||||
|
</IonItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Admin only -->
|
<!-- Admin only -->
|
||||||
@@ -87,7 +91,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
homeOutline, calendarOutline, boatOutline, personOutline,
|
homeOutline, calendarOutline, boatOutline, personOutline,
|
||||||
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
|
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
|
||||||
layersOutline, settingsOutline,
|
layersOutline, settingsOutline, bookmarkOutline,
|
||||||
} from 'ionicons/icons'
|
} from 'ionicons/icons'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
|||||||
23
app/composables/useBoatImage.ts
Normal file
23
app/composables/useBoatImage.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const BUCKET = 'boat-images'
|
||||||
|
|
||||||
|
export function useBoatImage() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
|
||||||
|
function url(path: string | null | undefined, width?: number, height?: number): string {
|
||||||
|
if (!path) return ''
|
||||||
|
const opts = width && height
|
||||||
|
? { transform: { width, height, resize: 'cover' as const } }
|
||||||
|
: undefined
|
||||||
|
const { data } = supabase.storage.from(BUCKET).getPublicUrl(path, opts)
|
||||||
|
return data.publicUrl as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnail: (path: string | null | undefined) => url(path, 150, 150),
|
||||||
|
small: (path: string | null | undefined) => url(path, 400, 300),
|
||||||
|
medium: (path: string | null | undefined) => url(path, 800, 600),
|
||||||
|
large: (path: string | null | undefined) => url(path, 1200, 900),
|
||||||
|
original: (path: string | null | undefined) => url(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
499
app/pages/admin/boat.vue
Normal file
499
app/pages/admin/boat.vue
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonMenuButton />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Manage Boats</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="boats.length === 0" class="empty-text">No boats yet. Add one to get started.</p>
|
||||||
|
|
||||||
|
<IonCard v-else v-for="boat in boats" :key="boat.id" class="boat-card">
|
||||||
|
<div class="boat-card-inner">
|
||||||
|
<div class="boat-thumb-wrap">
|
||||||
|
<img
|
||||||
|
v-if="boat.img_src"
|
||||||
|
:src="boatImage.thumbnail(boat.img_src)"
|
||||||
|
class="boat-thumb"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div v-else class="boat-thumb-placeholder">
|
||||||
|
<IonIcon :icon="boatOutline" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="boat-info">
|
||||||
|
<div class="boat-name">{{ boat.display_name || boat.name }}</div>
|
||||||
|
<div class="boat-meta">
|
||||||
|
<span v-if="boat.class">{{ boat.class }}</span>
|
||||||
|
<span v-if="boat.year">{{ boat.year }}</span>
|
||||||
|
<span>{{ boat.max_passengers }} pax</span>
|
||||||
|
</div>
|
||||||
|
<div class="cert-chips" v-if="boat.required_certs.length">
|
||||||
|
<span v-for="c in boat.required_certs" :key="c" class="cert-chip">{{ c }}</span>
|
||||||
|
</div>
|
||||||
|
<IonBadge :color="boat.booking_available ? 'success' : 'medium'" class="avail-badge">
|
||||||
|
{{ boat.booking_available ? 'Available' : 'Out of service' }}
|
||||||
|
</IonBadge>
|
||||||
|
</div>
|
||||||
|
<div class="boat-actions">
|
||||||
|
<IonButton fill="clear" @click="openEdit(boat)">
|
||||||
|
<IonIcon slot="icon-only" :icon="pencilOutline" />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton fill="clear" color="danger" @click="confirmDelete(boat)">
|
||||||
|
<IonIcon slot="icon-only" :icon="trashOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<!-- Create / Edit modal -->
|
||||||
|
<IonModal :is-open="showModal" @did-dismiss="closeModal">
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>{{ editing ? 'Edit Boat' : 'New Boat' }}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="closeModal">Cancel</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
|
||||||
|
<!-- Image upload section -->
|
||||||
|
<div class="image-section">
|
||||||
|
<div class="image-preview-wrap" @click="triggerFileInput">
|
||||||
|
<img v-if="imagePreviewUrl" :src="imagePreviewUrl" class="image-preview" alt="Boat preview" />
|
||||||
|
<div v-else-if="form.img_src" class="image-preview-wrap">
|
||||||
|
<img :src="boatImage.medium(form.img_src)" class="image-preview" alt="Current boat image" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="image-placeholder">
|
||||||
|
<IonIcon :icon="cameraOutline" class="camera-icon" />
|
||||||
|
<span>Tap to upload photo</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="imagePreviewUrl || form.img_src" class="image-overlay">
|
||||||
|
<IonIcon :icon="cameraOutline" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
class="hidden-file-input"
|
||||||
|
@change="onFileSelected"
|
||||||
|
/>
|
||||||
|
<p v-if="pendingFile" class="image-hint">{{ pendingFile.name }} — will upload on save</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Name <span class="required">*</span></IonLabel>
|
||||||
|
<IonInput v-model="form.name" placeholder="e.g. J27-1" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Display Name</IonLabel>
|
||||||
|
<IonInput v-model="form.display_name" placeholder="e.g. Blue Heron" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Class</IonLabel>
|
||||||
|
<IonInput v-model="form.class" placeholder="e.g. J/27" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Year</IonLabel>
|
||||||
|
<IonInput v-model.number="form.year" type="number" placeholder="e.g. 1988" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Max Passengers</IonLabel>
|
||||||
|
<IonInput v-model.number="form.max_passengers" type="number" :min="1" :max="20" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel>
|
||||||
|
<h3>Available for Booking</h3>
|
||||||
|
</IonLabel>
|
||||||
|
<IonToggle v-model="form.booking_available" slot="end" color="success" />
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
|
||||||
|
<!-- Required certifications -->
|
||||||
|
<h4 class="section-title ion-margin-top">Required Certifications</h4>
|
||||||
|
<div class="cert-edit-area">
|
||||||
|
<div class="cert-chips-edit" v-if="form.required_certs.length">
|
||||||
|
<span v-for="(c, i) in form.required_certs" :key="i" class="cert-chip-edit">
|
||||||
|
{{ c }}
|
||||||
|
<button class="cert-remove" @click="removeCert(i)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-else class="empty-text-sm">No certifications required.</p>
|
||||||
|
<div class="cert-add-row">
|
||||||
|
<IonInput
|
||||||
|
v-model="newCert"
|
||||||
|
placeholder="Add cert code (e.g. j27)"
|
||||||
|
class="cert-input"
|
||||||
|
@keyup.enter="addCert"
|
||||||
|
/>
|
||||||
|
<IonButton @click="addCert" :disabled="!newCert.trim()" fill="outline">
|
||||||
|
<IonIcon slot="icon-only" :icon="addOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
class="ion-margin-top"
|
||||||
|
:disabled="!form.name.trim() || saving"
|
||||||
|
@click="saveBoat"
|
||||||
|
>
|
||||||
|
<IonSpinner v-if="saving" name="crescent" slot="start" />
|
||||||
|
{{ saving ? 'Saving…' : 'Save Boat' }}
|
||||||
|
</IonButton>
|
||||||
|
</IonContent>
|
||||||
|
</IonModal>
|
||||||
|
|
||||||
|
<IonAlert
|
||||||
|
:is-open="deleteAlert.show"
|
||||||
|
header="Delete Boat"
|
||||||
|
:message="`Delete '${deleteAlert.name}'? All slots and reservations for this boat will also be deleted.`"
|
||||||
|
:buttons="[
|
||||||
|
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
|
||||||
|
{ text: 'Delete', role: 'destructive', handler: deleteBoat },
|
||||||
|
]"
|
||||||
|
@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, IonBadge,
|
||||||
|
IonList, IonItem, IonLabel, IonInput, IonToggle, IonSpinner,
|
||||||
|
IonModal, IonAlert, IonToast,
|
||||||
|
} from '@ionic/vue'
|
||||||
|
import { addOutline, pencilOutline, trashOutline, boatOutline, cameraOutline } from 'ionicons/icons'
|
||||||
|
import type { Database } from '~/types/supabase'
|
||||||
|
|
||||||
|
type Boat = Database['public']['Tables']['boats']['Row']
|
||||||
|
|
||||||
|
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
const boatImage = useBoatImage()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editing = ref<Boat | null>(null)
|
||||||
|
const boats = ref<Boat[]>([])
|
||||||
|
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||||
|
const deleteAlert = reactive({ show: false, id: '', name: '' })
|
||||||
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const pendingFile = ref<File | null>(null)
|
||||||
|
const imagePreviewUrl = ref<string>('')
|
||||||
|
const newCert = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
class: '',
|
||||||
|
year: null as number | null,
|
||||||
|
max_passengers: 6,
|
||||||
|
booking_available: true,
|
||||||
|
required_certs: [] as string[],
|
||||||
|
img_src: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(fetchBoats)
|
||||||
|
|
||||||
|
async function fetchBoats() {
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await supabase.from('boats').select('*').order('name')
|
||||||
|
boats.value = data ?? []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
resetForm()
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(boat: Boat) {
|
||||||
|
editing.value = boat
|
||||||
|
form.name = boat.name
|
||||||
|
form.display_name = boat.display_name ?? ''
|
||||||
|
form.class = boat.class ?? ''
|
||||||
|
form.year = boat.year ?? null
|
||||||
|
form.max_passengers = boat.max_passengers
|
||||||
|
form.booking_available = boat.booking_available
|
||||||
|
form.required_certs = [...boat.required_certs]
|
||||||
|
form.img_src = boat.img_src
|
||||||
|
pendingFile.value = null
|
||||||
|
imagePreviewUrl.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
editing.value = null
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.name = ''
|
||||||
|
form.display_name = ''
|
||||||
|
form.class = ''
|
||||||
|
form.year = null
|
||||||
|
form.max_passengers = 6
|
||||||
|
form.booking_available = true
|
||||||
|
form.required_certs = []
|
||||||
|
form.img_src = null
|
||||||
|
pendingFile.value = null
|
||||||
|
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
|
||||||
|
imagePreviewUrl.value = ''
|
||||||
|
newCert.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileInput() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelected(event: Event) {
|
||||||
|
const file = (event.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
pendingFile.value = file
|
||||||
|
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
|
||||||
|
imagePreviewUrl.value = URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCert() {
|
||||||
|
const c = newCert.value.trim().toLowerCase()
|
||||||
|
if (c && !form.required_certs.includes(c)) {
|
||||||
|
form.required_certs.push(c)
|
||||||
|
}
|
||||||
|
newCert.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCert(index: number) {
|
||||||
|
form.required_certs.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBoat() {
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
display_name: form.display_name.trim() || null,
|
||||||
|
class: form.class.trim() || null,
|
||||||
|
year: form.year || null,
|
||||||
|
max_passengers: form.max_passengers,
|
||||||
|
booking_available: form.booking_available,
|
||||||
|
required_certs: form.required_certs,
|
||||||
|
}
|
||||||
|
|
||||||
|
let boatId = editing.value?.id
|
||||||
|
let error
|
||||||
|
|
||||||
|
if (editing.value) {
|
||||||
|
;({ error } = await supabase.from('boats').update(payload).eq('id', boatId))
|
||||||
|
} else {
|
||||||
|
const { data, error: insertError } = await supabase
|
||||||
|
.from('boats').insert(payload).select('id').single()
|
||||||
|
error = insertError
|
||||||
|
boatId = data?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
saving.value = false
|
||||||
|
showToast('Save failed: ' + error.message, 'danger')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload image if a new file was selected
|
||||||
|
if (pendingFile.value && boatId) {
|
||||||
|
const ext = pendingFile.value.name.split('.').pop() ?? 'jpg'
|
||||||
|
const path = `boats/${boatId}/original.${ext}`
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from('boat-images')
|
||||||
|
.upload(path, pendingFile.value, { upsert: true, contentType: pendingFile.value.type })
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
saving.value = false
|
||||||
|
showToast('Boat saved but image upload failed: ' + uploadError.message, 'warning')
|
||||||
|
await fetchBoats()
|
||||||
|
closeModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase.from('boats').update({ img_src: path }).eq('id', boatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false
|
||||||
|
closeModal()
|
||||||
|
await fetchBoats()
|
||||||
|
showToast(editing.value ? 'Boat updated' : 'Boat created', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(boat: Boat) {
|
||||||
|
deleteAlert.id = boat.id
|
||||||
|
deleteAlert.name = boat.display_name || boat.name
|
||||||
|
deleteAlert.show = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBoat() {
|
||||||
|
deleteAlert.show = false
|
||||||
|
const { error } = await supabase.from('boats').delete().eq('id', deleteAlert.id)
|
||||||
|
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
|
||||||
|
await fetchBoats()
|
||||||
|
showToast('Boat deleted', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, color: string) {
|
||||||
|
toast.message = message
|
||||||
|
toast.color = color
|
||||||
|
toast.show = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.boat-card { margin: 0 0 0.75rem; }
|
||||||
|
.boat-card-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.boat-thumb-wrap { flex-shrink: 0; }
|
||||||
|
.boat-thumb {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.boat-thumb-placeholder {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
}
|
||||||
|
.boat-info { flex: 1; min-width: 0; }
|
||||||
|
.boat-name { font-weight: 600; font-size: 1rem; }
|
||||||
|
.boat-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
.cert-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.3rem 0; }
|
||||||
|
.cert-chip {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--ion-color-primary-tint);
|
||||||
|
color: var(--ion-color-primary-shade);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.avail-badge { font-size: 0.7rem; }
|
||||||
|
.boat-actions { display: flex; flex-direction: column; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Modal image section */
|
||||||
|
.image-section { margin-bottom: 1rem; }
|
||||||
|
.image-preview-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.image-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.image-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.camera-icon { font-size: 2.5rem; }
|
||||||
|
.image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.image-preview-wrap:hover .image-overlay { opacity: 1; }
|
||||||
|
.hidden-file-input { display: none; }
|
||||||
|
.image-hint { font-size: 0.78rem; color: var(--ion-color-medium); margin: 0.25rem 0 0; }
|
||||||
|
|
||||||
|
.required { color: var(--ion-color-danger); }
|
||||||
|
.section-title { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.5rem; }
|
||||||
|
|
||||||
|
/* Cert editor */
|
||||||
|
.cert-edit-area { margin-bottom: 0.75rem; }
|
||||||
|
.cert-chips-edit { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
|
||||||
|
.cert-chip-edit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--ion-color-primary-tint);
|
||||||
|
color: var(--ion-color-primary-shade);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.cert-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--ion-color-primary-shade);
|
||||||
|
}
|
||||||
|
.cert-add-row { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.cert-input { flex: 1; }
|
||||||
|
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
|
||||||
|
.empty-text-sm { color: var(--ion-color-medium); font-size: 0.85rem; margin: 0.25rem 0; }
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
</IonButtons>
|
</IonButtons>
|
||||||
<IonTitle>Manage Slots</IonTitle>
|
<IonTitle>Manage Slots</IonTitle>
|
||||||
<IonButtons slot="end">
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="showWeekApply = true">
|
||||||
|
<IonIcon slot="icon-only" :icon="calendarOutline" />
|
||||||
|
</IonButton>
|
||||||
<IonButton @click="showAddSlot = true">
|
<IonButton @click="showAddSlot = true">
|
||||||
<IonIcon slot="icon-only" :icon="addOutline" />
|
<IonIcon slot="icon-only" :icon="addOutline" />
|
||||||
</IonButton>
|
</IonButton>
|
||||||
@@ -127,6 +130,52 @@
|
|||||||
</IonContent>
|
</IonContent>
|
||||||
</IonModal>
|
</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
|
<IonToast
|
||||||
v-model:is-open="toast.show"
|
v-model:is-open="toast.show"
|
||||||
:message="toast.message"
|
:message="toast.message"
|
||||||
@@ -143,10 +192,10 @@ import {
|
|||||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||||
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonToggle,
|
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonToggle,
|
||||||
IonSelect, IonSelectOption, IonSpinner, IonModal, IonInput, IonToast,
|
IonSelect, IonSelectOption, IonSpinner, IonModal, IonInput, IonToast, IonCheckbox,
|
||||||
} from '@ionic/vue'
|
} from '@ionic/vue'
|
||||||
import {
|
import {
|
||||||
addOutline, timeOutline, trashOutline, constructOutline,
|
addOutline, timeOutline, trashOutline, constructOutline, calendarOutline,
|
||||||
} from 'ionicons/icons'
|
} from 'ionicons/icons'
|
||||||
import type { Database, TimeTuple } from '~/types/supabase'
|
import type { Database, TimeTuple } from '~/types/supabase'
|
||||||
|
|
||||||
@@ -162,6 +211,23 @@ const supabase = useSupabaseClient() as any
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const addingSlot = ref(false)
|
const addingSlot = ref(false)
|
||||||
const showAddSlot = 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 selectedDate = ref(todayIso())
|
||||||
const boats = ref<Boat[]>([])
|
const boats = ref<Boat[]>([])
|
||||||
const templates = ref<Template[]>([])
|
const templates = ref<Template[]>([])
|
||||||
@@ -280,6 +346,28 @@ async function applyTemplate(boatId: string, templateId: string) {
|
|||||||
showToast(`Template "${template.name}" applied`, 'success')
|
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) {
|
function showToast(message: string, color: string) {
|
||||||
toast.message = message
|
toast.message = message
|
||||||
toast.color = color
|
toast.color = color
|
||||||
|
|||||||
386
app/pages/admin/reservations.vue
Normal file
386
app/pages/admin/reservations.vue
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
<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,
|
||||||
|
} 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchBoats(), fetchMembers()])
|
||||||
|
await fetchReservations()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([filterDateFrom, filterDateTo], 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>
|
||||||
@@ -132,6 +132,7 @@ import {
|
|||||||
} from '@ionic/vue'
|
} from '@ionic/vue'
|
||||||
import { timeOutline, warningOutline } from 'ionicons/icons'
|
import { timeOutline, warningOutline } from 'ionicons/icons'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
import { toDateToronto } from '~/utils/toronto'
|
||||||
import type { Database } from '~/types/supabase'
|
import type { Database } from '~/types/supabase'
|
||||||
|
|
||||||
type Boat = Database['public']['Tables']['boats']['Row']
|
type Boat = Database['public']['Tables']['boats']['Row']
|
||||||
@@ -143,6 +144,7 @@ definePageMeta({ layout: false })
|
|||||||
const supabase = useSupabaseClient() as any
|
const supabase = useSupabaseClient() as any
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const step = ref<1 | 2>(1)
|
const step = ref<1 | 2>(1)
|
||||||
const loadingSlots = ref(false)
|
const loadingSlots = ref(false)
|
||||||
@@ -180,7 +182,32 @@ interface BoatSlotEntry {
|
|||||||
|
|
||||||
const availableByBoat = ref<BoatSlotEntry[]>([])
|
const availableByBoat = ref<BoatSlotEntry[]>([])
|
||||||
|
|
||||||
watch(selectedDate, loadSlots, { immediate: true })
|
// Pre-populate from schedule deep-link: ?boatId=&startTime=&endTime=
|
||||||
|
onMounted(async () => {
|
||||||
|
const { boatId, startTime, endTime } = route.query as Record<string, string>
|
||||||
|
if (boatId && startTime && endTime) {
|
||||||
|
const { data: boat } = await supabase.from('boats').select('*').eq('id', boatId).single()
|
||||||
|
if (boat) {
|
||||||
|
selectedBoat.value = boat as Boat
|
||||||
|
selectedSlot.value = {
|
||||||
|
id: '',
|
||||||
|
boat_id: boatId,
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: endTime,
|
||||||
|
user_id: null,
|
||||||
|
created_at: '',
|
||||||
|
}
|
||||||
|
// Set the date strip to the slot's date so Back returns to correct day
|
||||||
|
selectedDate.value = toDateToronto(new Date(startTime))
|
||||||
|
step.value = 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normal flow: load slots for today
|
||||||
|
await loadSlots()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedDate, loadSlots)
|
||||||
|
|
||||||
async function loadSlots() {
|
async function loadSlots() {
|
||||||
loadingSlots.value = true
|
loadingSlots.value = true
|
||||||
|
|||||||
478
app/pages/schedule.vue
Normal file
478
app/pages/schedule.vue
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonMenuButton />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Schedule</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="goToday" :disabled="focusDate === todayToronto()">
|
||||||
|
<IonIcon slot="icon-only" :icon="todayOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent>
|
||||||
|
<!-- Date navigation bar -->
|
||||||
|
<div class="date-nav">
|
||||||
|
<IonButton fill="clear" @click="navPrev">
|
||||||
|
<IonIcon slot="icon-only" :icon="chevronBackOutline" />
|
||||||
|
</IonButton>
|
||||||
|
<span class="date-nav-label" v-if="isMobile">{{ fmtDateLong(focusDate) }}</span>
|
||||||
|
<span class="date-nav-label" v-else>
|
||||||
|
{{ fmtDayHeader(visibleDates[0]!).weekday }} {{ fmtDayHeader(visibleDates[0]!).short }}
|
||||||
|
–
|
||||||
|
{{ fmtDayHeader(visibleDates[6]!).weekday }} {{ fmtDayHeader(visibleDates[6]!).short }}
|
||||||
|
</span>
|
||||||
|
<IonButton fill="clear" @click="navNext">
|
||||||
|
<IonIcon slot="icon-only" :icon="chevronForwardOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="ion-text-center ion-padding-top" style="padding: 3rem 0">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Mobile: single-day stacked view ─────────────────────── -->
|
||||||
|
<div v-else-if="isMobile" class="mobile-schedule">
|
||||||
|
<div v-for="boat in boats" :key="boat.id" class="mobile-boat-card">
|
||||||
|
<div class="mobile-boat-header">
|
||||||
|
<div class="mobile-boat-info">
|
||||||
|
<span class="mobile-boat-name">{{ boat.display_name || boat.name }}</span>
|
||||||
|
<span v-if="boat.class" class="mobile-boat-class">{{ boat.class }}</span>
|
||||||
|
</div>
|
||||||
|
<IonBadge v-if="!boat.booking_available" color="medium">Out of service</IonBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!boat.booking_available" class="slots-empty">Not available for booking.</div>
|
||||||
|
<div v-else-if="daySlots(boat.id, focusDate).length === 0" class="slots-empty">No slots today.</div>
|
||||||
|
<div v-else class="mobile-slots">
|
||||||
|
<div
|
||||||
|
v-for="slot in daySlots(boat.id, focusDate)"
|
||||||
|
:key="slot.id"
|
||||||
|
class="slot-block"
|
||||||
|
:class="[slotClass(slot), slot.type === 'available' ? 'slot-tappable' : '']"
|
||||||
|
:title="slotTitle(slot)"
|
||||||
|
:role="slot.type === 'available' ? 'button' : undefined"
|
||||||
|
:tabindex="slot.type === 'available' ? 0 : undefined"
|
||||||
|
@click="slot.type === 'available' && bookSlot(slot)"
|
||||||
|
@keydown.enter="slot.type === 'available' && bookSlot(slot)"
|
||||||
|
>
|
||||||
|
<div class="slot-main-row">
|
||||||
|
<span class="slot-time">{{ fmtTime(slot.startTime) }}–{{ fmtTime(slot.endTime) }}</span>
|
||||||
|
<IonIcon v-if="slot.type === 'available'" :icon="chevronForwardOutline" class="slot-chevron" />
|
||||||
|
</div>
|
||||||
|
<span class="slot-status">{{ slotLabel(slot) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Desktop: week grid ───────────────────────────────────── -->
|
||||||
|
<div v-else class="desktop-schedule-wrap">
|
||||||
|
<div class="desktop-schedule">
|
||||||
|
<!-- Header row -->
|
||||||
|
<div class="grid-header-row">
|
||||||
|
<div class="grid-boat-col grid-header-cell"></div>
|
||||||
|
<div
|
||||||
|
v-for="date in visibleDates"
|
||||||
|
:key="date"
|
||||||
|
class="grid-day-col grid-header-cell"
|
||||||
|
:class="{ 'is-today': date === todayToronto() }"
|
||||||
|
>
|
||||||
|
<span class="grid-day-weekday">{{ fmtDayHeader(date).weekday }}</span>
|
||||||
|
<span class="grid-day-short">{{ fmtDayHeader(date).short }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- One row per boat -->
|
||||||
|
<div v-for="boat in boats" :key="boat.id" class="grid-data-row">
|
||||||
|
<div class="grid-boat-col grid-boat-label">
|
||||||
|
<div class="grid-boat-name">{{ boat.display_name || boat.name }}</div>
|
||||||
|
<div v-if="boat.class" class="grid-boat-class">{{ boat.class }}</div>
|
||||||
|
<IonBadge v-if="!boat.booking_available" color="medium" class="oos-badge">OOS</IonBadge>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="date in visibleDates"
|
||||||
|
:key="date"
|
||||||
|
class="grid-day-col grid-cell"
|
||||||
|
:class="{ 'is-today': date === todayToronto(), 'cell-oos': !boat.booking_available }"
|
||||||
|
>
|
||||||
|
<template v-if="boat.booking_available">
|
||||||
|
<div v-if="daySlots(boat.id, date).length === 0" class="cell-empty">—</div>
|
||||||
|
<div v-else class="cell-slots">
|
||||||
|
<div
|
||||||
|
v-for="slot in daySlots(boat.id, date)"
|
||||||
|
:key="slot.id"
|
||||||
|
class="slot-block slot-sm"
|
||||||
|
:class="slotClass(slot)"
|
||||||
|
:title="slotTitle(slot)"
|
||||||
|
>
|
||||||
|
<span class="slot-time-sm">{{ fmtTime(slot.startTime) }}</span>
|
||||||
|
<span class="slot-sep">–</span>
|
||||||
|
<span class="slot-time-sm">{{ fmtTime(slot.endTime) }}</span>
|
||||||
|
<span class="slot-status-sm">{{ slotLabelShort(slot) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="cell-empty">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="legend">
|
||||||
|
<span class="legend-item slot-block slot-available">Available</span>
|
||||||
|
<span class="legend-item slot-block slot-confirmed">Confirmed</span>
|
||||||
|
<span class="legend-item slot-block slot-tentative">Tentative</span>
|
||||||
|
<span class="legend-item slot-block slot-pending">Pending</span>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
||||||
|
IonButtons, IonMenuButton, IonButton, IonIcon, IonBadge, IonSpinner,
|
||||||
|
} from '@ionic/vue'
|
||||||
|
import {
|
||||||
|
chevronBackOutline, chevronForwardOutline, todayOutline,
|
||||||
|
} from 'ionicons/icons'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
import type { Database, ReservationStatus } from '~/types/supabase'
|
||||||
|
import {
|
||||||
|
todayToronto, toDateToronto, addDays,
|
||||||
|
fmtTime, fmtDateLong, fmtDayHeader, weekDates, utcRange,
|
||||||
|
} from '~/utils/toronto'
|
||||||
|
|
||||||
|
type Boat = Database['public']['Tables']['boats']['Row']
|
||||||
|
type Interval = Database['public']['Tables']['intervals']['Row']
|
||||||
|
type SlotView = Database['public']['Views']['reservation_slots']['Row']
|
||||||
|
|
||||||
|
interface SlotBlock {
|
||||||
|
id: string
|
||||||
|
boatId: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
type: 'available' | 'booked'
|
||||||
|
status: ReservationStatus | null
|
||||||
|
}
|
||||||
|
|
||||||
|
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
|
||||||
|
// ── Responsive ──────────────────────────────────────────────
|
||||||
|
const isMobile = ref(true)
|
||||||
|
function checkBreakpoint() { isMobile.value = window.innerWidth < 768 }
|
||||||
|
onMounted(() => { checkBreakpoint(); window.addEventListener('resize', checkBreakpoint) })
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', checkBreakpoint))
|
||||||
|
|
||||||
|
// ── Date state ──────────────────────────────────────────────
|
||||||
|
const focusDate = ref(todayToronto())
|
||||||
|
|
||||||
|
const visibleDates = computed(() =>
|
||||||
|
isMobile.value ? [focusDate.value] : weekDates(focusDate.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
function navPrev() {
|
||||||
|
focusDate.value = isMobile.value
|
||||||
|
? addDays(focusDate.value, -1)
|
||||||
|
: addDays(focusDate.value, -7)
|
||||||
|
}
|
||||||
|
function navNext() {
|
||||||
|
focusDate.value = isMobile.value
|
||||||
|
? addDays(focusDate.value, +1)
|
||||||
|
: addDays(focusDate.value, +7)
|
||||||
|
}
|
||||||
|
function goToday() { focusDate.value = todayToronto() }
|
||||||
|
|
||||||
|
// ── Data ────────────────────────────────────────────────────
|
||||||
|
const loading = ref(true)
|
||||||
|
const boats = ref<Boat[]>([])
|
||||||
|
const intervals = ref<Interval[]>([])
|
||||||
|
const slotViews = ref<SlotView[]>([])
|
||||||
|
|
||||||
|
async function fetchBoats() {
|
||||||
|
const { data } = await supabase.from('boats').select('*').order('name')
|
||||||
|
boats.value = data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSchedule() {
|
||||||
|
loading.value = true
|
||||||
|
const dates = visibleDates.value
|
||||||
|
const { from, to } = utcRange(dates[0]!, dates[dates.length - 1]!)
|
||||||
|
|
||||||
|
const [intRes, slotRes] = await Promise.all([
|
||||||
|
supabase.from('intervals').select('*').gte('start_time', from).lte('start_time', to),
|
||||||
|
supabase.from('reservation_slots').select('*').gte('start_time', from).lte('start_time', to),
|
||||||
|
])
|
||||||
|
|
||||||
|
intervals.value = intRes.data ?? []
|
||||||
|
slotViews.value = slotRes.data ?? []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchBoats()
|
||||||
|
await fetchSchedule()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-fetch when the visible date range changes
|
||||||
|
watch(visibleDates, fetchSchedule)
|
||||||
|
|
||||||
|
// ── Slot computation ─────────────────────────────────────────
|
||||||
|
function daySlots(boatId: string, dateIso: string): SlotBlock[] {
|
||||||
|
const dayIntervals = intervals.value
|
||||||
|
.filter(i =>
|
||||||
|
i.boat_id === boatId &&
|
||||||
|
toDateToronto(new Date(i.start_time)) === dateIso
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||||
|
|
||||||
|
return dayIntervals.map(interval => {
|
||||||
|
const res = slotViews.value.find(r =>
|
||||||
|
r.boat_id === boatId &&
|
||||||
|
r.start_time >= interval.start_time &&
|
||||||
|
r.start_time < interval.end_time
|
||||||
|
)
|
||||||
|
if (res) {
|
||||||
|
return {
|
||||||
|
id: res.id,
|
||||||
|
boatId,
|
||||||
|
startTime: interval.start_time,
|
||||||
|
endTime: interval.end_time,
|
||||||
|
type: 'booked',
|
||||||
|
status: res.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: interval.id,
|
||||||
|
boatId,
|
||||||
|
startTime: interval.start_time,
|
||||||
|
endTime: interval.end_time,
|
||||||
|
type: 'available',
|
||||||
|
status: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display helpers ──────────────────────────────────────────
|
||||||
|
function slotClass(slot: SlotBlock) {
|
||||||
|
return slot.type === 'available' ? 'slot-available' : `slot-${slot.status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotLabel(slot: SlotBlock): string {
|
||||||
|
if (slot.type === 'available') return 'Available'
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
confirmed: 'Confirmed',
|
||||||
|
tentative: 'Tentative',
|
||||||
|
pending: 'Pending',
|
||||||
|
}
|
||||||
|
return labels[slot.status ?? ''] ?? 'Booked'
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotLabelShort(slot: SlotBlock): string {
|
||||||
|
if (slot.type === 'available') return 'Free'
|
||||||
|
return slotLabel(slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookSlot(slot: SlotBlock) {
|
||||||
|
router.push({
|
||||||
|
path: '/reservations/create',
|
||||||
|
query: {
|
||||||
|
boatId: slot.boatId,
|
||||||
|
startTime: slot.startTime,
|
||||||
|
endTime: slot.endTime,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotTitle(slot: SlotBlock): string {
|
||||||
|
return `${fmtTime(slot.startTime)}–${fmtTime(slot.endTime)} · ${slotLabel(slot)}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Date navigation bar ───────────────────────────────────── */
|
||||||
|
.date-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
.date-nav-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Slot block base ────────────────────────────────────────── */
|
||||||
|
.slot-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 56px;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.slot-available { background: var(--ion-color-success); color: #fff; }
|
||||||
|
.slot-confirmed { background: var(--ion-color-medium); color: #fff; }
|
||||||
|
.slot-tentative { background: var(--ion-color-primary); color: #fff; }
|
||||||
|
.slot-pending { background: var(--ion-color-warning); color: #333; }
|
||||||
|
|
||||||
|
.slot-main-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.slot-time { font-variant-numeric: tabular-nums; font-size: 0.9rem; }
|
||||||
|
.slot-status { font-size: 0.75rem; opacity: 0.85; }
|
||||||
|
.slot-chevron { font-size: 1rem; opacity: 0.8; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.slot-tappable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.12s, transform 0.12s;
|
||||||
|
}
|
||||||
|
.slot-tappable:active {
|
||||||
|
filter: brightness(0.88);
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile schedule ────────────────────────────────────────── */
|
||||||
|
.mobile-schedule { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
|
||||||
|
.mobile-boat-card {
|
||||||
|
border: 1px solid var(--ion-color-light-shade);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mobile-boat-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
border-bottom: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
.mobile-boat-info { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||||
|
.mobile-boat-name { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
.mobile-boat-class { font-size: 0.78rem; color: var(--ion-color-medium); }
|
||||||
|
|
||||||
|
.mobile-slots {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.slots-empty {
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop schedule ───────────────────────────────────────── */
|
||||||
|
.desktop-schedule-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-schedule {
|
||||||
|
display: grid;
|
||||||
|
/* boat-label column + 7 day columns */
|
||||||
|
grid-template-columns: 130px repeat(7, minmax(110px, 1fr));
|
||||||
|
min-width: 900px;
|
||||||
|
border-top: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-header-row,
|
||||||
|
.grid-data-row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-header-cell {
|
||||||
|
padding: 0.5rem 0.5rem;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
border-bottom: 2px solid var(--ion-color-light-shade);
|
||||||
|
border-right: 1px solid var(--ion-color-light-shade);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.grid-day-col.grid-header-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.grid-day-weekday { font-size: 0.72rem; color: var(--ion-color-medium); text-transform: uppercase; font-weight: 600; }
|
||||||
|
.grid-day-short { font-size: 0.85rem; font-weight: 600; }
|
||||||
|
|
||||||
|
.grid-header-cell.is-today,
|
||||||
|
.grid-cell.is-today {
|
||||||
|
background: color-mix(in srgb, var(--ion-color-primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-boat-col {
|
||||||
|
border-right: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
.grid-boat-label {
|
||||||
|
padding: 0.6rem 0.65rem;
|
||||||
|
background: var(--ion-color-light-tint);
|
||||||
|
border-bottom: 1px solid var(--ion-color-light-shade);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.grid-boat-name { font-weight: 600; font-size: 0.85rem; }
|
||||||
|
.grid-boat-class { font-size: 0.72rem; color: var(--ion-color-medium); }
|
||||||
|
.oos-badge { font-size: 0.65rem; margin-top: 0.2rem; }
|
||||||
|
|
||||||
|
.grid-cell {
|
||||||
|
padding: 0.4rem 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--ion-color-light-shade);
|
||||||
|
border-right: 1px solid var(--ion-color-light-shade);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.cell-empty { color: var(--ion-color-light-shade); text-align: center; font-size: 1.1rem; padding: 0.5rem 0; }
|
||||||
|
.cell-oos { background: repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(0,0,0,0.03) 4px, rgba(0,0,0,0.03) 8px); }
|
||||||
|
.cell-slots { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
|
||||||
|
/* Compact slot variant for desktop cells */
|
||||||
|
.slot-sm {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.05rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
.slot-time-sm { font-variant-numeric: tabular-nums; font-size: 0.72rem; line-height: 1.2; }
|
||||||
|
.slot-sep { display: none; }
|
||||||
|
.slot-status-sm { font-size: 0.65rem; opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ── Legend ─────────────────────────────────────────────────── */
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
.legend-item { font-size: 0.75rem; padding: 0.2rem 0.6rem; }
|
||||||
|
</style>
|
||||||
83
app/utils/toronto.ts
Normal file
83
app/utils/toronto.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export const TZ = 'America/Toronto'
|
||||||
|
|
||||||
|
/** YYYY-MM-DD for right now in Toronto */
|
||||||
|
export function todayToronto(): string {
|
||||||
|
return toDateToronto(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD in Toronto for any Date */
|
||||||
|
export function toDateToronto(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', { timeZone: TZ }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add n calendar days to a YYYY-MM-DD string.
|
||||||
|
* Uses noon UTC so the shift is always exactly 24 h and never crosses
|
||||||
|
* a Toronto DST boundary ambiguously.
|
||||||
|
*/
|
||||||
|
export function addDays(isoDate: string, n: number): string {
|
||||||
|
const d = new Date(isoDate + 'T12:00:00Z')
|
||||||
|
d.setUTCDate(d.getUTCDate() + n)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HH:MM in Toronto for a UTC ISO string */
|
||||||
|
export function fmtTime(utcIso: string): string {
|
||||||
|
return new Date(utcIso).toLocaleTimeString('en-CA', {
|
||||||
|
timeZone: TZ,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Wednesday, April 20" style label for a YYYY-MM-DD string */
|
||||||
|
export function fmtDateLong(isoDate: string): string {
|
||||||
|
return new Date(isoDate + 'T12:00:00Z').toLocaleDateString('en-CA', {
|
||||||
|
timeZone: TZ,
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** { weekday: "Wed", short: "Apr 20" } for a YYYY-MM-DD string */
|
||||||
|
export function fmtDayHeader(isoDate: string): { weekday: string; short: string } {
|
||||||
|
const d = new Date(isoDate + 'T12:00:00Z')
|
||||||
|
return {
|
||||||
|
weekday: d.toLocaleDateString('en-CA', { timeZone: TZ, weekday: 'short' }),
|
||||||
|
short: d.toLocaleDateString('en-CA', { timeZone: TZ, month: 'short', day: 'numeric' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YYYY-MM-DD of the Monday that starts the ISO week containing isoDate.
|
||||||
|
* Uses the en-CA weekday name to determine day-of-week in Toronto.
|
||||||
|
*/
|
||||||
|
export function weekMonday(isoDate: string): string {
|
||||||
|
const d = new Date(isoDate + 'T12:00:00Z')
|
||||||
|
const wd = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, weekday: 'short' }).format(d)
|
||||||
|
const offsets: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 }
|
||||||
|
const offset = offsets[wd] ?? 0
|
||||||
|
const monday = new Date(d)
|
||||||
|
monday.setUTCDate(d.getUTCDate() - offset)
|
||||||
|
return monday.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All 7 YYYY-MM-DD strings for the ISO week containing isoDate (Mon → Sun) */
|
||||||
|
export function weekDates(isoDate: string): string[] {
|
||||||
|
const mon = weekMonday(isoDate)
|
||||||
|
return Array.from({ length: 7 }, (_, i) => addDays(mon, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC range string pair that safely covers all Toronto-local timestamps
|
||||||
|
* falling within the isoFrom…isoTo date range.
|
||||||
|
* Adds a 1-day buffer on each side so client-side date filtering is authoritative.
|
||||||
|
*/
|
||||||
|
export function utcRange(isoFrom: string, isoTo: string): { from: string; to: string } {
|
||||||
|
return {
|
||||||
|
from: addDays(isoFrom, -1) + 'T00:00:00Z',
|
||||||
|
to: addDays(isoTo, +1) + 'T23:59:59Z',
|
||||||
|
}
|
||||||
|
}
|
||||||
44
supabase/migrations/20260420200000_boat_images_storage.sql
Normal file
44
supabase/migrations/20260420200000_boat_images_storage.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Create boat-images storage bucket
|
||||||
|
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
values (
|
||||||
|
'boat-images',
|
||||||
|
'boat-images',
|
||||||
|
true,
|
||||||
|
10485760,
|
||||||
|
array['image/jpeg', 'image/png', 'image/webp']
|
||||||
|
)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
|
||||||
|
-- Public read (bucket is public, but explicit policy is required for RLS)
|
||||||
|
create policy "Anyone can read boat images" on storage.objects
|
||||||
|
for select using (bucket_id = 'boat-images');
|
||||||
|
|
||||||
|
-- Admins/boatswains can upload
|
||||||
|
create policy "Admins can upload boat images" on storage.objects
|
||||||
|
for insert with check (
|
||||||
|
bucket_id = 'boat-images' and
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins/boatswains can replace/update
|
||||||
|
create policy "Admins can update boat images" on storage.objects
|
||||||
|
for update using (
|
||||||
|
bucket_id = 'boat-images' and
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins/boatswains can delete
|
||||||
|
create policy "Admins can delete boat images" on storage.objects
|
||||||
|
for delete using (
|
||||||
|
bucket_id = 'boat-images' and
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user