Compare commits
12 Commits
76b0498a18
...
59d2729719
| Author | SHA1 | Date | |
|---|---|---|---|
|
59d2729719
|
|||
|
9f398e5509
|
|||
|
2fb236cf97
|
|||
|
7bc0573455
|
|||
|
68a2b8ffff
|
|||
|
ce696a5a04
|
|||
|
b0d6ec877b
|
|||
|
c03ad48615
|
|||
|
55bc1acbb3
|
|||
|
cd692a6f3b
|
|||
|
737de91bbc
|
|||
|
a6e357f973
|
@@ -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
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
APPWRITE_API_ENDPOINT='https://appwrite.oys.undock.ca/v1'
|
||||
APPWRITE_API_PROJECT='bab'
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true
|
||||
}
|
||||
@@ -21,7 +21,8 @@
|
||||
"file": "^0.2.2",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "3",
|
||||
"vue-router": "4"
|
||||
"vue-router": "4",
|
||||
"vue3-google-login": "^2.0.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@quasar/app-vite": "^1.9.1",
|
||||
|
||||
@@ -48,12 +48,12 @@ module.exports = configure(function (/* ctx */) {
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
||||
build: {
|
||||
env: require('dotenv').config({ path: '.env.local' }).parsed,
|
||||
env: require('dotenv').config().parsed,
|
||||
target: {
|
||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||
node: 'node16',
|
||||
},
|
||||
vueRouterMode: 'hash', // available values: 'hash', 'history'
|
||||
vueRouterMode: 'history', // available values: 'hash', 'history'
|
||||
// vueRouterBase,
|
||||
// vueDevtools,
|
||||
// vueOptionsAPI: false,
|
||||
|
||||
@@ -1,40 +1,74 @@
|
||||
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 { Dialog, Notify } from 'quasar';
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
const client = new Client();
|
||||
|
||||
// appwrite.io SaaS
|
||||
// client
|
||||
// .setEndpoint('https://api.bab.toal.ca/v1')
|
||||
// .setProject('653ef6f76baf06d68034');
|
||||
// const appDatabaseId = '654ac5044d1c446feb71';
|
||||
let APPWRITE_API_ENDPOINT, APPWRITE_API_PROJECT;
|
||||
|
||||
// Private self-hosted appwrite
|
||||
if (process.env.APPWRITE_API_ENDPOINT && process.env.APPWRITE_API_PROJECT)
|
||||
client
|
||||
.setEndpoint(process.env.APPWRITE_API_ENDPOINT)
|
||||
.setProject(process.env.APPWRITE_API_PROJECT);
|
||||
if (process.env.APPWRITE_API_ENDPOINT && process.env.APPWRITE_API_PROJECT) {
|
||||
APPWRITE_API_ENDPOINT = process.env.APPWRITE_API_ENDPOINT;
|
||||
APPWRITE_API_PROJECT = 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 AppwriteIds = {
|
||||
databaseId: '65ee1cbf9c2493faf15f',
|
||||
collection: {
|
||||
boat: '66341910003e287cd71c',
|
||||
reservation: '663f8847000b8f5e29bb',
|
||||
skillTags: '66072582a74d94a4bd01',
|
||||
task: '65ee1cd5b550023fae4f',
|
||||
taskTags: '65ee21d72d5c8007c34c',
|
||||
timeBlock: '66361869002883fb4c4b',
|
||||
timeBlockTemplate: '66361f480007fdd639af',
|
||||
},
|
||||
};
|
||||
const pwresetUrl = process.env.DEV
|
||||
? 'http://localhost:4000/pwreset'
|
||||
: 'https://oys.undock.ca/pwreset';
|
||||
|
||||
const AppwriteIds = process.env.DEV
|
||||
? {
|
||||
databaseId: '65ee1cbf9c2493faf15f',
|
||||
collection: {
|
||||
boat: '66341910003e287cd71c',
|
||||
reservation: '663f8847000b8f5e29bb',
|
||||
skillTags: '66072582a74d94a4bd01',
|
||||
task: '65ee1cd5b550023fae4f',
|
||||
taskTags: '65ee21d72d5c8007c34c',
|
||||
interval: '66361869002883fb4c4b',
|
||||
intervalTemplate: '66361f480007fdd639af',
|
||||
},
|
||||
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 databases = new Databases(client);
|
||||
const functions = new Functions(client);
|
||||
const teams = new Teams(client);
|
||||
|
||||
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({
|
||||
type: 'primary',
|
||||
position: 'top',
|
||||
@@ -75,39 +109,57 @@ function login(email: string, password: string) {
|
||||
group: false,
|
||||
});
|
||||
const authStore = useAuthStore();
|
||||
authStore
|
||||
.login(email, password)
|
||||
.then(() => {
|
||||
notification({
|
||||
type: 'positive',
|
||||
message: 'Logged in!',
|
||||
timeout: 2000,
|
||||
spinner: false,
|
||||
icon: 'check_circle',
|
||||
});
|
||||
console.log('Redirecting to index page');
|
||||
appRouter.replace({ name: 'index' });
|
||||
})
|
||||
.catch(function (reason: Error) {
|
||||
notification({
|
||||
type: 'negative',
|
||||
message: 'Login failed.',
|
||||
timeout: 1,
|
||||
});
|
||||
try {
|
||||
await authStore.login(email, password);
|
||||
notification({
|
||||
type: 'positive',
|
||||
message: 'Logged in!',
|
||||
timeout: 2000,
|
||||
spinner: false,
|
||||
icon: 'check_circle',
|
||||
});
|
||||
console.log('Redirecting to index page');
|
||||
appRouter.replace({ name: 'index' });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AppwriteException) {
|
||||
if (error.type === 'user_session_already_exists') {
|
||||
appRouter.replace({ name: 'index' });
|
||||
notification({
|
||||
type: 'positive',
|
||||
message: 'Already Logged in!',
|
||||
timeout: 2000,
|
||||
spinner: false,
|
||||
icon: 'check_circle',
|
||||
});
|
||||
return;
|
||||
}
|
||||
Dialog.create({
|
||||
title: 'Login Error!',
|
||||
message: reason.message,
|
||||
message: error.message,
|
||||
persistent: true,
|
||||
});
|
||||
}
|
||||
notification({
|
||||
type: 'negative',
|
||||
message: 'Login failed.',
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(email: string) {
|
||||
await account.createRecovery(email, pwresetUrl);
|
||||
}
|
||||
|
||||
export {
|
||||
client,
|
||||
account,
|
||||
teams,
|
||||
databases,
|
||||
functions,
|
||||
ID,
|
||||
AppwriteIds,
|
||||
login,
|
||||
logout,
|
||||
resetPassword,
|
||||
};
|
||||
|
||||
@@ -4,37 +4,49 @@
|
||||
show-if-above
|
||||
:width="200"
|
||||
:breakpoint="1024"
|
||||
@update:model-value="$emit('drawer-toggle')"
|
||||
>
|
||||
@update:model-value="$emit('drawer-toggle')">
|
||||
<q-scroll-area class="fit">
|
||||
<q-list padding class="menu-list">
|
||||
<template v-for="link in enabledLinks" :key="link.name">
|
||||
<!-- TODO: Template this to be DRY --><q-item
|
||||
<q-list
|
||||
padding
|
||||
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"
|
||||
>
|
||||
:to="link.to">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="link.icon" />
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section> {{ link.name }} </q-item-section>
|
||||
<q-item-section>{{ link.name }}</q-item-section>
|
||||
</q-item>
|
||||
<q-list v-if="link.sublinks">
|
||||
<div v-for="sublink in link.sublinks" :key="sublink.name">
|
||||
<q-item clickable v-ripple :to="sublink.to" class="q-ml-md">
|
||||
<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> {{ sublink.name }} </q-item-section>
|
||||
<q-item-section>{{ sublink.name }}</q-item-section>
|
||||
</q-item>
|
||||
</div></q-list
|
||||
>
|
||||
</div>
|
||||
</q-list>
|
||||
</template>
|
||||
<q-item clickable v-ripple @click="logout()">
|
||||
<q-item-section avatar><q-icon name="logout" /></q-item-section
|
||||
><q-item-section>Logout</q-item-section>
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
@click="logout()">
|
||||
<q-item-section avatar><q-icon name="logout" /></q-item-section>
|
||||
<q-item-section>Logout</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-scroll-area>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<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-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||
<q-img
|
||||
:src="boat.imgSrc"
|
||||
:fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h6 text-left">{{ boat.name }}</div>
|
||||
<div class="col text-right">{{ boat.class }}</div>
|
||||
|
||||
@@ -113,12 +113,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
copyIntervalTemplate,
|
||||
timeTuplesOverlapped,
|
||||
useScheduleStore,
|
||||
} from 'src/stores/schedule';
|
||||
import { useScheduleStore } from 'src/stores/schedule';
|
||||
import { IntervalTemplate } from 'src/stores/schedule.types';
|
||||
import { copyIntervalTemplate, timeTuplesOverlapped } from 'src/utils/schedule';
|
||||
import { ref } from 'vue';
|
||||
const alert = ref(false);
|
||||
const overlapped = ref();
|
||||
|
||||
@@ -1,63 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<CalendarHeaderComponent v-model="selectedDate" />
|
||||
<div class="boat-schedule-table-component">
|
||||
<QCalendarDay
|
||||
ref="calendar"
|
||||
class="q-pa-xs"
|
||||
flat
|
||||
animated
|
||||
dense
|
||||
:disabled-before="disabledBefore"
|
||||
interval-height="24"
|
||||
interval-count="18"
|
||||
interval-start="06:00"
|
||||
:short-interval-label="true"
|
||||
v-model="selectedDate"
|
||||
:column-count="boats.length"
|
||||
v-touch-swipe.left.right="handleSwipe"
|
||||
>
|
||||
<template #head-day="{ scope }">
|
||||
<div style="text-align: center; font-weight: 800">
|
||||
{{ getBoatDisplayName(scope) }}
|
||||
</div>
|
||||
</template>
|
||||
<q-card>
|
||||
<q-toolbar>
|
||||
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
|
||||
<q-btn
|
||||
icon="close"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
v-close-popup />
|
||||
</q-toolbar>
|
||||
<q-separator />
|
||||
<CalendarHeaderComponent v-model="selectedDate" />
|
||||
<div class="boat-schedule-table-component">
|
||||
<QCalendarDay
|
||||
ref="calendar"
|
||||
class="q-pa-xs"
|
||||
flat
|
||||
animated
|
||||
dense
|
||||
:disabled-before="disabledBefore"
|
||||
interval-height="24"
|
||||
interval-count="18"
|
||||
interval-start="06:00"
|
||||
:short-interval-label="true"
|
||||
v-model="selectedDate"
|
||||
:column-count="boats.length"
|
||||
v-touch-swipe.left.right="handleSwipe">
|
||||
<template #head-day="{ scope }">
|
||||
<div style="text-align: center; font-weight: 800">
|
||||
{{ getBoatDisplayName(scope) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #day-body="{ scope }">
|
||||
<div v-for="block in getBoatBlocks(scope)" :key="block.$id">
|
||||
<template #day-body="{ scope }">
|
||||
<div
|
||||
class="timeblock"
|
||||
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
|
||||
:style="
|
||||
blockStyles(block, scope.timeStartPos, scope.timeDurationHeight)
|
||||
"
|
||||
:id="block.id"
|
||||
@click="selectBlock($event, scope, block)"
|
||||
>
|
||||
{{ boats[scope.columnIndex].name }}<br />
|
||||
{{ selectedBlock?.$id === block.$id ? 'Selected' : 'Available' }}
|
||||
v-for="block in getBoatBlocks(scope)"
|
||||
:key="block.$id">
|
||||
<div
|
||||
class="timeblock"
|
||||
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
|
||||
:style="
|
||||
blockStyles(
|
||||
block,
|
||||
scope.timeStartPos,
|
||||
scope.timeDurationHeight
|
||||
)
|
||||
"
|
||||
:id="block.id"
|
||||
@click="selectBlock($event, scope, block)"
|
||||
v-close-popup>
|
||||
{{ boats[scope.columnIndex].name }}
|
||||
<br />
|
||||
{{
|
||||
selectedBlock?.$id === block.$id ? 'Selected' : 'Available'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="reservation in getBoatReservations(scope)"
|
||||
:key="reservation.$id"
|
||||
>
|
||||
<div
|
||||
class="reservation"
|
||||
:style="
|
||||
reservationStyles(
|
||||
reservation,
|
||||
scope.timeStartPos,
|
||||
scope.timeDurationHeight
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ getUserName(reservation.user) || 'loading...' }}
|
||||
v-for="reservation in getBoatReservations(scope)"
|
||||
:key="reservation.$id">
|
||||
<div
|
||||
class="reservation column"
|
||||
:style="
|
||||
reservationStyles(
|
||||
reservation,
|
||||
scope.timeStartPos,
|
||||
scope.timeDurationHeight
|
||||
)
|
||||
">
|
||||
{{ getUserName(reservation.user) || 'loading...' }}
|
||||
<br />
|
||||
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</QCalendarDay>
|
||||
</div>
|
||||
</template>
|
||||
</QCalendarDay>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -91,7 +111,6 @@ const calendar = ref<QCalendarDay | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await useBoatStore().fetchBoats();
|
||||
await scheduleStore.fetchIntervals();
|
||||
await scheduleStore.fetchIntervalTemplates();
|
||||
});
|
||||
|
||||
@@ -169,6 +188,7 @@ interface DayBodyScope {
|
||||
|
||||
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||
// TODO: Disable blocks before today with updateDisabled and/or comparison
|
||||
if (scope.timestamp.disabled) return false;
|
||||
selectedBlock.value === block
|
||||
? (selectedBlock.value = null)
|
||||
: (selectedBlock.value = block);
|
||||
@@ -176,7 +196,7 @@ function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||
|
||||
const boatBlocks = computed((): Record<string, Interval[]> => {
|
||||
return scheduleStore
|
||||
.getIntervalsForDate(selectedDate.value)
|
||||
.getIntervals(selectedDate.value)
|
||||
.reduce((result, interval) => {
|
||||
if (!result[interval.boatId]) result[interval.boatId] = [];
|
||||
if (
|
||||
|
||||
@@ -3,48 +3,55 @@
|
||||
<q-page-container>
|
||||
<q-page class="flex bg-image flex-center">
|
||||
<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-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>
|
||||
<div class="text-center q-pt-sm">
|
||||
<div class="col text-h6">Log in</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-form class="q-gutter-md">
|
||||
<q-form>
|
||||
<q-card-section class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="email"
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
color="darkblue"
|
||||
filled
|
||||
></q-input>
|
||||
filled></q-input>
|
||||
<q-input
|
||||
v-model="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
color="darkblue"
|
||||
filled
|
||||
></q-input>
|
||||
<q-btn
|
||||
type="submit"
|
||||
@click="login(email, password)"
|
||||
label="Login"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
<!-- <q-btn
|
||||
filled></q-input>
|
||||
<q-card-actions>
|
||||
<q-btn
|
||||
type="button"
|
||||
@click="doLogin"
|
||||
label="Login"
|
||||
color="primary"></q-btn>
|
||||
<q-space />
|
||||
<q-btn
|
||||
flat
|
||||
color="secondary"
|
||||
to="/pwreset">
|
||||
Reset password
|
||||
</q-btn>
|
||||
<!-- <q-btn
|
||||
type="button"
|
||||
@click="register"
|
||||
color="secondary"
|
||||
label="Register"
|
||||
flat
|
||||
></q-btn> -->
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<q-card-section><GoogleOauthComponent /></q-card-section>
|
||||
</q-card-actions>
|
||||
</q-card-section>
|
||||
</q-form>
|
||||
<!-- <q-card-section><GoogleOauthComponent /></q-card-section> -->
|
||||
</q-card>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
@@ -69,8 +76,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { login } from 'boot/appwrite';
|
||||
import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
|
||||
// import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const doLogin = async () => {
|
||||
login(email.value, password.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
190
src/pages/ResetPassword.vue
Normal file
190
src/pages/ResetPassword.vue
Normal 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>
|
||||
@@ -1,109 +1,131 @@
|
||||
<template>
|
||||
<q-page>
|
||||
<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>
|
||||
<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">New Booking</div>
|
||||
<div class="text-caption text-grey-8">for: {{ bookingForm.name }}</div>
|
||||
</q-card-section>
|
||||
<q-list class="q-px-xs">
|
||||
<q-separator />
|
||||
<q-item
|
||||
class="q-px-none"
|
||||
clickable
|
||||
@click="boatSelect = true">
|
||||
<q-item-section>
|
||||
<q-item-label> Name: {{ bookingForm.name }} </q-item-label>
|
||||
<q-card
|
||||
v-if="bookingForm.boat"
|
||||
flat>
|
||||
<q-card-section>
|
||||
<q-img
|
||||
:src="bookingForm.boat?.imgSrc"
|
||||
:fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h7 text-left">
|
||||
{{ bookingForm.boat?.name }}
|
||||
</div>
|
||||
<div class="col text-right text-caption">
|
||||
{{ bookingForm.boat?.class }}
|
||||
</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section horizontal>
|
||||
<q-card-section>
|
||||
<q-list
|
||||
dense
|
||||
class="row">
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-badge
|
||||
color="primary"
|
||||
label="Start" />
|
||||
</q-item-section>
|
||||
<q-item-section class="text-body2">
|
||||
{{ formatDate(bookingForm.startDate) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item class="q-ma-none">
|
||||
<q-item-section avatar>
|
||||
<q-badge
|
||||
color="primary"
|
||||
label="End" />
|
||||
</q-item-section>
|
||||
<q-item-section class="text-body2">
|
||||
{{ formatDate(bookingForm.endDate) }}
|
||||
</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>
|
||||
<q-field
|
||||
readonly
|
||||
filled
|
||||
v-else>
|
||||
Tap to Select a Boat / Time
|
||||
</q-field>
|
||||
</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="interval" />
|
||||
|
||||
<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>
|
||||
<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="Reset"
|
||||
@click="onReset"
|
||||
color="secondary"
|
||||
size="md" />
|
||||
<q-btn
|
||||
label="Submit"
|
||||
@click="onSubmit"
|
||||
color="primary" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-dialog
|
||||
v-model="boatSelect"
|
||||
full-width>
|
||||
<BoatScheduleTableComponent v-model="interval" />
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import { date, useQuasar } from 'quasar';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||
import { getNewId } from 'src/utils/misc';
|
||||
import { formatDate } from 'src/utils/schedule';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useReservationStore } from 'src/stores/reservation';
|
||||
|
||||
@@ -113,42 +135,57 @@ interface BookingForm {
|
||||
boat?: Boat;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
reason: string;
|
||||
members: { name: string }[];
|
||||
guests: { name: string }[];
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
|
||||
|
||||
const auth = useAuthStore();
|
||||
const dateFormat = 'MMM D, YYYY h:mm A';
|
||||
const resourceView = ref(true);
|
||||
const interval = ref<Interval>();
|
||||
const bookingForm = ref<BookingForm>({
|
||||
const newForm = {
|
||||
bookingId: getNewId(),
|
||||
name: auth.currentUser?.name,
|
||||
boat: <Boat | undefined>undefined,
|
||||
startDate: date.formatDate(new Date(), dateFormat),
|
||||
endDate: date.formatDate(new Date(), dateFormat),
|
||||
members: [{ name: 'Karen Henrikso' }, { name: "Rich O'hare" }],
|
||||
guests: [{ name: 'Bob Barker' }, { name: 'Taylor Swift' }],
|
||||
});
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
reason: 'Open Sail',
|
||||
members: [],
|
||||
guests: [],
|
||||
comment: '',
|
||||
};
|
||||
const bookingForm = ref<BookingForm>({ ...newForm });
|
||||
const router = useRouter();
|
||||
const reservationStore = useReservationStore();
|
||||
const $q = useQuasar();
|
||||
const boatSelect = ref(false);
|
||||
|
||||
watch(interval, (new_interval) => {
|
||||
bookingForm.value.boat = useBoatStore().boats.find(
|
||||
(b) => b.$id === new_interval?.boatId
|
||||
);
|
||||
bookingForm.value.startDate = date.formatDate(
|
||||
new_interval?.start,
|
||||
dateFormat
|
||||
);
|
||||
bookingForm.value.endDate = date.formatDate(new_interval?.end, dateFormat);
|
||||
bookingForm.value.startDate = new_interval?.start;
|
||||
bookingForm.value.endDate = new_interval?.end;
|
||||
});
|
||||
|
||||
const bookingDuration = computed((): { hours: number; minutes: number } => {
|
||||
if (bookingForm.value.startDate && bookingForm.value.endDate) {
|
||||
const start = new Date(bookingForm.value.startDate).getTime();
|
||||
const end = new Date(bookingForm.value.endDate).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 onReset = () => {
|
||||
// TODO
|
||||
interval.value = undefined;
|
||||
bookingForm.value = { ...newForm };
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
const booking = bookingForm.value;
|
||||
if (
|
||||
@@ -163,9 +200,12 @@ const onSubmit = () => {
|
||||
end: booking.endDate,
|
||||
user: auth.currentUser.$id,
|
||||
status: 'confirmed',
|
||||
reason: booking.reason,
|
||||
comment: booking.comment,
|
||||
};
|
||||
console.log(reservation);
|
||||
// TODO: Fix this. It will always look successful
|
||||
reservationStore.createReservation(reservation);
|
||||
reservationStore.createReservation(reservation); // Probably should pass the notify as a callback to the reservation creation.
|
||||
$q.notify({
|
||||
color: 'green-4',
|
||||
textColor: 'white',
|
||||
@@ -174,28 +214,4 @@ const onSubmit = () => {
|
||||
});
|
||||
router.go(-1);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<div class="subcontent">
|
||||
<navigation-bar @today="onToday" @prev="onPrev" @next="onNext" />
|
||||
<div class="row justify-center">
|
||||
<q-calendar-scheduler
|
||||
ref="calendar"
|
||||
v-model="selectedDate"
|
||||
v-model:model-resources="boatStore.boats"
|
||||
resource-key="$id"
|
||||
resource-label="displayName"
|
||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||
view="week"
|
||||
bordered
|
||||
animated
|
||||
cell-width="150px"
|
||||
day-min-height="50px"
|
||||
@change="onChange"
|
||||
@moved="onMoved"
|
||||
@click-date="onClickDate"
|
||||
@click-time="onClickTime"
|
||||
@click-interval="onClickInterval"
|
||||
@click-head-day="onClickHeadDay"
|
||||
>
|
||||
<template #day="{ scope }">
|
||||
<div v-for="event in boatReservationEvents(scope)" :key="event.id">
|
||||
<div v-if="event.start !== undefined" class="booking-event">
|
||||
<span class="title q-calendar__ellipsis">
|
||||
{{ useAuthStore().getUserNameById(event.user) }}
|
||||
<q-tooltip>{{
|
||||
event.start +
|
||||
' - ' +
|
||||
boatStore.getBoatById(event.resource)?.name
|
||||
}}</q-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
<q-card class="subcontent">
|
||||
<navigation-bar
|
||||
@today="onToday"
|
||||
@prev="onPrev"
|
||||
@next="onNext" />
|
||||
<q-calendar-scheduler
|
||||
ref="calendar"
|
||||
v-model="selectedDate"
|
||||
v-model:model-resources="boatStore.boats"
|
||||
resource-key="$id"
|
||||
resource-label="displayName"
|
||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||
:view="$q.screen.gt.md ? 'week' : 'day'"
|
||||
bordered
|
||||
animated
|
||||
day-min-height="50px"
|
||||
@change="onChange"
|
||||
@moved="onMoved"
|
||||
@click-date="onClickDate"
|
||||
@click-time="onClickTime"
|
||||
@click-interval="onClickInterval"
|
||||
@click-head-day="onClickHeadDay">
|
||||
<template #day="{ scope }">
|
||||
<div
|
||||
v-for="event in boatReservationEvents(scope)"
|
||||
:key="event.id">
|
||||
<div
|
||||
v-if="event.start !== undefined"
|
||||
class="booking-event">
|
||||
<span class="title q-calendar__ellipsis">
|
||||
{{ useAuthStore().getUserNameById(event.user) }} @
|
||||
{{ renderTime(event.start) }}
|
||||
<q-tooltip>
|
||||
{{ renderTime(event.start) + ' - ' + renderTime(event.end) }}
|
||||
</q-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</q-calendar-scheduler>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</q-calendar-scheduler>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
@@ -53,10 +55,12 @@ import { QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
|
||||
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
const selectedDate = ref(today());
|
||||
const boatStore = useBoatStore();
|
||||
const calendar = ref();
|
||||
const $q = useQuasar();
|
||||
|
||||
interface DayScope {
|
||||
timestamp: Timestamp;
|
||||
@@ -68,6 +72,10 @@ interface DayScope {
|
||||
droppable: boolean;
|
||||
}
|
||||
|
||||
const renderTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString();
|
||||
};
|
||||
onMounted(() => boatStore.fetchBoats());
|
||||
// Method declarations
|
||||
|
||||
|
||||
179
src/pages/schedule/ListBookingsPage.vue
Normal file
179
src/pages/schedule/ListBookingsPage.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<q-card
|
||||
clas="q-ma-md"
|
||||
bordered
|
||||
v-if="!reservations">
|
||||
<q-card-section>
|
||||
<div class="text-h6">You don't have any 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>
|
||||
<template
|
||||
v-else
|
||||
v-for="(reservation, index) in sortedBookings"
|
||||
:key="reservation.$id">
|
||||
<q-toolbar
|
||||
class="bg-secondary glossy text-white"
|
||||
v-if="showMarker(index, sortedBookings)">
|
||||
Past
|
||||
</q-toolbar>
|
||||
<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) }}
|
||||
</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
|
||||
@click="modifyReservation(reservation)">
|
||||
Modify
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
@click="cancelReservation(reservation)">
|
||||
Cancel
|
||||
</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</template>
|
||||
<q-dialog v-model="cancelDialog">
|
||||
<q-card>
|
||||
<q-card-section class="row items-center">
|
||||
<q-avatar
|
||||
icon="stop"
|
||||
color="negative"
|
||||
text-color="white" />
|
||||
<span class="q-ml-sm">
|
||||
This will delete your reservation for
|
||||
{{ boatStore.getBoatById(currentReservation?.resource) }} on
|
||||
{{ formatDate(currentReservation?.start) }}
|
||||
</span>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
flat
|
||||
label="Cancel"
|
||||
color="primary"
|
||||
v-close-popup />
|
||||
<q-btn
|
||||
flat
|
||||
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 { Reservation } from 'src/stores/schedule.types';
|
||||
import { formatDate } from 'src/utils/schedule';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
const reservationStore = useReservationStore();
|
||||
const reservations = reservationStore.getUserReservations();
|
||||
const boatStore = useBoatStore();
|
||||
const currentReservation = ref<Reservation>();
|
||||
const cancelDialog = ref(false);
|
||||
|
||||
const sortedBookings = computed(() =>
|
||||
reservations.value
|
||||
?.slice()
|
||||
.sort((a, b) => new Date(b.start).getTime() - new Date(a.start).getTime())
|
||||
);
|
||||
|
||||
const isPast = (itemDate: Date | string): boolean => {
|
||||
if (!(itemDate instanceof Date)) {
|
||||
itemDate = new Date(itemDate);
|
||||
}
|
||||
console.log(itemDate);
|
||||
const currentDate = new Date();
|
||||
return itemDate < currentDate;
|
||||
};
|
||||
|
||||
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))
|
||||
);
|
||||
};
|
||||
|
||||
const cancelReservation = (reservation: Reservation) => {
|
||||
currentReservation.value = reservation;
|
||||
cancelDialog.value = true;
|
||||
};
|
||||
|
||||
const modifyReservation = (reservation: Reservation) => {
|
||||
return reservation;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
boatStore.fetchBoats();
|
||||
reservationStore.fetchUserReservations();
|
||||
});
|
||||
</script>
|
||||
@@ -113,7 +113,7 @@
|
||||
/></q-item>
|
||||
<q-separator spaced />
|
||||
<IntervalTemplateComponent
|
||||
v-for="template in timeblockTemplates"
|
||||
v-for="template in intervalTemplates"
|
||||
:key="template.$id"
|
||||
:model-value="template"
|
||||
/>
|
||||
@@ -146,11 +146,7 @@ import {
|
||||
today,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import {
|
||||
blocksOverlapped,
|
||||
buildInterval,
|
||||
useScheduleStore,
|
||||
} from 'src/stores/schedule';
|
||||
import { useScheduleStore } from 'src/stores/schedule';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import type {
|
||||
Interval,
|
||||
@@ -161,12 +157,13 @@ import { date } from 'quasar';
|
||||
import IntervalTemplateComponent from 'src/components/scheduling/IntervalTemplateComponent.vue';
|
||||
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { buildInterval, intervalsOverlapped } from 'src/utils/schedule';
|
||||
|
||||
const selectedDate = ref(today());
|
||||
const { fetchBoats } = useBoatStore();
|
||||
const scheduleStore = useScheduleStore();
|
||||
const { boats } = storeToRefs(useBoatStore());
|
||||
const { timeblockTemplates } = storeToRefs(useScheduleStore());
|
||||
const intervalTemplates = scheduleStore.getIntervalTemplates();
|
||||
const calendar = ref();
|
||||
const overlapped = ref();
|
||||
const alert = ref(false);
|
||||
@@ -185,12 +182,11 @@ const newTemplate = ref<IntervalTemplate>({
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchBoats();
|
||||
await scheduleStore.fetchIntervals();
|
||||
await scheduleStore.fetchIntervalTemplates();
|
||||
});
|
||||
|
||||
const filteredIntervals = (date: Timestamp, boat: Boat) => {
|
||||
return scheduleStore.getIntervals(date, boat).value;
|
||||
return scheduleStore.getIntervals(date, boat);
|
||||
};
|
||||
|
||||
const sortedIntervals = (date: Timestamp, boat: Boat) => {
|
||||
@@ -210,7 +206,7 @@ function createTemplate() {
|
||||
newTemplate.value.$id = 'unsaved';
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -218,12 +214,14 @@ function getIntervals(date: Timestamp, boat: Boat) {
|
||||
return scheduleStore.getIntervals(date, boat);
|
||||
}
|
||||
|
||||
function timeBlocksFromTemplate(
|
||||
function intervalsFromTemplate(
|
||||
boat: Boat,
|
||||
templateId: string,
|
||||
date: string
|
||||
): Interval[] {
|
||||
const template = timeblockTemplates.value.find((t) => t.$id === templateId);
|
||||
const template = scheduleStore
|
||||
.getIntervalTemplates()
|
||||
.value.find((t) => t.$id === templateId);
|
||||
return template
|
||||
? template.timeTuples.map((timeTuple: TimeTuple) =>
|
||||
buildInterval(boat, timeTuple, date)
|
||||
@@ -280,13 +278,13 @@ function onDrop(
|
||||
const templateId = e.dataTransfer.getData('ID');
|
||||
const date = scope.timestamp.date;
|
||||
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];
|
||||
overlapped.value = boatsToApply
|
||||
.map((boat) =>
|
||||
blocksOverlapped(
|
||||
intervalsOverlapped(
|
||||
existingIntervals.concat(
|
||||
timeBlocksFromTemplate(boat, templateId, date)
|
||||
intervalsFromTemplate(boat, templateId, date)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import routes from './routes';
|
||||
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
|
||||
* directly export the Router instantiation;
|
||||
@@ -35,14 +39,33 @@ export default route(function (/* { store, ssrContext } */) {
|
||||
history: createHistory(process.env.VUE_ROUTER_BASE),
|
||||
});
|
||||
|
||||
Router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
Router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
const currentUser = authStore.currentUser;
|
||||
const authRequired = !publicRoutes.includes(to.path);
|
||||
const requiredRoles = to.meta?.requiredRoles as string[];
|
||||
|
||||
if (auth.currentUser) {
|
||||
return to.meta.accountRoute ? { name: 'index' } : true;
|
||||
} else {
|
||||
return to.name == 'login' ? true : { name: 'login' };
|
||||
if (authRequired && !currentUser) {
|
||||
return next('/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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
|
||||
export const links = [
|
||||
{
|
||||
name: 'Home',
|
||||
@@ -27,6 +29,13 @@ export const links = [
|
||||
front_links: true,
|
||||
enabled: true,
|
||||
sublinks: [
|
||||
{
|
||||
name: 'List',
|
||||
to: '/schedule/list',
|
||||
icon: 'list',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Book',
|
||||
to: '/schedule/book',
|
||||
@@ -47,6 +56,7 @@ export const links = [
|
||||
icon: 'edit_calendar',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
requiredRoles: ['Schedule Admins'],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -80,11 +90,20 @@ export const links = [
|
||||
},
|
||||
];
|
||||
|
||||
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);
|
||||
link.sublinks = link.sublinks.filter(
|
||||
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
|
||||
);
|
||||
}
|
||||
return link;
|
||||
});
|
||||
|
||||
@@ -40,11 +40,16 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('src/pages/schedule/BoatScheduleView.vue'),
|
||||
name: 'boat-schedule',
|
||||
},
|
||||
{
|
||||
path: 'list',
|
||||
component: () => import('src/pages/schedule/ListBookingsPage.vue'),
|
||||
name: 'list-bookings',
|
||||
},
|
||||
{
|
||||
path: 'manage',
|
||||
component: () => import('src/pages/schedule/ManageCalendar.vue'),
|
||||
name: 'manage-schedule',
|
||||
meta: { requiresScheduleAdmin: true },
|
||||
meta: { requiredRoles: ['Schedule Admins'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -102,7 +107,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('layouts/AdminLayout.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
meta: { requiredRoles: ['admin'] },
|
||||
children: [
|
||||
{
|
||||
path: '/user',
|
||||
@@ -124,6 +129,22 @@ const routes: RouteRecordRaw[] = [
|
||||
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',
|
||||
component: () => import('pages/TermsOfServicePage.vue'),
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
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 { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
|
||||
const currentUserTeams = ref<Models.TeamList<Models.Preferences> | null>(
|
||||
null
|
||||
);
|
||||
const userNames = ref<Record<string, string>>({});
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
currentUser.value = await account.get();
|
||||
currentUserTeams.value = await teams.list();
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
currentUserTeams.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const currentUserTeamNames = computed(() =>
|
||||
currentUserTeams.value
|
||||
? currentUserTeams.value.teams.map((team) => team.name)
|
||||
: []
|
||||
);
|
||||
|
||||
const hasRequiredRole = (requiredRoles: string[]): boolean => {
|
||||
return requiredRoles.some((role) =>
|
||||
currentUserTeamNames.value.includes(role)
|
||||
);
|
||||
};
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
await account.create(ID.unique(), email, password);
|
||||
return await login(email, password);
|
||||
}
|
||||
async function login(email: string, password: string) {
|
||||
await account.createEmailPasswordSession(email, password);
|
||||
currentUser.value = await account.get();
|
||||
await init();
|
||||
}
|
||||
|
||||
async function googleLogin() {
|
||||
account.createOAuth2Session(
|
||||
OAuthProvider.Google,
|
||||
@@ -38,7 +56,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
userNames.value[id] = '';
|
||||
functions
|
||||
.createExecution(
|
||||
'664038294b5473ef0c8d',
|
||||
'userinfo',
|
||||
'',
|
||||
false,
|
||||
'/userinfo/' + id,
|
||||
@@ -65,6 +83,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return {
|
||||
currentUser,
|
||||
getUserNameById,
|
||||
hasRequiredRole,
|
||||
register,
|
||||
login,
|
||||
googleLogin,
|
||||
|
||||
@@ -39,8 +39,9 @@ export const useBoatStore = defineStore('boat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getBoatById = (id: string): Boat | null => {
|
||||
return boats.value.find((b) => b.$id === id) || null;
|
||||
const getBoatById = (id: string | null | undefined): Boat | null => {
|
||||
if (!id) return null;
|
||||
return boats.value?.find((b) => b.$id === id) || null;
|
||||
};
|
||||
|
||||
return { boats, fetchBoats, getBoatById };
|
||||
|
||||
@@ -5,11 +5,14 @@ import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||
import { ID, Query } from 'appwrite';
|
||||
import { date } from 'quasar';
|
||||
import { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar';
|
||||
import { LoadingTypes } from 'src/utils/misc';
|
||||
import { useAuthStore } from './auth';
|
||||
|
||||
export const useReservationStore = defineStore('reservation', () => {
|
||||
type LoadingTypes = 'loaded' | 'pending' | undefined;
|
||||
const reservations = ref<Map<string, Reservation>>(new Map());
|
||||
const datesLoaded = ref<Record<string, LoadingTypes>>({});
|
||||
const userReservations = ref<Reservation[]>();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Fetch reservations for a specific date range
|
||||
const fetchReservationsForDateRange = async (
|
||||
@@ -39,7 +42,7 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
setDateLoaded(startDate, endDate, 'loaded');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reservations', error);
|
||||
setDateLoaded(startDate, endDate, undefined);
|
||||
setDateLoaded(startDate, endDate, 'error');
|
||||
}
|
||||
};
|
||||
const createReservation = async (reservation: Reservation) => {
|
||||
@@ -51,11 +54,38 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
reservation
|
||||
);
|
||||
reservations.value.set(response.$id, response as Reservation);
|
||||
console.info('Reservation booked: ', response);
|
||||
} catch (e) {
|
||||
console.error('Error creating Reservation: ' + 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;
|
||||
}
|
||||
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.interval,
|
||||
id
|
||||
);
|
||||
reservations.value.delete(id);
|
||||
console.info(`Deleted reservation: ${id}`);
|
||||
} catch (e) {
|
||||
console.error('Error deleting reservation: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
// Set the loading state for dates
|
||||
const setDateLoaded = (start: Date, end: Date, state: LoadingTypes) => {
|
||||
if (start > end) return [];
|
||||
@@ -135,12 +165,33 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getUserReservations = () => {
|
||||
return userReservations;
|
||||
};
|
||||
|
||||
const fetchUserReservations = async () => {
|
||||
if (!authStore.currentUser) return;
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.reservation,
|
||||
[Query.equal('user', authStore.currentUser.$id)]
|
||||
);
|
||||
userReservations.value = response.documents as Reservation[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reservations for user: ', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getReservationsByDate,
|
||||
createReservation,
|
||||
deleteReservation,
|
||||
fetchReservationsForDateRange,
|
||||
isReservationOverlapped,
|
||||
isResourceTimeOverlapped,
|
||||
getConflictingReservations,
|
||||
fetchUserReservations,
|
||||
getUserReservations,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ export function getSampleReservations(): Reservation[] {
|
||||
end: '10:00',
|
||||
boat: '66359729003825946ae1',
|
||||
status: 'confirmed',
|
||||
reason: 'Open Sail',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
@@ -79,6 +80,7 @@ export function getSampleReservations(): Reservation[] {
|
||||
end: '19:00',
|
||||
boat: '66359729003825946ae1',
|
||||
status: 'confirmed',
|
||||
reason: 'Open Sail',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
@@ -87,6 +89,7 @@ export function getSampleReservations(): Reservation[] {
|
||||
end: '13:00',
|
||||
boat: '663597030029b71c7a9b',
|
||||
status: 'tentative',
|
||||
reason: 'Open Sail',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
@@ -95,6 +98,7 @@ export function getSampleReservations(): Reservation[] {
|
||||
end: '13:00',
|
||||
boat: '663597030029b71c7a9b',
|
||||
status: 'pending',
|
||||
reason: 'Open Sail',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
@@ -103,6 +107,7 @@ export function getSampleReservations(): Reservation[] {
|
||||
end: '19:00',
|
||||
boat: '663596b9000235ffea55',
|
||||
status: 'confirmed',
|
||||
reason: 'Private Sail',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
@@ -110,6 +115,7 @@ export function getSampleReservations(): Reservation[] {
|
||||
start: '13:00',
|
||||
end: '16:00',
|
||||
boat: '663596b9000235ffea55',
|
||||
reason: 'Open Sail',
|
||||
},
|
||||
];
|
||||
const boatStore = useBoatStore();
|
||||
@@ -137,7 +143,9 @@ export function getSampleReservations(): Reservation[] {
|
||||
end: date.adjustDate(now, makeOpts(splitTime(entry.end))).toISOString(),
|
||||
resource: boat.$id,
|
||||
reservationDate: now,
|
||||
reason: entry.reason,
|
||||
status: entry.status as StatusTypes,
|
||||
comment: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,127 +1,78 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ComputedRef, computed, ref } from 'vue';
|
||||
import { Ref, computed, ref } from 'vue';
|
||||
import { Boat } from './boat';
|
||||
import {
|
||||
Timestamp,
|
||||
parseDate,
|
||||
parsed,
|
||||
compareDate,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
|
||||
import { IntervalTemplate, TimeTuple, Interval } from './schedule.types';
|
||||
import { IntervalTemplate, Interval, IntervalRecord } 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;
|
||||
}
|
||||
import { ID, Models, Query } from 'appwrite';
|
||||
import { arrayToTimeTuples } from 'src/utils/schedule';
|
||||
|
||||
export const useScheduleStore = defineStore('schedule', () => {
|
||||
// TODO: Implement functions to dynamically pull this data.
|
||||
const intervals = ref<Interval[]>([]);
|
||||
const intervals = ref<Map<string, Interval>>(new Map());
|
||||
const intervalDates = ref<IntervalRecord>({});
|
||||
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 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.boatId : true;
|
||||
return isWithinDay && matchesBoat;
|
||||
});
|
||||
}).value;
|
||||
};
|
||||
|
||||
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() {
|
||||
async function fetchIntervals(dateString: string) {
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlock
|
||||
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.
|
||||
]
|
||||
);
|
||||
intervals.value = response.documents as Interval[];
|
||||
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 timeblocks', error);
|
||||
console.error('Failed to fetch intervals', error);
|
||||
intervalDates.value[dateString] = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
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.timeBlockTemplate
|
||||
AppwriteIds.collection.intervalTemplate
|
||||
);
|
||||
intervalTemplates.value = response.documents.map(
|
||||
(d: Models.Document): IntervalTemplate => {
|
||||
@@ -140,11 +91,11 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlock,
|
||||
AppwriteIds.collection.interval,
|
||||
ID.unique(),
|
||||
interval
|
||||
);
|
||||
intervals.value.push(response as Interval);
|
||||
intervals.value.set(response.$id, response as Interval);
|
||||
} catch (e) {
|
||||
console.error('Error creating Interval: ' + e);
|
||||
}
|
||||
@@ -154,12 +105,12 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
if (interval.$id) {
|
||||
const response = await databases.updateDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlock,
|
||||
AppwriteIds.collection.interval,
|
||||
interval.$id,
|
||||
{ ...interval, $id: undefined }
|
||||
);
|
||||
intervals.value.push(response as Interval);
|
||||
console.log(`Saved Interval: ${interval.$id}`);
|
||||
intervals.value.set(response.$id, response as Interval);
|
||||
console.info(`Saved Interval: ${interval.$id}`);
|
||||
} else {
|
||||
console.error('Update interval called without an ID');
|
||||
}
|
||||
@@ -171,10 +122,11 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlock,
|
||||
AppwriteIds.collection.interval,
|
||||
id
|
||||
);
|
||||
intervals.value = intervals.value.filter((block) => block.$id !== id);
|
||||
intervals.value.delete(id);
|
||||
console.info(`Deleted interval: ${id}`);
|
||||
} catch (e) {
|
||||
console.error('Error deleting Interval: ' + e);
|
||||
}
|
||||
@@ -183,7 +135,7 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlockTemplate,
|
||||
AppwriteIds.collection.intervalTemplate,
|
||||
ID.unique(),
|
||||
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
|
||||
);
|
||||
@@ -196,7 +148,7 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlockTemplate,
|
||||
AppwriteIds.collection.intervalTemplate,
|
||||
id
|
||||
);
|
||||
intervalTemplates.value = intervalTemplates.value.filter(
|
||||
@@ -213,7 +165,7 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlockTemplate,
|
||||
AppwriteIds.collection.intervalTemplate,
|
||||
id,
|
||||
{
|
||||
name: template.name,
|
||||
@@ -234,10 +186,8 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
};
|
||||
|
||||
return {
|
||||
timeblocks: intervals,
|
||||
timeblockTemplates: intervalTemplates,
|
||||
getIntervalsForDate,
|
||||
getIntervals,
|
||||
getIntervalTemplates,
|
||||
fetchIntervals,
|
||||
fetchIntervalTemplates,
|
||||
createInterval,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Models } from 'appwrite';
|
||||
import { LoadingTypes } from 'src/utils/misc';
|
||||
|
||||
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
||||
export type Reservation = Partial<Models.Document> & {
|
||||
@@ -7,6 +8,8 @@ export type Reservation = Partial<Models.Document> & {
|
||||
end: string;
|
||||
resource: string; // Boat ID
|
||||
status?: StatusTypes;
|
||||
reason: string;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
// 24 hrs in advance only 2 weekday, and 1 weekend slot
|
||||
@@ -27,3 +30,7 @@ export type IntervalTemplate = Partial<Models.Document> & {
|
||||
name: string;
|
||||
timeTuples: TimeTuple[];
|
||||
};
|
||||
|
||||
export interface IntervalRecord {
|
||||
[key: string]: LoadingTypes;
|
||||
}
|
||||
|
||||
@@ -5,3 +5,5 @@ export function getNewId(): string {
|
||||
// Trivial placeholder
|
||||
//return Math.max(...reservations.value.map((item) => item.id)) + 1;
|
||||
}
|
||||
|
||||
export type LoadingTypes = 'loaded' | 'pending' | 'error' | undefined;
|
||||
|
||||
77
src/utils/schedule.ts
Normal file
77
src/utils/schedule.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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 {
|
||||
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 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 = {
|
||||
boatId: resource.$id,
|
||||
start: new Date(blockDate + 'T' + time[0]).toISOString(),
|
||||
end: new Date(blockDate + 'T' + time[1]).toISOString(),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatDate(inputDate: string | undefined): string {
|
||||
if (!inputDate) return '';
|
||||
return date.formatDate(new Date(inputDate), 'ddd MMM Do hh:mm A');
|
||||
}
|
||||
@@ -5278,6 +5278,11 @@ vue-tsc@^1.8.22:
|
||||
"@vue/language-core" "1.8.27"
|
||||
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:
|
||||
version "3.4.25"
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.25.tgz#e59d4ed36389647b52ff2fd7aa84bb6691f4205b"
|
||||
|
||||
Reference in New Issue
Block a user