Added available timeslot overview and filter selector

main
Filip Borum Poulsen 3 years ago
parent f6c5e0f0c4
commit 8d3e683402

@ -8,8 +8,11 @@
"name": "merit-opgave-client",
"version": "0.0.0",
"dependencies": {
"@popperjs/core": "^2.11.7",
"bootstrap": "^5.2.3",
"dayjs": "^1.11.7",
"pinia": "^2.0.34",
"v-calendar": "^3.0.3",
"vue": "^3.2.47",
"vue-router": "^4.1.6"
},
@ -390,18 +393,27 @@
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz",
"integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/lodash": {
"version": "4.14.194",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.194.tgz",
"integrity": "sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g=="
},
"node_modules/@types/node": {
"version": "18.15.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz",
"integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==",
"dev": true
},
"node_modules/@types/resize-observer-browser": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz",
"integrity": "sha512-G9eN0Sn0ii9PWQ3Vl72jDPgeJwRWhv2Qk/nQkJuWmRmOB4HX3/BhD5SE1dZs/hzPZL/WKnvF0RHdTSG54QJFyg=="
},
"node_modules/@vitejs/plugin-vue": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.1.0.tgz",
@ -754,6 +766,31 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz",
"integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w=="
},
"node_modules/date-fns": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==",
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/date-fns-tz": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.3.8.tgz",
"integrity": "sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ==",
"peerDependencies": {
"date-fns": ">=2.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -1374,6 +1411,11 @@
"node": ">=4"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@ -1970,6 +2012,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/v-calendar": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/v-calendar/-/v-calendar-3.0.3.tgz",
"integrity": "sha512-Skpp/nMoFqFadm94aWj0oOfazoux5T5Ug3/pbRbdolkoDrnVcL7Ronw1/SGFRUPGOwnLdYwhKPhrhSE1segW6w==",
"dependencies": {
"@types/lodash": "^4.14.165",
"@types/resize-observer-browser": "^0.1.7",
"date-fns": "^2.16.1",
"date-fns-tz": "^1.0.12",
"lodash": "^4.17.20",
"vue-screen-utils": "^1.0.0-beta.13"
},
"peerDependencies": {
"@popperjs/core": "^2.0.0",
"vue": "^3.2.0"
}
},
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
@ -2055,6 +2114,14 @@
"vue": "^3.2.0"
}
},
"node_modules/vue-screen-utils": {
"version": "1.0.0-beta.13",
"resolved": "https://registry.npmjs.org/vue-screen-utils/-/vue-screen-utils-1.0.0-beta.13.tgz",
"integrity": "sha512-EJ/8TANKhFj+LefDuOvZykwMr3rrLFPLNb++lNBqPOpVigT2ActRg6icH9RFQVm4nHwlHIHSGm5OY/Clar9yIg==",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/vue-template-compiler": {
"version": "2.7.14",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.14.tgz",

@ -10,8 +10,11 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@popperjs/core": "^2.11.7",
"bootstrap": "^5.2.3",
"dayjs": "^1.11.7",
"pinia": "^2.0.34",
"v-calendar": "^3.0.3",
"vue": "^3.2.47",
"vue-router": "^4.1.6"
},

