Visual improvements

This commit is contained in:
2024-05-24 08:11:47 -04:00
parent ce696a5a04
commit 68a2b8ffff
13 changed files with 441 additions and 162 deletions

View File

@@ -53,7 +53,7 @@ module.exports = configure(function (/* ctx */) {
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

@@ -14,25 +14,25 @@ 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) {
else if (process.env.DEV) { APPWRITE_API_ENDPOINT = 'http://localhost:4000/api/v1';
client APPWRITE_API_PROJECT = '65ede55a213134f2b688';
.setEndpoint('http://localhost:4000/api/v1')
.setProject('65ede55a213134f2b688');
} else { } else {
client.setEndpoint('https://appwrite.oys.undock.ca/v1').setProject('bab'); APPWRITE_API_ENDPOINT = 'https://appwrite.oys.undock.ca/v1';
APPWRITE_API_PROJECT = 'bab';
} }
//TODO move this to config file client.setEndpoint(APPWRITE_API_ENDPOINT).setProject(APPWRITE_API_PROJECT);
const pwresetUrl = process.env.DEV
? 'http://localhost:4000/pwreset'
: 'https://oys.undock.ca/pwreset';
const AppwriteIds = process.env.DEV const AppwriteIds = process.env.DEV
? { ? {
databaseId: '65ee1cbf9c2493faf15f', databaseId: '65ee1cbf9c2493faf15f',
@@ -121,20 +121,36 @@ async function login(email: string, password: string) {
console.log('Redirecting to index page'); console.log('Redirecting to index page');
appRouter.replace({ name: 'index' }); appRouter.replace({ name: 'index' });
} catch (error: unknown) { } catch (error: unknown) {
notification({
type: 'negative',
message: 'Login failed.',
timeout: 2000,
});
if (error instanceof AppwriteException) { 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({ Dialog.create({
title: 'Login Error!', title: 'Login Error!',
message: error.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,
@@ -145,4 +161,5 @@ export {
AppwriteIds, AppwriteIds,
login, login,
logout, logout,
resetPassword,
}; };

View File

@@ -31,8 +31,7 @@
clickable clickable
v-ripple v-ripple
:to="sublink.to" :to="sublink.to"
class="q-ml-md" class="q-ml-md">
v-if="hasRole(sublink.requiredRoles)">
<q-item-section avatar> <q-item-section avatar>
<q-icon :name="sublink.icon" /> <q-icon :name="sublink.icon" />
</q-item-section> </q-item-section>
@@ -57,17 +56,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { enabledLinks } from 'src/router/navlinks.js'; import { enabledLinks } from 'src/router/navlinks.js';
import { useAuthStore } from 'src/stores/auth';
import { logout } from 'boot/appwrite'; import { logout } from 'boot/appwrite';
const authStore = useAuthStore();
function hasRole(roles: string[] | undefined) {
if (roles === undefined) return true;
const hasRole = authStore.hasRequiredRole(roles);
return hasRole;
}
defineProps(['drawer']); defineProps(['drawer']);
defineEmits(['drawer-toggle']); defineEmits(['drawer-toggle']);

View File

@@ -71,7 +71,7 @@
"> ">
{{ getUserName(reservation.user) || 'loading...' }} {{ getUserName(reservation.user) || 'loading...' }}
<br /> <br />
<q-chip icon="key">{{ reservation.reason }}</q-chip> <q-chip>{{ reservation.reason }}</q-chip>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -3,47 +3,54 @@
<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="button" type="button"
@click="doLogin" @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-form>
<!-- <q-card-section><GoogleOauthComponent /></q-card-section> --> <!-- <q-card-section><GoogleOauthComponent /></q-card-section> -->
</q-card> </q-card>
</q-page> </q-page>

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,88 +1,98 @@
<template> <template>
<div class="q-pa-md row q-gutter-sm"> <div class="q-pa-xs row q-gutter-xs">
<q-card <q-card
flat flat
bordered bordered
class="col-md-4 col-sm-8 col-xs-12"> class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
<q-card-section> <q-card-section>
<div class="text-h5 q-mt-none q-mb-xs">New Booking</div> <div class="text-h5 q-mt-none q-mb-xs">New Booking</div>
<div class="text-caption text-grey-8">for: {{ bookingForm.name }}</div> <div class="text-caption text-grey-8">for: {{ bookingForm.name }}</div>
</q-card-section> </q-card-section>
<q-separator /> <q-list class="q-px-xs">
<q-item <q-separator />
clickable <q-item
@click="boatSelect = true"> class="q-px-none"
<q-item-section avatar> clickable
@click="boatSelect = true">
<!-- <q-item-section avatar>
<q-icon <q-icon
color="primary" color="primary"
name="event" /> name="event" />
</q-item-section> </q-item-section> -->
<q-item-section> <q-item-section>
<q-card v-if="bookingForm.boat"> <q-card v-if="bookingForm.boat">
<q-card-section> <q-card-section>
<q-img <q-img
:src="bookingForm.boat?.imgSrc" :src="bookingForm.boat?.imgSrc"
:fit="'scale-down'"> :fit="'scale-down'">
<div class="row absolute-top"> <div class="row absolute-top">
<div class="col text-h6 text-left"> <div class="col text-h7 text-left">
{{ bookingForm.boat?.name }} {{ bookingForm.boat?.name }}
</div>
<div class="col text-right text-caption">
{{ bookingForm.boat?.class }}
</div>
</div> </div>
<div class="col text-right"> </q-img>
{{ bookingForm.boat?.class }} </q-card-section>
</div> <q-separator />
</div> <q-card-section horizontal>
</q-img> <q-card-section>
</q-card-section> <q-list
<q-separator /> dense
<q-card-section class="q-py-none"> class="row">
<q-list <q-item>
dense <q-item-section avatar>
class="row"> <q-badge
<q-item> color="primary"
<q-item-section avatar> label="Start" />
<q-badge </q-item-section>
outline <q-item-section class="text-caption">
color="primary" {{
label="Start" /> date.formatDate(
</q-item-section> new Date(bookingForm.startDate as string),
<q-item-section> 'ddd MMM Do @ hh:mm A'
{{ )
date.formatDate( }}
new Date(bookingForm.startDate as string), </q-item-section>
'ddd MMM Do @ hh:mm A' </q-item>
) <q-item class="q-ma-none">
}} <q-item-section avatar>
</q-item-section> <q-badge
</q-item> color="primary"
<q-item class="q-ma-none"> label="End" />
<q-item-section avatar> </q-item-section>
<q-badge <q-item-section class="text-caption">
outline {{
color="primary" date.formatDate(
label="End" /> new Date(bookingForm.endDate as string),
</q-item-section> 'ddd MMM Do @ hh:mm A'
<q-item-section> )
{{ }}
date.formatDate( </q-item-section>
new Date(bookingForm.endDate as string), </q-item>
'ddd MMM Do @ hh:mm A' </q-list>
) </q-card-section>
}} <q-separator vertical />
</q-item-section> <q-card-section class="col-3 flex flex-center bg-grey-4">
</q-item> {{ bookingDuration }} hours
</q-list> </q-card-section>
</q-card-section> </q-card-section>
</q-card> </q-card>
<div v-else>Tap to Select a Boat / Time</div> <q-field
</q-item-section> readonly
</q-item> filled
<q-list dense> v-else>
<q-item> Tap to Select a Boat / Time
<q-item-section avatar> </q-field>
</q-item-section>
</q-item>
<q-item class="q-px-none">
<!-- <q-item-section avatar>
<q-icon <q-icon
color="primary" color="primary"
name="category" /> name="category" />
</q-item-section> </q-item-section> -->
<q-item-section> <q-item-section>
<q-select <q-select
filled filled
@@ -91,6 +101,21 @@
label="Reason for sail" /> label="Reason for sail" />
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item class="q-px-none">
<!-- <q-item-section avatar>
<q-icon
color="primary"
name="text" />
</q-item-section> -->
<q-item-section>
<q-input
v-model="bookingForm.comment"
clearable
autogrow
filled
label="Additional Comments (optional)" />
</q-item-section>
</q-item>
</q-list> </q-list>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn <q-btn
@@ -113,7 +138,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useAuthStore } from 'src/stores/auth'; import { useAuthStore } from 'src/stores/auth';
import { Boat, useBoatStore } from 'src/stores/boat'; import { Boat, useBoatStore } from 'src/stores/boat';
import { date, useQuasar } from 'quasar'; import { date, useQuasar } from 'quasar';
@@ -132,6 +157,7 @@ interface BookingForm {
reason: string; reason: string;
members: { name: string }[]; members: { name: string }[];
guests: { name: string }[]; guests: { name: string }[];
comment?: string;
} }
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other']; const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
@@ -147,6 +173,7 @@ const newForm = {
reason: 'Open Sail', reason: 'Open Sail',
members: [], members: [],
guests: [], guests: [],
comment: '',
}; };
const bookingForm = ref<BookingForm>({ ...newForm }); const bookingForm = ref<BookingForm>({ ...newForm });
const router = useRouter(); const router = useRouter();
@@ -162,6 +189,16 @@ watch(interval, (new_interval) => {
bookingForm.value.endDate = new_interval?.end; bookingForm.value.endDate = new_interval?.end;
}); });
const bookingDuration = computed((): 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;
return Math.floor(delta / 3600) % 24;
}
return 0;
});
const onReset = () => { const onReset = () => {
bookingForm.value = { ...newForm }; bookingForm.value = { ...newForm };
}; };
@@ -180,6 +217,7 @@ const onSubmit = () => {
user: auth.currentUser.$id, user: auth.currentUser.$id,
status: 'confirmed', status: 'confirmed',
reason: booking.reason, reason: booking.reason,
comment: booking.comment,
}; };
console.log(reservation); console.log(reservation);
// TODO: Fix this. It will always look successful // TODO: Fix this. It will always look successful

