Many tweaks to booking form.

This commit is contained in:
2023-12-01 00:08:29 -05:00
parent aed0462e05
commit 8600000e24
7 changed files with 312 additions and 242 deletions

View File

@@ -1,32 +1,46 @@
<template> <template>
<div class="row justify-center"> <q-card-section>
<q-btn-group rounded> <div class="text-caption text-justify">
<q-btn Use the calendar to pick a date. Tap a box in the grid for the boat and
color="primary" start time. Select the duration below.
rounded
icon="keyboard_arrow_left"
label="Prev"
@click="onPrev"
/>
<q-btn
color="primary"
rounded
icon="today"
label="Today"
@click="onToday"
/>
<q-btn
color="primary"
rounded
icon-right="keyboard_arrow_right"
label="Next"
@click="onNext"
/>
</q-btn-group>
</div> </div>
<div style="width: 100%; display: flex; justify-content: center">
<div
style="
width: 50%;
max-width: 350px;
display: flex;
justify-content: space-between;
"
>
<span
class="q-button"
style="cursor: pointer; user-select: none"
@click="onPrev"
>&lt;</span
>
{{ formattedMonth }}
<span
class="q-button"
style="cursor: pointer; user-select: none"
@click="onNext"
>&gt;</span
>
</div>
</div>
<div
style="
display: flex;
justify-content: center;
align-items: center;
flex-wrap: nowrap;
"
>
<div style="display: flex; width: 100%">
<q-calendar-month <q-calendar-month
ref="calendar" ref="calendar"
v-model="selectedDate" v-model="selectedDate"
:disabled-before="disabledBefore"
animated animated
bordered bordered
mini-mode mini-mode
@@ -35,9 +49,9 @@
@moved="onMoved" @moved="onMoved"
@click-date="onClickDate" @click-date="onClickDate"
/> />
<div style="float: right; display: flex; max-width: 1024px; width: 100%"> </div></div
></q-card-section>
<q-calendar-resource <q-calendar-resource
ref="calendar"
v-model="selectedDate" v-model="selectedDate"
:model-resources="boatStore.boats" :model-resources="boatStore.boats"
resource-key="id" resource-key="id"
@@ -62,8 +76,35 @@
<q-badge outline :label="event.title" :style="getStyle(event)" /> <q-badge outline :label="event.title" :style="getStyle(event)" />
</template> </template>
</template> </template>
</q-calendar-resource>
<template #resource-label="{ scope: { resource } }">
<div class="col-12">
{{ resource.name }}
<q-icon v-if="resource.defects" name="warning" color="warning" />
</div> </div>
</template>
</q-calendar-resource>
<q-card-section>
<q-select
filled
v-model="duration"
:options="durations"
dense
@update:model-value="onUpdateDuration"
label="Duration (hours)"
stack-label
><template v-slot:append><q-icon name="timelapse" /></template></q-select
></q-card-section>
<q-card-section>
<q-btn
color="primary"
class="full-width"
icon="keyboard_arrow_down"
icon-right="keyboard_arrow_down"
label="Next: Crew & Passengers"
@click="onCloseSection"
/></q-card-section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -73,10 +114,16 @@ import {
TimestampOrNull, TimestampOrNull,
today, today,
parseDate, parseDate,
parseTimestamp,
addToDate,
Timestamp,
} from '@quasar/quasar-ui-qcalendar'; } from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat'; import { Boat, useBoatStore } from 'src/stores/boat';
import { useScheduleStore } from 'src/stores/schedule'; import { useScheduleStore } from 'src/stores/schedule';
import { date } from 'quasar'; import { date } from 'quasar';
import { computed } from 'vue';
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
type ResourceIntervalScope = { type ResourceIntervalScope = {
resource: Boat; resource: Boat;
@@ -86,15 +133,37 @@ type ResourceIntervalScope = {
}; };
const statusLookup = { const statusLookup = {
tentative: ['#f2c037', 'white'],
confirmed: ['#14539a', 'white'], confirmed: ['#14539a', 'white'],
pending: ['white', 'grey'], pending: ['#f2c037', 'white'],
tentative: ['white', 'grey'],
}; };
const calendar = ref(); const calendar = ref();
const boatStore = useBoatStore(); const boatStore = useBoatStore();
const scheduleStore = useScheduleStore(); const scheduleStore = useScheduleStore();
const selectedDate = ref(today()); const selectedDate = ref(today());
const duration = ref(1);
const formattedMonth = computed(() => {
const date = new Date(selectedDate.value);
return monthFormatter()?.format(date);
});
const disabledBefore = computed(() => {
const todayTs = parseTimestamp(today()) as Timestamp;
return addToDate(todayTs, { day: -1 }).date;
});
function monthFormatter() {
try {
return new Intl.DateTimeFormat('en-CA' || undefined, {
month: 'long',
timeZone: 'UTC',
});
} catch (e) {
//
}
}
function getEvents(scope: ResourceIntervalScope) { function getEvents(scope: ResourceIntervalScope) {
const resourceEvents = scheduleStore.getBoatReservations( const resourceEvents = scheduleStore.getBoatReservations(
@@ -131,11 +200,8 @@ function getStyle(event: {
}; };
} }
const emit = defineEmits(['onClickDate', 'onClickTime']); const emit = defineEmits(['onClickTime', 'onCloseSection', 'onUpdateDuration']);
function onToday() {
calendar.value.moveToToday();
}
function onPrev() { function onPrev() {
calendar.value.prev(); calendar.value.prev();
} }
@@ -143,16 +209,28 @@ function onNext() {
calendar.value.next(); calendar.value.next();
} }
function onClickDate(data) { function onClickDate(data) {
emit('onClickDate', data); return;
} }
function onClickTime(data) { function onClickTime(data) {
// TODO: Add a duration picker, here.
emit('onClickTime', data); emit('onClickTime', data);
} }
function onCloseSection() {
emit('onCloseSection');
}
function onUpdateDuration(value) {
emit('onUpdateDuration', value);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
const onClickInterval = () => {}; const onClickInterval = () => {};
const onClickHeadResources = onClickInterval; // eslint-disable-next-line @typescript-eslint/no-empty-function
const onClickResource = onClickInterval; const onClickHeadResources = () => {};
const onResourceExpanded = onClickInterval; // eslint-disable-next-line @typescript-eslint/no-empty-function
const onMoved = onClickInterval; const onClickResource = () => {};
const onChange = onClickInterval; // eslint-disable-next-line @typescript-eslint/no-empty-function
const onResourceExpanded = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onMoved = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onChange = () => {};
</script> </script>

View File

@@ -0,0 +1,40 @@
<template>
<q-select
v-model="boat"
:options="boats"
option-value="id"
option-label="name"
label="Boat"
>
<template v-slot:prepend>
<q-item-section avatar>
<q-img v-if="boat?.iconsrc" :src="boat?.iconsrc" />
<q-icon v-else name="sailing" />
</q-item-section>
</template>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-img :src="scope.opt.iconsrc" />
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.name }}</q-item-label>
<q-item-label caption>{{ scope.opt.class }}</q-item-label>
</q-item-section>
<q-item-section avatar v-if="scope.opt.defects">
<q-icon name="warning" color="warning" />
<q-tooltip class="bg-amber text-black shadow-7"
>This boat has notices. Select it to see details.
</q-tooltip>
</q-item-section>
</q-item>
</template>
</q-select>
</template>
<script setup lang="ts">
import { Boat, useBoatStore } from 'src/stores/boat';
const boats = useBoatStore().boats;
const boat = <Boat | undefined>undefined;
</script>

View File

@@ -6,7 +6,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import BoatPreviewComponent from 'src/components/BoatPreviewComponent.vue'; import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { useBoatStore } from 'src/stores/boat'; import { useBoatStore } from 'src/stores/boat';
import ToolbarComponent from 'src/components/ToolbarComponent.vue'; import ToolbarComponent from 'src/components/ToolbarComponent.vue';

View File

@@ -1,47 +1,36 @@
<template> <template>
<q-page padding> <q-page padding>
<q-card> <q-list>
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md"> <q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
<q-input bottom-slots v-model="bookingForm.name" label="Name" readonly> <q-input
bottom-slots
v-model="bookingForm.name"
label="Creating reservation for"
readonly
>
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="person" /> <q-icon name="person" />
</template> </template>
</q-input> </q-input>
<q-select <q-expansion-item
v-model="bookingForm.boat" expand-separator
:options="boats" v-model="resourceView"
option-value="id" icon="calendar_month"
option-label="name" label="Boat and Time"
label="Boat" default-opened
:caption="bookingSummary"
> >
<template v-slot:prepend> <q-separator />
<q-item-section avatar> <resource-schedule-viewer-component
<q-img @on-click-time="onClickTime"
v-if="bookingForm.boat?.iconsrc" @on-close-section="() => (resourceView = !resourceView)"
:src="bookingForm.boat?.iconsrc" @on-update-duration="
/> (value) => {
<q-icon v-else name="sailing" /> bookingForm.duration = value;
</q-item-section> }
</template> "
/></q-expansion-item>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-img :src="scope.opt.iconsrc" />
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.name }}</q-item-label>
<q-item-label caption>{{ scope.opt.class }}</q-item-label>
</q-item-section>
<q-item-section avatar v-if="scope.opt.defects">
<q-icon name="warning" color="warning" />
<q-tooltip class="bg-amber text-black shadow-7"
>This boat has defects. Select it to see details.
</q-tooltip>
</q-item-section>
</q-item>
</template>
</q-select>
<q-banner <q-banner
rounded rounded
class="bg-warning text-grey-10" class="bg-warning text-grey-10"
@@ -50,7 +39,7 @@
<template v-slot:avatar> <template v-slot:avatar>
<q-icon name="warning" color="grey-10" /> <q-icon name="warning" color="grey-10" />
</template> </template>
This boat currently has the following defects: This boat currently has the following notices:
<ol> <ol>
<li <li
v-for="defect in bookingForm.boat.defects" v-for="defect in bookingForm.boat.defects"
@@ -60,145 +49,65 @@
</li> </li>
</ol> </ol>
</q-banner> </q-banner>
<q-btn <q-expansion-item
label="View Schedule" expand-separator
color="primary" icon="people"
flat label="Crew and Passengers"
@click="showSchedule = true" default-opened
>
<q-dialog
v-model="showSchedule"
full-width
cover
transition-show="scale"
transition-hide="scale"
>
<q-card>
<resource-schedule-viewer-component
@on-click-date="onClickDate"
@on-click-time="onClickTime"
/>
</q-card>
</q-dialog>
</q-btn>
<q-input
v-model="bookingForm.startDate"
label="Check-Out"
label-color="accent"
readonly
>
<template v-slot:prepend>
<q-icon name="event" class="cursor-pointer"> </q-icon>
</template>
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
@before-show="startDateOrTime = true"
>
<q-date
v-model="bookingForm.startDate"
mask="ddd MMM D, YYYY h:mm A"
:options="limitDate"
v-if="startDateOrTime"
>
<div class="row items-center justify-end">
<q-btn
label="Next"
color="primary"
flat
@click="startDateOrTime = false"
/>
</div>
</q-date>
<q-time
v-model="bookingForm.startDate"
mask="ddd MMM D, YYYY h:mm A"
:minute-options="minOpts"
v-else
>
<div class="row items-center justify-end">
<q-btn v-close-popup label="Close" color="primary" flat />
</div>
</q-time>
</q-popup-proxy>
</q-input>
<q-input
v-model="bookingForm.endDate"
label="Check-In"
label-color="accent"
readonly
>
<template v-slot:prepend>
<q-icon name="event" class="cursor-pointer"> </q-icon>
</template>
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
@before-show="endDateOrTime = true"
>
<q-date
v-model="bookingForm.startDate"
mask="ddd MMM D, YYYY h:mm A"
:options="limitDate"
v-if="endDateOrTime"
>
<div class="row items-center justify-end">
<q-btn v-close-popup label="Cancel" color="accent" flat />
<q-btn
label="Next"
color="primary"
flat
@click="endDateOrTime = !endDateOrTime"
/>
</div>
</q-date>
<q-time
v-model="bookingForm.endDate"
mask="ddd MMM D, YYYY h:mm A"
:minute-options="minOpts"
v-else
>
<div class="row items-center justify-end">
<q-btn v-close-popup label="Close" color="primary" flat />
</div>
</q-time>
</q-popup-proxy>
</q-input>
<q-chip icon="timelapse"
>Booking Duration: {{ bookingDuration }}</q-chip
> >
<q-separator />
</q-expansion-item>
<q-item-section> <q-item-section>
<q-btn label="Submit" type="submit" color="primary" /> <q-btn label="Submit" type="submit" color="primary" />
</q-item-section> </q-item-section> </q-form
</q-form> ></q-list>
</q-card>
</q-page> </q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { reactive, ref, computed, 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 } from 'quasar'; import { Dialog, date } from 'quasar';
import ResourceScheduleViewerComponent from 'src/components/ResourceScheduleViewerComponent.vue'; import ResourceScheduleViewerComponent from 'src/components/ResourceScheduleViewerComponent.vue';
import { makeDateTime } from '@quasar/quasar-ui-qcalendar'; import { makeDateTime } from '@quasar/quasar-ui-qcalendar';
import { useScheduleStore, Reservation } from 'src/stores/schedule';
const auth = useAuthStore(); const auth = useAuthStore();
const boats = useBoatStore().boats;
const dateFormat = 'ddd MMM D, YYYY h:mm A'; const dateFormat = 'ddd MMM D, YYYY h:mm A';
const startDateOrTime = ref(true); const resourceView = ref(true);
const endDateOrTime = ref(true); const scheduleStore = useScheduleStore();
const showSchedule = ref(false); const bookingForm = reactive({
const bookingForm = ref({ bookingId: scheduleStore.getNewId(),
name: auth.currentUser?.name, name: auth.currentUser?.name,
boat: <Boat | undefined>undefined, boat: <Boat | undefined>undefined,
startDate: date.formatDate(new Date(), dateFormat), startDate: date.formatDate(new Date(), dateFormat),
endDate: date.formatDate( endDate: computed(() =>
date.addToDate(new Date(), { hours: 2 }), date.formatDate(
date.addToDate(bookingForm.startDate, {
hours: bookingForm.duration,
}),
dateFormat dateFormat
)
), ),
duration: 1,
});
watch(bookingForm, (b, a) => {
const newRes = <Reservation>{
id: b.bookingId,
user: b.name,
resource: b.boat,
start: date.extractDate(b.startDate, dateFormat),
end: date.extractDate(b.endDate, dateFormat),
reservationDate: new Date(),
status: 'tentative',
};
//TODO: Turn this into a validator.
scheduleStore.isOverlapped(newRes)
? Dialog.create({ message: 'This booking overlaps another!' })
: scheduleStore.addOrCreateReservation(newRes);
}); });
const onReset = () => { const onReset = () => {
@@ -209,22 +118,18 @@ const onSubmit = () => {
// TODO // TODO
}; };
const onClickDate = (data) => {
console.log(data);
};
const onClickTime = (data) => { const onClickTime = (data) => {
bookingForm.value.boat = data.scope.resource; bookingForm.boat = data.scope.resource;
bookingForm.value.startDate = date.formatDate( bookingForm.startDate = date.formatDate(
date.addToDate(makeDateTime(data.scope.timestamp), { hours: 5 }), // A terrible hack to convert back to EST. TODO: FIX!!!! date.addToDate(makeDateTime(data.scope.timestamp), { hours: 5 }), // A terrible hack to convert back to EST. TODO: FIX!!!!
dateFormat dateFormat
); );
showSchedule.value = false; console.log(bookingForm.startDate);
console.log(bookingForm.value.startDate);
}; };
const bookingDuration = computed(() => { const bookingDuration = computed(() => {
const diff = date.getDateDiff( const diff = date.getDateDiff(
bookingForm.value.endDate, bookingForm.endDate,
bookingForm.value.startDate, bookingForm.startDate,
'minutes' 'minutes'
); );
return diff <= 0 return diff <= 0
@@ -233,6 +138,12 @@ const bookingDuration = computed(() => {
(diff % 60 > 0 ? ' ' + (diff % 60) + ' minutes' : ''); (diff % 60 > 0 ? ' ' + (diff % 60) + ' minutes' : '');
}); });
const bookingSummary = computed(() => {
return bookingForm.boat && bookingForm.startDate && bookingForm.endDate
? `${bookingForm.boat.name} @ ${bookingForm.startDate} for ${bookingDuration.value}`
: '';
});
const limitDate = (startDate: string) => { const limitDate = (startDate: string) => {
return date.isBetweenDates( return date.isBetweenDates(
startDate, startDate,
@@ -241,6 +152,4 @@ const limitDate = (startDate: string) => {
{ inclusiveFrom: true, inclusiveTo: true, onlyDate: true } { inclusiveFrom: true, inclusiveTo: true, onlyDate: true }
); );
}; };
const minOpts = [0, 15, 30, 45, 60];
</script> </script>

View File

@@ -5,6 +5,7 @@ import { date } from 'quasar';
import { DateOptions } from 'quasar'; import { DateOptions } from 'quasar';
export interface Reservation { export interface Reservation {
id: number;
user: string; user: string;
start: Date; start: Date;
end: Date; end: Date;
@@ -16,6 +17,7 @@ export interface Reservation {
function getSampleData(): Reservation[] { function getSampleData(): Reservation[] {
const sampleData = [ const sampleData = [
{ {
id: 1,
user: 'John Smith', user: 'John Smith',
start: '12:00', start: '12:00',
end: '14:00', end: '14:00',
@@ -23,6 +25,7 @@ function getSampleData(): Reservation[] {
status: 'confirmed', status: 'confirmed',
}, },
{ {
id: 2,
user: 'Bob Barker', user: 'Bob Barker',
start: '18:00', start: '18:00',
end: '20:00', end: '20:00',
@@ -30,6 +33,7 @@ function getSampleData(): Reservation[] {
status: 'confirmed', status: 'confirmed',
}, },
{ {
id: 3,
user: 'Peter Parker', user: 'Peter Parker',
start: '8:00', start: '8:00',
end: '10:00', end: '10:00',
@@ -37,6 +41,7 @@ function getSampleData(): Reservation[] {
status: 'tentative', status: 'tentative',
}, },
{ {
id: 4,
user: 'Vince McMahon', user: 'Vince McMahon',
start: '13:00', start: '13:00',
end: '17:00', end: '17:00',
@@ -44,13 +49,20 @@ function getSampleData(): Reservation[] {
status: 'pending', status: 'pending',
}, },
{ {
id: 5,
user: 'Heather Graham', user: 'Heather Graham',
start: '06:00', start: '06:00',
end: '09:00', end: '09:00',
boat: 3, boat: 3,
status: 'confirmed', status: 'confirmed',
}, },
{ user: 'Lawrence Fishburne', start: '18:00', end: '20:00', boat: 3 }, {
id: 6,
user: 'Lawrence Fishburne',
start: '18:00',
end: '20:00',
boat: 3,
},
]; ];
const boatStore = useBoatStore(); const boatStore = useBoatStore();
const now = new Date(); const now = new Date();
@@ -64,6 +76,7 @@ function getSampleData(): Reservation[] {
return sampleData.map((entry): Reservation => { return sampleData.map((entry): Reservation => {
const boat = <Boat>boatStore.boats.find((b) => b.id == entry.boat); const boat = <Boat>boatStore.boats.find((b) => b.id == entry.boat);
return { return {
id: entry.id,
user: entry.user, user: entry.user,
start: date.adjustDate(now, makeOpts(splitTime(entry.start))), start: date.adjustDate(now, makeOpts(splitTime(entry.start))),
end: date.adjustDate(now, makeOpts(splitTime(entry.end))), end: date.adjustDate(now, makeOpts(splitTime(entry.end))),
@@ -80,7 +93,6 @@ export const useScheduleStore = defineStore('schedule', () => {
boat: number | string, boat: number | string,
curDate: Date curDate: Date
): Reservation[] => { ): Reservation[] => {
console.log(reservations.value);
return reservations.value.filter((x) => { return reservations.value.filter((x) => {
return ( return (
(x.start.getDate() == curDate.getDate() || (x.start.getDate() == curDate.getDate() ||
@@ -91,5 +103,36 @@ export const useScheduleStore = defineStore('schedule', () => {
); );
}); });
}; };
return { reservations, getBoatReservations };
const isOverlapped = (res: Reservation) => {
const lapped = reservations.value.filter(
(entry: Reservation) =>
entry.resource == res.resource &&
((entry.start <= res.start && entry.end > res.start) ||
(entry.end >= res.end && entry.start <= res.end))
);
return lapped.length > 0;
};
const getNewId = () => {
// Trivial placeholder
return Math.max(...reservations.value.map((item) => item.id)) + 1;
};
const addOrCreateReservation = (reservation: Reservation) => {
const index = reservations.value.findIndex(
(res) => res.id == reservation.id
);
index != -1
? (reservations.value[index] = reservation)
: reservations.value.push(reservation);
};
return {
reservations,
getBoatReservations,
getNewId,
addOrCreateReservation,
isOverlapped,
};
}); });