@ -0,0 +1,83 @@
<template>
<div class="centerSelector">
<div class="header">
<div>Center</div>
</div>
<div class="centers">
<div class="center" v-for="center in centers" :key="center.id" v-on:click="clicked(center.id)"
v-bind:class="{ 'selected': selectedCenters.includes(center.id) }">
{{ center.name }}
</div>
</div>
</div>
</template>
<script lang="ts">
import type { Center } from '@/interfaces/center';
export default {
name: "CenterSelector",
emits: ["selectionChanged"],
async mounted() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/center`);
if (res.status === 200) {
// @ts-ignore
this.centers = await res.json();
}
},
data() {
return {
centers: [] as Center[],
selectedCenters: [] as number[]
};
},
methods: {
clicked(id: number) {
if (this.selectedCenters.includes(id)) {
this.selectedCenters = this.selectedCenters.filter(c => c !== id);
} else {
this.selectedCenters.push(id);
}
this.$emit("selectionChanged", this.selectedCenters);
}
}
}
</script>
<style scoped>
.centerSelector {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
min-width: fit-content;
width: 300px;
/* padding: 5px; */
font-size: 1em;
}
.header {
border-bottom: 1px solid #cbd5e1;
font-weight: 500;
font-size: 1.1em;
}
.header div {
padding: 10px;
}
.centers {
padding: 10px;
}
.center {
cursor: pointer;
user-select: none;
}
.center.selected {
font-weight: 500;
color: #2563eb;
}
.center:hover {
opacity: 0.8;
}</style>

@ -26,7 +26,7 @@ const { loggedIn } = storeToRefs(loginStore);
<style scoped>
.NavBar {
width: 100vw;
width: 100%;
/* position: absolute;
top: 0;
left: 0;

@ -0,0 +1,100 @@
<template>
<div class="trainerSelector">
<div class="header">
<div>Trainer</div>
</div>
<div class="trainers">
<div class="trainer" v-for="trainer in trainers" :key="trainer.id" v-on:click="clicked(trainer.id)"
v-bind:class="{ 'selected': selectedTrainers.includes(trainer.id) }">
{{ trainer.first_name }} {{ trainer.last_name }}
</div>
</div>
</div>
</template>
<script lang="ts">
import type { Trainer } from '@/interfaces/trainer';
export default {
name: "TrainerSelector",
emits: ["selectionChanged"],
props: {
centers: Array<number>
},
async mounted() {
const filters = this.centers?.map(c => `center=${c}`).join("&");
console.log(filters)
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/trainer?${filters}`);
if (res.status === 200) {
this.trainers = await res.json();
}
},
watch: {
centers: {
async handler(centers: number[]) {
const filters = centers?.map(c => `center=${c}`).join("&");
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/trainer?${filters}`);
if (res.status === 200) {
this.trainers = await res.json();
}
},
deep: true
}
},
data() {
return {
trainers: [] as Trainer[],
selectedTrainers: [] as number[]
};
},
methods: {
clicked(id: number) {
if (this.selectedTrainers.includes(id)) {
this.selectedTrainers = this.selectedTrainers.filter(c => c !== id);
} else {
this.selectedTrainers.push(id);
}
this.$emit("selectionChanged", this.selectedTrainers);
}
}
}
</script>
<style scoped>
.trainerSelector {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
min-width: fit-content;
width: 300px;
/* padding: 5px; */
font-size: 1em;
}
.header {
border-bottom: 1px solid #cbd5e1;
font-weight: 500;
font-size: 1.1em;
}
.header div {
padding: 10px;
}
.trainers {
padding: 10px;
}
.trainer {
cursor: pointer;
user-select: none;
}
.trainer.selected {
font-weight: 500;
color: #2563eb;
}
.trainer:hover {
opacity: 0.8;
}
</style>

@ -0,0 +1,7 @@
export interface Center {
id: number
name: string
city: string
zip_code: number
address: string
}

@ -0,0 +1,4 @@
export interface Timeslot {
startDate: Date
endDate: Date
}

@ -0,0 +1,7 @@
export interface Trainer {
id: number
first_name: string
last_name: string
center_id: number
center_name: string
}

@ -0,0 +1,20 @@
import type { Timeslot } from "./timeslot";
import type { Trainer } from "./trainer";
export interface TrainerWithTimeslots extends Trainer {
timeslots: Timeslot[]
}
export interface TrainerWithDateGroupedTimeslots extends Trainer {
dates: DateGroupedTimeslotsList
timeslots: Timeslot[]
}
export interface DateGroupedTimeslotsList {
[key: string]: DateGroupedTimeslots
}
export interface DateGroupedTimeslots {
timeslots: Timeslot[]
date: string
}

@ -2,15 +2,16 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import VCalendar from 'v-calendar';
import 'bootstrap/dist/css/bootstrap.css';
import 'v-calendar/style.css';
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
app.use(VCalendar, {})
app.mount('#app')
import "bootstrap/dist/js/bootstrap.js";
app.mount('#app')

@ -1,3 +1,213 @@
<template>
</template>
<div class="Home">
<VDatePicker class="dateSelector" v-model.range="range" mode="date" :rules="datePickerRules" timezone="utc"
:input-debounce="50" />
<CenterSelector class="centerSelector" @selectionChanged="centerSelectionChanged"></CenterSelector>
<TrainerSelector class="trainerSelector" v-bind:centers="centers" @selectionChanged="trainerSelectionChanged">
</TrainerSelector>
<div class="trainers">
<div class="trainer" v-for="trainer in trainersWithDateGroupedTimeslots" :key="trainer.id">
<div class="top">
{{ trainer.first_name }} {{ trainer.last_name }}
</div>
<div class="dates">
<div class="date" v-for="date in trainer.dates">
{{ date.date }}
<div class="timeslots">
<div class="timeslot" v-for="timeslot in date.timeslots"
@click="createOrder(trainer, timeslot)">
{{ formatTime(timeslot.startDate) }} - {{ formatTime(timeslot.endDate) }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- {{ trainersWithTimeslots }} -->
</div>
</template>
<style scoped>
.Home {
display: grid;
grid-template-columns: 1fr 3fr 1fr;
grid-template-rows: auto auto auto;
grid-column-gap: 50px;
grid-row-gap: 30px;
padding: 50px;
}
.dateSelector {
grid-area: 1 / 1 / 2 / 2;
}
.centerSelector {
grid-area: 1 / 3 / 2 / 4;
}
.trainerSelector {
grid-area: 2 / 3 / 3 / 4;
}
.trainers {
grid-area: 1 / 2 / 4 / 3;
display: flex;
gap: 10px;
flex-direction: column;
}
.trainer .top {
border-bottom: 1px solid #cbd5e1;
padding: 20px;
font-size: large;
}
.trainer {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
}
.dates {
padding: 20px;
}
.timeslots {
border-top: 1px solid #cbd5e1;
display: flex;
flex-wrap: wrap;
}
.timeslot {
padding: 5px;
border: 1px solid #cbd5e1;
margin: 5px;
cursor: pointer;
}
.timeslot:hover {
border-color: black;
}
</style>
<script lang="ts">
import CenterSelector from '@/components/CenterSelector.vue';
import TrainerSelector from '@/components/TrainerSelector.vue';
import type { Timeslot } from '@/interfaces/timeslot';
import type { DateGroupedTimeslots, DateGroupedTimeslotsList, TrainerWithDateGroupedTimeslots, TrainerWithTimeslots } from '@/interfaces/trainerWithTimeslots';
import dayjs from 'dayjs';
import LocalizedFormat from "dayjs/plugin/localizedFormat"
import { } from "dayjs/locale/da";
import type { Trainer } from '@/interfaces/trainer';
dayjs.extend(LocalizedFormat);
export default {
name: "Home",
components: {
CenterSelector,
TrainerSelector
},
data() {
return {
range: {
start: new Date(),
end: new Date()
},
datePickerRules: {
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0
},
centers: [] as number[],
trainers: [] as number[],
trainersWithTimeslots: [] as TrainerWithTimeslots[]
}
},
computed: {
trainersWithDateGroupedTimeslots() {
return this.trainersWithTimeslots.map(trainer => {
const dates: DateGroupedTimeslotsList = {};
trainer.timeslots.forEach(timeslot => {
console.log(new Date(timeslot.startDate).toISOString().split("T")[0]);
const day: string = new Date(timeslot.startDate).toISOString().split("T")[0];
if (!dates[day]) {
dates[day] = {
date: this.formatDate(new Date(day)),
timeslots: [] as Timeslot[]
};
}
dates[day].timeslots.push(timeslot);
});
const trainerWithDateGroupedTimeslots: TrainerWithDateGroupedTimeslots = { ...trainer, dates };
return trainerWithDateGroupedTimeslots;
});
}
},
watch: {
centers: {
handler() {
this.fetchTimeslots();
},
deep: true
},
trainers: {
handler() {
this.fetchTimeslots();
},
deep: true
},
range: {
handler() {
this.fetchTimeslots();
},
deep: true
}
},
methods: {
createOrder(trainer: Trainer, timeslot: Timeslot) {
console.log(trainer);
console.log(timeslot);
this.$router.push({
path: "/createOrder", query: {
trainer: trainer.id,
startDate: new Date(timeslot.startDate).toISOString(),
endDate: new Date(timeslot.endDate).toISOString()
}
});
},
formatDate(date: Date): string {
let output: string = dayjs(date).locale("da").format("dddd D. MMM YYYY");
let outputStringArray = output.split("");
outputStringArray[0] = outputStringArray[0].toLocaleUpperCase();
output = outputStringArray.join("");
return output;
},
formatTime(date: Date): string {
let output: string = dayjs(date).locale("da").format("HH:mm");
return output;
},
centerSelectionChanged(selection: number[]) {
this.centers = selection;
},
trainerSelectionChanged(selection: number[]) {
this.trainers = selection;
},
async fetchTimeslots() {
const centerFilters = this.centers.map(c => `center=${c}`);
const trainerFilters = this.trainers.map(c => `trainer=${c}`);
const startDateFilter = `startDate=${this.range.start.toISOString()}`;
const endDateFilter = `endDate=${this.range.end.toISOString()}`;
const filters = [...centerFilters, ...trainerFilters, startDateFilter, endDateFilter].join("&");
console.log(filters);
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/timeslot?${filters}`);
if (res.status === 200) {
this.trainersWithTimeslots = await res.json();
console.log(this.trainersWithTimeslots);
}
}
}
}
</script>

