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,69 +1,110 @@
<template>
<div class="row justify-center">
<q-btn-group rounded>
<q-btn
color="primary"
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>
<q-calendar-month
ref="calendar"
<q-card-section>
<div class="text-caption text-justify">
Use the calendar to pick a date. Tap a box in the grid for the boat and
start time. Select the duration below.
</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
ref="calendar"
v-model="selectedDate"
:disabled-before="disabledBefore"
animated
bordered
mini-mode
date-type="rounded"
@change="onChange"
@moved="onMoved"
@click-date="onClickDate"
/>
</div></div
></q-card-section>
<q-calendar-resource
v-model="selectedDate"
:model-resources="boatStore.boats"
resource-key="id"
resource-label="name"
:interval-start="6"
:interval-count="18"
cell-width="64"
resource-min-height="40"
animated
bordered
mini-mode
date-type="rounded"
@change="onChange"
@moved="onMoved"
@resource-expanded="onResourceExpanded"
@click-date="onClickDate"
/>
<div style="float: right; display: flex; max-width: 1024px; width: 100%">
<q-calendar-resource
ref="calendar"
v-model="selectedDate"
:model-resources="boatStore.boats"
resource-key="id"
resource-label="name"
:interval-start="6"
:interval-count="18"
cell-width="64"
resource-min-height="40"
animated
bordered
@change="onChange"
@moved="onMoved"
@resource-expanded="onResourceExpanded"
@click-date="onClickDate"
@click-time="onClickTime"
@click-resource="onClickResource"
@click-head-resources="onClickHeadResources"
@click-interval="onClickInterval"
>
<template #resource-intervals="{ scope }">
<template v-for="(event, index) in getEvents(scope)" :key="index">
<q-badge outline :label="event.title" :style="getStyle(event)" />
</template>
@click-time="onClickTime"
@click-resource="onClickResource"
@click-head-resources="onClickHeadResources"
@click-interval="onClickInterval"
>
<template #resource-intervals="{ scope }">
<template v-for="(event, index) in getEvents(scope)" :key="index">
<q-badge outline :label="event.title" :style="getStyle(event)" />
</template>
</q-calendar-resource>
</div>
</template>
<template #resource-label="{ scope: { resource } }">
<div class="col-12">
{{ resource.name }}
<q-icon v-if="resource.defects" name="warning" color="warning" />
</div>
</template>
</q-calendar-resource>
<q-card-section>
<q-select
filled
v-model="duration"
:options="durations"
dense
@update:model-value="onUpdateDuration"
label="Duration (hours)"
stack-label
><template v-slot:append><q-icon name="timelapse" /></template></q-select
></q-card-section>
<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>
<script setup lang="ts">
@@ -73,10 +114,16 @@ import {
TimestampOrNull,
today,
parseDate,
parseTimestamp,
addToDate,
Timestamp,
} from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import { useScheduleStore } from 'src/stores/schedule';
import { date } from 'quasar';
import { computed } from 'vue';
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
type ResourceIntervalScope = {
resource: Boat;
@@ -86,15 +133,37 @@ type ResourceIntervalScope = {
};
const statusLookup = {
tentative: ['#f2c037', 'white'],
confirmed: ['#14539a', 'white'],
pending: ['white', 'grey'],
pending: ['#f2c037', 'white'],
tentative: ['white', 'grey'],
};
const calendar = ref();
const boatStore = useBoatStore();
const scheduleStore = useScheduleStore();
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) {
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() {
calendar.value.prev();
}
@@ -143,16 +209,28 @@ function onNext() {
calendar.value.next();
}
function onClickDate(data) {
emit('onClickDate', data);
return;
}
function onClickTime(data) {
// TODO: Add a duration picker, here.
emit('onClickTime', data);
}
function onCloseSection() {
emit('onCloseSection');
}
function onUpdateDuration(value) {
emit('onUpdateDuration', value);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onClickInterval = () => {};
const onClickHeadResources = onClickInterval;
const onClickResource = onClickInterval;
const onResourceExpanded = onClickInterval;
const onMoved = onClickInterval;
const onChange = onClickInterval;
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onClickHeadResources = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onClickResource = () => {};
// 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>

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>
<script lang="ts" setup>
import BoatPreviewComponent from 'src/components/BoatPreviewComponent.vue';
import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue';
import { ref } from 'vue';
import { useBoatStore } from 'src/stores/boat';
import ToolbarComponent from 'src/components/ToolbarComponent.vue';

View File

@@ -1,47 +1,36 @@
<template>
<q-page padding>
<q-card>
<q-list>
<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>
<q-icon name="person" />
</template>
</q-input>
<q-select
v-model="bookingForm.boat"
:options="boats"
option-value="id"
option-label="name"
label="Boat"
<q-expansion-item
expand-separator
v-model="resourceView"
icon="calendar_month"
label="Boat and Time"
default-opened
:caption="bookingSummary"
>
<template v-slot:prepend>
<q-item-section avatar>
<q-img
v-if="bookingForm.boat?.iconsrc"
:src="bookingForm.boat?.iconsrc"
/>
<q-icon v-else name="sailing" />
</q-item-section>
</template>
<q-separator />
<resource-schedule-viewer-component
@on-click-time="onClickTime"
@on-close-section="() => (resourceView = !resourceView)"
@on-update-duration="
(value) => {
bookingForm.duration = value;
}
"
/></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
rounded
class="bg-warning text-grey-10"
@@ -50,7 +39,7 @@
<template v-slot:avatar>
<q-icon name="warning" color="grey-10" />
</template>
This boat currently has the following defects:
This boat currently has the following notices:
<ol>
<li
v-for="defect in bookingForm.boat.defects"
@@ -60,145 +49,65 @@
</li>
</ol>
</q-banner>
<q-btn
label="View Schedule"
color="primary"
flat
@click="showSchedule = true"
>
<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-expansion-item
expand-separator
icon="people"
label="Crew and Passengers"
default-opened
>
<q-separator />
</q-expansion-item>
<q-item-section>
<q-btn label="Submit" type="submit" color="primary" />
</q-item-section>
</q-form>
</q-card>
</q-item-section> </q-form
></q-list>
</q-page>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { reactive, ref, computed, watch } from 'vue';
import { useAuthStore } from 'src/stores/auth';
import { Boat, useBoatStore } from 'src/stores/boat';
import { date } from 'quasar';
import { Dialog, date } from 'quasar';
import ResourceScheduleViewerComponent from 'src/components/ResourceScheduleViewerComponent.vue';
import { makeDateTime } from '@quasar/quasar-ui-qcalendar';
import { useScheduleStore, Reservation } from 'src/stores/schedule';
const auth = useAuthStore();
const boats = useBoatStore().boats;
const dateFormat = 'ddd MMM D, YYYY h:mm A';
const startDateOrTime = ref(true);
const endDateOrTime = ref(true);
const showSchedule = ref(false);
const bookingForm = ref({
const resourceView = ref(true);
const scheduleStore = useScheduleStore();
const bookingForm = reactive({
bookingId: scheduleStore.getNewId(),
name: auth.currentUser?.name,
boat: <Boat | undefined>undefined,
startDate: date.formatDate(new Date(), dateFormat),
endDate: date.formatDate(
date.addToDate(new Date(), { hours: 2 }),
dateFormat
endDate: computed(() =>
date.formatDate(
date.addToDate(bookingForm.startDate, {
hours: bookingForm.duration,
}),
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 = () => {
@@ -209,22 +118,18 @@ const onSubmit = () => {
// TODO
};
const onClickDate = (data) => {
console.log(data);
};
const onClickTime = (data) => {
bookingForm.value.boat = data.scope.resource;
bookingForm.value.startDate = date.formatDate(
bookingForm.boat = data.scope.resource;
bookingForm.startDate = date.formatDate(
date.addToDate(makeDateTime(data.scope.timestamp), { hours: 5 }), // A terrible hack to convert back to EST. TODO: FIX!!!!
dateFormat
);
showSchedule.value = false;
console.log(bookingForm.value.startDate);
console.log(bookingForm.startDate);
};
const bookingDuration = computed(() => {
const diff = date.getDateDiff(
bookingForm.value.endDate,
bookingForm.value.startDate,
bookingForm.endDate,
bookingForm.startDate,
'minutes'
);
return diff <= 0
@@ -233,6 +138,12 @@ const bookingDuration = computed(() => {
(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) => {
return date.isBetweenDates(
startDate,
@@ -241,6 +152,4 @@ const limitDate = (startDate: string) => {
{ inclusiveFrom: true, inclusiveTo: true, onlyDate: true }
);
};
const minOpts = [0, 15, 30, 45, 60];
</script>

View File

@@ -5,6 +5,7 @@ import { date } from 'quasar';
import { DateOptions } from 'quasar';
export interface Reservation {
id: number;
user: string;
start: Date;
end: Date;
@@ -16,6 +17,7 @@ export interface Reservation {
function getSampleData(): Reservation[] {
const sampleData = [
{
id: 1,
user: 'John Smith',
start: '12:00',
end: '14:00',
@@ -23,6 +25,7 @@ function getSampleData(): Reservation[] {
status: 'confirmed',
},
{
id: 2,
user: 'Bob Barker',
start: '18:00',
end: '20:00',
@@ -30,6 +33,7 @@ function getSampleData(): Reservation[] {
status: 'confirmed',
},
{
id: 3,
user: 'Peter Parker',
start: '8:00',
end: '10:00',
@@ -37,6 +41,7 @@ function getSampleData(): Reservation[] {
status: 'tentative',
},
{
id: 4,
user: 'Vince McMahon',
start: '13:00',
end: '17:00',
@@ -44,13 +49,20 @@ function getSampleData(): Reservation[] {
status: 'pending',
},
{
id: 5,
user: 'Heather Graham',
start: '06:00',
end: '09:00',
boat: 3,
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 now = new Date();
@@ -64,6 +76,7 @@ function getSampleData(): Reservation[] {
return sampleData.map((entry): Reservation => {
const boat = <Boat>boatStore.boats.find((b) => b.id == entry.boat);
return {
id: entry.id,
user: entry.user,
start: date.adjustDate(now, makeOpts(splitTime(entry.start))),
end: date.adjustDate(now, makeOpts(splitTime(entry.end))),
@@ -80,7 +93,6 @@ export const useScheduleStore = defineStore('schedule', () => {
boat: number | string,
curDate: Date
): Reservation[] => {
console.log(reservations.value);
return reservations.value.filter((x) => {
return (
(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,
};
});