23 Commits

Author SHA1 Message Date
7fc640d679 v0.6.1 2024-06-05 20:36:45 -04:00
91b54cf791 Bigger buttons 2024-06-04 16:41:43 -04:00
27b15a37f7 Bump version
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m18s
2024-06-04 16:35:42 -04:00
947b463fe2 Minor UI tweaks 2024-06-03 23:04:32 -04:00
c3098b073f UI Enhancements 2024-06-03 12:01:38 -04:00
b2420b270c Fix booking update and reactivity
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m28s
2024-06-02 10:08:57 -04:00
9104ccab0f Many improvements. Still no reactivity on List 2024-06-02 08:48:14 -04:00
387af2e6ce Sorted out a bunch of reactivity issues 2024-05-29 10:00:48 -04:00
6654132120 Add Delete Reservation function 2024-05-26 07:13:20 -04:00
59d2729719 Fix bug
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m6s
2024-05-25 08:34:25 -04:00
9f398e5509 Add List View 2024-05-24 20:45:04 -04:00
2fb236cf97 Style edits 2024-05-24 08:36:28 -04:00
7bc0573455 Add minutes to booking duration 2024-05-24 08:24:46 -04:00
68a2b8ffff Visual improvements 2024-05-24 08:11:47 -04:00
ce696a5a04 Small tweak to boat cards 2024-05-23 10:02:37 -04:00
b0d6ec877b More auth / role checks for navlinks 2024-05-23 09:55:02 -04:00
c03ad48615 Team based role auth for routes 2024-05-23 09:32:22 -04:00
55bc1acbb3 Many esthetic changes 2024-05-22 17:18:02 -04:00
cd692a6f3b Fix login bug. Improve reservations 2024-05-21 16:32:31 -04:00
737de91bbc Update naming 2024-05-20 21:53:09 -04:00
a6e357f973 Disable click on disabled slots 2024-05-19 07:18:13 -04:00
76b0498a18 Booking
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m57s
2024-05-18 10:07:09 -04:00
d6339815aa Navigation Tweaks 2024-05-18 08:49:56 -04:00
39 changed files with 1975 additions and 901 deletions

View File

@@ -1,9 +0,0 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
APPWRITE_API_ENDPOINT='https://appwrite.oys.undock.ca/v1'
APPWRITE_API_PROJECT='bab'

View File

