refactor: everything to nuxt.js

This commit is contained in:
2026-03-19 14:30:36 -04:00
parent 6e1f58cd8e
commit bb3042014e
159 changed files with 6786 additions and 11198 deletions

View File

@@ -0,0 +1 @@
<template><div /></template>

View 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>

View 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')">&lt; Prev</q-btn>
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">Next &gt;</q-btn>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits(['today', 'prev', 'next']);
</script>

View 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>

View 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>

View 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>