refactor: everything to nuxt.js
This commit is contained in:
227
app/components/BoatReservationComponent.vue
Normal file
227
app/components/BoatReservationComponent.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import BoatScheduleTableComponent from '~/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||
import { formatDate } from '~/utils/schedule';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
interface BookingForm {
|
||||
$id?: string;
|
||||
user?: string;
|
||||
interval?: Interval | null;
|
||||
reason?: string;
|
||||
members?: string[];
|
||||
guests?: string[];
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
|
||||
|
||||
const boatStore = useBoatStore();
|
||||
const auth = useAuthStore();
|
||||
const newForm = {
|
||||
user: auth.currentUser?.$id,
|
||||
interval: {} as Interval,
|
||||
reason: 'Open Sail',
|
||||
members: [],
|
||||
guests: [],
|
||||
comment: '',
|
||||
};
|
||||
const reservation = defineModel<Reservation>();
|
||||
const reservationStore = useReservationStore();
|
||||
const boatSelect = ref(false);
|
||||
const bookingForm = ref<BookingForm>({ ...newForm });
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
|
||||
watch(reservation, (newReservation) => {
|
||||
if (!newReservation) {
|
||||
bookingForm.value = newForm;
|
||||
} else {
|
||||
bookingForm.value = {
|
||||
...newReservation,
|
||||
user: auth.currentUser?.$id,
|
||||
interval: {
|
||||
start: newReservation.start,
|
||||
end: newReservation.end,
|
||||
resource: newReservation.resource,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const updateInterval = (interval: Interval | null | undefined) => {
|
||||
bookingForm.value.interval = interval;
|
||||
boatSelect.value = false;
|
||||
};
|
||||
|
||||
const bookingDuration = computed((): { hours: number; minutes: number } => {
|
||||
if (bookingForm.value.interval?.start && bookingForm.value.interval?.end) {
|
||||
const start = new Date(bookingForm.value.interval.start).getTime();
|
||||
const end = new Date(bookingForm.value.interval.end).getTime();
|
||||
const delta = Math.abs(end - start) / 1000;
|
||||
const hours = Math.floor(delta / 3600) % 24;
|
||||
const minutes = Math.floor(delta - hours * 3600) % 60;
|
||||
return { hours, minutes };
|
||||
}
|
||||
return { hours: 0, minutes: 0 };
|
||||
});
|
||||
|
||||
const bookingName = computed(() => auth.getUserNameById(bookingForm.value?.user));
|
||||
|
||||
const boat = computed((): Boat | null => {
|
||||
const boatId = bookingForm.value.interval?.resource;
|
||||
return boatStore.getBoatById(boatId);
|
||||
});
|
||||
|
||||
const onDelete = () => {
|
||||
reservationStore.deleteReservation(reservation.value?.$id);
|
||||
$router.go(-1);
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
bookingForm.value.interval = null;
|
||||
bookingForm.value = reservation.value
|
||||
? {
|
||||
...reservation.value,
|
||||
interval: {
|
||||
start: reservation.value.start,
|
||||
end: reservation.value.end,
|
||||
resource: reservation.value.resource,
|
||||
},
|
||||
}
|
||||
: { ...newForm };
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const booking = bookingForm.value;
|
||||
if (
|
||||
!(
|
||||
booking.interval &&
|
||||
booking.interval.resource &&
|
||||
booking.interval.start &&
|
||||
booking.interval.end &&
|
||||
auth.currentUser
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const newReservation = <Reservation>{
|
||||
resource: booking.interval.resource,
|
||||
start: booking.interval.start,
|
||||
end: booking.interval.end,
|
||||
user: auth.currentUser.$id,
|
||||
status: 'confirmed',
|
||||
reason: booking.reason,
|
||||
comment: booking.comment,
|
||||
$id: reservation.value?.$id,
|
||||
};
|
||||
const status = $q.notify({
|
||||
color: 'secondary',
|
||||
textColor: 'white',
|
||||
message: 'Submitting Reservation',
|
||||
spinner: true,
|
||||
closeBtn: 'Dismiss',
|
||||
position: 'top',
|
||||
timeout: 0,
|
||||
group: false,
|
||||
});
|
||||
try {
|
||||
const r = await reservationStore.createOrUpdateReservation(newReservation);
|
||||
status({
|
||||
color: 'positive',
|
||||
icon: 'cloud_done',
|
||||
message: `Booking ${newReservation.$id ? 'updated' : 'created'}: ${
|
||||
boatStore.getBoatById(r.resource)?.name
|
||||
} at ${formatDate(r.start)}`,
|
||||
spinner: false,
|
||||
});
|
||||
} catch (e) {
|
||||
status({ color: 'negative', icon: 'error', spinner: false, message: 'Failed to book!' + e });
|
||||
}
|
||||
$router.go(-1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="q-pa-xs row q-gutter-xs">
|
||||
<q-card flat class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
|
||||
<q-card-section>
|
||||
<div class="text-h5 q-mt-none q-mb-xs">
|
||||
{{ reservation ? 'Modify Booking' : 'New Booking' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
|
||||
</q-card-section>
|
||||
<q-list class="q-px-xs">
|
||||
<q-item class="q-pa-none" clickable @click="boatSelect = true">
|
||||
<q-card v-if="boat" class="col-12">
|
||||
<q-card-section>
|
||||
<q-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h7 text-left">{{ boat.name }}</div>
|
||||
<div class="col text-right text-caption">{{ boat.class }}</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section horizontal>
|
||||
<q-card-section class="col-9">
|
||||
<q-list dense class="row">
|
||||
<q-item class="q-ma-none col-12">
|
||||
<q-item-section avatar>
|
||||
<q-badge color="primary" label="Start" />
|
||||
</q-item-section>
|
||||
<q-item-section class="text-body2">
|
||||
{{ formatDate(bookingForm.interval?.start) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item class="q-ma-none col-12">
|
||||
<q-item-section avatar>
|
||||
<q-badge color="primary" label="End" />
|
||||
</q-item-section>
|
||||
<q-item-section class="text-body2" style="min-width: 150px">
|
||||
{{ formatDate(bookingForm.interval?.end) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-separator vertical />
|
||||
<q-card-section class="col-3 flex flex-center bg-grey-4">
|
||||
{{ bookingDuration.hours }} hours
|
||||
<div v-if="bookingDuration.minutes">
|
||||
<q-separator />
|
||||
{{ bookingDuration.minutes }} mins
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div v-else class="col-12">
|
||||
<q-field filled>Tap to Select a Boat / Time</q-field>
|
||||
</div>
|
||||
</q-item>
|
||||
<q-item class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-select filled v-model="bookingForm.reason" :options="reason_options" label="Reason for sail" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-input v-model="bookingForm.comment" clearable autogrow filled label="Additional Comments (optional)" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-card-actions align="right">
|
||||
<q-btn label="Delete" color="negative" size="lg" v-if="reservation?.$id" @click="onDelete" />
|
||||
<q-btn label="Reset" @click="onReset" size="lg" color="secondary" />
|
||||
<q-btn label="Submit" @click="onSubmit" size="lg" color="primary" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-dialog v-model="boatSelect" full-width>
|
||||
<BoatScheduleTableComponent :model-value="bookingForm.interval" @update:model-value="updateInterval" />
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
8
app/components/BottomNavComponent.vue
Normal file
8
app/components/BottomNavComponent.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<q-tabs class="mobile-only">
|
||||
<q-route-tab name="Boats" icon="sailing" to="/boat" />
|
||||
<q-route-tab name="Schedule" icon="calendar_month" to="/schedule" />
|
||||
</q-tabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
35
app/components/CertificationComponent.vue
Normal file
35
app/components/CertificationComponent.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
const certifications = [
|
||||
{ title: 'J/27 Skipper', badgeText: 'J/27', description: 'Certified to be a skipper on a J/27 class boat.' },
|
||||
{ title: 'Capri 25 Skipper', badgeText: 'Capri25', description: 'Certified to be a skipper on a Capri 25 class boat.' },
|
||||
{ title: 'Night', badgeText: 'Night', description: 'Certified to operate boats at night' },
|
||||
{ title: 'Navigation', badgeText: 'Nav', description: 'Advanced Navigation' },
|
||||
{ title: 'Crew', badgeText: 'crew', description: 'Crew certification.' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Certification</div>
|
||||
<q-item
|
||||
v-for="cert in certifications"
|
||||
:key="cert.title"
|
||||
clickable
|
||||
v-ripple
|
||||
class="rounded-borders"
|
||||
:class="$q.dark.isActive ? 'bg-grey-9 text-white' : 'bg-grey-2'">
|
||||
<q-item-section avatar>
|
||||
<q-avatar rounded>
|
||||
<q-icon :name="`check`" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ cert.title }}</q-item-label>
|
||||
<q-item-label caption>
|
||||
<q-badge color="green-4" text-color="black">{{ cert.badgeText }}</q-badge>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span>{{ cert.description }}</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
78
app/components/LeftDrawer.vue
Normal file
78
app/components/LeftDrawer.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import { Dialog } from 'quasar';
|
||||
import { useNavLinks } from '~/utils/navlinks';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { APP_VERSION } from '~/utils/version';
|
||||
|
||||
defineProps(['drawer']);
|
||||
defineEmits(['drawer-toggle']);
|
||||
|
||||
const { enabledLinks } = useNavLinks();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
function showAbout() {
|
||||
Dialog.create({
|
||||
title: 'OYS Borrow a Boat',
|
||||
message: `Version ${APP_VERSION}<br>Manage a Borrow a Boat program for a Yacht Club.<br><br>© Oakville Yacht Squadron`,
|
||||
html: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await authStore.logout();
|
||||
await navigateTo('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-drawer
|
||||
:model-value="drawer"
|
||||
show-if-above
|
||||
:width="200"
|
||||
:breakpoint="1024"
|
||||
@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">
|
||||
<q-item clickable v-ripple :to="link.to">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="link.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span :class="link.color ? `text-${link.color}` : ''">
|
||||
{{ link.name }}
|
||||
</span>
|
||||
</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">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="sublink.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span :class="sublink.color ? `text-${sublink.color}` : ''">
|
||||
{{ sublink.name }}
|
||||
</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</q-list>
|
||||
</template>
|
||||
<q-item clickable v-ripple @click="showAbout()">
|
||||
<q-item-section avatar><q-icon name="info" /></q-item-section>
|
||||
<q-item-section>About</q-item-section>
|
||||
</q-item>
|
||||
<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>
|
||||
</q-drawer>
|
||||
</template>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.menu-list .q-item
|
||||
border-radius: 0 32px 32px 0
|
||||
</style>
|
||||
33
app/components/ReferencePreviewComponent.vue
Normal file
33
app/components/ReferencePreviewComponent.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReferenceEntry } from '~/stores/reference';
|
||||
|
||||
defineProps({ entries: Array<ReferenceEntry> });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card flat bordered class="my-card" v-for="entry in entries" :key="entry.title">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col">
|
||||
<div class="text-h6">{{ entry.title }}</div>
|
||||
<div class="text-subtitle2">{{ entry.subtitle }}</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-separator />
|
||||
<q-card-actions>
|
||||
<q-btn flat :to="'reference/' + entry.id + '/view'">Read</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</template>
|
||||
168
app/components/ResourceScheduleViewerComponent.vue
Normal file
168
app/components/ResourceScheduleViewerComponent.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<!-- Abandoned: superseded by block-based booking. Retained for future reference. -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { TimestampOrNull, Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
QCalendarResource,
|
||||
QCalendarMonth,
|
||||
today,
|
||||
parseTimestamp,
|
||||
addToDate,
|
||||
parsed,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { date } from 'quasar';
|
||||
import { computed } from 'vue';
|
||||
import type { StatusTypes } from '~/utils/schedule.types';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
interface EventData {
|
||||
event: object;
|
||||
scope: { timestamp: object; columnindex: number; activeDate: boolean; droppable: boolean };
|
||||
}
|
||||
|
||||
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
||||
|
||||
interface ResourceIntervalScope {
|
||||
resource: Boat;
|
||||
intervals: [];
|
||||
timeStartPosX(start: TimestampOrNull): number;
|
||||
timeDurationWidth(duration: number): number;
|
||||
}
|
||||
|
||||
const statusLookup = {
|
||||
confirmed: ['#14539a', 'white'],
|
||||
pending: ['#f2c037', 'white'],
|
||||
tentative: ['white', 'grey'],
|
||||
};
|
||||
|
||||
const calendar = ref();
|
||||
const boatStore = useBoatStore();
|
||||
const reservationStore = useReservationStore();
|
||||
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||
const duration = ref(1);
|
||||
|
||||
const formattedMonth = computed(() => {
|
||||
const d = new Date(selectedDate.value);
|
||||
return monthFormatter()?.format(d);
|
||||
});
|
||||
|
||||
const disabledBefore = computed(() => {
|
||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||
return addToDate(todayTs, { day: -1 }).date;
|
||||
});
|
||||
|
||||
function monthFormatter() {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-CA', { month: 'long', timeZone: 'UTC' });
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
function getEvents(scope: ResourceIntervalScope) {
|
||||
const resourceEvents = reservationStore.getReservationsByDate(selectedDate.value, scope.resource.$id);
|
||||
return resourceEvents.value.map((event) => ({
|
||||
left: scope.timeStartPosX(parsed(event.start)),
|
||||
width: scope.timeDurationWidth(date.getDateDiff(event.end, event.start, 'minutes')),
|
||||
title: event.user,
|
||||
status: event.status,
|
||||
}));
|
||||
}
|
||||
|
||||
function getStyle(event: { left: number; width: number; title: string; status: StatusTypes }) {
|
||||
return {
|
||||
position: 'absolute',
|
||||
background: event.status ? statusLookup[event.status][0] : 'white',
|
||||
color: event.status ? statusLookup[event.status][1] : '#14539a',
|
||||
left: `${event.left}px`,
|
||||
width: `${event.width}px`,
|
||||
height: '32px',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
}
|
||||
|
||||
const emit = defineEmits(['onClickTime', 'onUpdateDuration']);
|
||||
|
||||
function onPrev() { calendar.value.prev(); }
|
||||
function onNext() { calendar.value.next(); }
|
||||
function onClickDate(data: EventData) { return data; }
|
||||
function onClickTime(data: EventData) { emit('onClickTime', data); }
|
||||
function onUpdateDuration(value: EventData) { emit('onUpdateDuration', value); }
|
||||
const onClickInterval = () => {};
|
||||
const onClickHeadResources = () => {};
|
||||
const onClickResource = () => {};
|
||||
const onResourceExpanded = () => {};
|
||||
const onMoved = () => {};
|
||||
const onChange = () => {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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.
|
||||
</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"><</span>
|
||||
{{ formattedMonth }}
|
||||
<span class="q-button" style="cursor: pointer; user-select: none" @click="onNext">></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="displayName"
|
||||
resource-width="32"
|
||||
:interval-start="6"
|
||||
:interval-count="18"
|
||||
:interval-minutes="60"
|
||||
cell-width="48"
|
||||
style="--calendar-resources-width: 48px"
|
||||
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>
|
||||
</template>
|
||||
<template #resource-label="{ scope: { resource } }">
|
||||
<div class="col-12 .col-md-auto">
|
||||
{{ resource.displayName }}
|
||||
<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>
|
||||
</template>
|
||||
5
app/components/boat/BoatComponent.vue
Normal file
5
app/components/boat/BoatComponent.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>My component</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
35
app/components/boat/BoatPickerComponent.vue
Normal file
35
app/components/boat/BoatPickerComponent.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
|
||||
const boats = useBoatStore().boats;
|
||||
const boat = <Boat | undefined>undefined;
|
||||
</script>
|
||||
|
||||
<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>
|
||||
25
app/components/boat/BoatPreviewComponent.vue
Normal file
25
app/components/boat/BoatPreviewComponent.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
|
||||
defineProps({ boats: Array<Boat> });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="boats" class="row">
|
||||
<q-card
|
||||
v-for="boat in boats"
|
||||
:key="boat.$id"
|
||||
class="q-ma-sm col-xs-12 col-sm-6 col-xl-3">
|
||||
<q-card-section>
|
||||
<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>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
|
||||
</template>
|
||||
1
app/components/scheduling/BoatSelection.vue
Normal file
1
app/components/scheduling/BoatSelection.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
101
app/components/scheduling/IntervalTemplateComponent.vue
Normal file
101
app/components/scheduling/IntervalTemplateComponent.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||
import type { IntervalTemplate } from '~/utils/schedule.types';
|
||||
import { copyIntervalTemplate, timeTuplesOverlapped } from '~/utils/schedule';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const alert = ref(false);
|
||||
const overlapped = ref();
|
||||
const intervalTemplateStore = useIntervalTemplateStore();
|
||||
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
|
||||
const edit = ref(props.edit);
|
||||
const expanded = ref(props.edit);
|
||||
const template = ref(copyIntervalTemplate(props.modelValue));
|
||||
|
||||
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'saved'): void }>();
|
||||
|
||||
const revert = () => {
|
||||
template.value = copyIntervalTemplate(props.modelValue);
|
||||
edit.value = false;
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const toggleEdit = () => {
|
||||
edit.value = !edit.value;
|
||||
};
|
||||
|
||||
const deleteTemplate = (event: Event, tmpl: IntervalTemplate | undefined) => {
|
||||
if (tmpl?.$id) intervalTemplateStore.deleteIntervalTemplate(tmpl.$id);
|
||||
};
|
||||
|
||||
function onDragStart(e: DragEvent, tmpl: IntervalTemplate) {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('ID', tmpl.$id || '');
|
||||
}
|
||||
}
|
||||
|
||||
const saveTemplate = (evt: Event, tmpl: IntervalTemplate | undefined) => {
|
||||
if (!tmpl) return false;
|
||||
overlapped.value = timeTuplesOverlapped(tmpl.timeTuples);
|
||||
if (overlapped.value.length > 0) {
|
||||
alert.value = true;
|
||||
} else {
|
||||
edit.value = false;
|
||||
if (tmpl.$id && tmpl.$id !== 'unsaved') {
|
||||
intervalTemplateStore.updateIntervalTemplate(tmpl, tmpl.$id);
|
||||
} else {
|
||||
intervalTemplateStore.createIntervalTemplate(tmpl);
|
||||
emit('saved');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-expansion-item expand-icon-toggle draggable="true" @dragstart="onDragStart($event, template)" v-model="expanded">
|
||||
<template v-slot:header>
|
||||
<q-item-section>
|
||||
<q-input label="Template name" :borderless="!edit" dense v-model="template.name" v-if="edit" />
|
||||
<q-item-label v-if="!edit" class="cursor-pointer">{{ template.name }}</q-item-label>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<q-card flat>
|
||||
<q-card-section horizontal>
|
||||
<q-card-section class="q-pt-xs">
|
||||
<q-list dense>
|
||||
<q-item v-for="(item, index) in template.timeTuples" :key="item[0]">
|
||||
<q-input class="q-mx-sm" dense v-model="item[0]" type="time" label="Start" :borderless="!edit" :readonly="!edit" />
|
||||
<q-input class="q-mx-sm" dense v-model="item[1]" type="time" label="End" :borderless="!edit" :readonly="!edit">
|
||||
<template v-slot:after>
|
||||
<q-btn v-if="edit" round dense flat icon="delete" @click="template.timeTuples.splice(index, 1)" />
|
||||
</template>
|
||||
</q-input>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-btn v-if="edit" dense color="primary" size="sm" label="Add interval" @click="template.timeTuples.push(['00:00', '00:00'])" />
|
||||
</q-card-section>
|
||||
<q-card-actions vertical>
|
||||
<q-btn v-if="!edit" color="primary" icon="edit" label="Edit" @click="toggleEdit" />
|
||||
<q-btn v-if="edit" color="primary" icon="save" label="Save" @click="saveTemplate($event, template)" />
|
||||
<q-btn v-if="edit" color="secondary" icon="cancel" label="Cancel" @click="revert" />
|
||||
<q-btn color="negative" icon="delete" label="Delete" v-if="template.$id !== ''" @click="deleteTemplate($event, template)" />
|
||||
</q-card-actions>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-dialog v-model="alert">
|
||||
<q-card>
|
||||
<q-card-section><div class="text-h6">Overlapped blocks!</div></q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-chip square icon="schedule" v-for="item in overlapped" :key="item.start">
|
||||
{{ item.start }}-{{ item.end }}
|
||||
</q-chip>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="OK" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
13
app/components/scheduling/NavigationBar.vue
Normal file
13
app/components/scheduling/NavigationBar.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="row justify-center">
|
||||
<div class="q-pa-md q-gutter-sm row">
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">Today</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">< Prev</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">Next ></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits(['today', 'prev', 'next']);
|
||||
</script>
|
||||
69
app/components/scheduling/ReservationCardComponent.vue
Normal file
69
app/components/scheduling/ReservationCardComponent.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import type { Reservation } from '~/utils/schedule.types';
|
||||
import { formatDate, isPast } from '~/utils/schedule';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const cancelDialog = ref(false);
|
||||
const boatStore = useBoatStore();
|
||||
const reservationStore = useReservationStore();
|
||||
|
||||
const reservation = defineModel<Reservation>({ required: true });
|
||||
|
||||
const cancelReservation = () => {
|
||||
cancelDialog.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card
|
||||
bordered
|
||||
:class="isPast(reservation.end) ? 'text-blue-grey-6' : ''"
|
||||
class="q-ma-md">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col">
|
||||
<div class="text-h6">
|
||||
{{ boatStore.getBoatById(reservation.resource)?.name }}
|
||||
</div>
|
||||
<div class="text-subtitle2">
|
||||
<p>
|
||||
Start: {{ formatDate(reservation.start) }}<br />
|
||||
End: {{ formatDate(reservation.end) }}<br />
|
||||
Type: {{ reservation.reason }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-actions v-if="!isPast(reservation.end)">
|
||||
<q-btn flat size="lg" :to="`/schedule/edit/${reservation.$id}`">Modify</q-btn>
|
||||
<q-btn flat size="lg" @click="cancelReservation()">Delete</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-dialog v-model="cancelDialog">
|
||||
<q-card>
|
||||
<q-card-section class="row items-center">
|
||||
<q-avatar icon="warning" color="negative" text-color="white" />
|
||||
<span class="q-ml-md">Warning!</span>
|
||||
<p class="q-pt-md">
|
||||
This will delete your reservation for
|
||||
{{ boatStore.getBoatById(reservation?.resource)?.name }} on
|
||||
{{ formatDate(reservation?.start) }}
|
||||
</p>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat size="lg" label="Cancel" color="primary" v-close-popup />
|
||||
<q-btn
|
||||
flat
|
||||
size="lg"
|
||||
label="Delete"
|
||||
color="negative"
|
||||
@click="reservationStore.deleteReservation(reservation)"
|
||||
v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
236
app/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
236
app/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
QCalendarDay,
|
||||
diffTimestamp,
|
||||
today,
|
||||
parseTimestamp,
|
||||
parseDate,
|
||||
addToDate,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
|
||||
const intervalTemplateStore = useIntervalTemplateStore();
|
||||
const reservationStore = useReservationStore();
|
||||
const { boats } = storeToRefs(useBoatStore());
|
||||
const selectedBlock = defineModel<Interval | null>();
|
||||
const selectedDate = ref(today());
|
||||
const { getAvailableIntervals } = useIntervalStore();
|
||||
const calendar = ref<typeof QCalendarDay | null>(null);
|
||||
const now = ref(new Date());
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
await useBoatStore().fetchBoats();
|
||||
await intervalTemplateStore.fetchIntervalTemplates();
|
||||
intervalId = setInterval(() => { now.value = new Date(); }, 60000);
|
||||
});
|
||||
|
||||
onUnmounted(() => clearInterval(intervalId));
|
||||
|
||||
function handleSwipe({ direction }: { direction: string }) {
|
||||
if (direction === 'right') {
|
||||
calendar.value?.prev();
|
||||
} else {
|
||||
calendar.value?.next();
|
||||
}
|
||||
}
|
||||
|
||||
function reservationStyles(
|
||||
reservation: Reservation,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
return genericBlockStyle(
|
||||
parseDate(new Date(reservation.start)) as Timestamp,
|
||||
parseDate(new Date(reservation.end)) as Timestamp,
|
||||
timeStartPos,
|
||||
timeDurationHeight
|
||||
);
|
||||
}
|
||||
|
||||
function getUserName(userid: string) {
|
||||
return useAuthStore().getUserNameById(userid);
|
||||
}
|
||||
|
||||
function blockStyles(
|
||||
block: Interval,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
return genericBlockStyle(
|
||||
parseDate(new Date(block.start)) as Timestamp,
|
||||
parseDate(new Date(block.end)) as Timestamp,
|
||||
timeStartPos,
|
||||
timeDurationHeight
|
||||
);
|
||||
}
|
||||
|
||||
function getBoatDisplayName(scope: DayBodyScope) {
|
||||
return boats.value[scope.columnIndex]?.displayName ?? '';
|
||||
}
|
||||
|
||||
function beforeNow(time: Date) {
|
||||
return time < now.value || null;
|
||||
}
|
||||
|
||||
function genericBlockStyle(
|
||||
start: Timestamp,
|
||||
end: Timestamp,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
const s = { top: '', height: '', opacity: '' };
|
||||
if (timeStartPos && timeDurationHeight) {
|
||||
s.top = timeStartPos(start.time) + 'px';
|
||||
s.height =
|
||||
parseInt(timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)) - 1 + 'px';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface DayBodyScope {
|
||||
columnIndex: number;
|
||||
timeDurationHeight: string;
|
||||
timeStartPos: (time: string, clamp: boolean) => string;
|
||||
timestamp: Timestamp;
|
||||
}
|
||||
|
||||
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||
if (scope.timestamp.disabled || new Date(block.end) < new Date()) return false;
|
||||
selectedBlock.value = block;
|
||||
}
|
||||
|
||||
const boatReservations = computed((): Record<string, Reservation[]> => {
|
||||
return reservationStore
|
||||
.getReservationsByDate(selectedDate.value)
|
||||
.value.reduce((result, reservation) => {
|
||||
if (!result[reservation.resource]) result[reservation.resource] = [];
|
||||
result[reservation.resource]!.push(reservation);
|
||||
return result;
|
||||
}, <Record<string, Reservation[]>>{});
|
||||
});
|
||||
|
||||
function getBoatReservations(scope: DayBodyScope): Reservation[] {
|
||||
const boat = boats.value[scope.columnIndex];
|
||||
return boat ? boatReservations.value[boat.$id] ?? [] : [];
|
||||
}
|
||||
|
||||
const disabledBefore = computed(() => {
|
||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||
return addToDate(todayTs, { day: -1 }).date;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<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 getAvailableIntervals(scope.timestamp, boats[scope.columnIndex]).value"
|
||||
:key="block.$id">
|
||||
<div
|
||||
class="timeblock"
|
||||
:disabled="beforeNow(new Date(block.end))"
|
||||
: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' }}
|
||||
</div>
|
||||
</div>
|
||||
<div 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>
|
||||
</template>
|
||||
</QCalendarDay>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="sass">
|
||||
.boat-schedule-table-component
|
||||
display: flex
|
||||
max-height: 60vh
|
||||
max-width: 98vw
|
||||
.reservation
|
||||
display: flex
|
||||
position: absolute
|
||||
justify-content: center
|
||||
align-items: center
|
||||
text-align: center
|
||||
width: 100%
|
||||
opacity: 1
|
||||
margin: 0px
|
||||
text-overflow: ellipsis
|
||||
font-size: 0.8em
|
||||
cursor: pointer
|
||||
background: $accent
|
||||
color: white
|
||||
border: 1px solid black
|
||||
.timeblock
|
||||
display: flex
|
||||
position: absolute
|
||||
justify-content: center
|
||||
text-align: center
|
||||
align-items: center
|
||||
width: 100%
|
||||
opacity: 0.5
|
||||
margin: 0px
|
||||
text-overflow: ellipsis
|
||||
font-size: 0.8em
|
||||
cursor: pointer
|
||||
background: $primary
|
||||
color: white
|
||||
border: 1px solid black
|
||||
.selected
|
||||
opacity: 1 !important
|
||||
.q-calendar-day__interval--text
|
||||
font-size: 0.8em
|
||||
.q-calendar-day__day.q-current-day
|
||||
padding: 1px
|
||||
.q-calendar-day__head--days__column
|
||||
background: $primary
|
||||
color: white
|
||||
</style>
|
||||
192
app/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
192
app/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
addToDate,
|
||||
createDayList,
|
||||
createNativeLocaleFormatter,
|
||||
getEndOfWeek,
|
||||
getStartOfWeek,
|
||||
parseTimestamp,
|
||||
today,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
|
||||
const selectedDate = defineModel<string>();
|
||||
|
||||
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]);
|
||||
const locale = ref('en-CA');
|
||||
const monthFormatter = monthFormatterFunc();
|
||||
const dayFormatter = dayFormatterFunc();
|
||||
const weekdayFormatter = weekdayFormatterFunc();
|
||||
|
||||
const today2 = computed(() => parseTimestamp(today()));
|
||||
|
||||
const parsedStart = computed(() =>
|
||||
getStartOfWeek(
|
||||
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||
weekdays,
|
||||
today2.value as Timestamp
|
||||
)
|
||||
);
|
||||
|
||||
const parsedEnd = computed(() =>
|
||||
getEndOfWeek(
|
||||
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||
weekdays,
|
||||
today2.value as Timestamp
|
||||
)
|
||||
);
|
||||
|
||||
const days = computed(() => {
|
||||
if (parsedStart.value && parsedEnd.value) {
|
||||
return createDayList(parsedStart.value, parsedEnd.value, today2.value as Timestamp, weekdays);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const dayStyle = computed(() => ({ width: 100 / weekdays.length + '%' }));
|
||||
|
||||
function onPrev() {
|
||||
selectedDate.value = addToDate(parsedStart.value, { day: -7 }).date;
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
selectedDate.value = addToDate(parsedStart.value, { day: 7 }).date;
|
||||
}
|
||||
|
||||
function dayClass(day: Timestamp) {
|
||||
return { 'date-button': true, 'selected-date-button': selectedDate.value === day.date };
|
||||
}
|
||||
|
||||
function monthFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'long' };
|
||||
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'short' };
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||
}
|
||||
|
||||
function weekdayFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'long' };
|
||||
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'short' };
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||
}
|
||||
|
||||
function dayFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: '2-digit' };
|
||||
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: 'numeric' };
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title-bar" style="display: flex">
|
||||
<button tabindex="0" class="date-button direction-button direction-button__left" @click="onPrev">
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
<div class="dates-holder">
|
||||
<div :key="parsedStart?.date" class="internal-dates-holder">
|
||||
<div v-for="day in days" :key="day.date" :style="dayStyle">
|
||||
<button tabindex="0" style="width: 100%" :class="dayClass(day)" @click="selectedDate = day.date">
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
<div style="width: 100%; font-size: 0.9em">{{ monthFormatter(day, true) }}</div>
|
||||
<div style="width: 100%; font-size: 1.2em; font-weight: 700">{{ dayFormatter(day, false) }}</div>
|
||||
<div style="width: 100%; font-size: 1em">{{ weekdayFormatter(day, true) }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button tabindex="0" class="date-button direction-button direction-button__right" @click="onNext">
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="sass">
|
||||
.title-bar
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 70px
|
||||
background: white
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex: 1 0 100%
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
overflow: hidden
|
||||
border-radius: 3px
|
||||
user-select: none
|
||||
margin: 2px 0px 2px
|
||||
|
||||
.dates-holder
|
||||
position: relative
|
||||
width: 100%
|
||||
align-items: center
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
color: #fff
|
||||
overflow: hidden
|
||||
user-select: none
|
||||
|
||||
.internal-dates-holder
|
||||
position: relative
|
||||
width: 100%
|
||||
display: inline-flex
|
||||
flex: 1 1 100%
|
||||
flex-direction: row
|
||||
justify-content: space-between
|
||||
overflow: hidden
|
||||
user-select: none
|
||||
|
||||
.direction-button
|
||||
background: white
|
||||
color: $primary
|
||||
width: 40px
|
||||
max-width: 50px !important
|
||||
|
||||
.direction-button__left
|
||||
&:before
|
||||
content: '<'
|
||||
display: inline-flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
height: 100%
|
||||
font-weight: 900
|
||||
font-size: 3em
|
||||
|
||||
.direction-button__right
|
||||
&:before
|
||||
content: '>'
|
||||
display: inline-flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
height: 100%
|
||||
font-weight: 900
|
||||
font-size: 3em
|
||||
|
||||
.date-button
|
||||
color: $primary
|
||||
background: white
|
||||
z-index: 2
|
||||
height: 100%
|
||||
outline: 0
|
||||
cursor: pointer
|
||||
border-radius: 3px
|
||||
display: inline-flex
|
||||
flex: 1 0 auto
|
||||
flex-direction: column
|
||||
align-items: stretch
|
||||
position: relative
|
||||
border: 0
|
||||
vertical-align: middle
|
||||
padding: 0
|
||||
font-size: 14px
|
||||
line-height: 1.715em
|
||||
text-decoration: none
|
||||
font-weight: 500
|
||||
text-transform: uppercase
|
||||
text-align: center
|
||||
user-select: none
|
||||
|
||||
.selected-date-button
|
||||
color: white !important
|
||||
background: $primary !important
|
||||
</style>
|
||||
Reference in New Issue
Block a user