@@ -1,4 +0,0 @@
{
"singleQuote": true,
"semi": true
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "oys_bab", "name": "oys_bab",
"version": "0.0.2", "version": "0.6.1",
"description": "Manage a Borrow a Boat program for a Yacht Club", "description": "Manage a Borrow a Boat program for a Yacht Club",
"productName": "OYS Borrow a Boat", "productName": "OYS Borrow a Boat",
"author": "Patrick Toal <ptoal@takeflight.ca>", "author": "Patrick Toal <ptoal@takeflight.ca>",
@@ -21,7 +21,8 @@
"file": "^0.2.2", "file": "^0.2.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "3", "vue": "3",
"vue-router": "4" "vue-router": "4",
"vue3-google-login": "^2.0.26"
}, },
"devDependencies": { "devDependencies": {
"@quasar/app-vite": "^1.9.1", "@quasar/app-vite": "^1.9.1",

View File

@@ -48,12 +48,12 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: { build: {
env: require('dotenv').config({ path: '.env.local' }).parsed, env: require('dotenv').config().parsed,
target: { target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'], browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node16', node: 'node16',
}, },
vueRouterMode: 'hash', // available values: 'hash', 'history' vueRouterMode: 'history', // available values: 'hash', 'history'
// vueRouterBase, // vueRouterBase,
// vueDevtools, // vueDevtools,
// vueOptionsAPI: false, // vueOptionsAPI: false,

View File

@@ -5,6 +5,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, onMounted } from 'vue'; import { defineComponent, onMounted } from 'vue';
import { useAuthStore } from './stores/auth'; import { useAuthStore } from './stores/auth';
import { useBoatStore } from './stores/boat';
import { useReservationStore } from './stores/reservation';
defineComponent({ defineComponent({
name: 'OYS Borrow-a-Boat', name: 'OYS Borrow-a-Boat',
@@ -12,5 +14,7 @@ defineComponent({
onMounted(async () => { onMounted(async () => {
await useAuthStore().init(); await useAuthStore().init();
await useBoatStore().fetchBoats();
await useReservationStore().fetchUserReservations();
}); });
</script> </script>

View File

@@ -1,40 +1,74 @@
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
import { Client, Account, Databases, Functions, ID } from 'appwrite'; import {
Client,
Account,
Databases,
Functions,
ID,
AppwriteException,
Teams,
} from 'appwrite';
import { useAuthStore } from 'src/stores/auth'; import { useAuthStore } from 'src/stores/auth';
import { Dialog, Notify } from 'quasar'; import { Dialog, Notify } from 'quasar';
import type { Router } from 'vue-router'; import type { Router } from 'vue-router';
const client = new Client(); const client = new Client();
// appwrite.io SaaS let APPWRITE_API_ENDPOINT, APPWRITE_API_PROJECT;
// client
// .setEndpoint('https://api.bab.toal.ca/v1')
// .setProject('653ef6f76baf06d68034');
// const appDatabaseId = '654ac5044d1c446feb71';
// Private self-hosted appwrite // Private self-hosted appwrite
if (process.env.APPWRITE_API_ENDPOINT && process.env.APPWRITE_API_PROJECT) if (process.env.APPWRITE_API_ENDPOINT && process.env.APPWRITE_API_PROJECT) {
client APPWRITE_API_ENDPOINT = process.env.APPWRITE_API_ENDPOINT;
.setEndpoint(process.env.APPWRITE_API_ENDPOINT) APPWRITE_API_PROJECT = process.env.APPWRITE_API_PROJECT;
.setProject(process.env.APPWRITE_API_PROJECT); } else if (process.env.DEV) {
APPWRITE_API_ENDPOINT = 'http://localhost:4000/api/v1';
APPWRITE_API_PROJECT = '65ede55a213134f2b688';
} else {
APPWRITE_API_ENDPOINT = 'https://appwrite.oys.undock.ca/v1';
APPWRITE_API_PROJECT = 'bab';
}
client.setEndpoint(APPWRITE_API_ENDPOINT).setProject(APPWRITE_API_PROJECT);
//TODO move this to config file const pwresetUrl = process.env.DEV
const AppwriteIds = { ? 'http://localhost:4000/pwreset'
databaseId: '65ee1cbf9c2493faf15f', : 'https://oys.undock.ca/pwreset';
collection: {
boat: '66341910003e287cd71c', const AppwriteIds = process.env.DEV
reservation: '663f8847000b8f5e29bb', ? {
skillTags: '66072582a74d94a4bd01', databaseId: '65ee1cbf9c2493faf15f',
task: '65ee1cd5b550023fae4f', collection: {
taskTags: '65ee21d72d5c8007c34c', boat: 'boat',
timeBlock: '66361869002883fb4c4b', reservation: 'reservation',
timeBlockTemplate: '66361f480007fdd639af', skillTags: 'skillTags',
}, task: 'task',
}; taskTags: 'taskTags',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: 'userinfo',
},
}
: {
databaseId: 'bab_prod',
collection: {
boat: 'boat',
reservation: 'reservation',
skillTags: 'skillTags',
task: 'task',
taskTags: 'taskTags',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: '664038294b5473ef0c8d',
},
};
const account = new Account(client); const account = new Account(client);
const databases = new Databases(client); const databases = new Databases(client);
const functions = new Functions(client); const functions = new Functions(client);
const teams = new Teams(client);
let appRouter: Router; let appRouter: Router;
@@ -65,7 +99,7 @@ async function logout() {
}); });
} }
function login(email: string, password: string) { async function login(email: string, password: string) {
const notification = Notify.create({ const notification = Notify.create({
type: 'primary', type: 'primary',
position: 'top', position: 'top',
@@ -75,39 +109,56 @@ function login(email: string, password: string) {
group: false, group: false,
}); });
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore try {
.login(email, password) await authStore.login(email, password);
.then(() => { notification({
notification({ type: 'positive',
type: 'positive', message: 'Logged in!',
message: 'Logged in!', timeout: 2000,
timeout: 2000, spinner: false,
spinner: false, icon: 'check_circle',
icon: 'check_circle', });
}); appRouter.replace({ name: 'index' });
console.log('Redirecting to index page'); } catch (error: unknown) {
appRouter.replace({ name: 'index' }); if (error instanceof AppwriteException) {
}) if (error.type === 'user_session_already_exists') {
.catch(function (reason: Error) { appRouter.replace({ name: 'index' });
notification({ notification({
type: 'negative', type: 'positive',
message: 'Login failed.', message: 'Already Logged in!',
timeout: 1, timeout: 2000,
}); spinner: false,
icon: 'check_circle',
});
return;
}
Dialog.create({ Dialog.create({
title: 'Login Error!', title: 'Login Error!',
message: reason.message, message: error.message,
persistent: true, persistent: true,
}); });
}
notification({
type: 'negative',
message: 'Login failed.',
timeout: 2000,
}); });
}
} }
async function resetPassword(email: string) {
await account.createRecovery(email, pwresetUrl);
}
export { export {
client, client,
account, account,
teams,
databases, databases,
functions, functions,
ID, ID,
AppwriteIds, AppwriteIds,
login, login,
logout, logout,
resetPassword,
}; };

View File

@@ -0,0 +1,284 @@
<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>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useAuthStore } from 'src/stores/auth';
import { Boat, useBoatStore } from 'src/stores/boat';
import { Interval, Reservation } from 'src/stores/schedule.types';
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
import { formatDate } from 'src/utils/schedule';
import { useReservationStore } from 'src/stores/reservation';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
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 {
const updatedReservation = {
...newReservation,
user: auth.currentUser?.$id,
interval: {
start: newReservation.start,
end: newReservation.end,
resource: newReservation.resource,
},
};
bookingForm.value = updatedReservation;
}
});
const updateInterval = (interval: Interval) => {
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: hours, minutes: 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);
};
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
)
) {
// TODO: Make a proper validator
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>

View File

@@ -4,22 +4,57 @@
show-if-above show-if-above
:width="200" :width="200"
:breakpoint="1024" :breakpoint="1024"
@update:model-value="$emit('drawer-toggle')" @update:model-value="$emit('drawer-toggle')">
>
<q-scroll-area class="fit"> <q-scroll-area class="fit">
<q-list padding class="menu-list"> <q-list
<template v-for="link in enabledLinks" :key="link.name"> padding
<q-item clickable v-ripple :to="link.to"> class="menu-list">
<template
v-for="link in enabledLinks"
:key="link.name">
<!-- TODO: Template this to be DRY -->
<q-item
clickable
v-ripple
:to="link.to">
<q-item-section avatar> <q-item-section avatar>
<q-icon :name="link.icon" /> <q-icon :name="link.icon" />
</q-item-section> </q-item-section>
<q-item-section> {{ link.name }} </q-item-section> <q-item-section>
<span :class="link.color ? `text-${link.color}` : ''">
{{ link.name }}
</span>
</q-item-section>
</q-item> </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> </template>
<q-item clickable v-ripple @click="logout()"> <q-item
<q-item-section avatar><q-icon name="logout" /></q-item-section clickable
><q-item-section>Logout</q-item-section> 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-item>
</q-list> </q-list>
</q-scroll-area> </q-scroll-area>

View File

@@ -12,21 +12,20 @@
max-width: 350px; max-width: 350px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
" ">
>
<span <span
class="q-button" class="q-button"
style="cursor: pointer; user-select: none" style="cursor: pointer; user-select: none"
@click="onPrev" @click="onPrev">
>&lt;</span &lt;
> </span>
{{ formattedMonth }} {{ formattedMonth }}
<span <span
class="q-button" class="q-button"
style="cursor: pointer; user-select: none" style="cursor: pointer; user-select: none"
@click="onNext" @click="onNext">
>&gt;</span &gt;
> </span>
</div> </div>
</div> </div>
<div <div
@@ -35,8 +34,7 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-wrap: nowrap; flex-wrap: nowrap;
" ">
>
<div style="display: flex; width: 100%"> <div style="display: flex; width: 100%">
<q-calendar-month <q-calendar-month
ref="calendar" ref="calendar"
@@ -48,10 +46,10 @@
date-type="rounded" date-type="rounded"
@change="onChange" @change="onChange"
@moved="onMoved" @moved="onMoved"
@click-date="onClickDate" @click-date="onClickDate" />
/> </div>
</div></div </div>
></q-card-section> </q-card-section>
<q-calendar-resource <q-calendar-resource
v-model="selectedDate" v-model="selectedDate"
:model-resources="boatStore.boats" :model-resources="boatStore.boats"
@@ -73,18 +71,25 @@
@click-time="onClickTime" @click-time="onClickTime"
@click-resource="onClickResource" @click-resource="onClickResource"
@click-head-resources="onClickHeadResources" @click-head-resources="onClickHeadResources"
@click-interval="onClickInterval" @click-interval="onClickInterval">
>
<template #resource-intervals="{ scope }"> <template #resource-intervals="{ scope }">
<template v-for="(event, index) in getEvents(scope)" :key="index"> <template
<q-badge outline :label="event.title" :style="getStyle(event)" /> v-for="(event, index) in getEvents(scope)"
:key="index">
<q-badge
outline
:label="event.title"
:style="getStyle(event)" />
</template> </template>
</template> </template>
<template #resource-label="{ scope: { resource } }"> <template #resource-label="{ scope: { resource } }">
<div class="col-12 .col-md-auto"> <div class="col-12 .col-md-auto">
{{ resource.displayName }} {{ resource.displayName }}
<q-icon v-if="resource.defects" name="warning" color="warning" /> <q-icon
v-if="resource.defects"
name="warning"
color="warning" />
</div> </div>
</template> </template>
</q-calendar-resource> </q-calendar-resource>
@@ -97,9 +102,10 @@
dense dense
@update:model-value="onUpdateDuration" @update:model-value="onUpdateDuration"
label="Duration (hours)" label="Duration (hours)"
stack-label stack-label>
><template v-slot:append><q-icon name="timelapse" /></template></q-select <template v-slot:append><q-icon name="timelapse" /></template>
></q-card-section> </q-select>
</q-card-section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
@@ -117,6 +123,8 @@ import { useReservationStore } from 'src/stores/reservation';
import { date } from 'quasar'; import { date } from 'quasar';
import { computed } from 'vue'; import { computed } from 'vue';
import type { StatusTypes } from 'src/stores/schedule.types'; import type { StatusTypes } from 'src/stores/schedule.types';
import { useIntervalStore } from 'src/stores/interval';
import { storeToRefs } from 'pinia';
interface EventData { interface EventData {
event: object; event: object;
@@ -146,7 +154,7 @@ const statusLookup = {
const calendar = ref(); const calendar = ref();
const boatStore = useBoatStore(); const boatStore = useBoatStore();
const reservationStore = useReservationStore(); const reservationStore = useReservationStore();
const selectedDate = ref(today()); const { selectedDate } = storeToRefs(useIntervalStore());
const duration = ref(1); const duration = ref(1);
const formattedMonth = computed(() => { const formattedMonth = computed(() => {

View File

@@ -7,16 +7,16 @@
round round
icon="menu" icon="menu"
aria-label="Menu" aria-label="Menu"
@click="toggleLeftDrawer" @click="toggleLeftDrawer" />
/>
<q-toolbar-title> {{ pageTitle }} </q-toolbar-title> <q-toolbar-title>{{ pageTitle }}</q-toolbar-title>
<q-tabs shrink> <q-space />
<q-tab> </q-tab> <div>v2024.6.4.2</div>
</q-tabs>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<LeftDrawer :drawer="leftDrawerOpen" @drawer-toggle="toggleLeftDrawer" /> <LeftDrawer
:drawer="leftDrawerOpen"
@drawer-toggle="toggleLeftDrawer" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,8 +1,13 @@
<template> <template>
<div v-if="boats"> <div v-if="boats">
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card"> <q-card
v-for="boat in boats"
:key="boat.id"
class="mobile-card q-ma-sm">
<q-card-section> <q-card-section>
<q-img :src="boat.imgSrc" :fit="'scale-down'"> <q-img
:src="boat.imgSrc"
:fit="'scale-down'">
<div class="row absolute-top"> <div class="row absolute-top">
<div class="col text-h6 text-left">{{ boat.name }}</div> <div class="col text-h6 text-left">{{ boat.name }}</div>
<div class="col text-right">{{ boat.class }}</div> <div class="col text-right">{{ boat.class }}</div>

View File

@@ -3,8 +3,7 @@
expand-icon-toggle expand-icon-toggle
draggable="true" draggable="true"
@dragstart="onDragStart($event, template)" @dragstart="onDragStart($event, template)"
v-model="expanded" v-model="expanded">
>
<template v-slot:header> <template v-slot:header>
<q-item-section> <q-item-section>
<q-input <q-input
@@ -12,17 +11,21 @@
:borderless="!edit" :borderless="!edit"
dense dense
v-model="template.name" v-model="template.name"
v-if="edit" v-if="edit" />
/><q-item-label v-if="!edit" class="cursor-pointer">{{ <q-item-label
template.name v-if="!edit"
}}</q-item-label></q-item-section class="cursor-pointer">
> {{ template.name }}
</q-item-label>
</q-item-section>
</template> </template>
<q-card flat> <q-card flat>
<q-card-section horizontal> <q-card-section horizontal>
<q-card-section class="q-pt-xs"> <q-card-section class="q-pt-xs">
<q-list dense> <q-list dense>
<q-item v-for="(item, index) in template.timeTuples" :key="item[0]"> <q-item
v-for="(item, index) in template.timeTuples"
:key="item[0]">
<q-input <q-input
class="q-mx-sm" class="q-mx-sm"
dense dense
@@ -38,8 +41,7 @@
type="time" type="time"
label="End" label="End"
:borderless="!edit" :borderless="!edit"
:readonly="!edit" :readonly="!edit">
>
<template v-slot:after> <template v-slot:after>
<q-btn <q-btn
v-if="edit" v-if="edit"
@@ -47,46 +49,44 @@
dense dense
flat flat
icon="delete" icon="delete"
@click="template.timeTuples.splice(index, 1)" @click="template.timeTuples.splice(index, 1)" />
/> </template></q-input></q-item </template>
></q-list> </q-input>
</q-item>
</q-list>
<q-btn <q-btn
v-if="edit" v-if="edit"
dense dense
color="primary" color="primary"
size="sm" size="sm"
label="Add interval" label="Add interval"
@click="template.timeTuples.push(['00:00', '00:00'])" @click="template.timeTuples.push(['00:00', '00:00'])" />
/></q-card-section> </q-card-section>
<q-card-actions vertical> <q-card-actions vertical>
<q-btn <q-btn
v-if="!edit" v-if="!edit"
color="primary" color="primary"
icon="edit" icon="edit"
label="Edit" label="Edit"
@click="toggleEdit" @click="toggleEdit" />
/>
<q-btn <q-btn
v-if="edit" v-if="edit"
color="primary" color="primary"
icon="save" icon="save"
label="Save" label="Save"
@click="saveTemplate($event, template)" @click="saveTemplate($event, template)" />
/>
<q-btn <q-btn
v-if="edit" v-if="edit"
color="secondary" color="secondary"
icon="cancel" icon="cancel"
label="Cancel" label="Cancel"
@click="revert" @click="revert" />
/>
<q-btn <q-btn
color="negative" color="negative"
icon="delete" icon="delete"
label="Delete" label="Delete"
v-if="template.$id !== ''" v-if="template.$id !== ''"
@click="deleteTemplate($event, template)" @click="deleteTemplate($event, template)" />
/>
</q-card-actions> </q-card-actions>
</q-card-section> </q-card-section>
</q-card> </q-card>
@@ -101,28 +101,29 @@
square square
icon="schedule" icon="schedule"
v-for="item in overlapped" v-for="item in overlapped"
:key="item.start" :key="item.start">
> {{ item.start }}-{{ item.end }}
{{ item.start }}-{{ item.end }}</q-chip </q-chip>
>
</q-card-section> </q-card-section>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn flat label="OK" color="primary" v-close-popup /> <q-btn
</q-card-actions> </q-card flat
></q-dialog> label="OK"
color="primary"
v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
copyIntervalTemplate,
timeTuplesOverlapped,
useScheduleStore,
} from 'src/stores/schedule';
import { IntervalTemplate } from 'src/stores/schedule.types'; import { IntervalTemplate } from 'src/stores/schedule.types';
import { copyIntervalTemplate, timeTuplesOverlapped } from 'src/utils/schedule';
import { ref } from 'vue'; import { ref } from 'vue';
const alert = ref(false); const alert = ref(false);
const overlapped = ref(); const overlapped = ref();
const scheduleStore = useScheduleStore(); const intervalTemplateStore = useIntervalTemplateStore();
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>(); const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
const edit = ref(props.edit); const edit = ref(props.edit);
const expanded = ref(props.edit); const expanded = ref(props.edit);
@@ -144,7 +145,7 @@ const deleteTemplate = (
event: Event, event: Event,
template: IntervalTemplate | undefined template: IntervalTemplate | undefined
) => { ) => {
if (template?.$id) scheduleStore.deleteIntervalTemplate(template.$id); if (template?.$id) intervalTemplateStore.deleteIntervalTemplate(template.$id);
}; };
function onDragStart(e: DragEvent, template: IntervalTemplate) { function onDragStart(e: DragEvent, template: IntervalTemplate) {
@@ -162,9 +163,9 @@ const saveTemplate = (evt: Event, template: IntervalTemplate | undefined) => {
} else { } else {
edit.value = false; edit.value = false;
if (template.$id && template.$id !== 'unsaved') { if (template.$id && template.$id !== 'unsaved') {
scheduleStore.updateIntervalTemplate(template, template.$id); intervalTemplateStore.updateIntervalTemplate(template, template.$id);
} else { } else {
scheduleStore.createIntervalTemplate(template); intervalTemplateStore.createIntervalTemplate(template);
emit('saved'); emit('saved');
} }
} }

View File

@@ -0,0 +1,116 @@
<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 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-card-section>Some more information here...</q-card-section> -->
<q-separator />
<q-card-actions v-if="!isPast(reservation.end)">
<q-btn
flat
size="lg"
:to="{ name: 'edit-reservation', params: { id: 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>
<script setup lang="ts">
import { useBoatStore } from 'src/stores/boat';
import { useReservationStore } from 'src/stores/reservation';
import type { Reservation } from 'src/stores/schedule.types';
import { formatDate, isPast } from 'src/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>

View File

@@ -1,63 +1,86 @@
<template> <template>
<div> <div>
<CalendarHeaderComponent v-model="selectedDate" /> <q-card>
<div class="boat-schedule-table-component"> <q-toolbar>
<QCalendarDay <q-toolbar-title>Select a Boat and Time</q-toolbar-title>
ref="calendar" <q-btn
class="q-pa-xs" icon="close"
flat flat
animated round
dense dense
:disabled-before="disabledBefore" v-close-popup />
interval-height="24" </q-toolbar>
interval-count="18" <q-separator />
interval-start="06:00" <CalendarHeaderComponent v-model="selectedDate" />
:short-interval-label="true" <div class="boat-schedule-table-component">
v-model="selectedDate" <QCalendarDay
:column-count="boats.length" ref="calendar"
v-touch-swipe.left.right="handleSwipe" class="q-pa-xs"
> flat
<template #head-day="{ scope }"> animated
<div style="text-align: center; font-weight: 800"> dense
{{ getBoatDisplayName(scope) }} :disabled-before="disabledBefore"
</div> interval-height="24"
</template> 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 }"> <template #day-body="{ scope }">
<div v-for="block in getBoatBlocks(scope)" :key="block.$id">
<div <div
class="timeblock" v-for="block in getAvailableIntervals(
:class="selectedBlock?.$id === block.$id ? 'selected' : ''" scope.timestamp,
:style=" boats[scope.columnIndex]
blockStyles(block, scope.timeStartPos, scope.timeDurationHeight) )"
" :key="block.$id">
:id="block.id" <div
@click="selectBlock($event, scope, block)" class="timeblock"
> :disabled="beforeNow(new Date(block.end))"
{{ boats[scope.columnIndex].name }}<br /> :class="selectedBlock?.$id === block.$id ? 'selected' : ''"
{{ selectedBlock?.$id === block.$id ? 'Selected' : 'Available' }} :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>
</div>
<div
v-for="reservation in getBoatReservations(scope)"
:key="reservation.$id"
>
<div <div
class="reservation" v-for="reservation in getBoatReservations(scope)"
:style=" :key="reservation.$id">
reservationStyles( <div
reservation, class="reservation column"
scope.timeStartPos, :style="
scope.timeDurationHeight reservationStyles(
) reservation,
" scope.timeStartPos,
> scope.timeDurationHeight
{{ getUserName(reservation.user) || 'loading...' }} )
">
{{ getUserName(reservation.user) || 'loading...' }}
<br />
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
</div>
</div> </div>
</div> </template>
</template> </QCalendarDay>
</QCalendarDay> </div>
</div> </q-card>
</div> </div>
</template> </template>
@@ -73,28 +96,35 @@ import {
} from '@quasar/quasar-ui-qcalendar'; } from '@quasar/quasar-ui-qcalendar';
import CalendarHeaderComponent from './CalendarHeaderComponent.vue'; import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useBoatStore } from 'src/stores/boat'; import { useBoatStore } from 'src/stores/boat';
import { useScheduleStore } from 'src/stores/schedule';
import { useAuthStore } from 'src/stores/auth'; import { useAuthStore } from 'src/stores/auth';
import { Interval, Reservation } from 'src/stores/schedule.types'; import { Interval, Reservation } from 'src/stores/schedule.types';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useReservationStore } from 'src/stores/reservation'; import { useReservationStore } from 'src/stores/reservation';
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
import { useIntervalStore } from 'src/stores/interval';
const scheduleStore = useScheduleStore(); const intervalTemplateStore = useIntervalTemplateStore();
const reservationStore = useReservationStore(); const reservationStore = useReservationStore();
const { boats } = storeToRefs(useBoatStore()); const { boats } = storeToRefs(useBoatStore());
const selectedBlock = defineModel<Interval | null>(); const selectedBlock = defineModel<Interval | null>();
const selectedDate = ref(today()); const selectedDate = ref(today());
const { getAvailableIntervals } = useIntervalStore();
const calendar = ref<QCalendarDay | null>(null); const calendar = ref<QCalendarDay | null>(null);
const now = ref(new Date());
let intervalId: string | number | NodeJS.Timeout | undefined;
onMounted(async () => { onMounted(async () => {
await useBoatStore().fetchBoats(); await useBoatStore().fetchBoats();
await scheduleStore.fetchIntervals(); await intervalTemplateStore.fetchIntervalTemplates();
await scheduleStore.fetchIntervalTemplates(); intervalId = setInterval(function () {
now.value = new Date();
}, 60000);
}); });
onUnmounted(() => clearInterval(intervalId));
function handleSwipe({ ...event }) { function handleSwipe({ ...event }) {
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next(); event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
} }
@@ -134,6 +164,10 @@ function getBoatDisplayName(scope: DayBodyScope) {
: ''; : '';
} }
function beforeNow(time: Date) {
return time < now.value || null;
}
function genericBlockStyle( function genericBlockStyle(
start: Timestamp, start: Timestamp,
end: Timestamp, end: Timestamp,
@@ -154,9 +188,6 @@ function genericBlockStyle(
1 + 1 +
'px'; 'px';
} }
// if (selectedBlock.value?.id === block.id) {
// s.opacity = '1.0';
// }
return s; return s;
} }
@@ -168,29 +199,11 @@ interface DayBodyScope {
} }
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) { function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
// TODO: Disable blocks before today with updateDisabled and/or comparison if (scope.timestamp.disabled || new Date(block.end) < new Date())
selectedBlock.value === block return false;
? (selectedBlock.value = null) selectedBlock.value = block;
: (selectedBlock.value = block);
} }
const boatBlocks = computed((): Record<string, Interval[]> => {
return scheduleStore
.getIntervalsForDate(selectedDate.value)
.reduce((result, interval) => {
if (!result[interval.boatId]) result[interval.boatId] = [];
if (
!reservationStore.isResourceTimeOverlapped(
interval.boatId,
new Date(interval.start),
new Date(interval.end)
)
)
result[interval.boatId].push(interval);
return result;
}, <Record<string, Interval[]>>{});
});
const boatReservations = computed((): Record<string, Reservation[]> => { const boatReservations = computed((): Record<string, Reservation[]> => {
return reservationStore return reservationStore
.getReservationsByDate(selectedDate.value) .getReservationsByDate(selectedDate.value)
@@ -200,11 +213,6 @@ const boatReservations = computed((): Record<string, Reservation[]> => {
return result; return result;
}, <Record<string, Reservation[]>>{}); }, <Record<string, Reservation[]>>{});
}); });
function getBoatBlocks(scope: DayBodyScope): Interval[] {
const boat = boats.value[scope.columnIndex];
return boat ? boatBlocks.value[boat.$id] : [];
}
function getBoatReservations(scope: DayBodyScope): Reservation[] { function getBoatReservations(scope: DayBodyScope): Reservation[] {
const boat = boats.value[scope.columnIndex]; const boat = boats.value[scope.columnIndex];
return boat ? boatReservations.value[boat.$id] : []; return boat ? boatReservations.value[boat.$id] : [];

View File

@@ -7,11 +7,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue'; import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue';
import { ref } from 'vue'; import { onMounted } from 'vue';
import { useBoatStore } from 'src/stores/boat'; import { useBoatStore } from 'src/stores/boat';
import ToolbarComponent from 'src/components/ToolbarComponent.vue'; import ToolbarComponent from 'src/components/ToolbarComponent.vue';
import { storeToRefs } from 'pinia';
const boatStore = useBoatStore(); const boatStore = useBoatStore();
boatStore.fetchBoats(); const { boats } = storeToRefs(boatStore);
const boats = ref(useBoatStore().boats);
onMounted(() => boatStore.fetchBoats());
</script> </script>

View File

@@ -3,48 +3,55 @@
<q-page-container> <q-page-container>
<q-page class="flex bg-image flex-center"> <q-page class="flex bg-image flex-center">
<q-card <q-card
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }" v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
>
<q-card-section> <q-card-section>
<q-img fit="scale-down" src="~assets/oysqn_logo.png" /> <q-img
fit="scale-down"
src="~assets/oysqn_logo.png" />
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
<div class="text-center q-pt-sm"> <div class="text-center q-pt-sm">
<div class="col text-h6">Log in</div> <div class="col text-h6">Log in</div>
</div> </div>
</q-card-section> </q-card-section>
<q-card-section> <q-form>
<q-form class="q-gutter-md"> <q-card-section class="q-gutter-md">
<q-input <q-input
v-model="email" v-model="email"
label="E-Mail" label="E-Mail"
type="email" type="email"
color="darkblue" color="darkblue"
filled filled></q-input>
></q-input>
<q-input <q-input
v-model="password" v-model="password"
label="Password" label="Password"
type="password" type="password"
color="darkblue" color="darkblue"
filled filled></q-input>
></q-input> <q-card-actions>
<q-btn <q-btn
type="submit" type="button"
@click="login(email, password)" @click="doLogin"
label="Login" label="Login"
color="primary" color="primary"></q-btn>
></q-btn> <q-space />
<!-- <q-btn <q-btn
flat
color="secondary"
to="/pwreset">
Reset password
</q-btn>
<!-- <q-btn
type="button" type="button"
@click="register" @click="register"
color="secondary" color="secondary"
label="Register" label="Register"
flat flat
></q-btn> --> ></q-btn> -->
</q-form> </q-card-actions>
</q-card-section> </q-card-section>
<q-card-section><GoogleOauthComponent /></q-card-section> </q-form>
<!-- <q-card-section><GoogleOauthComponent /></q-card-section> -->
</q-card> </q-card>
</q-page> </q-page>
</q-page-container> </q-page-container>
@@ -69,8 +76,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { login } from 'boot/appwrite'; import { login } from 'boot/appwrite';
import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue'; // import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
const doLogin = async () => {
login(email.value, password.value);
};
</script> </script>

190
src/pages/ResetPassword.vue Normal file
View File

@@ -0,0 +1,190 @@
<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="~assets/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="col text-h6">Reset Password</div>
</div>
</q-card-section>
<q-form v-if="!isPasswordResetLink()">
<q-card-section class="q-ma-sm">
<q-input
v-model="email"
label="E-Mail"
type="email"
color="darkblue"
@keydown.enter.prevent="resetPw"
filled></q-input>
<div class="text-caption q-py-md">
Enter your e-mail address. If we have an account with that
address on file, you will be e-mailed a link to reset your
password.
</div>
<q-card-actions>
<q-btn
type="button"
@click="resetPw"
label="Send Reset Link"
color="primary"></q-btn>
<!-- <q-btn
type="button"
@click="register"
color="secondary"
label="Register"
flat
></q-btn> -->
</q-card-actions>
</q-card-section>
</q-form>
<q-form
@submit="submitNewPw"
v-else-if="validResetLink()">
<q-card-section class="q-ma-sm">
<q-input
v-model="password"
label="New Password"
type="password"
color="darkblue"
:rules="[validatePasswordStrength]"
lazy-rules
filled></q-input>
<q-input
v-model="confirmPassword"
label="Confirm New Password"
type="password"
color="darkblue"
:rules="[validatePasswordStrength]"
lazy-rules
filled></q-input>
<div class="text-caption q-py-md">Enter a new password.</div>
</q-card-section>
<q-card-actions>
<q-btn
type="submit"
label="Reset Password"
color="primary"></q-btn>
<!-- <q-btn
type="button"
@click="register"
color="secondary"
label="Register"
flat
></q-btn> -->
</q-card-actions>
</q-form>
<q-card
v-else
class="text-center">
<span class="text-h5">Invalid reset link.</span>
</q-card>
<!-- <q-card-section><GoogleOauthComponent /></q-card-section> -->
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('/src/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
/* background-image: linear-gradient(
135deg,
#ed232a 0%,
#ffffff 75%,
#14539a 100%
); */
}
</style>
<script setup lang="ts">
import { ref } from 'vue';
import { account, resetPassword } from 'boot/appwrite';
import { useRouter } from 'vue-router';
import { Dialog } from 'quasar';
// import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
const email = ref('');
const router = useRouter();
const password = ref('');
const confirmPassword = ref('');
const validatePasswordStrength = (val: string) => {
const hasUpperCase = /[A-Z]/.test(val);
const hasLowerCase = /[a-z]/.test(val);
const hasNumbers = /[0-9]/.test(val);
const hasNonAlphas = /[\W_]/.test(val);
const isValidLength = val.length >= 8;
return (
(hasUpperCase &&
hasLowerCase &&
hasNumbers &&
hasNonAlphas &&
isValidLength) ||
'Password must be at least 8 characters long and include uppercase, lowercase, number, and special character.'
);
};
const validatePasswordsMatch = (val: string) => {
return val === password.value || 'Passwords do not match.';
};
function isPasswordResetLink() {
const query = router.currentRoute.value.query;
return query && query.secret && query.userId && query.expire;
}
function validResetLink(): boolean {
const query = router.currentRoute.value.query;
const expire = query.expire ? new Date(query.expire + 'Z') : null;
return Boolean(
query && expire && query.secret && query.userId && new Date() < expire
);
}
function submitNewPw() {
const query = router.currentRoute.value.query;
if (
validatePasswordStrength(password.value) === true &&
validatePasswordsMatch(confirmPassword.value) === true
) {
account
.updateRecovery(
query.userId as string,
query.secret as string,
password.value
)
.then(() => {
Dialog.create({ message: 'Password Changed!' });
router.replace('/login');
})
.catch((e) =>
Dialog.create({
message: 'Password change failed! Error: ' + e.message,
})
);
}
}
function resetPw() {
resetPassword(email.value)
.then(() => router.replace('/login'))
.finally(() =>
Dialog.create({
message:
'If your address is in our system, you should receive an e-mail shortly.',
})
);
}
</script>

View File

@@ -1,176 +1,27 @@
<template> <template>
<q-page> <BoatReservationComponent v-model="newReservation" />
<q-list>
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-sm">
<q-item>
<q-item-section :avatar="true">
<q-icon name="person"
/></q-item-section>
<q-item-section>
<q-item-label> Name: {{ bookingForm.name }} </q-item-label>
</q-item-section>
</q-item>
<q-expansion-item
expand-separator
v-model="resourceView"
icon="calendar_month"
label="Boat and Time"
default-opened
class="q-mt-none"
:caption="bookingSummary"
>
<q-separator />
<q-banner :class="$q.dark.isActive ? 'bg-grey-9' : 'bg-grey-3'">
Use the calendar to pick a date. Select an available boat and
timeslot below.
</q-banner>
<BoatScheduleTableComponent v-model="timeblock" />
<q-banner
rounded
class="bg-warning text-grey-10"
style="max-width: 95vw; margin: auto"
v-if="bookingForm.boat && bookingForm.boat.defects.length > 0"
>
<template v-slot:avatar>
<q-icon name="warning" color="grey-10" />
</template>
{{ bookingForm.boat.name }} currently has the following notices:
<ol>
<li
v-for="defect in bookingForm.boat.defects"
:key="defect.description"
>
{{ defect.description }}
</li>
</ol>
</q-banner>
<q-card-section>
<q-btn
color="primary"
class="full-width"
icon="keyboard_arrow_down"
icon-right="keyboard_arrow_down"
label="Next: Crew & Passengers"
@click="resourceView = false"
/></q-card-section>
</q-expansion-item>
<q-expansion-item
expand-separator
icon="people"
label="Crew and Passengers"
default-opened
><q-banner v-if="bookingForm.boat"
>Passengers:
{{ bookingForm.members.length + bookingForm.guests.length }} /
{{ bookingForm.boat.maxPassengers }}</q-banner
>
<q-item
class="q-my-sm"
v-for="passenger in [...bookingForm.members, ...bookingForm.guests]"
:key="passenger.name"
>
<q-item-section avatar>
<q-avatar color="primary" text-color="white" size="sm">
{{
passenger.name
.split(' ')
.map((i) => i.charAt(0))
.join('')
.toUpperCase()
}}
</q-avatar>
</q-item-section>
<q-item-section>{{ passenger.name }}</q-item-section>
<q-item-section side>
<q-btn color="negative" flat dense round icon="cancel" />
</q-item-section>
</q-item>
<q-separator />
</q-expansion-item>
<q-item-section>
<q-btn label="Submit" type="submit" color="primary" />
</q-item-section> </q-form
></q-list>
</q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue'; import BoatReservationComponent from 'src/components/BoatReservationComponent.vue';
import { useAuthStore } from 'src/stores/auth'; import { useIntervalStore } from 'src/stores/interval';
import { Boat, useBoatStore } from 'src/stores/boat'; import { Interval, Reservation } from 'src/stores/schedule.types';
import { date } from 'quasar'; import { ref } from 'vue';
import { Interval } from 'src/stores/schedule.types'; import { useRoute } from 'vue-router';
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
import { getNewId } from 'src/utils/misc';
interface BookingForm { const $route = useRoute();
bookingId: string; const newReservation = ref<Reservation>();
name?: string;
boat?: Boat;
startDate?: string;
endDate?: string;
members: { name: string }[];
guests: { name: string }[];
}
const auth = useAuthStore(); if (typeof $route.query.interval === 'string') {
const dateFormat = 'MMM D, YYYY h:mm A'; useIntervalStore()
const resourceView = ref(true); .fetchInterval($route.query.interval)
const timeblock = ref<Interval>(); .then(
const bookingForm = ref<BookingForm>({ (interval: Interval) =>
bookingId: getNewId(), (newReservation.value = <Reservation>{
name: auth.currentUser?.name, resource: interval.resource,
boat: <Boat | undefined>undefined, start: interval.start,
startDate: date.formatDate(new Date(), dateFormat), end: interval.end,
endDate: date.formatDate(new Date(), dateFormat), })
members: [{ name: 'Karen Henrikso' }, { name: "Rich O'hare" }],
guests: [{ name: 'Bob Barker' }, { name: 'Taylor Swift' }],
});
watch(timeblock, (tb_new) => {
bookingForm.value.boat = useBoatStore().boats.find(
(b) => b.$id === tb_new?.boatId
);
bookingForm.value.startDate = date.formatDate(tb_new?.start, dateFormat);
bookingForm.value.endDate = date.formatDate(tb_new?.end, dateFormat);
});
// //TODO: Turn this into a validator.
// scheduleStore.isReservationOverlapped(newRes)
// ? Dialog.create({ message: 'This booking overlaps another!' })
// : scheduleStore.addOrCreateReservation(newRes);
const onReset = () => {
// TODO
};
const onSubmit = () => {
// TODO
};
const bookingDuration = computed(() => {
if (bookingForm.value.endDate && bookingForm.value.startDate) {
const diff = date.getDateDiff(
bookingForm.value.endDate,
bookingForm.value.startDate,
'minutes'
); );
return diff <= 0 }
? 'Invalid'
: (diff > 60 ? Math.trunc(diff / 60) + ' hours' : '') +
(diff % 60 > 0 ? ' ' + (diff % 60) + ' minutes' : '');
} else {
return 0;
}
});
const bookingSummary = computed(() => {
return bookingForm.value.boat &&
bookingForm.value.startDate &&
bookingForm.value.endDate
? `${bookingForm.value.boat.name} @ ${bookingForm.value.startDate} for ${bookingDuration.value}`
: '';
});
</script> </script>

View File

@@ -1,137 +1,163 @@
<template> <template>
<q-page padding> <q-page>
<div class="subcontent"> <div class="col">
<!-- <navigation-bar @today="onToday" @prev="onPrev" @next="onNext" /> --> <navigation-bar
@today="onToday"
<div class="row justify-center"> @prev="onPrev"
<q-calendar-day @next="onNext" />
ref="calendar" </div>
v-model="selectedDate" <div class="col q-ma-sm">
view="day" <q-calendar-scheduler
:max-days="3" ref="calendar"
bordered v-model="selectedDate"
animated v-model:model-resources="boatStore.boats"
transition-next="slide-left" resource-key="$id"
transition-prev="slide-right" resource-label="displayName"
@change="onChange" :weekdays="[1, 2, 3, 4, 5, 6, 0]"
@moved="onMoved" :view="$q.screen.gt.md ? 'week' : 'day'"
@click-date="onClickDate" v-touch-swipe.mouse.left.right="handleSwipe"
@click-time="onClickTime" :max-days="$q.screen.lt.sm ? 3 : 7"
@click-interval="onClickInterval" animated
@click-head-day="onClickHeadDay" bordered
> style="--calendar-resources-width: 40px">
<template <template #day="{ scope }">
#day-body="{ <div
scope: { timestamp, timeStartPos, timeDurationHeight }, v-for="interval in getSortedIntervals(
}" scope.timestamp,
> scope.resource
<template )"
v-for="event in reservationEvents(timestamp)" :key="interval.$id"
:key="event.id" class="q-pb-xs row"
> @click="createReservationFromInterval(interval)">
<div <q-badge
v-if="event.start !== undefined" multi-line
class="booking-event" :class="!interval.user ? 'cursor-pointer' : null"
:style="slotStyle(event, timeStartPos, timeDurationHeight)" class="col-12 q-pa-sm"
> :transparent="interval.user != undefined"
<span class="title q-calendar__ellipsis"> :color="interval.user ? 'secondary' : 'primary'"
{{ event.user }} :outline="!interval.user"
<q-tooltip>{{ :id="interval.id">
event.start + {{
' - ' + interval.user
boatStore.getBoatById(event.resource)?.name ? useAuthStore().getUserNameById(interval.user)
}}</q-tooltip> : 'Available'
</span> }}
</div> <br />
</template> {{ formatTime(interval.start) }} to
</template> <br />
</q-calendar-day> {{ formatTime(interval.end) }}
</div> </q-badge>
</div>
</template>
</q-calendar-scheduler>
</div> </div>
</q-page> </q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useReservationStore } from 'src/stores/reservation'; import { useReservationStore } from 'src/stores/reservation';
import { Reservation } from 'src/stores/schedule.types';
import { ref } from 'vue'; import { ref } from 'vue';
import { useAuthStore } from 'src/stores/auth';
const reservationStore = useReservationStore(); const reservationStore = useReservationStore();
import { import { getDate } from '@quasar/quasar-ui-qcalendar';
TimestampOrNull, import { QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
getDate,
parsed,
today,
} from '@quasar/quasar-ui-qcalendar';
import { QCalendarDay } from '@quasar/quasar-ui-qcalendar';
import { date } from 'quasar';
import { Timestamp } from '@quasar/quasar-ui-qcalendar'; import { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { useBoatStore } from 'src/stores/boat'; import { Boat, useBoatStore } from 'src/stores/boat';
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
import { useQuasar } from 'quasar';
import { formatTime } from 'src/utils/schedule';
import { useIntervalStore } from 'src/stores/interval';
import { Interval, Reservation } from 'src/stores/schedule.types';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
const selectedDate = ref(today());
const boatStore = useBoatStore(); const boatStore = useBoatStore();
const calendar = ref();
const $q = useQuasar();
const $router = useRouter();
const { getAvailableIntervals } = useIntervalStore();
const { selectedDate } = storeToRefs(useIntervalStore());
const currentUser = useAuthStore().currentUser;
// interface DayScope {
// timestamp: Timestamp;
// columnIndex: number;
// resource: object;
// resourceIndex: number;
// indentLevel: number;
// activeDate: boolean;
// droppable: boolean;
// }
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
return getAvailableIntervals(timestamp, boat)
.concat(boatReservationEvents(timestamp, boat))
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
};
// Method declarations // Method declarations
function slotStyle( // function slotStyle(
event: Reservation, // event: Reservation,
timeStartPos: (time: TimestampOrNull) => string, // timeStartPos: (time: TimestampOrNull) => string,
timeDurationHeight: (minutes: number) => string // timeDurationHeight: (minutes: number) => string
) { // ) {
const s = { // const s = {
top: '', // top: '',
height: '', // height: '',
'align-items': 'flex-start', // 'align-items': 'flex-start',
}; // };
if (timeStartPos && timeDurationHeight) { // if (timeStartPos && timeDurationHeight) {
s.top = timeStartPos(parsed(event.start)) + 'px'; // s.top = timeStartPos(parsed(event.start)) + 'px';
s.height = // s.height =
timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) + // timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) +
'px'; // 'px';
} // }
return s; // return s;
} // }
function reservationEvents(timestamp: Timestamp) { const createReservationFromInterval = (interval: Interval | Reservation) => {
return reservationStore.getReservationsByDate(getDate(timestamp)); if (interval.user) {
if (interval.user === currentUser?.$id) {
$router.push({ name: 'edit-reservation', params: { id: interval.$id } });
} else {
return false;
}
} else {
$router.push({
name: 'reserve-boat',
query: { interval: interval.$id },
});
}
};
function handleSwipe({ ...event }) {
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
} }
function onMoved(data: Event) { function boatReservationEvents(
console.log('onMoved', data); timestamp: Timestamp,
resource: Boat | undefined
) {
if (!resource) return [];
return reservationStore.getReservationsByDate(
getDate(timestamp),
(resource as Boat).$id
);
} }
function onChange(data: Event) { function onToday() {
console.log('onChange', data); calendar.value.moveToToday();
} }
function onClickDate(data: Event) { function onPrev() {
console.log('onClickDate', data); calendar.value.prev();
} }
function onClickTime(data: Event) { function onNext() {
console.log('onClickTime', data); calendar.value.next();
}
function onClickInterval(data: Event) {
console.log('onClickInterval', data);
}
function onClickHeadDay(data: Event) {
console.log('onClickHeadDay', data);
} }
</script> </script>
<style lang="sass" scoped> <style lang="sass">
.q-calendar-scheduler__resource
.booking-event background-color: $primary
position: absolute
font-size: 12px
justify-content: space-evenly
margin: 0 1px
text-overflow: ellipsis
overflow: hidden
color: white color: white
max-width: 100% font-weight: bold
background: #027BE3FF
cursor: pointer
.title
position: relative
display: flex
justify-content: center
align-items: center
height: 100%
</style> </style>

