refactor: everything to nuxt.js
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
BIN
app/assets/OYS-Burgee.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
app/assets/OYS-Burgee_square.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
app/assets/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
app/assets/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
app/assets/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
app/assets/favicon-64x64.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
app/assets/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
app/assets/osqqn_logo_only.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
app/assets/oys_lighthouse.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
app/assets/oys_lighthouse_modern.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
app/assets/oysqn_logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/assets/oysqn_logo_only.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
app/assets/oysqn_logo_only_bordered.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
15
app/assets/quasar-logo-vertical.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
|
||||
<path
|
||||
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
|
||||
<path fill="#050A14"
|
||||
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
|
||||
<path fill="#050A14"
|
||||
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
|
||||
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
227
app/components/BoatReservationComponent.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import BoatScheduleTableComponent from '~/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||
import { formatDate } from '~/utils/schedule';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
interface BookingForm {
|
||||
$id?: string;
|
||||
user?: string;
|
||||
interval?: Interval | null;
|
||||
reason?: string;
|
||||
members?: string[];
|
||||
guests?: string[];
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
|
||||
|
||||
const boatStore = useBoatStore();
|
||||
const auth = useAuthStore();
|
||||
const newForm = {
|
||||
user: auth.currentUser?.$id,
|
||||
interval: {} as Interval,
|
||||
reason: 'Open Sail',
|
||||
members: [],
|
||||
guests: [],
|
||||
comment: '',
|
||||
};
|
||||
const reservation = defineModel<Reservation>();
|
||||
const reservationStore = useReservationStore();
|
||||
const boatSelect = ref(false);
|
||||
const bookingForm = ref<BookingForm>({ ...newForm });
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
|
||||
watch(reservation, (newReservation) => {
|
||||
if (!newReservation) {
|
||||
bookingForm.value = newForm;
|
||||
} else {
|
||||
bookingForm.value = {
|
||||
...newReservation,
|
||||
user: auth.currentUser?.$id,
|
||||
interval: {
|
||||
start: newReservation.start,
|
||||
end: newReservation.end,
|
||||
resource: newReservation.resource,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const updateInterval = (interval: Interval | null | undefined) => {
|
||||
bookingForm.value.interval = interval;
|
||||
boatSelect.value = false;
|
||||
};
|
||||
|
||||
const bookingDuration = computed((): { hours: number; minutes: number } => {
|
||||
if (bookingForm.value.interval?.start && bookingForm.value.interval?.end) {
|
||||
const start = new Date(bookingForm.value.interval.start).getTime();
|
||||
const end = new Date(bookingForm.value.interval.end).getTime();
|
||||
const delta = Math.abs(end - start) / 1000;
|
||||
const hours = Math.floor(delta / 3600) % 24;
|
||||
const minutes = Math.floor(delta - hours * 3600) % 60;
|
||||
return { hours, minutes };
|
||||
}
|
||||
return { hours: 0, minutes: 0 };
|
||||
});
|
||||
|
||||
const bookingName = computed(() => auth.getUserNameById(bookingForm.value?.user));
|
||||
|
||||
const boat = computed((): Boat | null => {
|
||||
const boatId = bookingForm.value.interval?.resource;
|
||||
return boatStore.getBoatById(boatId);
|
||||
});
|
||||
|
||||
const onDelete = () => {
|
||||
reservationStore.deleteReservation(reservation.value?.$id);
|
||||
$router.go(-1);
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
bookingForm.value.interval = null;
|
||||
bookingForm.value = reservation.value
|
||||
? {
|
||||
...reservation.value,
|
||||
interval: {
|
||||
start: reservation.value.start,
|
||||
end: reservation.value.end,
|
||||
resource: reservation.value.resource,
|
||||
},
|
||||
}
|
||||
: { ...newForm };
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const booking = bookingForm.value;
|
||||
if (
|
||||
!(
|
||||
booking.interval &&
|
||||
booking.interval.resource &&
|
||||
booking.interval.start &&
|
||||
booking.interval.end &&
|
||||
auth.currentUser
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const newReservation = <Reservation>{
|
||||
resource: booking.interval.resource,
|
||||
start: booking.interval.start,
|
||||
end: booking.interval.end,
|
||||
user: auth.currentUser.$id,
|
||||
status: 'confirmed',
|
||||
reason: booking.reason,
|
||||
comment: booking.comment,
|
||||
$id: reservation.value?.$id,
|
||||
};
|
||||
const status = $q.notify({
|
||||
color: 'secondary',
|
||||
textColor: 'white',
|
||||
message: 'Submitting Reservation',
|
||||
spinner: true,
|
||||
closeBtn: 'Dismiss',
|
||||
position: 'top',
|
||||
timeout: 0,
|
||||
group: false,
|
||||
});
|
||||
try {
|
||||
const r = await reservationStore.createOrUpdateReservation(newReservation);
|
||||
status({
|
||||
color: 'positive',
|
||||
icon: 'cloud_done',
|
||||
message: `Booking ${newReservation.$id ? 'updated' : 'created'}: ${
|
||||
boatStore.getBoatById(r.resource)?.name
|
||||
} at ${formatDate(r.start)}`,
|
||||
spinner: false,
|
||||
});
|
||||
} catch (e) {
|
||||
status({ color: 'negative', icon: 'error', spinner: false, message: 'Failed to book!' + e });
|
||||
}
|
||||
$router.go(-1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="q-pa-xs row q-gutter-xs">
|
||||
<q-card flat class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
|
||||
<q-card-section>
|
||||
<div class="text-h5 q-mt-none q-mb-xs">
|
||||
{{ reservation ? 'Modify Booking' : 'New Booking' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
|
||||
</q-card-section>
|
||||
<q-list class="q-px-xs">
|
||||
<q-item class="q-pa-none" clickable @click="boatSelect = true">
|
||||
<q-card v-if="boat" class="col-12">
|
||||
<q-card-section>
|
||||
<q-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h7 text-left">{{ boat.name }}</div>
|
||||
<div class="col text-right text-caption">{{ boat.class }}</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section horizontal>
|
||||
<q-card-section class="col-9">
|
||||
<q-list dense class="row">
|
||||
<q-item class="q-ma-none col-12">
|
||||
<q-item-section avatar>
|
||||
<q-badge color="primary" label="Start" />
|
||||
</q-item-section>
|
||||
<q-item-section class="text-body2">
|
||||
{{ formatDate(bookingForm.interval?.start) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item class="q-ma-none col-12">
|
||||
<q-item-section avatar>
|
||||
<q-badge color="primary" label="End" />
|
||||
</q-item-section>
|
||||
<q-item-section class="text-body2" style="min-width: 150px">
|
||||
{{ formatDate(bookingForm.interval?.end) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-separator vertical />
|
||||
<q-card-section class="col-3 flex flex-center bg-grey-4">
|
||||
{{ bookingDuration.hours }} hours
|
||||
<div v-if="bookingDuration.minutes">
|
||||
<q-separator />
|
||||
{{ bookingDuration.minutes }} mins
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div v-else class="col-12">
|
||||
<q-field filled>Tap to Select a Boat / Time</q-field>
|
||||
</div>
|
||||
</q-item>
|
||||
<q-item class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-select filled v-model="bookingForm.reason" :options="reason_options" label="Reason for sail" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-input v-model="bookingForm.comment" clearable autogrow filled label="Additional Comments (optional)" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-card-actions align="right">
|
||||
<q-btn label="Delete" color="negative" size="lg" v-if="reservation?.$id" @click="onDelete" />
|
||||
<q-btn label="Reset" @click="onReset" size="lg" color="secondary" />
|
||||
<q-btn label="Submit" @click="onSubmit" size="lg" color="primary" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-dialog v-model="boatSelect" full-width>
|
||||
<BoatScheduleTableComponent :model-value="bookingForm.interval" @update:model-value="updateInterval" />
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
8
app/components/BottomNavComponent.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<q-tabs class="mobile-only">
|
||||
<q-route-tab name="Boats" icon="sailing" to="/boat" />
|
||||
<q-route-tab name="Schedule" icon="calendar_month" to="/schedule" />
|
||||
</q-tabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
35
app/components/CertificationComponent.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
const certifications = [
|
||||
{ title: 'J/27 Skipper', badgeText: 'J/27', description: 'Certified to be a skipper on a J/27 class boat.' },
|
||||
{ title: 'Capri 25 Skipper', badgeText: 'Capri25', description: 'Certified to be a skipper on a Capri 25 class boat.' },
|
||||
{ title: 'Night', badgeText: 'Night', description: 'Certified to operate boats at night' },
|
||||
{ title: 'Navigation', badgeText: 'Nav', description: 'Advanced Navigation' },
|
||||
{ title: 'Crew', badgeText: 'crew', description: 'Crew certification.' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Certification</div>
|
||||
<q-item
|
||||
v-for="cert in certifications"
|
||||
:key="cert.title"
|
||||
clickable
|
||||
v-ripple
|
||||
class="rounded-borders"
|
||||
:class="$q.dark.isActive ? 'bg-grey-9 text-white' : 'bg-grey-2'">
|
||||
<q-item-section avatar>
|
||||
<q-avatar rounded>
|
||||
<q-icon :name="`check`" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ cert.title }}</q-item-label>
|
||||
<q-item-label caption>
|
||||
<q-badge color="green-4" text-color="black">{{ cert.badgeText }}</q-badge>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span>{{ cert.description }}</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
78
app/components/LeftDrawer.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import { Dialog } from 'quasar';
|
||||
import { useNavLinks } from '~/utils/navlinks';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { APP_VERSION } from '~/utils/version';
|
||||
|
||||
defineProps(['drawer']);
|
||||
defineEmits(['drawer-toggle']);
|
||||
|
||||
const { enabledLinks } = useNavLinks();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
function showAbout() {
|
||||
Dialog.create({
|
||||
title: 'OYS Borrow a Boat',
|
||||
message: `Version ${APP_VERSION}<br>Manage a Borrow a Boat program for a Yacht Club.<br><br>© Oakville Yacht Squadron`,
|
||||
html: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await authStore.logout();
|
||||
await navigateTo('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-drawer
|
||||
:model-value="drawer"
|
||||
show-if-above
|
||||
:width="200"
|
||||
:breakpoint="1024"
|
||||
@update:model-value="$emit('drawer-toggle')">
|
||||
<q-scroll-area class="fit">
|
||||
<q-list padding class="menu-list">
|
||||
<template v-for="link in enabledLinks" :key="link.name">
|
||||
<q-item clickable v-ripple :to="link.to">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="link.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span :class="link.color ? `text-${link.color}` : ''">
|
||||
{{ link.name }}
|
||||
</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-list v-if="link.sublinks">
|
||||
<div v-for="sublink in link.sublinks" :key="sublink.name">
|
||||
<q-item clickable v-ripple :to="sublink.to" class="q-ml-md">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="sublink.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span :class="sublink.color ? `text-${sublink.color}` : ''">
|
||||
{{ sublink.name }}
|
||||
</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</q-list>
|
||||
</template>
|
||||
<q-item clickable v-ripple @click="showAbout()">
|
||||
<q-item-section avatar><q-icon name="info" /></q-item-section>
|
||||
<q-item-section>About</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-ripple @click="logout()">
|
||||
<q-item-section avatar><q-icon name="logout" /></q-item-section>
|
||||
<q-item-section>Logout</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-scroll-area>
|
||||
</q-drawer>
|
||||
</template>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.menu-list .q-item
|
||||
border-radius: 0 32px 32px 0
|
||||
</style>
|
||||
33
app/components/ReferencePreviewComponent.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReferenceEntry } from '~/stores/reference';
|
||||
|
||||
defineProps({ entries: Array<ReferenceEntry> });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card flat bordered class="my-card" v-for="entry in entries" :key="entry.title">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col">
|
||||
<div class="text-h6">{{ entry.title }}</div>
|
||||
<div class="text-subtitle2">{{ entry.subtitle }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn color="grey-7" round flat icon="more_vert">
|
||||
<q-menu cover auto-close>
|
||||
<q-list>
|
||||
<q-item clickable><q-item-section>Remove Card</q-item-section></q-item>
|
||||
<q-item clickable><q-item-section>Send Feedback</q-item-section></q-item>
|
||||
<q-item clickable><q-item-section>Share</q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-actions>
|
||||
<q-btn flat :to="'reference/' + entry.id + '/view'">Read</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</template>
|
||||
168
app/components/ResourceScheduleViewerComponent.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<!-- Abandoned: superseded by block-based booking. Retained for future reference. -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { TimestampOrNull, Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
QCalendarResource,
|
||||
QCalendarMonth,
|
||||
today,
|
||||
parseTimestamp,
|
||||
addToDate,
|
||||
parsed,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { date } from 'quasar';
|
||||
import { computed } from 'vue';
|
||||
import type { StatusTypes } from '~/utils/schedule.types';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
interface EventData {
|
||||
event: object;
|
||||
scope: { timestamp: object; columnindex: number; activeDate: boolean; droppable: boolean };
|
||||
}
|
||||
|
||||
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
||||
|
||||
interface ResourceIntervalScope {
|
||||
resource: Boat;
|
||||
intervals: [];
|
||||
timeStartPosX(start: TimestampOrNull): number;
|
||||
timeDurationWidth(duration: number): number;
|
||||
}
|
||||
|
||||
const statusLookup = {
|
||||
confirmed: ['#14539a', 'white'],
|
||||
pending: ['#f2c037', 'white'],
|
||||
tentative: ['white', 'grey'],
|
||||
};
|
||||
|
||||
const calendar = ref();
|
||||
const boatStore = useBoatStore();
|
||||
const reservationStore = useReservationStore();
|
||||
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||
const duration = ref(1);
|
||||
|
||||
const formattedMonth = computed(() => {
|
||||
const d = new Date(selectedDate.value);
|
||||
return monthFormatter()?.format(d);
|
||||
});
|
||||
|
||||
const disabledBefore = computed(() => {
|
||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||
return addToDate(todayTs, { day: -1 }).date;
|
||||
});
|
||||
|
||||
function monthFormatter() {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-CA', { month: 'long', timeZone: 'UTC' });
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
function getEvents(scope: ResourceIntervalScope) {
|
||||
const resourceEvents = reservationStore.getReservationsByDate(selectedDate.value, scope.resource.$id);
|
||||
return resourceEvents.value.map((event) => ({
|
||||
left: scope.timeStartPosX(parsed(event.start)),
|
||||
width: scope.timeDurationWidth(date.getDateDiff(event.end, event.start, 'minutes')),
|
||||
title: event.user,
|
||||
status: event.status,
|
||||
}));
|
||||
}
|
||||
|
||||
function getStyle(event: { left: number; width: number; title: string; status: StatusTypes }) {
|
||||
return {
|
||||
position: 'absolute',
|
||||
background: event.status ? statusLookup[event.status][0] : 'white',
|
||||
color: event.status ? statusLookup[event.status][1] : '#14539a',
|
||||
left: `${event.left}px`,
|
||||
width: `${event.width}px`,
|
||||
height: '32px',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
}
|
||||
|
||||
const emit = defineEmits(['onClickTime', 'onUpdateDuration']);
|
||||
|
||||
function onPrev() { calendar.value.prev(); }
|
||||
function onNext() { calendar.value.next(); }
|
||||
function onClickDate(data: EventData) { return data; }
|
||||
function onClickTime(data: EventData) { emit('onClickTime', data); }
|
||||
function onUpdateDuration(value: EventData) { emit('onUpdateDuration', value); }
|
||||
const onClickInterval = () => {};
|
||||
const onClickHeadResources = () => {};
|
||||
const onClickResource = () => {};
|
||||
const onResourceExpanded = () => {};
|
||||
const onMoved = () => {};
|
||||
const onChange = () => {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card-section>
|
||||
<div class="text-caption text-justify">
|
||||
Use the calendar to pick a date. Tap a box in the grid for the boat and start time.
|
||||
</div>
|
||||
<div style="width: 100%; display: flex; justify-content: center">
|
||||
<div style="width: 50%; max-width: 350px; display: flex; justify-content: space-between">
|
||||
<span class="q-button" style="cursor: pointer; user-select: none" @click="onPrev"><</span>
|
||||
{{ formattedMonth }}
|
||||
<span class="q-button" style="cursor: pointer; user-select: none" @click="onNext">></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: center; align-items: center; flex-wrap: nowrap">
|
||||
<div style="display: flex; width: 100%">
|
||||
<q-calendar-month
|
||||
ref="calendar"
|
||||
v-model="selectedDate"
|
||||
:disabled-before="disabledBefore"
|
||||
animated
|
||||
bordered
|
||||
mini-mode
|
||||
date-type="rounded"
|
||||
@change="onChange"
|
||||
@moved="onMoved"
|
||||
@click-date="onClickDate" />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-calendar-resource
|
||||
v-model="selectedDate"
|
||||
:model-resources="boatStore.boats"
|
||||
resource-key="id"
|
||||
resource-label="displayName"
|
||||
resource-width="32"
|
||||
:interval-start="6"
|
||||
:interval-count="18"
|
||||
:interval-minutes="60"
|
||||
cell-width="48"
|
||||
style="--calendar-resources-width: 48px"
|
||||
resource-min-height="40"
|
||||
animated
|
||||
bordered
|
||||
@change="onChange"
|
||||
@moved="onMoved"
|
||||
@resource-expanded="onResourceExpanded"
|
||||
@click-date="onClickDate"
|
||||
@click-time="onClickTime"
|
||||
@click-resource="onClickResource"
|
||||
@click-head-resources="onClickHeadResources"
|
||||
@click-interval="onClickInterval">
|
||||
<template #resource-intervals="{ scope }">
|
||||
<template v-for="(event, index) in getEvents(scope)" :key="index">
|
||||
<q-badge outline :label="event.title" :style="getStyle(event)" />
|
||||
</template>
|
||||
</template>
|
||||
<template #resource-label="{ scope: { resource } }">
|
||||
<div class="col-12 .col-md-auto">
|
||||
{{ resource.displayName }}
|
||||
<q-icon v-if="resource.defects" name="warning" color="warning" />
|
||||
</div>
|
||||
</template>
|
||||
</q-calendar-resource>
|
||||
<q-card-section>
|
||||
<q-select filled v-model="duration" :options="durations" dense @update:model-value="onUpdateDuration" label="Duration (hours)" stack-label>
|
||||
<template v-slot:append><q-icon name="timelapse" /></template>
|
||||
</q-select>
|
||||
</q-card-section>
|
||||
</template>
|
||||
5
app/components/boat/BoatComponent.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>My component</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
35
app/components/boat/BoatPickerComponent.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
|
||||
const boats = useBoatStore().boats;
|
||||
const boat = <Boat | undefined>undefined;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-select v-model="boat" :options="boats" option-value="id" option-label="name" label="Boat">
|
||||
<template v-slot:prepend>
|
||||
<q-item-section avatar>
|
||||
<q-img v-if="boat?.iconSrc" :src="boat?.iconSrc" />
|
||||
<q-icon v-else name="sailing" />
|
||||
</q-item-section>
|
||||
</template>
|
||||
<template v-slot:option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section avatar>
|
||||
<q-img :src="scope.opt.iconsrc" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ scope.opt.name }}</q-item-label>
|
||||
<q-item-label caption>{{ scope.opt.class }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar v-if="scope.opt.defects">
|
||||
<q-icon name="warning" color="warning" />
|
||||
<q-tooltip class="bg-amber text-black shadow-7">
|
||||
This boat has notices. Select it to see details.
|
||||
</q-tooltip>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
</template>
|
||||
25
app/components/boat/BoatPreviewComponent.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
|
||||
defineProps({ boats: Array<Boat> });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="boats" class="row">
|
||||
<q-card
|
||||
v-for="boat in boats"
|
||||
:key="boat.$id"
|
||||
class="q-ma-sm col-xs-12 col-sm-6 col-xl-3">
|
||||
<q-card-section>
|
||||
<q-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h6 text-left">{{ boat.name }}</div>
|
||||
<div class="col text-right">{{ boat.class }}</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
|
||||
</template>
|
||||
1
app/components/scheduling/BoatSelection.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
101
app/components/scheduling/IntervalTemplateComponent.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||
import type { IntervalTemplate } from '~/utils/schedule.types';
|
||||
import { copyIntervalTemplate, timeTuplesOverlapped } from '~/utils/schedule';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const alert = ref(false);
|
||||
const overlapped = ref();
|
||||
const intervalTemplateStore = useIntervalTemplateStore();
|
||||
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
|
||||
const edit = ref(props.edit);
|
||||
const expanded = ref(props.edit);
|
||||
const template = ref(copyIntervalTemplate(props.modelValue));
|
||||
|
||||
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'saved'): void }>();
|
||||
|
||||
const revert = () => {
|
||||
template.value = copyIntervalTemplate(props.modelValue);
|
||||
edit.value = false;
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const toggleEdit = () => {
|
||||
edit.value = !edit.value;
|
||||
};
|
||||
|
||||
const deleteTemplate = (event: Event, tmpl: IntervalTemplate | undefined) => {
|
||||
if (tmpl?.$id) intervalTemplateStore.deleteIntervalTemplate(tmpl.$id);
|
||||
};
|
||||
|
||||
function onDragStart(e: DragEvent, tmpl: IntervalTemplate) {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('ID', tmpl.$id || '');
|
||||
}
|
||||
}
|
||||
|
||||
const saveTemplate = (evt: Event, tmpl: IntervalTemplate | undefined) => {
|
||||
if (!tmpl) return false;
|
||||
overlapped.value = timeTuplesOverlapped(tmpl.timeTuples);
|
||||
if (overlapped.value.length > 0) {
|
||||
alert.value = true;
|
||||
} else {
|
||||
edit.value = false;
|
||||
if (tmpl.$id && tmpl.$id !== 'unsaved') {
|
||||
intervalTemplateStore.updateIntervalTemplate(tmpl, tmpl.$id);
|
||||
} else {
|
||||
intervalTemplateStore.createIntervalTemplate(tmpl);
|
||||
emit('saved');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-expansion-item expand-icon-toggle draggable="true" @dragstart="onDragStart($event, template)" v-model="expanded">
|
||||
<template v-slot:header>
|
||||
<q-item-section>
|
||||
<q-input label="Template name" :borderless="!edit" dense v-model="template.name" v-if="edit" />
|
||||
<q-item-label v-if="!edit" class="cursor-pointer">{{ template.name }}</q-item-label>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<q-card flat>
|
||||
<q-card-section horizontal>
|
||||
<q-card-section class="q-pt-xs">
|
||||
<q-list dense>
|
||||
<q-item v-for="(item, index) in template.timeTuples" :key="item[0]">
|
||||
<q-input class="q-mx-sm" dense v-model="item[0]" type="time" label="Start" :borderless="!edit" :readonly="!edit" />
|
||||
<q-input class="q-mx-sm" dense v-model="item[1]" type="time" label="End" :borderless="!edit" :readonly="!edit">
|
||||
<template v-slot:after>
|
||||
<q-btn v-if="edit" round dense flat icon="delete" @click="template.timeTuples.splice(index, 1)" />
|
||||
</template>
|
||||
</q-input>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-btn v-if="edit" dense color="primary" size="sm" label="Add interval" @click="template.timeTuples.push(['00:00', '00:00'])" />
|
||||
</q-card-section>
|
||||
<q-card-actions vertical>
|
||||
<q-btn v-if="!edit" color="primary" icon="edit" label="Edit" @click="toggleEdit" />
|
||||
<q-btn v-if="edit" color="primary" icon="save" label="Save" @click="saveTemplate($event, template)" />
|
||||
<q-btn v-if="edit" color="secondary" icon="cancel" label="Cancel" @click="revert" />
|
||||
<q-btn color="negative" icon="delete" label="Delete" v-if="template.$id !== ''" @click="deleteTemplate($event, template)" />
|
||||
</q-card-actions>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-dialog v-model="alert">
|
||||
<q-card>
|
||||
<q-card-section><div class="text-h6">Overlapped blocks!</div></q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-chip square icon="schedule" v-for="item in overlapped" :key="item.start">
|
||||
{{ item.start }}-{{ item.end }}
|
||||
</q-chip>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="OK" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
13
app/components/scheduling/NavigationBar.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="row justify-center">
|
||||
<div class="q-pa-md q-gutter-sm row">
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">Today</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">< Prev</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">Next ></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits(['today', 'prev', 'next']);
|
||||
</script>
|
||||
69
app/components/scheduling/ReservationCardComponent.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import type { Reservation } from '~/utils/schedule.types';
|
||||
import { formatDate, isPast } from '~/utils/schedule';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const cancelDialog = ref(false);
|
||||
const boatStore = useBoatStore();
|
||||
const reservationStore = useReservationStore();
|
||||
|
||||
const reservation = defineModel<Reservation>({ required: true });
|
||||
|
||||
const cancelReservation = () => {
|
||||
cancelDialog.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card
|
||||
bordered
|
||||
:class="isPast(reservation.end) ? 'text-blue-grey-6' : ''"
|
||||
class="q-ma-md">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col">
|
||||
<div class="text-h6">
|
||||
{{ boatStore.getBoatById(reservation.resource)?.name }}
|
||||
</div>
|
||||
<div class="text-subtitle2">
|
||||
<p>
|
||||
Start: {{ formatDate(reservation.start) }}<br />
|
||||
End: {{ formatDate(reservation.end) }}<br />
|
||||
Type: {{ reservation.reason }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-actions v-if="!isPast(reservation.end)">
|
||||
<q-btn flat size="lg" :to="`/schedule/edit/${reservation.$id}`">Modify</q-btn>
|
||||
<q-btn flat size="lg" @click="cancelReservation()">Delete</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-dialog v-model="cancelDialog">
|
||||
<q-card>
|
||||
<q-card-section class="row items-center">
|
||||
<q-avatar icon="warning" color="negative" text-color="white" />
|
||||
<span class="q-ml-md">Warning!</span>
|
||||
<p class="q-pt-md">
|
||||
This will delete your reservation for
|
||||
{{ boatStore.getBoatById(reservation?.resource)?.name }} on
|
||||
{{ formatDate(reservation?.start) }}
|
||||
</p>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat size="lg" label="Cancel" color="primary" v-close-popup />
|
||||
<q-btn
|
||||
flat
|
||||
size="lg"
|
||||
label="Delete"
|
||||
color="negative"
|
||||
@click="reservationStore.deleteReservation(reservation)"
|
||||
v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
236
app/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
QCalendarDay,
|
||||
diffTimestamp,
|
||||
today,
|
||||
parseTimestamp,
|
||||
parseDate,
|
||||
addToDate,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
|
||||
const intervalTemplateStore = useIntervalTemplateStore();
|
||||
const reservationStore = useReservationStore();
|
||||
const { boats } = storeToRefs(useBoatStore());
|
||||
const selectedBlock = defineModel<Interval | null>();
|
||||
const selectedDate = ref(today());
|
||||
const { getAvailableIntervals } = useIntervalStore();
|
||||
const calendar = ref<typeof QCalendarDay | null>(null);
|
||||
const now = ref(new Date());
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
await useBoatStore().fetchBoats();
|
||||
await intervalTemplateStore.fetchIntervalTemplates();
|
||||
intervalId = setInterval(() => { now.value = new Date(); }, 60000);
|
||||
});
|
||||
|
||||
onUnmounted(() => clearInterval(intervalId));
|
||||
|
||||
function handleSwipe({ direction }: { direction: string }) {
|
||||
if (direction === 'right') {
|
||||
calendar.value?.prev();
|
||||
} else {
|
||||
calendar.value?.next();
|
||||
}
|
||||
}
|
||||
|
||||
function reservationStyles(
|
||||
reservation: Reservation,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
return genericBlockStyle(
|
||||
parseDate(new Date(reservation.start)) as Timestamp,
|
||||
parseDate(new Date(reservation.end)) as Timestamp,
|
||||
timeStartPos,
|
||||
timeDurationHeight
|
||||
);
|
||||
}
|
||||
|
||||
function getUserName(userid: string) {
|
||||
return useAuthStore().getUserNameById(userid);
|
||||
}
|
||||
|
||||
function blockStyles(
|
||||
block: Interval,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
return genericBlockStyle(
|
||||
parseDate(new Date(block.start)) as Timestamp,
|
||||
parseDate(new Date(block.end)) as Timestamp,
|
||||
timeStartPos,
|
||||
timeDurationHeight
|
||||
);
|
||||
}
|
||||
|
||||
function getBoatDisplayName(scope: DayBodyScope) {
|
||||
return boats.value[scope.columnIndex]?.displayName ?? '';
|
||||
}
|
||||
|
||||
function beforeNow(time: Date) {
|
||||
return time < now.value || null;
|
||||
}
|
||||
|
||||
function genericBlockStyle(
|
||||
start: Timestamp,
|
||||
end: Timestamp,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
const s = { top: '', height: '', opacity: '' };
|
||||
if (timeStartPos && timeDurationHeight) {
|
||||
s.top = timeStartPos(start.time) + 'px';
|
||||
s.height =
|
||||
parseInt(timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)) - 1 + 'px';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface DayBodyScope {
|
||||
columnIndex: number;
|
||||
timeDurationHeight: string;
|
||||
timeStartPos: (time: string, clamp: boolean) => string;
|
||||
timestamp: Timestamp;
|
||||
}
|
||||
|
||||
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||
if (scope.timestamp.disabled || new Date(block.end) < new Date()) return false;
|
||||
selectedBlock.value = block;
|
||||
}
|
||||
|
||||
const boatReservations = computed((): Record<string, Reservation[]> => {
|
||||
return reservationStore
|
||||
.getReservationsByDate(selectedDate.value)
|
||||
.value.reduce((result, reservation) => {
|
||||
if (!result[reservation.resource]) result[reservation.resource] = [];
|
||||
result[reservation.resource]!.push(reservation);
|
||||
return result;
|
||||
}, <Record<string, Reservation[]>>{});
|
||||
});
|
||||
|
||||
function getBoatReservations(scope: DayBodyScope): Reservation[] {
|
||||
const boat = boats.value[scope.columnIndex];
|
||||
return boat ? boatReservations.value[boat.$id] ?? [] : [];
|
||||
}
|
||||
|
||||
const disabledBefore = computed(() => {
|
||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||
return addToDate(todayTs, { day: -1 }).date;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<q-card>
|
||||
<q-toolbar>
|
||||
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
|
||||
<q-btn icon="close" flat round dense v-close-popup />
|
||||
</q-toolbar>
|
||||
<q-separator />
|
||||
<CalendarHeaderComponent v-model="selectedDate" />
|
||||
<div class="boat-schedule-table-component">
|
||||
<QCalendarDay
|
||||
ref="calendar"
|
||||
class="q-pa-xs"
|
||||
flat
|
||||
animated
|
||||
dense
|
||||
:disabled-before="disabledBefore"
|
||||
interval-height="24"
|
||||
interval-count="18"
|
||||
interval-start="06:00"
|
||||
:short-interval-label="true"
|
||||
v-model="selectedDate"
|
||||
:column-count="boats.length"
|
||||
v-touch-swipe.left.right="handleSwipe">
|
||||
<template #head-day="{ scope }">
|
||||
<div style="text-align: center; font-weight: 800">
|
||||
{{ getBoatDisplayName(scope) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #day-body="{ scope }">
|
||||
<div
|
||||
v-for="block in getAvailableIntervals(scope.timestamp, boats[scope.columnIndex]).value"
|
||||
:key="block.$id">
|
||||
<div
|
||||
class="timeblock"
|
||||
:disabled="beforeNow(new Date(block.end))"
|
||||
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
|
||||
:style="blockStyles(block, scope.timeStartPos, scope.timeDurationHeight)"
|
||||
:id="block.$id"
|
||||
@click="selectBlock($event, scope, block)">
|
||||
{{ boats[scope.columnIndex]?.name }}<br />
|
||||
{{ selectedBlock?.$id === block.$id ? 'Selected' : 'Available' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="reservation in getBoatReservations(scope)" :key="reservation.$id">
|
||||
<div
|
||||
class="reservation column"
|
||||
:style="reservationStyles(reservation, scope.timeStartPos, scope.timeDurationHeight)">
|
||||
{{ getUserName(reservation.user) || 'loading...' }}<br />
|
||||
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</QCalendarDay>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="sass">
|
||||
.boat-schedule-table-component
|
||||
display: flex
|
||||
max-height: 60vh
|
||||
max-width: 98vw
|
||||
.reservation
|
||||
display: flex
|
||||
position: absolute
|
||||
justify-content: center
|
||||
align-items: center
|
||||
text-align: center
|
||||
width: 100%
|
||||
opacity: 1
|
||||
margin: 0px
|
||||
text-overflow: ellipsis
|
||||
font-size: 0.8em
|
||||
cursor: pointer
|
||||
background: $accent
|
||||
color: white
|
||||
border: 1px solid black
|
||||
.timeblock
|
||||
display: flex
|
||||
position: absolute
|
||||
justify-content: center
|
||||
text-align: center
|
||||
align-items: center
|
||||
width: 100%
|
||||
opacity: 0.5
|
||||
margin: 0px
|
||||
text-overflow: ellipsis
|
||||
font-size: 0.8em
|
||||
cursor: pointer
|
||||
background: $primary
|
||||
color: white
|
||||
border: 1px solid black
|
||||
.selected
|
||||
opacity: 1 !important
|
||||
.q-calendar-day__interval--text
|
||||
font-size: 0.8em
|
||||
.q-calendar-day__day.q-current-day
|
||||
padding: 1px
|
||||
.q-calendar-day__head--days__column
|
||||
background: $primary
|
||||
color: white
|
||||
</style>
|
||||
192
app/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
addToDate,
|
||||
createDayList,
|
||||
createNativeLocaleFormatter,
|
||||
getEndOfWeek,
|
||||
getStartOfWeek,
|
||||
parseTimestamp,
|
||||
today,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
|
||||
const selectedDate = defineModel<string>();
|
||||
|
||||
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]);
|
||||
const locale = ref('en-CA');
|
||||
const monthFormatter = monthFormatterFunc();
|
||||
const dayFormatter = dayFormatterFunc();
|
||||
const weekdayFormatter = weekdayFormatterFunc();
|
||||
|
||||
const today2 = computed(() => parseTimestamp(today()));
|
||||
|
||||
const parsedStart = computed(() =>
|
||||
getStartOfWeek(
|
||||
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||
weekdays,
|
||||
today2.value as Timestamp
|
||||
)
|
||||
);
|
||||
|
||||
const parsedEnd = computed(() =>
|
||||
getEndOfWeek(
|
||||
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||
weekdays,
|
||||
today2.value as Timestamp
|
||||
)
|
||||
);
|
||||
|
||||
const days = computed(() => {
|
||||
if (parsedStart.value && parsedEnd.value) {
|
||||
return createDayList(parsedStart.value, parsedEnd.value, today2.value as Timestamp, weekdays);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const dayStyle = computed(() => ({ width: 100 / weekdays.length + '%' }));
|
||||
|
||||
function onPrev() {
|
||||
selectedDate.value = addToDate(parsedStart.value, { day: -7 }).date;
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
selectedDate.value = addToDate(parsedStart.value, { day: 7 }).date;
|
||||
}
|
||||
|
||||
function dayClass(day: Timestamp) {
|
||||
return { 'date-button': true, 'selected-date-button': selectedDate.value === day.date };
|
||||
}
|
||||
|
||||
function monthFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'long' };
|
||||
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'short' };
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||
}
|
||||
|
||||
function weekdayFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'long' };
|
||||
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'short' };
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||
}
|
||||
|
||||
function dayFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: '2-digit' };
|
||||
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: 'numeric' };
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title-bar" style="display: flex">
|
||||
<button tabindex="0" class="date-button direction-button direction-button__left" @click="onPrev">
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
<div class="dates-holder">
|
||||
<div :key="parsedStart?.date" class="internal-dates-holder">
|
||||
<div v-for="day in days" :key="day.date" :style="dayStyle">
|
||||
<button tabindex="0" style="width: 100%" :class="dayClass(day)" @click="selectedDate = day.date">
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
<div style="width: 100%; font-size: 0.9em">{{ monthFormatter(day, true) }}</div>
|
||||
<div style="width: 100%; font-size: 1.2em; font-weight: 700">{{ dayFormatter(day, false) }}</div>
|
||||
<div style="width: 100%; font-size: 1em">{{ weekdayFormatter(day, true) }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button tabindex="0" class="date-button direction-button direction-button__right" @click="onNext">
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="sass">
|
||||
.title-bar
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 70px
|
||||
background: white
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex: 1 0 100%
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
overflow: hidden
|
||||
border-radius: 3px
|
||||
user-select: none
|
||||
margin: 2px 0px 2px
|
||||
|
||||
.dates-holder
|
||||
position: relative
|
||||
width: 100%
|
||||
align-items: center
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
color: #fff
|
||||
overflow: hidden
|
||||
user-select: none
|
||||
|
||||
.internal-dates-holder
|
||||
position: relative
|
||||
width: 100%
|
||||
display: inline-flex
|
||||
flex: 1 1 100%
|
||||
flex-direction: row
|
||||
justify-content: space-between
|
||||
overflow: hidden
|
||||
user-select: none
|
||||
|
||||
.direction-button
|
||||
background: white
|
||||
color: $primary
|
||||
width: 40px
|
||||
max-width: 50px !important
|
||||
|
||||
.direction-button__left
|
||||
&:before
|
||||
content: '<'
|
||||
display: inline-flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
height: 100%
|
||||
font-weight: 900
|
||||
font-size: 3em
|
||||
|
||||
.direction-button__right
|
||||
&:before
|
||||
content: '>'
|
||||
display: inline-flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
height: 100%
|
||||
font-weight: 900
|
||||
font-size: 3em
|
||||
|
||||
.date-button
|
||||
color: $primary
|
||||
background: white
|
||||
z-index: 2
|
||||
height: 100%
|
||||
outline: 0
|
||||
cursor: pointer
|
||||
border-radius: 3px
|
||||
display: inline-flex
|
||||
flex: 1 0 auto
|
||||
flex-direction: column
|
||||
align-items: stretch
|
||||
position: relative
|
||||
border: 0
|
||||
vertical-align: middle
|
||||
padding: 0
|
||||
font-size: 14px
|
||||
line-height: 1.715em
|
||||
text-decoration: none
|
||||
font-weight: 500
|
||||
text-transform: uppercase
|
||||
text-align: center
|
||||
user-select: none
|
||||
|
||||
.selected-date-button
|
||||
color: white !important
|
||||
background: $primary !important
|
||||
</style>
|
||||
28
app/layouts/admin.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const leftDrawer = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout view="hHh Lpr fFf">
|
||||
<q-header elevated>
|
||||
<q-toolbar>
|
||||
<q-btn flat round dense icon="menu" @click="leftDrawer = !leftDrawer" />
|
||||
<q-toolbar-title>Admin</q-toolbar-title>
|
||||
</q-toolbar>
|
||||
<q-tabs>
|
||||
<q-route-tab icon="person" to="/admin/user" replace label="Users" />
|
||||
<q-route-tab icon="directions_boat" to="/admin/boat" replace label="Boats" />
|
||||
</q-tabs>
|
||||
</q-header>
|
||||
|
||||
<q-drawer v-model="leftDrawer" side="left" bordered content-class="bg-grey-2">
|
||||
<q-scroll-area class="fit q-pa-sm" />
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<slot />
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
45
app/layouts/default.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import BottomNavComponent from '~/components/BottomNavComponent.vue';
|
||||
import LeftDrawer from '~/components/LeftDrawer.vue';
|
||||
import { APP_VERSION } from '~/utils/version';
|
||||
|
||||
const q = useQuasar();
|
||||
const route = useRoute();
|
||||
const leftDrawerOpen = ref(false);
|
||||
|
||||
function toggleLeftDrawer() {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||
}
|
||||
|
||||
q.addressbarColor?.set('#14539a');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout view="hHh Lpr fFf">
|
||||
<q-header elevated>
|
||||
<q-toolbar>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="menu"
|
||||
aria-label="Menu"
|
||||
@click="toggleLeftDrawer" />
|
||||
<q-toolbar-title>{{ route?.meta?.title as string }}</q-toolbar-title>
|
||||
<q-space />
|
||||
<div>v{{ APP_VERSION }}</div>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
<LeftDrawer
|
||||
:drawer="leftDrawerOpen"
|
||||
@drawer-toggle="toggleLeftDrawer" />
|
||||
<q-page-container>
|
||||
<slot />
|
||||
</q-page-container>
|
||||
<q-footer>
|
||||
<BottomNavComponent />
|
||||
</q-footer>
|
||||
</q-layout>
|
||||
</template>
|
||||
27
app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Public routes (set via definePageMeta({ public: true }) in each page)
|
||||
if (to.meta.public === true) {
|
||||
// Redirect already-authenticated users away from /login
|
||||
if (to.path === '/login' && authStore.currentUser) {
|
||||
return navigateTo('/');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other routes require auth
|
||||
if (!authStore.currentUser) {
|
||||
return navigateTo('/login');
|
||||
}
|
||||
|
||||
// Role-based access: pages set requiredRoles via definePageMeta
|
||||
const requiredRoles = to.meta.requiredRoles as string[] | undefined;
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
if (!authStore.hasRequiredRole(requiredRoles)) {
|
||||
return abortNavigation();
|
||||
}
|
||||
}
|
||||
});
|
||||
20
app/pages/[...slug].vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div style="font-size: 30vh">404</div>
|
||||
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
|
||||
<q-btn
|
||||
class="q-mt-xl"
|
||||
color="white"
|
||||
text-color="blue"
|
||||
unelevated
|
||||
to="/"
|
||||
label="Go Home"
|
||||
no-caps />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
9
app/pages/admin/boat.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<!-- content -->
|
||||
</q-page>
|
||||
</template>
|
||||
9
app/pages/admin/user.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<!-- content -->
|
||||
</q-page>
|
||||
</template>
|
||||
34
app/pages/auth/callback.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
|
||||
definePageMeta({ public: true, layout: false });
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const userId = route.query.userId as string | undefined;
|
||||
const secret = route.query.secret as string | undefined;
|
||||
|
||||
if (!userId || !secret) {
|
||||
error.value = 'Invalid magic link — missing parameters.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.magicURLLogin(userId, secret);
|
||||
await navigateTo('/');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error.value = 'Login failed. The link may have expired.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<div v-if="error" class="text-negative text-body1">{{ error }}</div>
|
||||
<q-spinner v-else color="primary" size="50px" />
|
||||
</q-page>
|
||||
</template>
|
||||
18
app/pages/boat.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import BoatPreviewComponent from '~/components/boat/BoatPreviewComponent.vue';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
definePageMeta({ title: 'Boats' });
|
||||
|
||||
const boatStore = useBoatStore();
|
||||
const { boats } = storeToRefs(boatStore);
|
||||
|
||||
onMounted(() => boatStore.fetchBoats());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<boat-preview-component :boats="boats" />
|
||||
</q-page>
|
||||
</template>
|
||||
11
app/pages/certification.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import CertificationComponent from '~/components/CertificationComponent.vue';
|
||||
|
||||
definePageMeta({ title: 'Certifications' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<CertificationComponent />
|
||||
</q-page>
|
||||
</template>
|
||||
24
app/pages/checklist.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ title: 'Checklists' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<q-card bordered separator style="max-width: 400px">
|
||||
<q-card-section clickable v-ripple>
|
||||
<div class="text-h6">Engine Starting</div>
|
||||
<div class="text-subtitle2">
|
||||
Proper procedures for starting an outboard engine.
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card bordered separator style="max-width: 400px">
|
||||
<q-card-section clickable v-ripple>
|
||||
<div class="text-h6">Pre-Sail Checklist J/27</div>
|
||||
<div class="text-subtitle2">
|
||||
Mandatory Safety and Equipment readiness check for J/27 class boats.
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
28
app/pages/index.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import { useNavLinks } from '~/utils/navlinks';
|
||||
|
||||
definePageMeta({ title: 'OYS Borrow a Boat' });
|
||||
|
||||
const { enabledLinks } = useNavLinks();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page class="row justify-center">
|
||||
<q-img alt="OYS Logo" src="/oysqn_logo.png" fit="scale-down" />
|
||||
<q-list class="full-width mobile-only">
|
||||
<q-item
|
||||
v-for="link in enabledLinks.filter((x) => x.front_links)"
|
||||
:key="link.name">
|
||||
<q-btn
|
||||
:icon="link.icon"
|
||||
color="primary"
|
||||
:size="`1.25em`"
|
||||
:to="link.to"
|
||||
:label="link.name"
|
||||
rounded
|
||||
class="full-width"
|
||||
:align="'left'" />
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-page>
|
||||
</template>
|
||||
127
app/pages/login.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Dialog, Notify } from 'quasar';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { AppwriteException } from 'appwrite';
|
||||
|
||||
definePageMeta({ public: true, layout: false });
|
||||
|
||||
const email = ref('');
|
||||
const token = ref('');
|
||||
const userId = ref<string | undefined>();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const sendMagicLink = async () => {
|
||||
if (!email.value) {
|
||||
Dialog.create({ message: 'Please enter your e-mail address.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await authStore.createMagicURLSession(email.value);
|
||||
Dialog.create({ message: 'Check your e-mail for a magic login link.' });
|
||||
} catch {
|
||||
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
|
||||
}
|
||||
};
|
||||
|
||||
const doTokenLogin = async () => {
|
||||
if (!userId.value) {
|
||||
try {
|
||||
const sessionToken = await authStore.createTokenSession(email.value);
|
||||
userId.value = sessionToken.userId;
|
||||
Dialog.create({ message: 'Check your e-mail for your login code.' });
|
||||
} catch {
|
||||
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
|
||||
}
|
||||
} else {
|
||||
const notification = Notify.create({
|
||||
type: 'primary',
|
||||
position: 'top',
|
||||
spinner: true,
|
||||
message: 'Logging you in...',
|
||||
timeout: 8000,
|
||||
group: false,
|
||||
});
|
||||
try {
|
||||
await authStore.tokenLogin(userId.value, token.value);
|
||||
notification({ type: 'positive', message: 'Logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
|
||||
await navigateTo('/');
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AppwriteException) {
|
||||
if (error.type === 'user_session_already_exists') {
|
||||
notification({ type: 'positive', message: 'Already logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
|
||||
await navigateTo('/');
|
||||
return;
|
||||
}
|
||||
Dialog.create({ title: 'Login Error!', message: error.message, persistent: true });
|
||||
}
|
||||
notification({ type: 'negative', message: 'Login failed.', timeout: 2000 });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
<q-page class="flex bg-image flex-center">
|
||||
<q-card
|
||||
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||
<q-card-section>
|
||||
<q-img fit="scale-down" src="/oysqn_logo.png" />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-center q-pt-sm">
|
||||
<div class="col text-h6">Log in</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-form @keydown.enter.prevent="doTokenLogin">
|
||||
<q-card-section class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="email"
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
color="darkblue"
|
||||
filled />
|
||||
<q-input
|
||||
v-if="userId"
|
||||
v-model="token"
|
||||
label="6-digit code"
|
||||
type="number"
|
||||
color="darkblue"
|
||||
filled />
|
||||
</q-card-section>
|
||||
</q-form>
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row justify-center q-ma-sm">
|
||||
<q-btn
|
||||
v-if="!userId"
|
||||
type="button"
|
||||
@click="sendMagicLink"
|
||||
color="secondary"
|
||||
label="Send Magic Link"
|
||||
style="width: 300px" />
|
||||
</div>
|
||||
<div class="row justify-center q-ma-sm">
|
||||
<q-btn
|
||||
type="button"
|
||||
@click="doTokenLogin"
|
||||
color="primary"
|
||||
:label="userId ? 'Login' : 'Send Code'"
|
||||
style="width: 300px" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bg-image {
|
||||
background-image: url('~/assets/oys_lighthouse.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: center;
|
||||
background-size: cover;
|
||||
}
|
||||
</style>
|
||||
43
app/pages/privacy-policy.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
<q-page padding>
|
||||
<h1>Privacy Policy for Undock.ca</h1>
|
||||
|
||||
<p>
|
||||
At Undock, accessible from https://undock.ca, one of our main priorities is the
|
||||
privacy of our visitors. This Privacy Policy document contains types of information
|
||||
that is collected and recorded by Undock and how we use it.
|
||||
</p>
|
||||
|
||||
<h2>General Data Protection Regulation (GDPR)</h2>
|
||||
<p>We are a Data Controller of your information.</p>
|
||||
|
||||
<h2>Log Files</h2>
|
||||
<p>
|
||||
Undock follows a standard procedure of using log files. These files log visitors
|
||||
when they visit websites. The information collected by log files include internet
|
||||
protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and
|
||||
time stamp, referring/exit pages, and possibly the number of clicks.
|
||||
</p>
|
||||
|
||||
<h2>Cookies and Web Beacons</h2>
|
||||
<p>
|
||||
Like any other website, Undock uses "cookies". These cookies are used to store
|
||||
information including visitors' preferences, and the pages on the website that
|
||||
the visitor accessed or visited.
|
||||
</p>
|
||||
|
||||
<h2>Consent</h2>
|
||||
<p>
|
||||
By using our website, you hereby consent to our Privacy Policy and agree to its
|
||||
<a href="/terms-of-service">terms</a>.
|
||||
</p>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
76
app/pages/profile.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { ref } from 'vue';
|
||||
|
||||
definePageMeta({ title: 'Member Profile' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const newName = ref<string | undefined>();
|
||||
|
||||
const editName = async () => {
|
||||
if (newName.value) {
|
||||
try {
|
||||
await authStore.updateName(newName.value);
|
||||
newName.value = undefined;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
} else {
|
||||
newName.value = authStore.currentUser?.name || '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding class="row">
|
||||
<q-list class="col-sm-4 col-12">
|
||||
<q-separator />
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="person" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label caption>Name</q-item-label>
|
||||
<q-input
|
||||
filled
|
||||
v-model="newName"
|
||||
@keydown.enter.prevent="editName"
|
||||
v-if="newName !== undefined" />
|
||||
<div v-else>{{ authStore.currentUser?.name }}</div>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-btn
|
||||
square
|
||||
@click="editName"
|
||||
:icon="newName !== undefined ? 'check' : 'edit'" />
|
||||
<q-btn
|
||||
v-if="newName !== undefined"
|
||||
square
|
||||
color="negative"
|
||||
@click="newName = undefined"
|
||||
icon="cancel" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="email" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label caption>E-mail</q-item-label>
|
||||
{{ authStore.currentUser?.email }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label overline>Certifications</q-item-label>
|
||||
<div>
|
||||
<q-chip square icon="verified" color="green" text-color="white">J/27</q-chip>
|
||||
<q-chip square icon="verified" color="blue" text-color="white">Capri25</q-chip>
|
||||
<q-chip square icon="verified" color="grey-9" text-color="white">Night</q-chip>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-page>
|
||||
</template>
|
||||
38
app/pages/pwreset.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
// NOTE: Password reset removed — auth is magic link + OTP only.
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
<q-page class="flex bg-image flex-center">
|
||||
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||
<q-card-section>
|
||||
<q-img fit="scale-down" src="/oysqn_logo.png" />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-center q-pt-sm">
|
||||
<div class="text-h6">Password Reset</div>
|
||||
<div class="text-body2 q-mt-md">
|
||||
This application uses magic link and email code login — no password is required.
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="text-center">
|
||||
<q-btn flat color="primary" label="Back to Login" to="/login" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bg-image {
|
||||
background-image: url('~/assets/oys_lighthouse.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: center;
|
||||
background-size: cover;
|
||||
}
|
||||
</style>
|
||||
7
app/pages/reference.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ title: 'Reference' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
13
app/pages/reference/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import ReferencePreviewComponent from '~/components/ReferencePreviewComponent.vue';
|
||||
import { ref } from 'vue';
|
||||
import { useReferenceStore } from '~/stores/reference';
|
||||
|
||||
const items = ref(useReferenceStore().allItems);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<ReferencePreviewComponent :entries="items" />
|
||||
</q-page>
|
||||
</template>
|
||||
11
app/pages/reference/reference/[id]/view.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<div class="text-h4">Engine Starting</div>
|
||||
<q-video
|
||||
title="Engine Starting"
|
||||
:ratio="16 / 9"
|
||||
src="https://www.youtube.com/embed/GMHMLDlkKcE" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
7
app/pages/schedule.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ title: 'Schedule' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
28
app/pages/schedule/book.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const route = useRoute();
|
||||
const newReservation = ref<Reservation>();
|
||||
|
||||
if (typeof route.query.interval === 'string') {
|
||||
useIntervalStore()
|
||||
.fetchInterval(route.query.interval)
|
||||
.then(
|
||||
(interval: Interval) =>
|
||||
(newReservation.value = <Reservation>{
|
||||
resource: interval.resource,
|
||||
start: interval.start,
|
||||
end: interval.end,
|
||||
})
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<BoatReservationComponent v-model="newReservation" />
|
||||
</q-page>
|
||||
</template>
|
||||
20
app/pages/schedule/edit/[id].vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import type { Reservation } from '~/utils/schedule.types';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const route = useRoute();
|
||||
const reservation = ref<Reservation>();
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string;
|
||||
reservation.value = await useReservationStore().getReservationById(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<BoatReservationComponent v-model="reservation" />
|
||||
</q-page>
|
||||
</template>
|
||||
22
app/pages/schedule/index.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { useNavLinks } from '~/utils/navlinks';
|
||||
|
||||
const { enabledLinks } = useNavLinks();
|
||||
const navlinks = enabledLinks.find((link) => link.name === 'Schedule')?.sublinks;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<q-item v-for="link in navlinks" :key="link.name">
|
||||
<q-btn
|
||||
:icon="link.icon"
|
||||
:color="link.color ? link.color : 'primary'"
|
||||
size="1.25em"
|
||||
:to="link.to"
|
||||
:label="link.name"
|
||||
rounded
|
||||
class="full-width"
|
||||
align="left" />
|
||||
</q-item>
|
||||
</q-page>
|
||||
</template>
|
||||
55
app/pages/schedule/list.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import ReservationCardComponent from '~/components/scheduling/ReservationCardComponent.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const reservationStore = useReservationStore();
|
||||
onMounted(() => reservationStore.fetchUserReservations());
|
||||
|
||||
const tab = ref('upcoming');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<q-tabs v-model="tab" inline-label class="text-primary">
|
||||
<q-tab name="upcoming" icon="schedule" label="Upcoming" />
|
||||
<q-tab name="past" icon="history" label="Past" />
|
||||
</q-tabs>
|
||||
<q-separator />
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
<q-tab-panel name="upcoming" class="q-pa-none">
|
||||
<q-card clas="q-ma-md" v-if="!reservationStore.futureUserReservations.length">
|
||||
<q-card-section>
|
||||
<div class="text-h6">You don't have any upcoming bookings!</div>
|
||||
<div class="text-h8">Why don't you go make one?</div>
|
||||
</q-card-section>
|
||||
<q-card-actions>
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="event"
|
||||
:size="`1.25em`"
|
||||
label="Book Now"
|
||||
rounded
|
||||
class="full-width"
|
||||
:align="'left'"
|
||||
to="/schedule/book" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="reservation in reservationStore.futureUserReservations"
|
||||
:key="reservation.$id">
|
||||
<ReservationCardComponent :modelValue="reservation" />
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="past" class="q-pa-none">
|
||||
<div
|
||||
v-for="reservation in reservationStore.pastUserReservations"
|
||||
:key="reservation.$id">
|
||||
<ReservationCardComponent :modelValue="reservation" />
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-page>
|
||||
</template>
|
||||
238
app/pages/schedule/manage.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
QCalendarScheduler,
|
||||
today,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
|
||||
import { date } from 'quasar';
|
||||
import IntervalTemplateComponent from '~/components/scheduling/IntervalTemplateComponent.vue';
|
||||
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { buildInterval, intervalsOverlapped } from '~/utils/schedule';
|
||||
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||
|
||||
definePageMeta({ requiredRoles: ['Schedule Admins'] });
|
||||
|
||||
const selectedDate = ref(today());
|
||||
const { fetchBoats } = useBoatStore();
|
||||
const intervalStore = useIntervalStore();
|
||||
const intervalTemplateStore = useIntervalTemplateStore();
|
||||
const { boats } = storeToRefs(useBoatStore());
|
||||
const intervalTemplates = intervalTemplateStore.getIntervalTemplates();
|
||||
const calendar = ref();
|
||||
const overlapped = ref();
|
||||
const alert = ref(false);
|
||||
const newTemplate = ref<IntervalTemplate>({
|
||||
$id: '',
|
||||
name: 'NewTemplate',
|
||||
timeTuples: [['09:00', '12:00']],
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchBoats();
|
||||
await intervalTemplateStore.fetchIntervalTemplates();
|
||||
});
|
||||
|
||||
const filteredIntervals = (ts: Timestamp, boat: Boat) => {
|
||||
return intervalStore.getIntervals(ts, boat);
|
||||
};
|
||||
|
||||
const sortedIntervals = (ts: Timestamp, boat: Boat) => {
|
||||
return computed(() =>
|
||||
filteredIntervals(ts, boat).value.sort(
|
||||
(a, b) => Date.parse(a.start) - Date.parse(b.start)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
function resetNewTemplate() {
|
||||
newTemplate.value = {
|
||||
$id: 'unsaved',
|
||||
name: 'NewTemplate',
|
||||
timeTuples: [['09:00', '12:00']],
|
||||
};
|
||||
}
|
||||
function createTemplate() {
|
||||
newTemplate.value.$id = 'unsaved';
|
||||
}
|
||||
function createIntervals(boat: Boat, templateId: string, dateStr: string) {
|
||||
const intervals = intervalsFromTemplate(boat, templateId, dateStr);
|
||||
intervals.forEach((interval) => intervalStore.createInterval(interval));
|
||||
}
|
||||
|
||||
function getIntervals(ts: Timestamp, boat: Boat) {
|
||||
return intervalStore.getIntervals(ts, boat);
|
||||
}
|
||||
|
||||
function intervalsFromTemplate(
|
||||
boat: Boat,
|
||||
templateId: string,
|
||||
dateStr: string
|
||||
): Interval[] {
|
||||
const template = intervalTemplateStore
|
||||
.getIntervalTemplates()
|
||||
.value.find((t) => t.$id === templateId);
|
||||
return template
|
||||
? template.timeTuples.map((timeTuple: TimeTuple) =>
|
||||
buildInterval(boat, timeTuple, dateStr)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
function deleteBlock(block: Interval) {
|
||||
if (block.$id) {
|
||||
intervalStore.deleteInterval(block.$id);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnter(e: DragEvent, type: string) {
|
||||
if (type === 'day' || type === 'head-day') {
|
||||
e.preventDefault();
|
||||
if (e.target instanceof HTMLDivElement)
|
||||
e.target.classList.add('bg-secondary');
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, type: string) {
|
||||
if (type === 'day' || type === 'head-day') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave(e: DragEvent, type: string) {
|
||||
if (type === 'day' || type === 'head-day') {
|
||||
e.preventDefault();
|
||||
if (e.target instanceof HTMLDivElement)
|
||||
e.target.classList.remove('bg-secondary');
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(
|
||||
e: DragEvent,
|
||||
type: string,
|
||||
scope: { resource: Boat; timestamp: Timestamp }
|
||||
) {
|
||||
if (e.target instanceof HTMLDivElement)
|
||||
e.target.classList.remove('bg-secondary');
|
||||
|
||||
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
|
||||
const templateId = e.dataTransfer.getData('ID');
|
||||
const dateStr = scope.timestamp.date;
|
||||
const resource = scope.resource;
|
||||
const existingIntervals = getIntervals(scope.timestamp, resource);
|
||||
const boatsToApply = type === 'head-day' ? boats.value : [resource];
|
||||
overlapped.value = boatsToApply
|
||||
.map((boat) =>
|
||||
intervalsOverlapped(
|
||||
existingIntervals.value.concat(
|
||||
intervalsFromTemplate(boat, templateId, dateStr)
|
||||
)
|
||||
)
|
||||
)
|
||||
.flat(1);
|
||||
if (overlapped.value.length === 0) {
|
||||
boatsToApply.map((b) => createIntervals(b, templateId, dateStr));
|
||||
} else {
|
||||
alert.value = true;
|
||||
}
|
||||
}
|
||||
if (e.target instanceof HTMLDivElement)
|
||||
e.target.classList.remove('bg-secondary');
|
||||
return false;
|
||||
}
|
||||
|
||||
function onToday() { calendar.value.moveToToday(); }
|
||||
function onPrev() { calendar.value.prev(); }
|
||||
function onNext() { calendar.value.next(); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fit row">
|
||||
<div class="q-pa-md">
|
||||
<div class="scheduler col-12">
|
||||
<NavigationBar @next="onNext" @today="onToday" @prev="onPrev" />
|
||||
<q-calendar-scheduler
|
||||
ref="calendar"
|
||||
v-model="selectedDate"
|
||||
v-model:model-resources="boats"
|
||||
resource-key="$id"
|
||||
resource-label="name"
|
||||
view="week"
|
||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||
animated
|
||||
bordered
|
||||
:drag-enter-func="onDragEnter"
|
||||
:drag-over-func="onDragOver"
|
||||
:drag-leave-func="onDragLeave"
|
||||
:drop-func="onDrop"
|
||||
day-min-height="50px"
|
||||
cell-width="150px">
|
||||
<template #day="{ scope }">
|
||||
<div
|
||||
v-if="filteredIntervals(scope.timestamp, scope.resource).value.length"
|
||||
style="display: flex; flex-wrap: wrap; justify-content: space-evenly; align-items: center; font-size: 12px;">
|
||||
<template
|
||||
v-for="block in sortedIntervals(scope.timestamp, scope.resource).value"
|
||||
:key="block.$id">
|
||||
<q-chip class="cursor-pointer">
|
||||
{{ date.formatDate(block.start, 'HH:mm') }} -
|
||||
{{ date.formatDate(block.end, 'HH:mm') }}
|
||||
</q-chip>
|
||||
<q-btn size="xs" icon="delete" round @click="deleteBlock(block)" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</q-calendar-scheduler>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-pa-md" style="width: 400px">
|
||||
<q-list padding bordered class="rounded-borders">
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label overline>Availability Templates</q-item-label>
|
||||
<q-item-label caption>
|
||||
Drag and drop a template to a boat / date to create booking availability
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-card-actions align="right">
|
||||
<q-btn label="Add Template" color="primary" @click="createTemplate" />
|
||||
</q-card-actions>
|
||||
<q-item v-if="newTemplate.$id === 'unsaved'">
|
||||
<IntervalTemplateComponent
|
||||
:model-value="newTemplate"
|
||||
:edit="true"
|
||||
@cancel="resetNewTemplate"
|
||||
@saved="resetNewTemplate" />
|
||||
</q-item>
|
||||
<q-separator spaced />
|
||||
<IntervalTemplateComponent
|
||||
v-for="template in intervalTemplates"
|
||||
:key="template.$id"
|
||||
:model-value="template" />
|
||||
</q-list>
|
||||
</div>
|
||||
<q-dialog v-model="alert">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Warning!</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
Conflicting times! Please delete overlapped items!
|
||||
<q-chip v-for="item in overlapped" :key="item.index">
|
||||
{{ boats.find((b) => b.$id === item.boatId)?.name }}:
|
||||
{{ date.formatDate(item.start, 'hh:mm') }} -
|
||||
{{ date.formatDate(item.end, 'hh:mm') }}
|
||||
</q-chip>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="OK" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
120
app/pages/schedule/view.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import { getDate, QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { formatTime } from '~/utils/schedule';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const reservationStore = useReservationStore();
|
||||
const boatStore = useBoatStore();
|
||||
const calendar = ref();
|
||||
const $q = useQuasar();
|
||||
const { getAvailableIntervals } = useIntervalStore();
|
||||
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||
const currentUser = useAuthStore().currentUser;
|
||||
|
||||
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
|
||||
return getAvailableIntervals(timestamp, boat)
|
||||
.value.concat(boatReservationEvents(timestamp, boat))
|
||||
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
||||
};
|
||||
|
||||
const createReservationFromInterval = (interval: Interval | Reservation) => {
|
||||
if (interval.user) {
|
||||
if (interval.user === currentUser?.$id) {
|
||||
navigateTo(`/schedule/edit/${interval.$id}`);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
navigateTo({ path: '/schedule/book', query: { interval: interval.$id } });
|
||||
}
|
||||
};
|
||||
|
||||
function handleSwipe({ ...event }: { direction: string }) {
|
||||
if (event.direction === 'right') {
|
||||
calendar.value?.prev();
|
||||
} else {
|
||||
calendar.value?.next();
|
||||
}
|
||||
}
|
||||
|
||||
const boatReservationEvents = (
|
||||
timestamp: Timestamp,
|
||||
resource: Boat | undefined
|
||||
): Reservation[] => {
|
||||
if (!resource) return [] as Reservation[];
|
||||
return reservationStore.getReservationsByDate(
|
||||
getDate(timestamp),
|
||||
(resource as Boat).$id
|
||||
).value;
|
||||
};
|
||||
|
||||
function onToday() { calendar.value.moveToToday(); }
|
||||
function onPrev() { calendar.value.prev(); }
|
||||
function onNext() { calendar.value.next(); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<div class="col">
|
||||
<navigation-bar @today="onToday" @prev="onPrev" @next="onNext" />
|
||||
</div>
|
||||
<div class="col q-ma-sm">
|
||||
<q-calendar-scheduler
|
||||
ref="calendar"
|
||||
v-model="selectedDate"
|
||||
v-model:model-resources="boatStore.boats"
|
||||
resource-key="$id"
|
||||
resource-label="displayName"
|
||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||
:view="$q.screen.gt.md ? 'week' : 'day'"
|
||||
v-touch-swipe.mouse.left.right="handleSwipe"
|
||||
:max-days="$q.screen.lt.sm ? 3 : 7"
|
||||
animated
|
||||
bordered
|
||||
style="--calendar-resources-width: 40px">
|
||||
<template #day="{ scope }">
|
||||
<div
|
||||
v-for="interval in getSortedIntervals(scope.timestamp, scope.resource)"
|
||||
:key="interval.$id"
|
||||
class="q-pb-xs row"
|
||||
@click="createReservationFromInterval(interval)">
|
||||
<q-badge
|
||||
multi-line
|
||||
:class="!interval.user ? 'cursor-pointer' : null"
|
||||
class="col-12 q-pa-sm"
|
||||
:transparent="interval.user != undefined"
|
||||
:color="interval.user ? 'secondary' : 'primary'"
|
||||
:outline="!interval.user"
|
||||
:id="interval.$id">
|
||||
{{
|
||||
interval.user
|
||||
? useAuthStore().getUserNameById(interval.user)
|
||||
: 'Available'
|
||||
}}
|
||||
<br />
|
||||
{{ formatTime(interval.start) }} to
|
||||
<br />
|
||||
{{ formatTime(interval.end) }}
|
||||
</q-badge>
|
||||
</div>
|
||||
</template>
|
||||
</q-calendar-scheduler>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<style lang="sass">
|
||||
.q-calendar-scheduler__resource
|
||||
background-color: $primary
|
||||
color: white
|
||||
font-weight: bold
|
||||
</style>
|
||||
40
app/pages/signup.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
// NOTE: Password-based registration removed (magic link + OTP only).
|
||||
// This page is a stub — registration is handled by admin invitation.
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
<q-page class="flex bg-image flex-center">
|
||||
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||
<q-card-section>
|
||||
<q-img fit="scale-down" src="/oysqn_logo.png" />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-center q-pt-sm">
|
||||
<div class="text-h6">Sign Up</div>
|
||||
<div class="text-body2 q-mt-md">
|
||||
Account registration is managed by the club administrator.
|
||||
Please contact your club admin to request access.
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="text-center">
|
||||
<q-btn flat color="primary" label="Back to Login" to="/login" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bg-image {
|
||||
background-image: url('~/assets/oys_lighthouse.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: center;
|
||||
background-size: cover;
|
||||
}
|
||||
</style>
|
||||
77
app/pages/terms-of-service.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
<q-page padding>
|
||||
<h1>Website Terms and Conditions of Use</h1>
|
||||
|
||||
<h2>1. Terms</h2>
|
||||
<p>
|
||||
By accessing this Website, accessible from https://undock.ca, you are
|
||||
agreeing to be bound by these Website Terms and Conditions of Use and
|
||||
agree that you are responsible for the agreement with any applicable
|
||||
local laws. If you disagree with any of these terms, you are
|
||||
prohibited from accessing this site. The materials contained in this
|
||||
Website are protected by copyright and trade mark law.
|
||||
</p>
|
||||
|
||||
<h2>2. Use License</h2>
|
||||
<p>
|
||||
Permission is granted to temporarily download one copy of the
|
||||
materials on undock.ca's Website for personal, non-commercial
|
||||
transitory viewing only. This is the grant of a license, not a
|
||||
transfer of title, and under this license you may not:
|
||||
</p>
|
||||
<ul>
|
||||
<li>modify or copy the materials;</li>
|
||||
<li>use the materials for any commercial purpose or for any public display;</li>
|
||||
<li>attempt to reverse engineer any software contained on undock.ca's Website;</li>
|
||||
<li>remove any copyright or other proprietary notations from the materials; or</li>
|
||||
<li>transferring the materials to another person or "mirror" the materials on any other server.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Disclaimer</h2>
|
||||
<p>
|
||||
All the materials on undock.ca's Website are provided "as is". undock.ca makes no
|
||||
warranties, may it be expressed or implied, therefore negates all other warranties.
|
||||
</p>
|
||||
|
||||
<h2>4. Limitations</h2>
|
||||
<p>
|
||||
undock.ca or its suppliers will not be hold accountable for any damages that will
|
||||
arise with the use or inability to use the materials on undock.ca's Website.
|
||||
</p>
|
||||
|
||||
<h2>5. Revisions and Errata</h2>
|
||||
<p>
|
||||
The materials appearing on undock.ca's Website may include technical, typographical,
|
||||
or photographic errors. undock.ca will not promise that any of the materials in this
|
||||
Website are accurate, complete, or current.
|
||||
</p>
|
||||
|
||||
<h2>6. Links</h2>
|
||||
<p>
|
||||
undock.ca has not reviewed all of the sites linked to its Website and is not
|
||||
responsible for the contents of any such linked site.
|
||||
</p>
|
||||
|
||||
<h2>7. Site Terms of Use Modifications</h2>
|
||||
<p>
|
||||
undock.ca may revise these Terms of Use for its Website at any time without prior notice.
|
||||
</p>
|
||||
|
||||
<h2>8. Your Privacy</h2>
|
||||
<p>Please read our <a href="/privacy-policy">Privacy Policy.</a></p>
|
||||
|
||||
<h2>9. Governing Law</h2>
|
||||
<p>
|
||||
Any claim related to undock.ca's Website shall be governed by the laws of ca without
|
||||
regards to its conflict of law provisions.
|
||||
</p>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
15
app/plugins/appwrite.client.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { initAppwriteClient } from '~/utils/appwrite';
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const config = useRuntimeConfig();
|
||||
const endpoint = config.public.appwriteEndpoint as string;
|
||||
const projectId = config.public.appwriteProjectId as string;
|
||||
if (!endpoint || !projectId) {
|
||||
console.error('Appwrite config missing — check NUXT_PUBLIC_APPWRITE_ENDPOINT and NUXT_PUBLIC_APPWRITE_PROJECT_ID');
|
||||
return;
|
||||
}
|
||||
initAppwriteClient(endpoint, projectId);
|
||||
const authStore = useAuthStore();
|
||||
await authStore.init();
|
||||
});
|
||||
111
app/stores/auth.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ID, account, functions, teams } from '~/utils/appwrite';
|
||||
import { ExecutionMethod, type Models } from 'appwrite';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useBoatStore } from './boat';
|
||||
import { useReservationStore } from './reservation';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
|
||||
const currentUserTeams = ref<Models.TeamList<Models.Preferences> | null>(
|
||||
null
|
||||
);
|
||||
const userNames = ref<Record<string, string>>({});
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
currentUser.value = await account.get();
|
||||
currentUserTeams.value = await teams.list();
|
||||
await useBoatStore().fetchBoats();
|
||||
await useReservationStore().fetchUserReservations();
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
currentUserTeams.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const currentUserTeamNames = computed(() =>
|
||||
currentUserTeams.value
|
||||
? currentUserTeams.value.teams.map((team) => team.name)
|
||||
: []
|
||||
);
|
||||
|
||||
const hasRequiredRole = (requiredRoles: string[]): boolean => {
|
||||
return requiredRoles.some((role) =>
|
||||
currentUserTeamNames.value.includes(role)
|
||||
);
|
||||
};
|
||||
|
||||
async function createTokenSession(email: string) {
|
||||
return await account.createEmailToken(ID.unique(), email);
|
||||
}
|
||||
|
||||
async function createMagicURLSession(email: string) {
|
||||
return await account.createMagicURLToken(
|
||||
ID.unique(),
|
||||
email,
|
||||
window.location.origin + '/auth/callback'
|
||||
);
|
||||
}
|
||||
|
||||
async function tokenLogin(userId: string, token: string) {
|
||||
await account.createSession(userId, token);
|
||||
await init();
|
||||
}
|
||||
|
||||
async function magicURLLogin(userId: string, secret: string) {
|
||||
await account.updateMagicURLSession(userId, secret);
|
||||
await init();
|
||||
}
|
||||
|
||||
function getUserNameById(id: string | undefined | null): string {
|
||||
if (!id) return 'No User';
|
||||
try {
|
||||
if (!userNames.value[id]) {
|
||||
userNames.value[id] = 'Loading...';
|
||||
functions
|
||||
.createExecution(
|
||||
'userinfo',
|
||||
'',
|
||||
false,
|
||||
'/userinfo/' + id,
|
||||
ExecutionMethod.GET
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.responseBody) {
|
||||
userNames.value[id] = JSON.parse(res.responseBody).name;
|
||||
} else {
|
||||
console.error(res, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get username. Error: ' + e);
|
||||
}
|
||||
return userNames.value[id] ?? 'Unknown';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
return account.deleteSession('current').then(() => {
|
||||
currentUser.value = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateName(name: string) {
|
||||
await account.updateName(name);
|
||||
currentUser.value = await account.get();
|
||||
}
|
||||
|
||||
return {
|
||||
currentUser,
|
||||
getUserNameById,
|
||||
hasRequiredRole,
|
||||
updateName,
|
||||
createTokenSession,
|
||||
createMagicURLSession,
|
||||
tokenLogin,
|
||||
magicURLLogin,
|
||||
logout,
|
||||
init,
|
||||
};
|
||||
});
|
||||
29
app/stores/boat.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||
import { ref } from 'vue';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
|
||||
export { type Boat } from '~/utils/boat.types';
|
||||
|
||||
export const useBoatStore = defineStore('boat', () => {
|
||||
const boats = ref<Boat[]>([]);
|
||||
|
||||
async function fetchBoats() {
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.boat
|
||||
);
|
||||
boats.value = response.documents as unknown as Boat[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch boats', error);
|
||||
}
|
||||
}
|
||||
|
||||
const getBoatById = (id: string | null | undefined): Boat | null => {
|
||||
if (!id) return null;
|
||||
return boats.value?.find((b) => b.$id === id) || null;
|
||||
};
|
||||
|
||||
return { boats, fetchBoats, getBoatById };
|
||||
});
|
||||
163
app/stores/interval.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import { today } from '@quasar/quasar-ui-qcalendar';
|
||||
import type { Interval } from '~/utils/schedule.types';
|
||||
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||
import { ID, Query } from 'appwrite';
|
||||
import { useReservationStore } from './reservation';
|
||||
import type { LoadingTypes } from '~/utils/misc';
|
||||
import { useRealtimeStore } from './realtime';
|
||||
|
||||
export const useIntervalStore = defineStore('interval', () => {
|
||||
const intervals = ref(new Map<string, Interval>());
|
||||
const dateStatus = ref(new Map<string, LoadingTypes>());
|
||||
|
||||
const selectedDate = ref<string>(today());
|
||||
|
||||
const reservationStore = useReservationStore();
|
||||
const realtimeStore = useRealtimeStore();
|
||||
|
||||
realtimeStore.register(
|
||||
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.interval}.documents`,
|
||||
(response) => {
|
||||
const payload = response.payload as unknown as Interval;
|
||||
if (!payload.$id) return;
|
||||
if (
|
||||
response.events.includes('databases.*.collections.*.documents.*.delete')
|
||||
) {
|
||||
intervals.value.delete(payload.$id);
|
||||
} else {
|
||||
intervals.value.set(payload.$id, payload);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const getIntervals = (date: Timestamp | string, boat?: Boat) => {
|
||||
const searchDate = typeof date === 'string' ? date : date.date;
|
||||
const dayStart = new Date(searchDate + 'T00:00');
|
||||
const dayEnd = new Date(searchDate + 'T23:59');
|
||||
if (dateStatus.value.get(searchDate) === undefined) {
|
||||
dateStatus.value.set(searchDate, 'pending');
|
||||
fetchIntervals(searchDate);
|
||||
}
|
||||
return computed(() => {
|
||||
return Array.from(intervals.value.values()).filter((interval) => {
|
||||
const intervalStart = new Date(interval.start);
|
||||
const intervalEnd = new Date(interval.end);
|
||||
|
||||
const isWithinDay = intervalStart < dayEnd && intervalEnd > dayStart;
|
||||
const matchesBoat = boat ? boat.$id === interval.resource : true;
|
||||
return isWithinDay && matchesBoat;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getAvailableIntervals = (date: Timestamp | string, boat?: Boat) => {
|
||||
return computed(() =>
|
||||
getIntervals(date, boat).value.filter((interval) => {
|
||||
return !reservationStore.isResourceTimeOverlapped(
|
||||
interval.resource,
|
||||
new Date(interval.start),
|
||||
new Date(interval.end)
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
async function fetchInterval(id: string): Promise<Interval> {
|
||||
return (await databases.getDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.interval,
|
||||
id
|
||||
)) as Interval;
|
||||
}
|
||||
|
||||
async function fetchIntervals(dateString: string) {
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.interval,
|
||||
[
|
||||
Query.greaterThanEqual(
|
||||
'end',
|
||||
new Date(dateString + 'T00:00').toISOString()
|
||||
),
|
||||
Query.lessThanEqual(
|
||||
'start',
|
||||
new Date(dateString + 'T23:59').toISOString()
|
||||
),
|
||||
Query.limit(50),
|
||||
]
|
||||
);
|
||||
response.documents.forEach((d) =>
|
||||
intervals.value.set(d.$id, d as unknown as Interval)
|
||||
);
|
||||
dateStatus.value.set(dateString, 'loaded');
|
||||
console.info(`Loaded ${response.documents.length} intervals from server`);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch intervals', error);
|
||||
dateStatus.value.set(dateString, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const createInterval = async (interval: Interval) => {
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.interval,
|
||||
ID.unique(),
|
||||
interval
|
||||
);
|
||||
intervals.value.set(response.$id, response as unknown as Interval);
|
||||
} catch (e) {
|
||||
console.error('Error creating Interval: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
const updateInterval = async (interval: Interval) => {
|
||||
try {
|
||||
if (interval.$id) {
|
||||
const response = await databases.updateDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.interval,
|
||||
interval.$id,
|
||||
{ ...interval, $id: undefined }
|
||||
);
|
||||
intervals.value.set(response.$id, response as unknown as Interval);
|
||||
console.info(`Saved Interval: ${interval.$id}`);
|
||||
} else {
|
||||
console.error('Update interval called without an ID');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error updating Interval: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteInterval = async (id: string) => {
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.interval,
|
||||
id
|
||||
);
|
||||
intervals.value.delete(id);
|
||||
console.info(`Deleted interval: ${id}`);
|
||||
} catch (e) {
|
||||
console.error('Error deleting Interval: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getIntervals,
|
||||
getAvailableIntervals,
|
||||
fetchIntervals,
|
||||
fetchInterval,
|
||||
createInterval,
|
||||
updateInterval,
|
||||
deleteInterval,
|
||||
selectedDate,
|
||||
intervals,
|
||||
};
|
||||
});
|
||||
101
app/stores/intervalTemplate.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import type { IntervalTemplate } from '~/utils/schedule.types';
|
||||
import { defineStore } from 'pinia';
|
||||
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||
import type { Models } from 'appwrite';
|
||||
import { ID } from 'appwrite';
|
||||
import { arrayToTimeTuples } from '~/utils/schedule';
|
||||
|
||||
export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
|
||||
const intervalTemplates = ref<IntervalTemplate[]>([]);
|
||||
|
||||
const getIntervalTemplates = (): Ref<IntervalTemplate[]> => {
|
||||
if (!intervalTemplates.value) fetchIntervalTemplates();
|
||||
return intervalTemplates;
|
||||
};
|
||||
|
||||
async function fetchIntervalTemplates() {
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.intervalTemplate
|
||||
);
|
||||
intervalTemplates.value = response.documents.map((d): IntervalTemplate => {
|
||||
const doc = d as unknown as { timeTuple: string[] } & Models.Document;
|
||||
return {
|
||||
...doc,
|
||||
timeTuples: arrayToTimeTuples(doc.timeTuple),
|
||||
} as unknown as IntervalTemplate;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch timeblock templates', error);
|
||||
}
|
||||
}
|
||||
|
||||
const createIntervalTemplate = async (template: IntervalTemplate) => {
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.intervalTemplate,
|
||||
ID.unique(),
|
||||
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
|
||||
);
|
||||
intervalTemplates.value.push(response as unknown as IntervalTemplate);
|
||||
} catch (e) {
|
||||
console.error('Error creating IntervalTemplate: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteIntervalTemplate = async (id: string) => {
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.intervalTemplate,
|
||||
id
|
||||
);
|
||||
intervalTemplates.value = intervalTemplates.value.filter(
|
||||
(template) => template.$id !== id
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Error deleting IntervalTemplate: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
const updateIntervalTemplate = async (
|
||||
template: IntervalTemplate,
|
||||
id: string
|
||||
) => {
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.intervalTemplate,
|
||||
id,
|
||||
{
|
||||
name: template.name,
|
||||
timeTuple: template.timeTuples.flat(2),
|
||||
}
|
||||
);
|
||||
intervalTemplates.value = intervalTemplates.value.map((b) =>
|
||||
b.$id !== id
|
||||
? b
|
||||
: ({
|
||||
...response,
|
||||
timeTuples: arrayToTimeTuples(
|
||||
(response as unknown as { timeTuple: string[] }).timeTuple
|
||||
),
|
||||
} as unknown as IntervalTemplate)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Error updating IntervalTemplate: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getIntervalTemplates,
|
||||
fetchIntervalTemplates,
|
||||
createIntervalTemplate,
|
||||
deleteIntervalTemplate,
|
||||
updateIntervalTemplate,
|
||||
};
|
||||
});
|
||||
21
app/stores/memberProfile.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export interface MemberProfile {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
certs: string[];
|
||||
slackID: string;
|
||||
userID: string;
|
||||
}
|
||||
|
||||
const getSampleData = () => ({
|
||||
firstName: 'Billy',
|
||||
lastName: 'Crystal',
|
||||
certs: ['j27', 'capri25'],
|
||||
});
|
||||
|
||||
export const useMemberProfileStore = defineStore('memberProfile', {
|
||||
state: () => ({
|
||||
...getSampleData(),
|
||||
}),
|
||||
});
|
||||
20
app/stores/realtime.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { client } from '~/utils/appwrite';
|
||||
import { ref } from 'vue';
|
||||
import type { RealtimeResponseEvent } from 'appwrite';
|
||||
|
||||
export const useRealtimeStore = defineStore('realtime', () => {
|
||||
const subscriptions = ref<Map<string, () => void>>(new Map());
|
||||
|
||||
const register = (
|
||||
channel: string,
|
||||
fn: (response: RealtimeResponseEvent<unknown>) => void
|
||||
) => {
|
||||
if (subscriptions.value.has(channel)) return;
|
||||
subscriptions.value.set(channel, client.subscribe(channel, fn));
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
};
|
||||
});
|
||||
123
app/stores/reference.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export interface ReferenceEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
subtitle?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function getSampleData(): ReferenceEntry[] {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
title: 'J/27 Background',
|
||||
category: 'general',
|
||||
tags: ['j27', 'info'],
|
||||
subtitle: 'Fast Fun Racer or Getaway Weekend Cruiser',
|
||||
content: `Its hard to imagine that a modern 27 foot sailboat with a classic look, superb
|
||||
stability, and easy to manage rig is such a fast boat. 123-126 PHRF. No longer do you
|
||||
have to substitute speed for comfort, or own separate boats for racing and cruising. The
|
||||
8' long cockpit seats 4 to 5 comfortably. Below deck you can sleep 5. And with head and
|
||||
stove, the J/27 is the perfect weekend cruiser.
|
||||
|
||||
Fun and Fast. There are some impressive victories to back this up, but that doesn't
|
||||
tell the whole story. The J/27 is fun and responsive. Nothing is more exhilarating than
|
||||
popping the J/27's kite in a good breeze for a downhill sleigh ride. 15+ knots planing
|
||||
off the wave-tops is easy. And most importantly, this off-wind speed doesn't sacrifice
|
||||
upwind performance. Going to windward in the J/27 is a dream, it has the solid, balanced
|
||||
"feel" of a traditional keelboat. The J/27 points higher and goes faster than many 30-35
|
||||
footers!
|
||||
|
||||
One-Design Racing. Even more fun is sailing a one-design race around the buoys. The
|
||||
J/27's close-windedness makes it very tactical, as even 5 degree wind shifts bring
|
||||
significant gains. Then off wind, you quickly learn to play gibe angles as the boat's
|
||||
acceleration gains you valuable ground on the competition. The J/27 is remarkably agile
|
||||
and responsive in lighter winds, which is unusual for a boat that feels so solid.
|
||||
|
||||
All-Day Comfort. Sailing past larger boats is always satisfying... especially when it's
|
||||
effortless and you can't be written off as being wet and uncomfortable. Design is the
|
||||
difference. It's all done from a cockpit which holds several people more than is possible
|
||||
on other 27-footers. Correctly angled backrests and decks at elbow level provide restful
|
||||
and secure seating. Harken mainsheet, vang, traveler, and backstay systems; four Barient
|
||||
winches; a beautiful double spreader, tapered, fractional rig spar by Hall . . . make
|
||||
control and adjustment easy for crew members no matter what the wind.
|
||||
|
||||
Get-away Weekend Cruiser. Take a break from the pace of life on land and spend time with
|
||||
family and friends sailing the J/27. It's a fun boat to sail, so everyone becomes involved.
|
||||
|
||||
The visibility, when steering with a responsive tiller gives the inexperienced that sense
|
||||
of control not found when spinning a tiny wheel on small cruisers with large trunk cabins.
|
||||
|
||||
The J/27 has a comfortable, open interior in teak with off-white surfaces. A main structural
|
||||
fiberglass bulkhead with oval opening separates the spacious double V-berth and head area
|
||||
from the main cabin. The main settee berth converts to a double. Aft of the galley to
|
||||
starboard is a comfortable quarter berth. Enough room below for a family of four or a
|
||||
couple for a nice weekend romp to your favorite sailing anchorage.
|
||||
|
||||
Durable and Stable. The J/27's secure big boat feel is created by concentrating 1530
|
||||
pounds of lead very low in the keel while using high strength to eight ratio laminates
|
||||
in the hull. Unidirectional E-glass on either side of pre-sealed Baltek CK57 aircraft
|
||||
grade, Lloyd's approved, end grain balsa sandwich construction means superior torsion and
|
||||
impact resistance. Light ends, low freeboard, and the low center of gravity of a lead keel
|
||||
coupled with low wetted surface and a generous sailplan of 362 sq. ft. achieves exceptional
|
||||
sail area and stability relative to displacement. Hence, sparkling performance in both
|
||||
light and heavy air...something that doesn't happen with iron keels and box-like hulls.
|
||||
|
||||
Strong Class Strict Rules. The J/27 Class Association, owner driven and over 190 boats
|
||||
strong, sail in North American, Midwinter, and Regional championships. A superb J/27 Class
|
||||
Newsletter keeps you up-to-date on Class activities, latest results, maintenance tips,
|
||||
cruising points of interest, and "go-fasts". And the J/27 Class Rules have sail limitations
|
||||
to help insure equal performance and resale value. The Class supports both the active
|
||||
racer and cruising sailor in addition to fleets throughout the U.S.`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Capri25 Background',
|
||||
category: 'general',
|
||||
tags: ['capri25', 'info'],
|
||||
subtitle: 'The Capri 25 (by Catalina) is nothing like a Catalina 25',
|
||||
content: `The Capri 25 (by Catalina) is nothing like a Catalina 25 ... The Capri
|
||||
is five inches shorter on deck, three feet shorter on the waterline, and weighs
|
||||
almost 1,400 pounds less than the Catalina, so we suppose you could call her a Catalina
|
||||
"Lite," especially since her towing weight is over a ton less, so you can use a smaller,
|
||||
lighter towing vehicle on the highway. Besides her lower weight, she has slightly more
|
||||
sail area and a sleeker fin keel, so she is also faster—way faster. In fact, her average
|
||||
PHRF rating is 171, which is, amazingly, 3 seconds per mile less than the legendary J/24,
|
||||
and a whopping 54 seconds less than the Catalina 25. Needless to say, part of her weight
|
||||
loss is accomplished by the omission of cabin furniture and other niceties like the
|
||||
Catalina's on-deck anchor locker. Other weight saving is achieved by eliminating 600
|
||||
pounds of ballast, and by using a then-new material, Coremat, to replace some of the
|
||||
hull and deck laminate. Best features: If you like round-the-buoys racing and/or
|
||||
socializing in a one-design fleet, this may be the boat for you. She has a bit more space
|
||||
below than a J/24, and six inches more headroom, but otherwise her character is in the
|
||||
same range. Worst features: Nothing significant noticed."`,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Outboard Engine Operation',
|
||||
subtitle: 'An overview of how outboard engines work.',
|
||||
category: 'howto',
|
||||
tags: ['manuals', 'howto', 'engine'],
|
||||
content: 'Lorem ipsum dolor met.',
|
||||
},
|
||||
] as ReferenceEntry[];
|
||||
}
|
||||
|
||||
export const useReferenceStore = defineStore('reference', {
|
||||
state: () => ({
|
||||
allItems: getSampleData(),
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getCategory(state) {
|
||||
return (category: string) => {
|
||||
return state.allItems.filter((c) => c.category === category);
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
actions: {},
|
||||
});
|
||||
283
app/stores/reservation.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type { Reservation } from '~/utils/schedule.types';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||
import { ID, Query } from 'appwrite';
|
||||
import { date, useQuasar } from 'quasar';
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import { parseDate, today } from '@quasar/quasar-ui-qcalendar';
|
||||
import type { LoadingTypes } from '~/utils/misc';
|
||||
import { useAuthStore } from './auth';
|
||||
import { isPast } from '~/utils/schedule';
|
||||
import { useRealtimeStore } from './realtime';
|
||||
|
||||
export const useReservationStore = defineStore('reservation', () => {
|
||||
const reservations = reactive<Map<string, Reservation>>(new Map());
|
||||
const datesLoaded = reactive<Record<string, LoadingTypes>>({});
|
||||
const userReservations = reactive<Map<string, Reservation>>(new Map());
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const $q = useQuasar();
|
||||
const realtimeStore = useRealtimeStore();
|
||||
|
||||
realtimeStore.register(
|
||||
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.reservation}.documents`,
|
||||
(response) => {
|
||||
const payload = response.payload as unknown as Reservation;
|
||||
if (payload.$id) {
|
||||
if (
|
||||
response.events.includes(
|
||||
'databases.*.collections.*.documents.*.delete'
|
||||
)
|
||||
) {
|
||||
reservations.delete(payload.$id);
|
||||
userReservations.delete(payload.$id);
|
||||
} else {
|
||||
reservations.set(payload.$id, payload);
|
||||
if (payload.user === authStore.currentUser?.$id)
|
||||
userReservations.set(payload.$id, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const fetchReservationsForDateRange = async (
|
||||
start: string = today(),
|
||||
end: string = start
|
||||
) => {
|
||||
const startDate = new Date(start < end ? start : end + 'T00:00');
|
||||
const endDate = new Date(start < end ? end : start + 'T23:59');
|
||||
|
||||
if (getUnloadedDates(startDate, endDate).length === 0) return;
|
||||
|
||||
setDateLoaded(startDate, endDate, 'pending');
|
||||
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.reservation,
|
||||
[
|
||||
Query.greaterThanEqual('end', startDate.toISOString()),
|
||||
Query.lessThanEqual('start', endDate.toISOString()),
|
||||
]
|
||||
);
|
||||
|
||||
response.documents.forEach((d) =>
|
||||
reservations.set(d.$id, d as unknown as Reservation)
|
||||
);
|
||||
setDateLoaded(startDate, endDate, 'loaded');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reservations', error);
|
||||
setDateLoaded(startDate, endDate, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getReservationById = async (id: string) => {
|
||||
try {
|
||||
const response = await databases.getDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.reservation,
|
||||
id
|
||||
);
|
||||
return response as unknown as Reservation;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reservation: ', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createOrUpdateReservation = async (
|
||||
reservation: Reservation
|
||||
): Promise<Reservation> => {
|
||||
let response;
|
||||
try {
|
||||
if (reservation.$id) {
|
||||
response = await databases.updateDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.reservation,
|
||||
reservation.$id,
|
||||
reservation
|
||||
);
|
||||
} else {
|
||||
response = await databases.createDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.reservation,
|
||||
ID.unique(),
|
||||
reservation
|
||||
);
|
||||
}
|
||||
reservations.set(response.$id, response as unknown as Reservation);
|
||||
userReservations.set(response.$id, response as unknown as Reservation);
|
||||
console.info('Reservation booked: ', response);
|
||||
return response as unknown as Reservation;
|
||||
} catch (e) {
|
||||
console.error('Error creating Reservation: ' + e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReservation = async (
|
||||
reservation: string | Reservation | null | undefined
|
||||
) => {
|
||||
if (!reservation) return false;
|
||||
const id = typeof reservation === 'string' ? reservation : reservation.$id;
|
||||
if (!id) return false;
|
||||
|
||||
const status = $q.notify({
|
||||
color: 'secondary',
|
||||
textColor: 'white',
|
||||
message: 'Deleting Reservation',
|
||||
spinner: true,
|
||||
closeBtn: 'Dismiss',
|
||||
position: 'top',
|
||||
timeout: 0,
|
||||
group: false,
|
||||
});
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.reservation,
|
||||
id
|
||||
);
|
||||
reservations.delete(id);
|
||||
userReservations.delete(id);
|
||||
console.info(`Deleted reservation: ${id}`);
|
||||
status({
|
||||
color: 'warning',
|
||||
message: 'Reservation Deleted',
|
||||
spinner: false,
|
||||
icon: 'delete',
|
||||
timeout: 4000,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error deleting reservation: ' + e);
|
||||
status({
|
||||
color: 'negative',
|
||||
message: 'Failed to Delete Reservation',
|
||||
spinner: false,
|
||||
icon: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setDateLoaded = (start: Date, end: Date, state: LoadingTypes) => {
|
||||
if (start > end) return [];
|
||||
let curDate = start;
|
||||
while (curDate < end) {
|
||||
datesLoaded[(parseDate(curDate) as Timestamp).date] = state;
|
||||
curDate = date.addToDate(curDate, { days: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
const getUnloadedDates = (start: Date, end: Date): string[] => {
|
||||
if (start > end) return [];
|
||||
let curDate = start;
|
||||
const unloaded = [];
|
||||
while (curDate < end) {
|
||||
const parsedDate = (parseDate(curDate) as Timestamp).date;
|
||||
if (datesLoaded[parsedDate] === undefined) unloaded.push(parsedDate);
|
||||
curDate = date.addToDate(curDate, { days: 1 });
|
||||
}
|
||||
return unloaded;
|
||||
};
|
||||
|
||||
const getReservationsByDate = (
|
||||
searchDate: string,
|
||||
boat?: string
|
||||
): ComputedRef<Reservation[]> => {
|
||||
if (!datesLoaded[searchDate]) {
|
||||
fetchReservationsForDateRange(searchDate);
|
||||
}
|
||||
const dayStart = new Date(searchDate + 'T00:00');
|
||||
const dayEnd = new Date(searchDate + 'T23:59');
|
||||
|
||||
return computed(() => {
|
||||
return Array.from(reservations.values()).filter((reservation) => {
|
||||
const reservationStart = new Date(reservation.start);
|
||||
const reservationEnd = new Date(reservation.end);
|
||||
|
||||
const isWithinDay =
|
||||
reservationStart < dayEnd && reservationEnd > dayStart;
|
||||
const matchesBoat = boat ? boat === reservation.resource : true;
|
||||
return isWithinDay && matchesBoat;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getConflictingReservations = (
|
||||
resource: string,
|
||||
start: Date,
|
||||
end: Date
|
||||
): Reservation[] => {
|
||||
return Array.from(reservations.values()).filter(
|
||||
(entry) =>
|
||||
entry.resource === resource &&
|
||||
new Date(entry.start) < end &&
|
||||
new Date(entry.end) > start
|
||||
);
|
||||
};
|
||||
|
||||
const isResourceTimeOverlapped = (
|
||||
resource: string,
|
||||
start: Date,
|
||||
end: Date
|
||||
): boolean => {
|
||||
return getConflictingReservations(resource, start, end).length > 0;
|
||||
};
|
||||
|
||||
const isReservationOverlapped = (res: Reservation): boolean => {
|
||||
return isResourceTimeOverlapped(
|
||||
res.resource,
|
||||
new Date(res.start),
|
||||
new Date(res.end)
|
||||
);
|
||||
};
|
||||
|
||||
const fetchUserReservations = async () => {
|
||||
if (!authStore.currentUser) return;
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.reservation,
|
||||
[Query.equal('user', authStore.currentUser.$id)]
|
||||
);
|
||||
response.documents.forEach((d) =>
|
||||
userReservations.set(d.$id, d as unknown as Reservation)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reservations for user: ', error);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedUserReservations = computed((): Reservation[] =>
|
||||
[...userReservations.values()].sort(
|
||||
(a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()
|
||||
)
|
||||
);
|
||||
|
||||
const futureUserReservations = computed((): Reservation[] => {
|
||||
if (!sortedUserReservations.value) return [];
|
||||
return sortedUserReservations.value.filter((b) => !isPast(b.end));
|
||||
});
|
||||
|
||||
const pastUserReservations = computed((): Reservation[] => {
|
||||
if (!sortedUserReservations.value) return [];
|
||||
return sortedUserReservations.value?.filter((b) => isPast(b.end));
|
||||
});
|
||||
|
||||
return {
|
||||
getReservationsByDate,
|
||||
getReservationById,
|
||||
createOrUpdateReservation,
|
||||
deleteReservation,
|
||||
fetchReservationsForDateRange,
|
||||
isReservationOverlapped,
|
||||
isResourceTimeOverlapped,
|
||||
getConflictingReservations,
|
||||
fetchUserReservations,
|
||||
sortedUserReservations,
|
||||
futureUserReservations,
|
||||
pastUserReservations,
|
||||
userReservations,
|
||||
};
|
||||
});
|
||||
41
app/utils/appwrite.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Client, Account, Databases, Functions, ID, Teams } from 'appwrite';
|
||||
|
||||
const client = new Client();
|
||||
|
||||
function initAppwriteClient(endpoint: string, projectId: string) {
|
||||
client.setEndpoint(endpoint).setProject(projectId);
|
||||
}
|
||||
|
||||
type AppwriteIDConfig = {
|
||||
databaseId: string;
|
||||
collection: {
|
||||
boat: string;
|
||||
reservation: string;
|
||||
interval: string;
|
||||
intervalTemplate: string;
|
||||
// task, taskTags, skillTags — parked; collections not yet created in bab_prod
|
||||
};
|
||||
function: {
|
||||
userinfo: string;
|
||||
};
|
||||
};
|
||||
|
||||
const AppwriteIds: AppwriteIDConfig = {
|
||||
databaseId: 'bab_prod',
|
||||
collection: {
|
||||
boat: 'boat',
|
||||
reservation: 'reservation',
|
||||
interval: 'interval',
|
||||
intervalTemplate: 'intervalTemplate',
|
||||
},
|
||||
function: {
|
||||
userinfo: 'userinfo',
|
||||
},
|
||||
};
|
||||
|
||||
const account = new Account(client);
|
||||
const databases = new Databases(client);
|
||||
const functions = new Functions(client);
|
||||
const teams = new Teams(client);
|
||||
|
||||
export { client, account, databases, functions, teams, ID, AppwriteIds, initAppwriteClient };
|
||||
20
app/utils/boat.types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Models } from 'appwrite';
|
||||
|
||||
export interface Boat extends Models.Document {
|
||||
$id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
class?: string;
|
||||
year?: number;
|
||||
imgSrc?: string;
|
||||
iconSrc?: string;
|
||||
bookingAvailable: boolean;
|
||||
requiredCerts: string[];
|
||||
maxPassengers: number;
|
||||
defects: {
|
||||
type: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
detail?: string;
|
||||
}[];
|
||||
}
|
||||
7
app/utils/misc.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function getNewId(): string {
|
||||
return [...Array(20)]
|
||||
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export type LoadingTypes = 'loaded' | 'pending' | 'error' | undefined;
|
||||
112
app/utils/navlinks.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
|
||||
export type Link = {
|
||||
name: string;
|
||||
to?: string;
|
||||
icon: string;
|
||||
front_links?: boolean;
|
||||
enabled?: boolean;
|
||||
color?: string;
|
||||
sublinks?: Link[];
|
||||
requiredRoles?: string[];
|
||||
};
|
||||
|
||||
export const links: Link[] = [
|
||||
{
|
||||
name: 'Home',
|
||||
to: '/',
|
||||
icon: 'home',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Profile',
|
||||
to: '/profile',
|
||||
icon: 'account_circle',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Boats',
|
||||
to: '/boat',
|
||||
icon: 'sailing',
|
||||
front_links: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Schedule',
|
||||
to: '/schedule',
|
||||
icon: 'calendar_month',
|
||||
front_links: true,
|
||||
enabled: true,
|
||||
sublinks: [
|
||||
{ name: 'My View', to: '/schedule/list', icon: 'list', front_links: false, enabled: true },
|
||||
{ name: 'Book', to: '/schedule/book', icon: 'more_time', front_links: false, enabled: true },
|
||||
{ name: 'Calendar', to: '/schedule/view', icon: 'calendar_month', front_links: false, enabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Certifications',
|
||||
to: '/certification',
|
||||
icon: 'verified',
|
||||
front_links: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
name: 'Checklists',
|
||||
to: '/checklist',
|
||||
icon: 'checklist',
|
||||
front_links: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
name: 'Reference',
|
||||
to: '/reference',
|
||||
icon: 'info_outline',
|
||||
front_links: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
name: 'Manage',
|
||||
icon: 'tune',
|
||||
enabled: true,
|
||||
requiredRoles: ['Schedule Admins'],
|
||||
color: 'negative',
|
||||
sublinks: [
|
||||
{
|
||||
name: 'Schedule',
|
||||
to: '/schedule/manage',
|
||||
icon: 'edit_calendar',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
color: 'accent',
|
||||
requiredRoles: ['Schedule Admins'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function useNavLinks() {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
function hasRole(roles: string[] | undefined) {
|
||||
if (roles === undefined) return true;
|
||||
return authStore.hasRequiredRole(roles);
|
||||
}
|
||||
|
||||
const enabledLinks = links
|
||||
.filter((link) => link.enabled)
|
||||
.map((link) => {
|
||||
if (link.sublinks) {
|
||||
return {
|
||||
...link,
|
||||
sublinks: link.sublinks.filter(
|
||||
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
|
||||
),
|
||||
};
|
||||
}
|
||||
return link;
|
||||
});
|
||||
|
||||
return { enabledLinks };
|
||||
}
|
||||
75
app/utils/schedule.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { date } from 'quasar';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
|
||||
|
||||
export function arrayToTimeTuples(arr: string[]) {
|
||||
const timeTuples: TimeTuple[] = [];
|
||||
for (let i = 0; i < arr.length; i += 2) {
|
||||
timeTuples.push([arr[i]!, arr[i + 1]!]);
|
||||
}
|
||||
return timeTuples;
|
||||
}
|
||||
|
||||
export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
|
||||
return intervalsOverlapped(
|
||||
tuples.map((tuples) => {
|
||||
return {
|
||||
resource: '',
|
||||
start: '01/01/2001 ' + tuples[0],
|
||||
end: '01/01/2001 ' + tuples[1],
|
||||
};
|
||||
})
|
||||
).map((t) => {
|
||||
return { ...t, start: t.start.split(' ')[1]!, end: t.end.split(' ')[1]! };
|
||||
});
|
||||
}
|
||||
|
||||
export function intervalsOverlapped(blocks: Interval[]): Interval[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
blocks
|
||||
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
|
||||
.reduce((acc: Interval[], block, i, arr) => {
|
||||
if (i > 0 && block.start < arr[i - 1]!.end)
|
||||
acc.push(arr[i - 1]!, block);
|
||||
return acc;
|
||||
}, [])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function copyTimeTuples(tuples: TimeTuple[]): TimeTuple[] {
|
||||
return tuples.map((t) => Object.assign([], t));
|
||||
}
|
||||
|
||||
export function copyIntervalTemplate(template: IntervalTemplate): IntervalTemplate {
|
||||
return {
|
||||
...template,
|
||||
timeTuples: copyTimeTuples(template.timeTuples || []),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInterval(resource: Boat, time: TimeTuple, blockDate: string): Interval {
|
||||
return {
|
||||
resource: resource.$id,
|
||||
start: new Date(blockDate + 'T' + time[0]).toISOString(),
|
||||
end: new Date(blockDate + 'T' + time[1]).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const isPast = (itemDate: Date | string): boolean => {
|
||||
if (!(itemDate instanceof Date)) {
|
||||
itemDate = new Date(itemDate);
|
||||
}
|
||||
return itemDate < new Date();
|
||||
};
|
||||
|
||||
export function formatDate(inputDate: string | undefined): string {
|
||||
if (!inputDate) return '';
|
||||
return date.formatDate(new Date(inputDate), 'ddd MMM Do hh:mm A');
|
||||
}
|
||||
|
||||
export function formatTime(inputDate: string | undefined): string {
|
||||
if (!inputDate) return '';
|
||||
return date.formatDate(new Date(inputDate), 'hh:mm A');
|
||||
}
|
||||
28
app/utils/schedule.types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Models } from 'appwrite';
|
||||
|
||||
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
||||
export type Reservation = Interval & {
|
||||
user: string;
|
||||
status?: StatusTypes;
|
||||
reason: string;
|
||||
comment: string;
|
||||
members?: string[];
|
||||
guests?: string[];
|
||||
};
|
||||
|
||||
// 24 hrs in advance only 2 weekday, and 1 weekend slot
|
||||
// Within 24 hrs, any available slot
|
||||
|
||||
export type TimeTuple = [start: string, end: string];
|
||||
|
||||
export type Interval = Partial<Models.Document> & {
|
||||
resource: string;
|
||||
start: string;
|
||||
end: string;
|
||||
user?: string;
|
||||
};
|
||||
|
||||
export type IntervalTemplate = Partial<Models.Document> & {
|
||||
name: string;
|
||||
timeTuples: TimeTuple[];
|
||||
};
|
||||
1
app/utils/version.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = '0.0.0';
|
||||