View File

@@ -1,44 +1,46 @@
<template> <template>
<q-page padding> <q-page padding>
<div class="subcontent"> <q-card class="subcontent">
<navigation-bar @today="onToday" @prev="onPrev" @next="onNext" /> <navigation-bar
<div class="row justify-center"> @today="onToday"
<q-calendar-scheduler @prev="onPrev"
ref="calendar" @next="onNext" />
v-model="selectedDate" <q-calendar-scheduler
v-model:model-resources="boatStore.boats" ref="calendar"
resource-key="$id" v-model="selectedDate"
resource-label="displayName" v-model:model-resources="boatStore.boats"
:weekdays="[1, 2, 3, 4, 5, 6, 0]" resource-key="$id"
view="week" resource-label="displayName"
bordered :weekdays="[1, 2, 3, 4, 5, 6, 0]"
animated :view="$q.screen.gt.md ? 'week' : 'day'"
cell-width="150px" bordered
day-min-height="50px" animated
@change="onChange" day-min-height="50px"
@moved="onMoved" @change="onChange"
@click-date="onClickDate" @moved="onMoved"
@click-time="onClickTime" @click-date="onClickDate"
@click-interval="onClickInterval" @click-time="onClickTime"
@click-head-day="onClickHeadDay" @click-interval="onClickInterval"
> @click-head-day="onClickHeadDay">
<template #day="{ scope }"> <template #day="{ scope }">
<div v-for="event in boatReservationEvents(scope)" :key="event.id"> <div
<div v-if="event.start !== undefined" class="booking-event"> v-for="event in boatReservationEvents(scope)"
<span class="title q-calendar__ellipsis"> :key="event.id">
{{ useAuthStore().getUserNameById(event.user) }} <div
<q-tooltip>{{ v-if="event.start !== undefined"
event.start + class="booking-event">
' - ' + <span class="title q-calendar__ellipsis">
boatStore.getBoatById(event.resource)?.name {{ useAuthStore().getUserNameById(event.user) }} @
}}</q-tooltip> {{ renderTime(event.start) }}
</span> <q-tooltip>
</div> {{ renderTime(event.start) + ' - ' + renderTime(event.end) }}
</q-tooltip>
</span>
</div> </div>
</template> </div>
</q-calendar-scheduler> </template>
</div> </q-calendar-scheduler>
</div> </q-card>
</q-page> </q-page>
</template> </template>
@@ -53,10 +55,12 @@ import { QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
import { Timestamp } from '@quasar/quasar-ui-qcalendar'; import { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat'; import { Boat, useBoatStore } from 'src/stores/boat';
import NavigationBar from 'src/components/scheduling/NavigationBar.vue'; import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
import { useQuasar } from 'quasar';
const selectedDate = ref(today()); const selectedDate = ref(today());
const boatStore = useBoatStore(); const boatStore = useBoatStore();
const calendar = ref(); const calendar = ref();
const $q = useQuasar();
interface DayScope { interface DayScope {
timestamp: Timestamp; timestamp: Timestamp;
@@ -68,6 +72,10 @@ interface DayScope {
droppable: boolean; droppable: boolean;
} }
const renderTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString();
};
onMounted(() => boatStore.fetchBoats()); onMounted(() => boatStore.fetchBoats());
// Method declarations // Method declarations

View File

@@ -1,3 +1,5 @@
import { useAuthStore } from 'src/stores/auth';
export const links = [ export const links = [
{ {
name: 'Home', name: 'Home',
@@ -81,11 +83,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 export const enabledLinks = links
.filter((link) => link.enabled) .filter((link) => link.enabled)
.map((link) => { .map((link) => {
if (link.sublinks) { if (link.sublinks) {
link.sublinks = link.sublinks.filter((sublink) => sublink.enabled); link.sublinks = link.sublinks.filter(
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
);
} }
return link; return link;
}); });

View File

@@ -124,6 +124,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

@@ -38,7 +38,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
async function login(email: string, password: string) { async function login(email: string, password: string) {
await account.createEmailPasswordSession(email, password); await account.createEmailPasswordSession(email, password);
init(); await init();
} }
async function googleLogin() { async function googleLogin() {

View File

@@ -145,6 +145,7 @@ export function getSampleReservations(): Reservation[] {
reservationDate: now, reservationDate: now,
reason: entry.reason, reason: entry.reason,
status: entry.status as StatusTypes, status: entry.status as StatusTypes,
comment: '',
}; };
}); });
} }

View File

@@ -9,6 +9,7 @@ export type Reservation = Partial<Models.Document> & {
resource: string; // Boat ID resource: string; // Boat ID
status?: StatusTypes; status?: StatusTypes;
reason: string; reason: string;
comment: string;
}; };
// 24 hrs in advance only 2 weekday, and 1 weekend slot // 24 hrs in advance only 2 weekday, and 1 weekend slot