@ -1,6 +1,6 @@
export interface Timeslot {
startTime: Date
endTime: Date
startDate: Date
endDate: Date
}
export interface WeeklyTimeslot {

@ -1,4 +1,5 @@
import dotenv from "dotenv";
import { exit } from "process";
dotenv.config();
import { client } from "../db";
@ -80,5 +81,6 @@ CREATE TABLE public.orders
);
`)
.then(()=>{
return client.end();
client.end();
exit(0);
})

@ -54,10 +54,16 @@ router.get("/timeslot", async (req: Request, res: Response) => {
}
const timeslotFilters: TimeslotFilters = validation.value;
const filterStartDate: dayjs.Dayjs = dayjs(timeslotFilters.startDate).utcOffset(0).startOf("date");
let filterStartDate: dayjs.Dayjs = dayjs(timeslotFilters.startDate).utcOffset(0).startOf("date");
const filterEndDate: dayjs.Dayjs = dayjs(timeslotFilters.endDate).utcOffset(0).startOf("date");
const nowDate: dayjs.Dayjs = dayjs().utcOffset(0).startOf("date");
if (filterStartDate.isBefore(nowDate)) {
filterStartDate = nowDate;
}
let weekdays = [];
for (let day: dayjs.Dayjs = filterStartDate; !day.isAfter(filterEndDate); day = day.add(1, "day")) {
weekdays.push(day.isoWeekday());
@ -163,8 +169,8 @@ router.get("/timeslot", async (req: Request, res: Response) => {
}
}
trainerWithAvailableTimeslots.timeslots.push({
startTime: startTime.toDate(),
endTime: endTime.toDate()
startDate: startTime.toDate(),
endDate: endTime.toDate()
});
}
}

Loading…
Cancel
Save