View File

@@ -0,0 +1,86 @@
<template>
<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="!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 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 pastUserReservations"
:key="reservation.$id">
<ReservationCardComponent :modelValue="reservation" />
</div>
</q-tab-panel>
</q-tab-panels>
</template>
<script setup lang="ts">
import { useReservationStore } from 'src/stores/reservation';
import ReservationCardComponent from 'src/components/scheduling/ReservationCardComponent.vue';
import { onMounted, ref } from 'vue';
const { futureUserReservations, pastUserReservations } = useReservationStore();
onMounted(() => useReservationStore().fetchUserReservations());
const tab = ref('upcoming');
// const showMarker = (
// index: number,
// items: Reservation[] | undefined
// ): boolean => {
// if (!items) return false;
// const currentItemDate = new Date(items[index].start);
// const nextItemDate = index > 0 ? new Date(items[index - 1].start) : null;
// // Show marker if current item is past and the next item is future or vice versa
// return (
// isPast(currentItemDate) && (nextItemDate === null || !isPast(nextItemDate))
// );
// };
</script>

View File

@@ -1,8 +1,13 @@
<template> <template>
<div class="fit row wrap justify-start items-start content-start"> <div class="fit row wrap justify-start items-start content-start">
<div class="q-pa-md"> <div class="q-pa-md">
<div class="scheduler" style="max-width: 1200px"> <div
<NavigationBar @next="onNext" @today="onToday" @prev="onPrev" /> class="scheduler"
style="max-width: 1200px">
<NavigationBar
@next="onNext"
@today="onToday"
@prev="onPrev" />
<q-calendar-scheduler <q-calendar-scheduler
ref="calendar" ref="calendar"
v-model="selectedDate" v-model="selectedDate"
@@ -17,10 +22,8 @@
:drag-over-func="onDragOver" :drag-over-func="onDragOver"
:drag-leave-func="onDragLeave" :drag-leave-func="onDragLeave"
:drop-func="onDrop" :drop-func="onDrop"
:day-min-height="50" day-min-height="50px"
:cell-width="150" cell-width="150px">
:day-height="0"
>
<template #day="{ scope }"> <template #day="{ scope }">
<div <div
v-if="filteredIntervals(scope.timestamp, scope.resource).length" v-if="filteredIntervals(scope.timestamp, scope.resource).length"
@@ -30,15 +33,13 @@
justify-content: space-evenly; justify-content: space-evenly;
align-items: center; align-items: center;
font-size: 12px; font-size: 12px;
" ">
>
<template <template
v-for="block in sortedIntervals( v-for="block in sortedIntervals(
scope.timestamp, scope.timestamp,
scope.resource scope.resource
)" )"
:key="block.id" :key="block.id">
>
<q-chip class="cursor-pointer"> <q-chip class="cursor-pointer">
{{ date.formatDate(block.start, 'HH:mm') }} - {{ date.formatDate(block.start, 'HH:mm') }} -
{{ date.formatDate(block.end, 'HH:mm') }} {{ date.formatDate(block.end, 'HH:mm') }}
@@ -78,46 +79,53 @@
).toISOString()) ).toISOString())
" "
/> />
</q-popup-edit>--> </q-chip </q-popup-edit>-->
><q-btn </q-chip>
<q-btn
size="xs" size="xs"
icon="delete" icon="delete"
round round
@click="deleteBlock(block)" @click="deleteBlock(block)" />
/>
</template> </template>
</div> </div>
</template> </template>
</q-calendar-scheduler> </q-calendar-scheduler>
</div> </div>
</div> </div>
<div class="q-pa-md" style="width: 400px"> <div
<q-list padding bordered class="rounded-borders"> class="q-pa-md"
style="width: 400px">
<q-list
padding
bordered
class="rounded-borders">
<q-item> <q-item>
<q-item-section> <q-item-section>
<q-item-label overline>Availability Templates</q-item-label> <q-item-label overline>Availability Templates</q-item-label>
<q-item-label caption <q-item-label caption>
>Drag and drop a template to a boat / date to create booking Drag and drop a template to a boat / date to create booking
availability</q-item-label availability
> </q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn label="Add Template" color="primary" @click="createTemplate" /> <q-btn
label="Add Template"
color="primary"
@click="createTemplate" />
</q-card-actions> </q-card-actions>
<q-item v-if="newTemplate.$id === 'unsaved'" <q-item v-if="newTemplate.$id === 'unsaved'">
><IntervalTemplateComponent <IntervalTemplateComponent
:model-value="newTemplate" :model-value="newTemplate"
:edit="true" :edit="true"
@cancel="resetNewTemplate" @cancel="resetNewTemplate"
@saved="resetNewTemplate" @saved="resetNewTemplate" />
/></q-item> </q-item>
<q-separator spaced /> <q-separator spaced />
<IntervalTemplateComponent <IntervalTemplateComponent
v-for="template in timeblockTemplates" v-for="template in intervalTemplates"
:key="template.$id" :key="template.$id"
:model-value="template" :model-value="template" />
/>
</q-list> </q-list>
</div> </div>
</div> </div>
@@ -128,16 +136,23 @@
</q-card-section> </q-card-section>
<q-card-section class="q-pt-none"> <q-card-section class="q-pt-none">
Conflicting times! Please delete overlapped items! Conflicting times! Please delete overlapped items!
<q-chip v-for="item in overlapped" :key="item.index" <q-chip
>{{ boats.find((b) => b.$id === item.boatId)?.name }}: 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.start, 'hh:mm') }} -
{{ date.formatDate(item.end, 'hh:mm') }} {{ date.formatDate(item.end, 'hh:mm') }}
</q-chip> </q-chip>
</q-card-section> </q-card-section>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn flat label="OK" color="primary" v-close-popup /> <q-btn
</q-card-actions> </q-card flat
></q-dialog> label="OK"
color="primary"
v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -147,11 +162,7 @@ import {
today, today,
} from '@quasar/quasar-ui-qcalendar'; } from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat'; import { Boat, useBoatStore } from 'src/stores/boat';
import { import { useIntervalStore } from 'src/stores/interval';
blocksOverlapped,
buildInterval,
useScheduleStore,
} from 'src/stores/schedule';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import type { import type {
Interval, Interval,
@@ -162,12 +173,15 @@ import { date } from 'quasar';
import IntervalTemplateComponent from 'src/components/scheduling/IntervalTemplateComponent.vue'; import IntervalTemplateComponent from 'src/components/scheduling/IntervalTemplateComponent.vue';
import NavigationBar from 'src/components/scheduling/NavigationBar.vue'; import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { buildInterval, intervalsOverlapped } from 'src/utils/schedule';
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
const selectedDate = ref(today()); const selectedDate = ref(today());
const { fetchBoats } = useBoatStore(); const { fetchBoats } = useBoatStore();
const scheduleStore = useScheduleStore(); const intervalStore = useIntervalStore();
const intervalTemplateStore = useIntervalTemplateStore();
const { boats } = storeToRefs(useBoatStore()); const { boats } = storeToRefs(useBoatStore());
const { timeblockTemplates } = storeToRefs(useScheduleStore()); const intervalTemplates = intervalTemplateStore.getIntervalTemplates();
const calendar = ref(); const calendar = ref();
const overlapped = ref(); const overlapped = ref();
const alert = ref(false); const alert = ref(false);
@@ -186,12 +200,11 @@ const newTemplate = ref<IntervalTemplate>({
onMounted(async () => { onMounted(async () => {
await fetchBoats(); await fetchBoats();
await scheduleStore.fetchIntervals(); await intervalTemplateStore.fetchIntervalTemplates();
await scheduleStore.fetchIntervalTemplates();
}); });
const filteredIntervals = (date: Timestamp, boat: Boat) => { const filteredIntervals = (date: Timestamp, boat: Boat) => {
return scheduleStore.getIntervals(date, boat).value; return intervalStore.getIntervals(date, boat);
}; };
const sortedIntervals = (date: Timestamp, boat: Boat) => { const sortedIntervals = (date: Timestamp, boat: Boat) => {
@@ -211,20 +224,22 @@ function createTemplate() {
newTemplate.value.$id = 'unsaved'; newTemplate.value.$id = 'unsaved';
} }
function createIntervals(boat: Boat, templateId: string, date: string) { function createIntervals(boat: Boat, templateId: string, date: string) {
const intervals = timeBlocksFromTemplate(boat, templateId, date); const intervals = intervalsFromTemplate(boat, templateId, date);
intervals.forEach((interval) => scheduleStore.createInterval(interval)); intervals.forEach((interval) => intervalStore.createInterval(interval));
} }
function getIntervals(date: Timestamp, boat: Boat) { function getIntervals(date: Timestamp, boat: Boat) {
return scheduleStore.getIntervals(date, boat); return intervalStore.getIntervals(date, boat);
} }
function timeBlocksFromTemplate( function intervalsFromTemplate(
boat: Boat, boat: Boat,
templateId: string, templateId: string,
date: string date: string
): Interval[] { ): Interval[] {
const template = timeblockTemplates.value.find((t) => t.$id === templateId); const template = intervalTemplateStore
.getIntervalTemplates()
.value.find((t) => t.$id === templateId);
return template return template
? template.timeTuples.map((timeTuple: TimeTuple) => ? template.timeTuples.map((timeTuple: TimeTuple) =>
buildInterval(boat, timeTuple, date) buildInterval(boat, timeTuple, date)
@@ -234,18 +249,14 @@ function timeBlocksFromTemplate(
function deleteBlock(block: Interval) { function deleteBlock(block: Interval) {
if (block.$id) { if (block.$id) {
scheduleStore.deleteInterval(block.$id); intervalStore.deleteInterval(block.$id);
} }
} }
function onDragEnter(e: DragEvent, type: string) { function onDragEnter(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') { if (type === 'day' || type === 'head-day') {
e.preventDefault(); e.preventDefault();
if ( if (e.target instanceof HTMLDivElement)
e.target instanceof HTMLDivElement &&
(e.target.classList.contains('q-calendar-scheduler__head--day') ||
e.target.classList.contains('q-calendar-scheduler__day'))
)
e.target.classList.add('bg-secondary'); e.target.classList.add('bg-secondary');
} }
} }
@@ -259,11 +270,7 @@ function onDragOver(e: DragEvent, type: string) {
function onDragLeave(e: DragEvent, type: string) { function onDragLeave(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') { if (type === 'day' || type === 'head-day') {
e.preventDefault(); e.preventDefault();
if ( if (e.target instanceof HTMLDivElement)
e.target instanceof HTMLDivElement &&
(e.target.classList.contains('q-calendar-scheduler__head--day') ||
e.target.classList.contains('q-calendar-scheduler__day'))
)
e.target.classList.remove('bg-secondary'); e.target.classList.remove('bg-secondary');
} }
} }
@@ -281,13 +288,13 @@ function onDrop(
const templateId = e.dataTransfer.getData('ID'); const templateId = e.dataTransfer.getData('ID');
const date = scope.timestamp.date; const date = scope.timestamp.date;
const resource = scope.resource; const resource = scope.resource;
const existingIntervals = getIntervals(scope.timestamp, resource).value; const existingIntervals = getIntervals(scope.timestamp, resource);
const boatsToApply = type === 'head-day' ? boats.value : [resource]; const boatsToApply = type === 'head-day' ? boats.value : [resource];
overlapped.value = boatsToApply overlapped.value = boatsToApply
.map((boat) => .map((boat) =>
blocksOverlapped( intervalsOverlapped(
existingIntervals.concat( existingIntervals.concat(
timeBlocksFromTemplate(boat, templateId, date) intervalsFromTemplate(boat, templateId, date)
) )
) )
) )

View File

@@ -0,0 +1,18 @@
<template>
<BoatReservationComponent v-model="reservation" />
</template>
<script setup lang="ts">
import BoatReservationComponent from 'src/components/BoatReservationComponent.vue';
import { useReservationStore } from 'src/stores/reservation';
import { Reservation } from 'src/stores/schedule.types';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
const reservation = ref<Reservation>();
onMounted(async () => {
const id = useRoute().params.id as string;
reservation.value = await useReservationStore().getReservationById(id);
});
</script>

View File

@@ -1,39 +1,25 @@
<template> <template>
<q-page padding> <q-page padding>
<q-item v-for="link in navlinks" :key="link.label"> <q-item
v-for="link in navlinks"
:key="link.name">
<q-btn <q-btn
:icon="link.icon" :icon="link.icon"
:color="link.color" :color="link.color ? link.color : 'primary'"
size="1.25em" size="1.25em"
:to="link.to" :to="link.to"
:label="link.label" :label="link.name"
rounded rounded
class="full-width" class="full-width"
align="left" align="left" />
/>
</q-item> </q-item>
</q-page> </q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const navlinks = [ import { enabledLinks } from 'src/router/navlinks';
{
icon: 'more_time', const navlinks = enabledLinks.find(
to: '/schedule/book', (link) => link.name === 'Schedule'
label: 'Create a Reservation', )?.sublinks;
color: 'primary',
},
{
icon: 'calendar_month',
to: '/schedule/view',
label: 'View Schedule',
color: 'primary',
},
{
icon: 'edit_calendar',
to: '/schedule/manage',
label: 'Manage Calendar',
color: 'accent',
},
];
</script> </script>

View File

@@ -1,7 +1,9 @@
<template> <template>
<ToolbarComponent pageTitle="Tasks" /> <ToolbarComponent pageTitle="Tasks" />
<q-page padding> <q-page padding>
<div class="q-pa-md" style="max-width: 400px"> <div
class="q-pa-md"
style="max-width: 400px">
<TaskEditComponent :taskId="taskId" /> <TaskEditComponent :taskId="taskId" />
</div> </div>
</q-page> </q-page>
@@ -9,7 +11,6 @@
<script setup lang="ts"> <script setup lang="ts">
const taskId = useRoute().params.id as string; const taskId = useRoute().params.id as string;
console.log(taskId);
import ToolbarComponent from 'src/components/ToolbarComponent.vue'; import ToolbarComponent from 'src/components/ToolbarComponent.vue';
import TaskEditComponent from 'src/components/task/TaskEditComponent.vue'; import TaskEditComponent from 'src/components/task/TaskEditComponent.vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';

View File

@@ -9,6 +9,10 @@ import {
import routes from './routes'; import routes from './routes';
import { useAuthStore } from 'src/stores/auth'; import { useAuthStore } from 'src/stores/auth';
const publicRoutes = routes
.filter((route) => route.meta?.publicRoute)
.map((r) => r.path);
/* /*
* If not building with SSR mode, you can * If not building with SSR mode, you can
* directly export the Router instantiation; * directly export the Router instantiation;
@@ -35,14 +39,33 @@ export default route(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE), history: createHistory(process.env.VUE_ROUTER_BASE),
}); });
Router.beforeEach((to) => { Router.beforeEach(async (to, from, next) => {
const auth = useAuthStore(); const authStore = useAuthStore();
const currentUser = authStore.currentUser;
const authRequired = !publicRoutes.includes(to.path);
const requiredRoles = to.meta?.requiredRoles as string[];
if (auth.currentUser) { if (authRequired && !currentUser) {
return to.meta.accountRoute ? { name: 'index' } : true; return next('/login');
} else {
return to.name == 'login' ? true : { name: 'login' };
} }
if (requiredRoles) {
if (!currentUser) {
return next('/login');
}
try {
const hasRole = authStore.hasRequiredRole(requiredRoles);
if (!hasRole) {
return next(from);
}
} catch (error) {
console.error('Failed to fetch user teams:', error);
return next('/error'); // Redirect to an error page or handle it as needed
}
}
next();
}); });
return Router; return Router;

View File

@@ -1,4 +1,17 @@
export const links = [ import { useAuthStore } from 'src/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', name: 'Home',
to: '/', to: '/',
@@ -26,6 +39,38 @@ export const links = [
icon: 'calendar_month', icon: 'calendar_month',
front_links: true, front_links: true,
enabled: 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: 'Manage',
to: '/schedule/manage',
icon: 'edit_calendar',
front_links: false,
enabled: true,
color: 'accent',
requiredRoles: ['Schedule Admins'],
},
],
}, },
{ {
name: 'Certifications', name: 'Certifications',
@@ -57,4 +102,20 @@ export const links = [
}, },
]; ];
export const enabledLinks = links.filter((link) => link.enabled); const authStore = useAuthStore();
function hasRole(roles: string[] | undefined) {
if (roles === undefined) return true;
const hasRole = authStore.hasRequiredRole(roles);
return hasRole;
}
export const enabledLinks = links
.filter((link) => link.enabled)
.map((link) => {
if (link.sublinks) {
link.sublinks = link.sublinks.filter(
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
);
}
return link;
});

View File

@@ -40,11 +40,23 @@ const routes: RouteRecordRaw[] = [
component: () => import('src/pages/schedule/BoatScheduleView.vue'), component: () => import('src/pages/schedule/BoatScheduleView.vue'),
name: 'boat-schedule', name: 'boat-schedule',
}, },
{
path: 'list',
component: () =>
import('src/pages/schedule/ListReservationsPage.vue'),
name: 'list-reservations',
},
{
path: 'edit/:id',
component: () =>
import('src/pages/schedule/ModifyBoatReservation.vue'),
name: 'edit-reservation',
},
{ {
path: 'manage', path: 'manage',
component: () => import('src/pages/schedule/ManageCalendar.vue'), component: () => import('src/pages/schedule/ManageCalendar.vue'),
name: 'manage-schedule', name: 'manage-schedule',
meta: { requiresScheduleAdmin: true }, meta: { requiredRoles: ['Schedule Admins'] },
}, },
], ],
}, },
@@ -102,7 +114,7 @@ const routes: RouteRecordRaw[] = [
{ {
path: '/admin', path: '/admin',
component: () => import('layouts/AdminLayout.vue'), component: () => import('layouts/AdminLayout.vue'),
meta: { requiresAdmin: true }, meta: { requiredRoles: ['admin'] },
children: [ children: [
{ {
path: '/user', path: '/user',
@@ -124,6 +136,22 @@ const routes: RouteRecordRaw[] = [
publicRoute: true, publicRoute: true,
}, },
}, },
{
path: '/pwreset',
component: () => import('pages/ResetPassword.vue'),
name: 'pwreset',
meta: {
publicRoute: true,
},
},
{
path: '/login',
component: () => import('pages/LoginPage.vue'),
name: 'login',
meta: {
publicRoute: true,
},
},
{ {
path: '/terms-of-service', path: '/terms-of-service',
component: () => import('pages/TermsOfServicePage.vue'), component: () => import('pages/TermsOfServicePage.vue'),

View File

@@ -1,28 +1,46 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ID, account, functions } from 'boot/appwrite'; import { ID, account, functions, teams } from 'boot/appwrite';
import { ExecutionMethod, OAuthProvider, type Models } from 'appwrite'; import { ExecutionMethod, OAuthProvider, type Models } from 'appwrite';
import { ref } from 'vue'; import { computed, ref } from 'vue';
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<Models.User<Models.Preferences> | null>(null); const currentUser = ref<Models.User<Models.Preferences> | null>(null);
const currentUserTeams = ref<Models.TeamList<Models.Preferences> | null>(
null
);
const userNames = ref<Record<string, string>>({}); const userNames = ref<Record<string, string>>({});
async function init() { async function init() {
try { try {
currentUser.value = await account.get(); currentUser.value = await account.get();
currentUserTeams.value = await teams.list();
} catch { } catch {
currentUser.value = null; 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 register(email: string, password: string) { async function register(email: string, password: string) {
await account.create(ID.unique(), email, password); await account.create(ID.unique(), email, password);
return await login(email, password); return await login(email, password);
} }
async function login(email: string, password: string) { async function login(email: string, password: string) {
await account.createEmailPasswordSession(email, password); await account.createEmailPasswordSession(email, password);
currentUser.value = await account.get(); await init();
} }
async function googleLogin() { async function googleLogin() {
account.createOAuth2Session( account.createOAuth2Session(
OAuthProvider.Google, OAuthProvider.Google,
@@ -32,13 +50,14 @@ export const useAuthStore = defineStore('auth', () => {
currentUser.value = await account.get(); currentUser.value = await account.get();
} }
function getUserNameById(id: string) { function getUserNameById(id: string | undefined | null): string {
if (!id) return 'No User';
try { try {
if (!userNames.value[id]) { if (!userNames.value[id]) {
userNames.value[id] = ''; userNames.value[id] = 'Loading...';
functions functions
.createExecution( .createExecution(
'664038294b5473ef0c8d', 'userinfo',
'', '',
false, false,
'/userinfo/' + id, '/userinfo/' + id,
@@ -65,6 +84,7 @@ export const useAuthStore = defineStore('auth', () => {
return { return {
currentUser, currentUser,
getUserNameById, getUserNameById,
hasRequiredRole,
register, register,
login, login,
googleLogin, googleLogin,

View File

@@ -39,8 +39,9 @@ export const useBoatStore = defineStore('boat', () => {
} }
} }
const getBoatById = (id: string): Boat | null => { const getBoatById = (id: string | null | undefined): Boat | null => {
return boats.value.find((b) => b.$id === id) || null; if (!id) return null;
return boats.value?.find((b) => b.$id === id) || null;
}; };
return { boats, fetchBoats, getBoatById }; return { boats, fetchBoats, getBoatById };

144
src/stores/interval.ts Normal file
View File

@@ -0,0 +1,144 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { Boat } from './boat';
import { Timestamp, today } from '@quasar/quasar-ui-qcalendar';
import { Interval, IntervalRecord } from './schedule.types';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Query } from 'appwrite';
import { useReservationStore } from './reservation';
export const useIntervalStore = defineStore('interval', () => {
// TODO: Implement functions to dynamically pull this data.
const intervals = ref<Map<string, Interval>>(new Map());
const intervalDates = ref<IntervalRecord>({});
const reservationStore = useReservationStore();
const selectedDate = ref<string>(today());
const getIntervals = (date: Timestamp | string, boat?: Boat): Interval[] => {
const searchDate = typeof date === 'string' ? date : date.date;
const dayStart = new Date(searchDate + 'T00:00');
const dayEnd = new Date(searchDate + 'T23:59');
if (!intervalDates.value[searchDate]) {
intervalDates.value[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;
});
}).value;
};
const getAvailableIntervals = (
date: Timestamp | string,
boat?: Boat
): Interval[] => {
return computed(() => {
return getIntervals(date, boat).filter((interval) => {
return !reservationStore.isResourceTimeOverlapped(
interval.resource,
new Date(interval.start),
new Date(interval.end)
);
});
}).value;
};
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), // We are asuming that we won't have more than 50 intervals per day.
]
);
response.documents.forEach((d) =>
intervals.value.set(d.$id, d as Interval)
);
intervalDates.value[dateString] = 'loaded';
console.info(`Loaded ${response.documents.length} intervals from server`);
} catch (error) {
console.error('Failed to fetch intervals', error);
intervalDates.value[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 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 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,
};
});

View File

@@ -0,0 +1,97 @@
import { Ref, ref } from 'vue';
import { IntervalTemplate } from './schedule.types';
import { defineStore } from 'pinia';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Models } from 'appwrite';
import { arrayToTimeTuples } from 'src/utils/schedule';
export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
const intervalTemplates = ref<IntervalTemplate[]>([]);
const getIntervalTemplates = (): Ref<IntervalTemplate[]> => {
// Should subscribe to get new intervaltemplates when they are created
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: Models.Document): IntervalTemplate => {
return {
...d,
timeTuples: arrayToTimeTuples(d.timeTuple),
} 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 IntervalTemplate);
} catch (e) {
console.error('Error updating 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.timeTuple),
} as IntervalTemplate)
);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
}
};
return {
getIntervalTemplates,
fetchIntervalTemplates,
createIntervalTemplate,
deleteIntervalTemplate,
updateIntervalTemplate,
};
});

View File

@@ -1,15 +1,21 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import type { Reservation } from './schedule.types'; import type { Reservation } from './schedule.types';
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { AppwriteIds, databases } from 'src/boot/appwrite'; import { AppwriteIds, databases } from 'src/boot/appwrite';
import { Query } from 'appwrite'; import { ID, Query } from 'appwrite';
import { date } from 'quasar'; import { date, useQuasar } from 'quasar';
import { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar'; import { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar';
import { LoadingTypes } from 'src/utils/misc';
import { useAuthStore } from './auth';
import { isPast } from 'src/utils/schedule';
export const useReservationStore = defineStore('reservation', () => { export const useReservationStore = defineStore('reservation', () => {
type LoadingTypes = 'loaded' | 'pending' | undefined;
const reservations = ref<Map<string, Reservation>>(new Map()); const reservations = ref<Map<string, Reservation>>(new Map());
const datesLoaded = ref<Record<string, LoadingTypes>>({}); const datesLoaded = ref<Record<string, LoadingTypes>>({});
const userReservations = ref<Map<string, Reservation>>(new Map());
// TODO: Come up with a better way of storing reservations by date & reservations for user
const authStore = useAuthStore();
const $q = useQuasar();
// Fetch reservations for a specific date range // Fetch reservations for a specific date range
const fetchReservationsForDateRange = async ( const fetchReservationsForDateRange = async (
@@ -39,7 +45,99 @@ export const useReservationStore = defineStore('reservation', () => {
setDateLoaded(startDate, endDate, 'loaded'); setDateLoaded(startDate, endDate, 'loaded');
} catch (error) { } catch (error) {
console.error('Failed to fetch reservations', error); console.error('Failed to fetch reservations', error);
setDateLoaded(startDate, endDate, undefined); setDateLoaded(startDate, endDate, 'error');
}
};
const getReservationById = async (id: string) => {
try {
const response = await databases.getDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
id
);
return response 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.value.set(response.$id, response as Reservation);
userReservations.value.set(response.$id, response as Reservation);
console.info('Reservation booked: ', response);
return response as Reservation;
} catch (e) {
console.error('Error creating Reservation: ' + e);
throw e;
}
};
const deleteReservation = async (
reservation: string | Reservation | null | undefined
) => {
if (!reservation) return false;
let id;
if (typeof reservation === 'string') {
id = reservation;
} else if ('$id' in reservation && typeof reservation.$id === 'string') {
id = reservation.$id;
} else {
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.value.delete(id);
userReservations.value.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',
});
} }
}; };
@@ -122,11 +220,72 @@ export const useReservationStore = defineStore('reservation', () => {
); );
}; };
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.value.set(d.$id, d as Reservation)
);
} catch (error) {
console.error('Failed to fetch reservations for user: ', error);
}
};
const sortedUserReservations = computed((): Reservation[] =>
[...userReservations.value?.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));
});
// Ensure reactivity for computed properties when Map is modified
watch(
reservations,
() => {
sortedUserReservations.value;
futureUserReservations.value;
pastUserReservations.value;
},
{ deep: true }
);
watch(
userReservations,
() => {
sortedUserReservations.value;
futureUserReservations.value;
pastUserReservations.value;
},
{ deep: true }
);
return { return {
getReservationsByDate, getReservationsByDate,
getReservationById,
createOrUpdateReservation,
deleteReservation,
fetchReservationsForDateRange, fetchReservationsForDateRange,
isReservationOverlapped, isReservationOverlapped,
isResourceTimeOverlapped, isResourceTimeOverlapped,
getConflictingReservations, getConflictingReservations,
fetchUserReservations,
sortedUserReservations,
futureUserReservations,
pastUserReservations,
userReservations,
}; };
}); });

View File

@@ -50,7 +50,7 @@ export function getSampleIntervals(): Interval[] {
return template.blocks.map((t: TimeTuple): Interval => { return template.blocks.map((t: TimeTuple): Interval => {
return { return {
$id: 'id' + Math.random().toString(32).slice(2), $id: 'id' + Math.random().toString(32).slice(2),
boatId: b.$id, resource: b.$id,
start: addToDate(tsToday, { day: i }).date + ' ' + t[0], start: addToDate(tsToday, { day: i }).date + ' ' + t[0],
end: addToDate(tsToday, { day: i }).date + ' ' + t[1], end: addToDate(tsToday, { day: i }).date + ' ' + t[1],
}; };
@@ -71,6 +71,7 @@ export function getSampleReservations(): Reservation[] {
end: '10:00', end: '10:00',
boat: '66359729003825946ae1', boat: '66359729003825946ae1',
status: 'confirmed', status: 'confirmed',
reason: 'Open Sail',
}, },
{ {
id: '2', id: '2',
@@ -79,6 +80,7 @@ export function getSampleReservations(): Reservation[] {
end: '19:00', end: '19:00',
boat: '66359729003825946ae1', boat: '66359729003825946ae1',
status: 'confirmed', status: 'confirmed',
reason: 'Open Sail',
}, },
{ {
id: '3', id: '3',
@@ -87,6 +89,7 @@ export function getSampleReservations(): Reservation[] {
end: '13:00', end: '13:00',
boat: '663597030029b71c7a9b', boat: '663597030029b71c7a9b',
status: 'tentative', status: 'tentative',
reason: 'Open Sail',
}, },
{ {
id: '4', id: '4',
@@ -95,6 +98,7 @@ export function getSampleReservations(): Reservation[] {
end: '13:00', end: '13:00',
boat: '663597030029b71c7a9b', boat: '663597030029b71c7a9b',
status: 'pending', status: 'pending',
reason: 'Open Sail',
}, },
{ {
id: '5', id: '5',
@@ -103,6 +107,7 @@ export function getSampleReservations(): Reservation[] {
end: '19:00', end: '19:00',
boat: '663596b9000235ffea55', boat: '663596b9000235ffea55',
status: 'confirmed', status: 'confirmed',
reason: 'Private Sail',
}, },
{ {
id: '6', id: '6',
@@ -110,6 +115,7 @@ export function getSampleReservations(): Reservation[] {
start: '13:00', start: '13:00',
end: '16:00', end: '16:00',
boat: '663596b9000235ffea55', boat: '663596b9000235ffea55',
reason: 'Open Sail',
}, },
]; ];
const boatStore = useBoatStore(); const boatStore = useBoatStore();
@@ -137,7 +143,9 @@ export function getSampleReservations(): Reservation[] {
end: date.adjustDate(now, makeOpts(splitTime(entry.end))).toISOString(), end: date.adjustDate(now, makeOpts(splitTime(entry.end))).toISOString(),
resource: boat.$id, resource: boat.$id,
reservationDate: now, reservationDate: now,
reason: entry.reason,
status: entry.status as StatusTypes, status: entry.status as StatusTypes,
comment: '',
}; };
}); });
} }

View File

@@ -1,250 +0,0 @@
import { defineStore } from 'pinia';
import { ComputedRef, computed, ref } from 'vue';
import { Boat } from './boat';
import {
Timestamp,
parseDate,
parsed,
compareDate,
} from '@quasar/quasar-ui-qcalendar';
import { IntervalTemplate, TimeTuple, Interval } from './schedule.types';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Models } from 'appwrite';
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 blocksOverlapped(
tuples.map((tuples) => {
return {
boatId: '',
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 blocksOverlapped(blocks: Interval[] | 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 {
/* When the time zone offset is absent, date-only forms are interpreted
as a UTC time and date-time forms are interpreted as local time. */
const result = {
boatId: resource.$id,
start: new Date(blockDate + 'T' + time[0]).toISOString(),
end: new Date(blockDate + 'T' + time[1]).toISOString(),
};
return result;
}
export const useScheduleStore = defineStore('schedule', () => {
// TODO: Implement functions to dynamically pull this data.
const intervals = ref<Interval[]>([]);
const intervalTemplates = ref<IntervalTemplate[]>([]);
const getIntervals = (
date: Timestamp,
boat: Boat
): ComputedRef<Interval[]> => {
return computed(() =>
intervals.value.filter((block) => {
return (
compareDate(parseDate(new Date(block.start)) as Timestamp, date) &&
block.boatId === boat.$id
);
})
);
};
const getIntervalsForDate = (date: string): Interval[] => {
// TODO: This needs to actually make sure we have the dates we need, stay in sync, etc.
return intervals.value.filter((b) => {
return compareDate(
parseDate(new Date(b.start)) as Timestamp,
parsed(date) as Timestamp
);
});
};
async function fetchIntervals() {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlock
);
intervals.value = response.documents as Interval[];
} catch (error) {
console.error('Failed to fetch timeblocks', error);
}
}
async function fetchIntervalTemplates() {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlockTemplate
);
intervalTemplates.value = response.documents.map(
(d: Models.Document): IntervalTemplate => {
return {
...d,
timeTuples: arrayToTimeTuples(d.timeTuple),
} as IntervalTemplate;
}
);
} catch (error) {
console.error('Failed to fetch timeblock templates', error);
}
}
const createInterval = async (interval: Interval) => {
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlock,
ID.unique(),
interval
);
intervals.value.push(response 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.timeBlock,
interval.$id,
{ ...interval, $id: undefined }
);
intervals.value.push(response as Interval);
console.log(`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.timeBlock,
id
);
intervals.value = intervals.value.filter((block) => block.$id !== id);
} catch (e) {
console.error('Error deleting Interval: ' + e);
}
};
const createIntervalTemplate = async (template: IntervalTemplate) => {
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlockTemplate,
ID.unique(),
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
);
intervalTemplates.value.push(response as IntervalTemplate);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
}
};
const deleteIntervalTemplate = async (id: string) => {
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlockTemplate,
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.timeBlockTemplate,
id,
{
name: template.name,
timeTuple: template.timeTuples.flat(2),
}
);
intervalTemplates.value = intervalTemplates.value.map((b) =>
b.$id !== id
? b
: ({
...response,
timeTuples: arrayToTimeTuples(response.timeTuple),
} as IntervalTemplate)
);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
}
};
return {
timeblocks: intervals,
timeblockTemplates: intervalTemplates,
getIntervalsForDate,
getIntervals,
fetchIntervals,
fetchIntervalTemplates,
createInterval,
updateInterval,
deleteInterval,
createIntervalTemplate,
deleteIntervalTemplate,
updateIntervalTemplate,
};
});

View File

@@ -1,12 +1,14 @@
import { Models } from 'appwrite'; import { Models } from 'appwrite';
import { LoadingTypes } from 'src/utils/misc';
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined; export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
export type Reservation = Partial<Models.Document> & { export type Reservation = Interval & {
user: string; user: string;
start: string;
end: string;
resource: string; // Boat ID
status?: StatusTypes; status?: StatusTypes;
reason: string;
comment: string;
members?: string[];
guests?: string[];
}; };
// 24 hrs in advance only 2 weekday, and 1 weekend slot // 24 hrs in advance only 2 weekday, and 1 weekend slot
@@ -16,14 +18,18 @@ export type Reservation = Partial<Models.Document> & {
objects in here? */ objects in here? */
export type TimeTuple = [start: string, end: string]; export type TimeTuple = [start: string, end: string];
export type Interval = Partial<Models.Document> & { export type Interval = Partial<Models.Document> & {
boatId: string; resource: string;
start: string; start: string;
end: string; end: string;
selected?: false;
}; };
export type IntervalTemplate = Partial<Models.Document> & { export type IntervalTemplate = Partial<Models.Document> & {
name: string; name: string;
timeTuples: TimeTuple[]; timeTuples: TimeTuple[];
}; };
export interface IntervalRecord {
[key: string]: LoadingTypes;
}

View File

@@ -5,3 +5,5 @@ export function getNewId(): string {
// Trivial placeholder // Trivial placeholder
//return Math.max(...reservations.value.map((item) => item.id)) + 1; //return Math.max(...reservations.value.map((item) => item.id)) + 1;
} }
export type LoadingTypes = 'loaded' | 'pending' | 'error' | undefined;

90
src/utils/schedule.ts Normal file
View File

@@ -0,0 +1,90 @@
import { date } from 'quasar';
import { Boat } from 'src/stores/boat';
import {
Interval,
IntervalTemplate,
TimeTuple,
} from 'src/stores/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[]
): 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 {
/* When the time zone offset is absent, date-only forms are interpreted
as a UTC time and date-time forms are interpreted as local time. */
const result = {
resource: resource.$id,
start: new Date(blockDate + 'T' + time[0]).toISOString(),
end: new Date(blockDate + 'T' + time[1]).toISOString(),
};
return result;
}
export const isPast = (itemDate: Date | string): boolean => {
if (!(itemDate instanceof Date)) {
itemDate = new Date(itemDate);
}
const currentDate = new Date();
return itemDate < currentDate;
};
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');
}

View File

@@ -5278,6 +5278,11 @@ vue-tsc@^1.8.22:
"@vue/language-core" "1.8.27" "@vue/language-core" "1.8.27"
semver "^7.5.4" semver "^7.5.4"
vue3-google-login@^2.0.26:
version "2.0.26"
resolved "https://registry.yarnpkg.com/vue3-google-login/-/vue3-google-login-2.0.26.tgz#0e55dbb3c6cbb78872dee0de800624c749d07882"
integrity sha512-BuTSIeSjINNHNPs+BDF4COnjWvff27IfCBDxK6JPRqvm57lF8iK4B3+zcG8ud6BXfZdyuiDlxletbEDgg4/RFA==
vue@3: vue@3:
version "3.4.25" version "3.4.25"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.25.tgz#e59d4ed36389647b52ff2fd7aa84bb6691f4205b" resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.25.tgz#e59d4ed36389647b52ff2fd7aa84bb6691f4205b"