From f391504cd865eb91c60ad23a533598c73ec403bf Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Sun, 24 Dec 2023 04:03:32 +0100 Subject: [PATCH 01/13] Revert "Delete program planner" This reverts commit 99c8f2f5b43dcdde6541364616bbdaaf77433c57. --- .../programPlanner/CalendarItem.vue | 91 ++++ frontend/src/layouts/CampManagementLayout.vue | 7 + .../campManagement/ProgramPlannerPage.vue | 496 ++++++++++++++++++ frontend/src/router/routes.ts | 6 + 4 files changed, 600 insertions(+) create mode 100644 frontend/src/components/campManagement/programPlanner/CalendarItem.vue create mode 100644 frontend/src/pages/campManagement/ProgramPlannerPage.vue diff --git a/frontend/src/components/campManagement/programPlanner/CalendarItem.vue b/frontend/src/components/campManagement/programPlanner/CalendarItem.vue new file mode 100644 index 00000000..28f4290c --- /dev/null +++ b/frontend/src/components/campManagement/programPlanner/CalendarItem.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/layouts/CampManagementLayout.vue b/frontend/src/layouts/CampManagementLayout.vue index 5743b559..0c858e8b 100644 --- a/frontend/src/layouts/CampManagementLayout.vue +++ b/frontend/src/layouts/CampManagementLayout.vue @@ -163,6 +163,13 @@ const items: NavigationItem[] = [ icon: 'email', to: undefined, }, + { + name: 'program_planner', + preview: true, + label: t('program_planner'), + icon: 'event', + to: { name: 'program-planner' }, + }, { name: 'room_planner', label: t('room_planner'), diff --git a/frontend/src/pages/campManagement/ProgramPlannerPage.vue b/frontend/src/pages/campManagement/ProgramPlannerPage.vue new file mode 100644 index 00000000..b12323ba --- /dev/null +++ b/frontend/src/pages/campManagement/ProgramPlannerPage.vue @@ -0,0 +1,496 @@ + + + + + diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts index c5fe5e70..b814b659 100644 --- a/frontend/src/router/routes.ts +++ b/frontend/src/router/routes.ts @@ -107,6 +107,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('pages/campManagement/ParticipantsIndexPage.vue'), }, + { + path: 'program-planner', + name: 'program-planner', + component: () => + import('pages/campManagement/ProgramPlannerPage.vue'), + }, { path: 'room-planner', name: 'room-planner', From 490bf80cdc9eace24f3e886de0abb6b40a38bc94 Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Fri, 2 Feb 2024 05:08:29 +0100 Subject: [PATCH 02/13] Wip: Work for program planner --- frontend/quasar.extensions.json | 2 +- .../programPlanner/CalendarDayItem.vue | 34 ++ .../programPlanner/CalendarItem.vue | 25 +- .../programPlanner/CalendarNavigationBar.vue | 85 +++ .../programPlanner/DragAndDropScope.ts | 4 + .../programPlanner/ProgramCalendar.vue | 265 +++++++++ .../dialogs/CalendarSettingsDialog.vue | 147 +++++ .../campManagement/ParticipantsIndexPage.vue | 1 - .../campManagement/ProgramPlannerPage.vue | 504 ++---------------- package-lock.json | 11 +- 10 files changed, 603 insertions(+), 475 deletions(-) create mode 100644 frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue create mode 100644 frontend/src/components/campManagement/programPlanner/CalendarNavigationBar.vue create mode 100644 frontend/src/components/campManagement/programPlanner/DragAndDropScope.ts create mode 100644 frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue create mode 100644 frontend/src/components/campManagement/programPlanner/dialogs/CalendarSettingsDialog.vue diff --git a/frontend/quasar.extensions.json b/frontend/quasar.extensions.json index bf5bfecb..e239c306 100644 --- a/frontend/quasar.extensions.json +++ b/frontend/quasar.extensions.json @@ -1,3 +1,3 @@ { "@quasar/qcalendar": {} -} +} \ No newline at end of file diff --git a/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue b/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue new file mode 100644 index 00000000..8d05f246 --- /dev/null +++ b/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/frontend/src/components/campManagement/programPlanner/CalendarItem.vue b/frontend/src/components/campManagement/programPlanner/CalendarItem.vue index 28f4290c..503fa35c 100644 --- a/frontend/src/components/campManagement/programPlanner/CalendarItem.vue +++ b/frontend/src/components/campManagement/programPlanner/CalendarItem.vue @@ -4,12 +4,11 @@ class="my-event" :class="badgeClasses" :style="badgeStyles" - :draggable="true" > - {{ props.event.title }} + {{ to(props.event.title) }} - {{ props.event.details }} + {{ to(props.event.details) }} @@ -17,8 +16,8 @@ diff --git a/frontend/src/components/campManagement/programPlanner/CalendarNavigationBar.vue b/frontend/src/components/campManagement/programPlanner/CalendarNavigationBar.vue new file mode 100644 index 00000000..4d9dbd6b --- /dev/null +++ b/frontend/src/components/campManagement/programPlanner/CalendarNavigationBar.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/components/campManagement/programPlanner/DragAndDropScope.ts b/frontend/src/components/campManagement/programPlanner/DragAndDropScope.ts new file mode 100644 index 00000000..2a7ea1d5 --- /dev/null +++ b/frontend/src/components/campManagement/programPlanner/DragAndDropScope.ts @@ -0,0 +1,4 @@ +export interface DragAndDropScope { + timestamp: Timestamp; + droppable: boolean; +} diff --git a/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue new file mode 100644 index 00000000..3bdf4a7f --- /dev/null +++ b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/frontend/src/components/campManagement/programPlanner/dialogs/CalendarSettingsDialog.vue b/frontend/src/components/campManagement/programPlanner/dialogs/CalendarSettingsDialog.vue new file mode 100644 index 00000000..87545aff --- /dev/null +++ b/frontend/src/components/campManagement/programPlanner/dialogs/CalendarSettingsDialog.vue @@ -0,0 +1,147 @@ + + + + + + + +title: 'Calendar Settings' + +fields: + dayStart: + label: 'Day start' + hint: '' + dayEnd: + label: 'Day end' + hint: '' + timeInterval: + label: 'Time interval' + hint: 'How many minutes should one interval have' + +actions: + save: 'Save' + cancel: 'Cancel' + + + + + + + diff --git a/frontend/src/pages/campManagement/ParticipantsIndexPage.vue b/frontend/src/pages/campManagement/ParticipantsIndexPage.vue index 0704f103..771e953f 100644 --- a/frontend/src/pages/campManagement/ParticipantsIndexPage.vue +++ b/frontend/src/pages/campManagement/ParticipantsIndexPage.vue @@ -5,7 +5,6 @@ > - -
Header
- - - - - - +
- + diff --git a/package-lock.json b/package-lock.json index 616a2e92..ae49f844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "devDependencies": {} }, "backend": { + "name": "@camp-registration/api", "version": "0.0.1", "license": "MIT", "dependencies": { @@ -105,6 +106,7 @@ } }, "common": { + "name": "@camp-registration/common", "version": "0.0.1", "license": "ISC", "devDependencies": { @@ -128,6 +130,7 @@ } }, "frontend": { + "name": "@camp-registration/web", "version": "0.0.1", "dependencies": { "@camp-registration/common": "*", @@ -2610,12 +2613,12 @@ } }, "node_modules/@quasar/quasar-app-extension-qcalendar": { - "version": "4.0.0-beta.16", - "resolved": "https://registry.npmjs.org/@quasar/quasar-app-extension-qcalendar/-/quasar-app-extension-qcalendar-4.0.0-beta.16.tgz", - "integrity": "sha512-Rj3KKjPFrE13cswlZAPcqdqi1YH9CeHMpWIw8xsNqdLhCoaRhMGbRas9fvHFLJOXpnsDaVwWINNgN/bBUyn99w==", + "version": "4.0.0-beta.15", + "resolved": "https://registry.npmjs.org/@quasar/quasar-app-extension-qcalendar/-/quasar-app-extension-qcalendar-4.0.0-beta.15.tgz", + "integrity": "sha512-i6hQkcP70LXLfVMPZMKQjSg3681gjZmASV3vq6ULzc0LhtBiPneLdVNNtH2itkWxAmaUj+1heQDI5Pa0F7VKLQ==", "dev": true, "dependencies": { - "@quasar/quasar-ui-qcalendar": "^4.0.0-beta.16" + "@quasar/quasar-ui-qcalendar": "^4.0.0-beta.15" }, "engines": { "node": ">= 10.0.0", From ffcc547e098bad44963bcff526615d03764812d1 Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Mon, 5 Feb 2024 09:06:24 +0100 Subject: [PATCH 03/13] Wip: Work for program planner --- backend/prisma/schema.prisma | 16 +++ backend/src/controllers/index.ts | 1 + .../controllers/program-event.controller.ts | 66 +++++++++ backend/src/resources/index.ts | 1 + .../src/resources/program-event.resource.ts | 20 +++ .../api/v1/camps/program-event.routes.ts | 55 +++++++ backend/src/services/index.ts | 1 + .../src/services/program-planner.service.ts | 50 +++++++ backend/src/validations/index.ts | 1 + .../validations/program-event.validation.ts | 72 ++++++++++ common/src/entities/ProgramEvent.ts | 10 +- .../programPlanner/ProgramCalendar.vue | 38 ++++- frontend/src/composables/serviceHandler.ts | 4 +- .../campManagement/ProgramPlannerPage.vue | 66 +++------ frontend/src/services/APIService.ts | 2 + frontend/src/services/ProgramEventService.ts | 58 ++++++++ frontend/src/stores/program-planner-store.ts | 135 ++++++++++++++++++ frontend/src/stores/room-planner-store.ts | 4 +- 18 files changed, 542 insertions(+), 58 deletions(-) create mode 100644 backend/src/controllers/program-event.controller.ts create mode 100644 backend/src/resources/program-event.resource.ts create mode 100644 backend/src/routes/api/v1/camps/program-event.routes.ts create mode 100644 backend/src/services/program-planner.service.ts create mode 100644 backend/src/validations/program-event.validation.ts create mode 100644 frontend/src/services/ProgramEventService.ts create mode 100644 frontend/src/stores/program-planner-store.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e41e66dd..66be6474 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -181,3 +181,19 @@ model File { @@map("files") } + +model ProgramEvent { + id String @id @unique(map: "program_event_id_unique") @db.Char(26) + /// [StringOrTranslation] + title Json + /// [StringOrTranslation] + details Json? + location String? + date String? + duration String? + time String? + color String? @map("background_color") + side String? + + @@map("program_events") +} diff --git a/backend/src/controllers/index.ts b/backend/src/controllers/index.ts index e51ea15b..559fcb88 100644 --- a/backend/src/controllers/index.ts +++ b/backend/src/controllers/index.ts @@ -8,3 +8,4 @@ export { default as roomController } from './room.controller'; export { default as bedController } from './bed.controller'; export { default as fileController } from './file.controller'; export { default as feedbackController } from './feedback.controller'; +export { default as programEventController } from './program-event.controller'; diff --git a/backend/src/controllers/program-event.controller.ts b/backend/src/controllers/program-event.controller.ts new file mode 100644 index 00000000..b0f64c97 --- /dev/null +++ b/backend/src/controllers/program-event.controller.ts @@ -0,0 +1,66 @@ +import { catchRequestAsync } from 'utils/catchAsync'; +import httpStatus from 'http-status'; +import { collection, resource } from 'resources/resource'; +import { programPlannerService } from 'services'; +import { programEventResource } from 'resources'; +import { routeModel } from 'utils/verifyModel'; + +const show = catchRequestAsync(async (req, res) => { + const room = routeModel(req.models.room); + + res.json(resource(programEventResource(room))); +}); + +const index = catchRequestAsync(async (req, res) => { + const { campId } = req.params; + const events = await programPlannerService.queryProgramEvent(campId); + const resources = events.map((value) => programEventResource(value)); + + res.json(collection(resources)); +}); + +const store = catchRequestAsync(async (req, res) => { + const { campId } = req.params; + const data = req.body; + const event = await programPlannerService.createProgramEvent(campId, { + title: data.title, + details: data.details, + location: data.location, + date: data.date, + time: data.time, + duration: data.duration, + color: data.color, + side: data.side, + }); + res.status(httpStatus.CREATED).json(resource(programEventResource(event))); +}); + +const update = catchRequestAsync(async (req, res) => { + const { roomId } = req.params; + const data = req.body; + const event = await programPlannerService.updateProgramEventById(roomId, { + title: data.title, + details: data.details, + location: data.location, + date: data.date, + time: data.time, + duration: data.duration, + color: data.color, + side: data.side, + }); + res.json(resource(programEventResource(event))); +}); + +const destroy = catchRequestAsync(async (req, res) => { + const { roomId } = req.params; + await programPlannerService.deleteProgramEventById(roomId); + res.status(httpStatus.NO_CONTENT).send(); +}); + +export default { + index, + show, + store, + update, + destroy, +}; diff --git a/backend/src/resources/index.ts b/backend/src/resources/index.ts index 7908c316..355e317f 100644 --- a/backend/src/resources/index.ts +++ b/backend/src/resources/index.ts @@ -10,3 +10,4 @@ export { default as campManagerResource } from './manager.resource'; export { default as roomResource } from './room.resource'; export { default as bedResource } from './bed.rescource'; export { default as fileResource } from './file.rescource'; +export { default as programEventResource } from './program-event.resource'; diff --git a/backend/src/resources/program-event.resource.ts b/backend/src/resources/program-event.resource.ts new file mode 100644 index 00000000..fc484980 --- /dev/null +++ b/backend/src/resources/program-event.resource.ts @@ -0,0 +1,20 @@ +import { ProgramEvent } from '@prisma/client'; +import type { ProgramEvent as ProgramEventResource } from '@camp-registration/common/entities'; + +const programEventResource = ( + programEvent: ProgramEvent, +): ProgramEventResource => { + return { + id: programEvent.id, + title: programEvent.title, + details: programEvent.details, + location: programEvent.location, + date: programEvent.date, + time: programEvent.time, + duration: programEvent.duration, + color: programEvent.color, + side: programEvent.side, + }; +}; + +export default programEventResource; diff --git a/backend/src/routes/api/v1/camps/program-event.routes.ts b/backend/src/routes/api/v1/camps/program-event.routes.ts new file mode 100644 index 00000000..e5d10f2d --- /dev/null +++ b/backend/src/routes/api/v1/camps/program-event.routes.ts @@ -0,0 +1,55 @@ +import express from 'express'; +import { auth, guard, validate } from 'middlewares'; +import { campManager } from 'guards'; +import { routeModel, verifyModelExists } from 'utils/verifyModel'; +import { catchParamAsync } from 'utils/catchAsync'; +import { programPlannerService } from 'services'; +import { programEventController } from 'controllers'; +import { programEventValidation } from 'validations'; + +const router = express.Router({ mergeParams: true }); + +router.param( + 'programEventId', + catchParamAsync(async (req, res, next, id) => { + const camp = routeModel(req.models.camp); + const template = await programPlannerService.getProgramEventById( + camp.id, + id, + ); + req.models.template = verifyModelExists(template); + next(); + }), +); + +router.get( + '/', + auth(), + guard([campManager]), + validate(programEventValidation.index), + programEventController.index, +); +router.get( + '/:programEventId', + auth(), + guard([campManager]), + validate(programEventValidation.show), + programEventController.show, +); +router.post('/', auth(), guard([campManager]), programEventController.store); +router.put( + '/:programEventId', + auth(), + guard([campManager]), + validate(programEventValidation.update), + programEventController.update, +); +router.delete( + '/:programEventId', + auth(), + guard([campManager]), + validate(programEventValidation.destroy), + programEventController.destroy, +); + +export default router; diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index 9febd79b..04190d41 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -10,3 +10,4 @@ export { default as fileService } from './file.service'; export { default as roomService } from './room.service'; export { default as bedService } from './bed.service'; export { default as feedbackService } from './feedback.service'; +export { default as programPlannerService } from './program-planner.service'; diff --git a/backend/src/services/program-planner.service.ts b/backend/src/services/program-planner.service.ts new file mode 100644 index 00000000..c5a13c02 --- /dev/null +++ b/backend/src/services/program-planner.service.ts @@ -0,0 +1,50 @@ +import { type Prisma } from '@prisma/client'; +import prisma from 'client'; +import { ulid } from 'utils/ulid'; + +const getProgramEventById = async (campId: string, id: string) => { + return prisma.programEvent.findFirst({ + where: { id, campId }, + }); +}; + +const queryProgramEvent = async (campId: string) => { + return prisma.programEvent.findMany({ + where: { campId }, + }); +}; + +const createProgramEvent = async ( + campId: string, + data: Omit, +) => { + return prisma.programEvent.create({ + data: { + id: ulid(), + campId, + ...data, + }, + }); +}; + +const updateProgramEventById = async ( + roomId: string, + updateBody: Omit, +) => { + return prisma.room.update({ + where: { id: roomId }, + data: updateBody, + }); +}; + +const deleteProgramEventById = async (id: string) => { + await prisma.programEvent.delete({ where: { id: id } }); +}; + +export default { + getProgramEventById, + queryProgramEvent, + createProgramEvent, + updateProgramEventById, + deleteProgramEventById, +}; diff --git a/backend/src/validations/index.ts b/backend/src/validations/index.ts index d335a461..d15ed993 100644 --- a/backend/src/validations/index.ts +++ b/backend/src/validations/index.ts @@ -9,3 +9,4 @@ export { default as roomValidation } from './room.validation'; export { default as bedValidation } from './bed.validation'; export { default as fileValidation } from './file.validation'; export { default as feedbackValidation } from './feedback.validation'; +export { default as programEventValidation } from './program-event.validation'; diff --git a/backend/src/validations/program-event.validation.ts b/backend/src/validations/program-event.validation.ts new file mode 100644 index 00000000..cf533bcc --- /dev/null +++ b/backend/src/validations/program-event.validation.ts @@ -0,0 +1,72 @@ +import Joi from 'joi'; +import JoiDate from '@joi/date'; +import { ProgramEvent } from '@camp-registration/common/entities'; + +const extendedJoi = Joi.extend(JoiDate); + +const translatableSchema = Joi.alternatives() + .try(Joi.string(), Joi.object().pattern(Joi.string(), Joi.string())) + .required(); +const timeSchema = Joi.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/); +const dateSchema = extendedJoi.date().format('YYYY-MM-DD'); + +const show = { + params: Joi.object({ + campId: Joi.string().required(), + programEventId: Joi.string().required(), + }), +}; + +const index = { + params: Joi.object({ + campId: Joi.string().required(), + }), +}; + +const store = { + params: Joi.object({ + campId: Joi.string().required(), + }), + body: Joi.object({ + title: translatableSchema.required(), + details: translatableSchema.optional(), + location: translatableSchema.optional(), + date: dateSchema.optional(), + time: timeSchema.optional(), + duration: Joi.number().min(0).optional(), + color: Joi.string().required(), + side: Joi.string().optional(), + }), +}; + +const update = { + params: Joi.object({ + campId: Joi.string().required(), + programEventId: Joi.string().required(), + }), + body: Joi.object({ + title: translatableSchema.optional(), + details: translatableSchema.optional(), + location: translatableSchema.optional(), + date: dateSchema.optional(), + time: timeSchema.optional(), + duration: Joi.number().min(0).optional(), + color: Joi.string().optional(), + side: Joi.string().optional(), + }), +}; + +const destroy = { + params: Joi.object({ + campId: Joi.string().required(), + programEventId: Joi.string().required(), + }), +}; + +export default { + show, + index, + store, + update, + destroy, +}; diff --git a/common/src/entities/ProgramEvent.ts b/common/src/entities/ProgramEvent.ts index 2961f447..8a4c3b18 100644 --- a/common/src/entities/ProgramEvent.ts +++ b/common/src/entities/ProgramEvent.ts @@ -2,12 +2,16 @@ import type { Identifiable } from './Identifiable'; import type { Translatable } from './Translatable'; export interface ProgramEvent extends Identifiable { - seriesId?: string; title: Translatable; details?: Translatable; + location?: Translatable; date?: string; - duration?: number; time?: string; - backgroundColor: string; + duration?: number; + color: string; side?: 'left' | 'right' | 'auto'; } + +export type ProgramEventCreateData = Omit; + +export type ProgramEventUpdateData = Partial; diff --git a/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue index 3bdf4a7f..9b56d080 100644 --- a/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue +++ b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue @@ -9,6 +9,8 @@ @previous="onPreciousNavigation" /> + +
(), { }); const emit = defineEmits<{ - (e: 'update', id: string, event: Partial): void; - (e: 'add', event: Omit): void; + (e: 'update', id: string, event: ProgramEventUpdateData): void; + (e: 'add', event: ProgramEventCreateData): void; (e: 'delete', id: string): void; }>(); @@ -103,6 +108,13 @@ const selectedDate = ref(initialSelectedDate()); const range = ref(1); +onMounted(() => { + // TODO Find better solution + setTimeout(() => { + updateIntervalHeight(); + }, 500); +}); + const intervalStart = computed(() => { const start = props.dayStart.split(':'); if (start.length !== 2) { @@ -123,6 +135,26 @@ const intervalCount = computed(() => { return minutes / props.timeInterval - intervalStart.value; }); +const intervalHeight = ref(10); + +watch(intervalCount, () => { + updateIntervalHeight(); +}); + +function onPageResize() { + updateIntervalHeight(); +} + +function updateIntervalHeight() { + const el = document.getElementsByClassName('q-calendar-day__body')[0]; + if (!el) { + return; + } + + const height = el.clientHeight - 10; + intervalHeight.value = height / intervalCount.value; +} + function initialSelectedDate(): string { return props.camp.startAt.split('T')[0]; } diff --git a/frontend/src/composables/serviceHandler.ts b/frontend/src/composables/serviceHandler.ts index b6e6eb0c..cc006929 100644 --- a/frontend/src/composables/serviceHandler.ts +++ b/frontend/src/composables/serviceHandler.ts @@ -219,7 +219,7 @@ export function useServiceHandler(storeName: string) { needsUpdate.value = false; } - function asyncUpdate(fn: () => Promise) { + function asyncAction(fn: () => Promise) { pendingRequests.value++; fn().finally(() => pendingRequests.value--); } @@ -333,7 +333,7 @@ export function useServiceHandler(storeName: string) { errorOnFailure, forceFetch, lazyFetch, - asyncUpdate, + asyncAction, handlerByType, withProgressNotification, withMultiProgressNotification, diff --git a/frontend/src/pages/campManagement/ProgramPlannerPage.vue b/frontend/src/pages/campManagement/ProgramPlannerPage.vue index c616270a..867f2482 100644 --- a/frontend/src/pages/campManagement/ProgramPlannerPage.vue +++ b/frontend/src/pages/campManagement/ProgramPlannerPage.vue @@ -4,8 +4,8 @@ :error="error" > import PageStateHandler from 'components/common/PageStateHandler.vue'; -import { computed, onMounted, ref } from 'vue'; +import { computed, onMounted } from 'vue'; import { useCampDetailsStore } from 'stores/camp-details-store'; import ProgramCalendar from 'components/campManagement/programPlanner/ProgramCalendar.vue'; import { storeToRefs } from 'pinia'; -import type { ProgramEvent } from '@camp-registration/common/entities'; - +import type { + ProgramEvent, + ProgramEventCreateData, +} from '@camp-registration/common/entities'; +import { useProgramPlannerStore } from 'stores/program-planner-store'; + +const programPlannerStore = useProgramPlannerStore(); +const { data: events } = storeToRefs(programPlannerStore); const campDetailsStore = useCampDetailsStore(); -const { data } = storeToRefs(campDetailsStore); +const { data: camp } = storeToRefs(campDetailsStore); onMounted(async () => { await campDetailsStore.fetchData(); }); const loading = computed(() => { - return campDetailsStore.isLoading; + return campDetailsStore.isLoading || programPlannerStore.isLoading; }); const error = computed(() => { - return campDetailsStore.error; + return campDetailsStore.error ?? programPlannerStore.error; }); -const events = ref([ - { - id: crypto.randomUUID(), - title: 'Left Test', - details: 'Full day', - backgroundColor: 'red', - side: 'left', - date: '2024-07-31', - }, - { - id: crypto.randomUUID(), - title: 'Auto test', - details: 'Full day', - backgroundColor: 'red', - side: 'auto', - date: '2024-08-01', - time: '10:30', - duration: 60, - }, -]); - -function onEventAdd(event: Omit) { - // TODO Call Store - events.value.push({ - ...event, - id: crypto.randomUUID(), - }); +function onEventAdd(event: ProgramEventCreateData) { + programPlannerStore.createEntry(event); } function onEventUpdate(id: string, eventUpdate: Partial) { - // TODO Call Store - events.value = events.value.map((event) => { - if (event.id !== id) { - return event; - } - - return { - ...event, - ...eventUpdate, - }; - }); + programPlannerStore.updateEntry(id, eventUpdate); } function onEventDelete(id: string) { - // TODO Call Store - events.value = events.value.filter((event) => event.id === id); + programPlannerStore.deleteEntry(id); } diff --git a/frontend/src/services/APIService.ts b/frontend/src/services/APIService.ts index 42b45bd0..8c375f27 100644 --- a/frontend/src/services/APIService.ts +++ b/frontend/src/services/APIService.ts @@ -8,6 +8,7 @@ import { useCampManagerService } from 'src/services/CampManagerService'; import axios, { AxiosError } from 'axios'; import { useFileService } from 'src/services/FileService'; import { useFeedbackService } from 'src/services/FeedbackService'; +import { useProgramEventService } from 'src/services/ProgramEventService'; export function useAPIService() { return { @@ -20,6 +21,7 @@ export function useAPIService() { ...useRoomService(), ...useFileService(), ...useFeedbackService(), + ...useProgramEventService(), }; } diff --git a/frontend/src/services/ProgramEventService.ts b/frontend/src/services/ProgramEventService.ts new file mode 100644 index 00000000..e305bb0b --- /dev/null +++ b/frontend/src/services/ProgramEventService.ts @@ -0,0 +1,58 @@ +import type { ProgramEvent } from '@camp-registration/common/entities'; +import { api } from 'boot/axios'; + +export function useProgramEventService() { + async function fetchProgramEvents(campId: string): Promise { + const response = await api.get(`camps/${campId}/program-events/`); + + return response?.data?.data; + } + + async function fetchProgramEvent( + campId: string, + programEventId: string, + ): Promise { + const response = await api.get( + `camps/${campId}/program-events/${programEventId}/`, + ); + + return response?.data?.data; + } + + async function createProgramEvent( + campId: string, + data: ProgramEvent, + ): Promise { + const response = await api.post(`camps/${campId}/program-events/`, data); + + return response?.data?.data; + } + + async function updateProgramEvent( + campId: string, + programEventId: string, + data: ProgramEvent, + ): Promise { + const response = await api.put( + `camps/${campId}/program-events/${programEventId}/`, + data, + ); + + return response?.data?.data; + } + + async function deleteProgramEvent( + campId: string, + programEventId: string, + ): Promise { + await api.delete(`camps/${campId}/program-events/${programEventId}/`); + } + + return { + fetchProgramEvents, + fetchProgramEvent, + createProgramEvent, + updateProgramEvent, + deleteProgramEvent, + }; +} diff --git a/frontend/src/stores/program-planner-store.ts b/frontend/src/stores/program-planner-store.ts new file mode 100644 index 00000000..d65322f4 --- /dev/null +++ b/frontend/src/stores/program-planner-store.ts @@ -0,0 +1,135 @@ +import { defineStore } from 'pinia'; +import type { + ProgramEvent, + ProgramEventCreateData, + ProgramEventUpdateData, +} from '@camp-registration/common/entities'; +import { useRoute } from 'vue-router'; +import { useAPIService } from 'src/services/APIService'; +import { useServiceHandler } from 'src/composables/serviceHandler'; +import { useAuthBus, useCampBus } from 'src/composables/bus'; + +export const useProgramPlannerStore = defineStore('program-planner', () => { + const route = useRoute(); + const apiService = useAPIService(); + const authBus = useAuthBus(); + const campBus = useCampBus(); + const { + data, + isLoading, + error, + reset, + invalidate, + withErrorNotification, + lazyFetch, + asyncAction, + checkNotNullWithError, + } = useServiceHandler('programPlanner'); + + // TODO Add translations + + authBus.on('logout', () => { + reset(); + }); + + campBus.on('change', () => { + invalidate(); + }); + + async function fetchData(id?: string): Promise { + const campId = id ?? (route.params.camp as string | undefined); + + const cid = checkNotNullWithError(campId); + await lazyFetch(async () => await apiService.fetchProgramEvents(cid)); + } + + async function createEntry(event: ProgramEventCreateData) { + const campId = route.params.camp as string; + checkNotNullWithError(campId); + + asyncAction(() => + withErrorNotification('create', async () => { + const result = await apiService.createProgramEvent(campId, event); + + // Add item to data + data.value = data.value?.map((value) => + value.id === event.id ? result : value, + ); + + return result; + }), + ); + + // Optimistic update + const tmpEvent = { + id: `#${crypto.randomUUID()}`, + ...event, + }; + + data.value?.push(tmpEvent); + } + + function isIdOptimistic(id: string): boolean { + return id.startsWith('#'); + } + + async function updateEntry(id: string, event: ProgramEventCreateData) { + const campId = route.params.camp as string; + checkNotNullWithError(campId); + + if (isIdOptimistic(id)) { + return withErrorNotification('update', () => { + throw 'Please wait...'; + }); + } + + asyncAction(() => + withErrorNotification('update', () => + apiService.updateProgramEvent(campId, id, event), + ), + ); + + // Optimistic update + const currentEvent = data.value?.find((event) => event.id === id); + if (!currentEvent) { + return; + } + const resultEvent: ProgramEvent = { + ...currentEvent, + ...event, + }; + data.value = data.value?.map((value) => + value.id === event.id ? resultEvent : value, + ); + } + + async function deleteEntry(id: string) { + const campId = route.params.camp as string; + checkNotNullWithError(campId); + + if (isIdOptimistic(id)) { + return withErrorNotification('update', () => { + throw 'Please wait...'; + }); + } + + asyncAction(() => + withErrorNotification('delete', () => + apiService.deleteProgramEvent(campId, id), + ), + ); + + data.value = data.value?.filter((event) => event.id === id); + } + + return { + reset, + data, + isLoading, + error, + fetchData, + createEntry, + updateEntry, + deleteEntry, + }; +}); diff --git a/frontend/src/stores/room-planner-store.ts b/frontend/src/stores/room-planner-store.ts index f33ee3c1..dc51c543 100644 --- a/frontend/src/stores/room-planner-store.ts +++ b/frontend/src/stores/room-planner-store.ts @@ -31,7 +31,7 @@ export const useRoomPlannerStore = defineStore('room-planner', () => { withProgressNotification, withErrorNotification, lazyFetch, - asyncUpdate, + asyncAction, requestPending, checkNotNullWithError, checkNotNullWithNotification, @@ -125,7 +125,7 @@ export const useRoomPlannerStore = defineStore('room-planner', () => { const bedId = room.beds[position].id; const registrationId = person?.id ?? null; - asyncUpdate(() => { + asyncAction(() => { return withErrorNotification('update-bed', () => { return apiService.updateBed(campId, roomId, bedId, registrationId); }); From 5b60867ce5ee6769e07ae89d47c3ea22ff196cf8 Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Thu, 8 Feb 2024 07:58:20 +0100 Subject: [PATCH 04/13] Wip: Work for program planner --- backend/@types/express/index.d.ts | 2 + backend/prisma/schema.prisma | 8 +- .../controllers/program-event.controller.ts | 4 +- backend/src/resources/camp.resource.ts | 2 +- .../src/resources/program-event.resource.ts | 10 +- .../src/routes/api/v1/camps/camp.routes.ts | 2 + .../api/v1/camps/program-event.routes.ts | 7 +- .../src/services/program-planner.service.ts | 4 +- common/src/entities/Camp.ts | 2 +- common/src/entities/Expense.ts | 6 +- common/src/entities/ProgramEvent.ts | 14 +- .../programPlanner/DragAndDropScope.ts | 2 + .../programPlanner/ProgramCalendar.vue | 33 ++++- .../dialogs/CalendarSettingsDialog.vue | 6 +- .../ProgramEventModificationDialog.vue | 129 ++++++++++++++++++ .../campManagement/ProgramPlannerPage.vue | 1 + frontend/src/services/ProgramEventService.ts | 10 +- frontend/src/stores/program-planner-store.ts | 12 +- 18 files changed, 214 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventModificationDialog.vue diff --git a/backend/@types/express/index.d.ts b/backend/@types/express/index.d.ts index 3e12596a..c3693822 100644 --- a/backend/@types/express/index.d.ts +++ b/backend/@types/express/index.d.ts @@ -8,6 +8,7 @@ import { Bed, Room, File, + ProgramEvent, } from '@prisma/client'; declare global { @@ -23,6 +24,7 @@ declare global { room?: Room & { beds: Bed[] }; bed?: Bed; file?: File; + programEvent?: ProgramEvent; }; } } diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 66be6474..c9bc28ad 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -66,6 +66,7 @@ model Camp { rooms Room[] templates Template[] files File[] + ProgramEvent ProgramEvent[] @@map("camps") } @@ -184,16 +185,19 @@ model File { model ProgramEvent { id String @id @unique(map: "program_event_id_unique") @db.Char(26) + campId String @map("camp_id") @db.Char(36) // Prisma does not support morth /// [StringOrTranslation] title Json /// [StringOrTranslation] details Json? - location String? + /// [StringOrTranslation] + location Json? date String? - duration String? + duration Int? time String? color String? @map("background_color") side String? + camp Camp? @relation(fields: [campId], references: [id], onDelete: Cascade) @@map("program_events") } diff --git a/backend/src/controllers/program-event.controller.ts b/backend/src/controllers/program-event.controller.ts index b0f64c97..2c4d5966 100644 --- a/backend/src/controllers/program-event.controller.ts +++ b/backend/src/controllers/program-event.controller.ts @@ -6,9 +6,9 @@ import { programEventResource } from 'resources'; import { routeModel } from 'utils/verifyModel'; const show = catchRequestAsync(async (req, res) => { - const room = routeModel(req.models.room); + const event = routeModel(req.models.programEvent); - res.json(resource(programEventResource(room))); + res.json(resource(programEventResource(event))); }); const index = catchRequestAsync(async (req, res) => { diff --git a/backend/src/resources/camp.resource.ts b/backend/src/resources/camp.resource.ts index 6dbecbfd..cf5ad662 100644 --- a/backend/src/resources/camp.resource.ts +++ b/backend/src/resources/camp.resource.ts @@ -24,7 +24,7 @@ const campResource = (camp: CampWithFreePlaces): CampResource => { endAt: camp.endAt.toISOString(), price: camp.price ?? null, location: camp.location ?? null, - freePlaces: camp.freePlaces, + freePlaces: camp.freePlaces ?? null, }; }; diff --git a/backend/src/resources/program-event.resource.ts b/backend/src/resources/program-event.resource.ts index fc484980..2a0ca332 100644 --- a/backend/src/resources/program-event.resource.ts +++ b/backend/src/resources/program-event.resource.ts @@ -9,11 +9,11 @@ const programEventResource = ( title: programEvent.title, details: programEvent.details, location: programEvent.location, - date: programEvent.date, - time: programEvent.time, - duration: programEvent.duration, - color: programEvent.color, - side: programEvent.side, + date: programEvent.date ?? null, + time: programEvent.time ?? null, + duration: programEvent.duration ?? null, + color: programEvent.color ?? 'white', + side: (programEvent.side as ProgramEventResource['side']) ?? null, }; }; diff --git a/backend/src/routes/api/v1/camps/camp.routes.ts b/backend/src/routes/api/v1/camps/camp.routes.ts index 284383a3..1eb16cde 100644 --- a/backend/src/routes/api/v1/camps/camp.routes.ts +++ b/backend/src/routes/api/v1/camps/camp.routes.ts @@ -11,6 +11,7 @@ import templateRoutes from './template.routes'; import roomRoutes from './rooms/room.routes'; import managerRoutes from './manager.routes'; import campFileRoutes from './files.routes'; +import programEventRoutes from './program-event.routes'; const router = express.Router({ mergeParams: true }); @@ -28,6 +29,7 @@ router.use('/:campId/templates', templateRoutes); router.use('/:campId/managers', managerRoutes); router.use('/:campId/rooms', roomRoutes); router.use('/:campId/files', campFileRoutes); +router.use('/:campId/program-events', programEventRoutes); router.get('/', validate(campValidation.index), campController.index); router.get( diff --git a/backend/src/routes/api/v1/camps/program-event.routes.ts b/backend/src/routes/api/v1/camps/program-event.routes.ts index e5d10f2d..3a040a73 100644 --- a/backend/src/routes/api/v1/camps/program-event.routes.ts +++ b/backend/src/routes/api/v1/camps/program-event.routes.ts @@ -13,11 +13,8 @@ router.param( 'programEventId', catchParamAsync(async (req, res, next, id) => { const camp = routeModel(req.models.camp); - const template = await programPlannerService.getProgramEventById( - camp.id, - id, - ); - req.models.template = verifyModelExists(template); + const event = await programPlannerService.getProgramEventById(camp.id, id); + req.models.programEvent = verifyModelExists(event); next(); }), ); diff --git a/backend/src/services/program-planner.service.ts b/backend/src/services/program-planner.service.ts index c5a13c02..84ec2e3c 100644 --- a/backend/src/services/program-planner.service.ts +++ b/backend/src/services/program-planner.service.ts @@ -16,7 +16,7 @@ const queryProgramEvent = async (campId: string) => { const createProgramEvent = async ( campId: string, - data: Omit, + data: Omit, ) => { return prisma.programEvent.create({ data: { @@ -31,7 +31,7 @@ const updateProgramEventById = async ( roomId: string, updateBody: Omit, ) => { - return prisma.room.update({ + return prisma.programEvent.update({ where: { id: roomId }, data: updateBody, }); diff --git a/common/src/entities/Camp.ts b/common/src/entities/Camp.ts index 0d7c3783..64b15c4c 100644 --- a/common/src/entities/Camp.ts +++ b/common/src/entities/Camp.ts @@ -17,7 +17,7 @@ export interface Camp extends Identifiable { maxAge: number; location: Translatable; price: number; - freePlaces?: Translatable; + freePlaces: Translatable | null; } export interface CampDetails extends Camp { diff --git a/common/src/entities/Expense.ts b/common/src/entities/Expense.ts index 2d4b645f..b33ae669 100644 --- a/common/src/entities/Expense.ts +++ b/common/src/entities/Expense.ts @@ -1,10 +1,10 @@ export interface Expense { id: number; name: string; - category?: string; + category: string | null; price: number; paid: boolean; paidBy: string; - date?: string; - recipient?: string; + date: string | null; + recipient: string | null; } diff --git a/common/src/entities/ProgramEvent.ts b/common/src/entities/ProgramEvent.ts index 8a4c3b18..7bd0ddac 100644 --- a/common/src/entities/ProgramEvent.ts +++ b/common/src/entities/ProgramEvent.ts @@ -3,13 +3,13 @@ import type { Translatable } from './Translatable'; export interface ProgramEvent extends Identifiable { title: Translatable; - details?: Translatable; - location?: Translatable; - date?: string; - time?: string; - duration?: number; - color: string; - side?: 'left' | 'right' | 'auto'; + details: Translatable | null; + location: Translatable | null; + date: string | null; + time: string | null; + duration: number | null; + color: string | null; + side: 'left' | 'right' | 'auto' | null; } export type ProgramEventCreateData = Omit; diff --git a/frontend/src/components/campManagement/programPlanner/DragAndDropScope.ts b/frontend/src/components/campManagement/programPlanner/DragAndDropScope.ts index 2a7ea1d5..9e5e530e 100644 --- a/frontend/src/components/campManagement/programPlanner/DragAndDropScope.ts +++ b/frontend/src/components/campManagement/programPlanner/DragAndDropScope.ts @@ -1,3 +1,5 @@ +import { Timestamp } from '@quasar/quasar-ui-qcalendar'; + export interface DragAndDropScope { timestamp: Timestamp; droppable: boolean; diff --git a/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue index 9b56d080..ae24baf0 100644 --- a/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue +++ b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue @@ -34,6 +34,9 @@ transition-next="slide-left" transition-prev="slide-right" class="fit absolute" + @click-time="onTimeEventAdd" + @click-day="onDayEventAdd" + @click-head-day="onDayEventAdd" > + + + + +title: 'Calendar Settings' + +fields: + dayStart: + label: 'Day start' + hint: '' + dayEnd: + label: 'Day end' + hint: '' + timeInterval: + label: 'Time interval' + hint: 'How many minutes should one interval have' + +actions: + save: 'Save' + cancel: 'Cancel' + + + diff --git a/frontend/src/pages/campManagement/ProgramPlannerPage.vue b/frontend/src/pages/campManagement/ProgramPlannerPage.vue index 867f2482..bd3df29c 100644 --- a/frontend/src/pages/campManagement/ProgramPlannerPage.vue +++ b/frontend/src/pages/campManagement/ProgramPlannerPage.vue @@ -34,6 +34,7 @@ const { data: camp } = storeToRefs(campDetailsStore); onMounted(async () => { await campDetailsStore.fetchData(); + await programPlannerStore.fetchData(); }); const loading = computed(() => { diff --git a/frontend/src/services/ProgramEventService.ts b/frontend/src/services/ProgramEventService.ts index e305bb0b..c86f6cbf 100644 --- a/frontend/src/services/ProgramEventService.ts +++ b/frontend/src/services/ProgramEventService.ts @@ -1,4 +1,8 @@ -import type { ProgramEvent } from '@camp-registration/common/entities'; +import type { + ProgramEvent, + ProgramEventCreateData, + ProgramEventUpdateData, +} from '@camp-registration/common/entities'; import { api } from 'boot/axios'; export function useProgramEventService() { @@ -21,7 +25,7 @@ export function useProgramEventService() { async function createProgramEvent( campId: string, - data: ProgramEvent, + data: ProgramEventCreateData, ): Promise { const response = await api.post(`camps/${campId}/program-events/`, data); @@ -31,7 +35,7 @@ export function useProgramEventService() { async function updateProgramEvent( campId: string, programEventId: string, - data: ProgramEvent, + data: ProgramEventUpdateData, ): Promise { const response = await api.put( `camps/${campId}/program-events/${programEventId}/`, diff --git a/frontend/src/stores/program-planner-store.ts b/frontend/src/stores/program-planner-store.ts index d65322f4..6b9b3f6c 100644 --- a/frontend/src/stores/program-planner-store.ts +++ b/frontend/src/stores/program-planner-store.ts @@ -47,13 +47,17 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { const campId = route.params.camp as string; checkNotNullWithError(campId); + // When we do optimistic updates, we do not know the id of the event before the requests finishes. + // Generate a temporary ID and replace it later + const tmpId = `#${crypto.randomUUID()}`; + asyncAction(() => withErrorNotification('create', async () => { const result = await apiService.createProgramEvent(campId, event); // Add item to data data.value = data.value?.map((value) => - value.id === event.id ? result : value, + value.id === tmpId ? result : value, ); return result; @@ -62,7 +66,7 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { // Optimistic update const tmpEvent = { - id: `#${crypto.randomUUID()}`, + id: tmpId, ...event, }; @@ -73,7 +77,7 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { return id.startsWith('#'); } - async function updateEntry(id: string, event: ProgramEventCreateData) { + async function updateEntry(id: string, event: ProgramEventUpdateData) { const campId = route.params.camp as string; checkNotNullWithError(campId); @@ -99,7 +103,7 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { ...event, }; data.value = data.value?.map((value) => - value.id === event.id ? resultEvent : value, + value.id === id ? resultEvent : value, ); } From 6642392a8bdf36095613c7c111b1d9275a89cd26 Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Fri, 8 Mar 2024 03:37:36 +0100 Subject: [PATCH 05/13] Add database migration --- .../migration.sql | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 backend/prisma/migrations/20240208064303_add_program_events_table/migration.sql diff --git a/backend/prisma/migrations/20240208064303_add_program_events_table/migration.sql b/backend/prisma/migrations/20240208064303_add_program_events_table/migration.sql new file mode 100644 index 00000000..78370950 --- /dev/null +++ b/backend/prisma/migrations/20240208064303_add_program_events_table/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE `program_events` ( + `id` CHAR(26) NOT NULL, + `camp_id` CHAR(36) NOT NULL, + `title` JSON NOT NULL, + `details` JSON NULL, + `location` JSON NULL, + `date` VARCHAR(191) NULL, + `duration` INTEGER NULL, + `time` VARCHAR(191) NULL, + `background_color` VARCHAR(191) NULL, + `side` VARCHAR(191) NULL, + + UNIQUE INDEX `program_event_id_unique`(`id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `program_events` ADD CONSTRAINT `program_events_camp_id_fkey` FOREIGN KEY (`camp_id`) REFERENCES `camps`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; From 994a40425eaa2dbea5866a1e4efab65dad54fb3a Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Wed, 9 Oct 2024 06:07:05 +0200 Subject: [PATCH 06/13] fix remove log --- backend/tests/integration/camp.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/tests/integration/camp.test.ts b/backend/tests/integration/camp.test.ts index b7f0858f..c039f97a 100644 --- a/backend/tests/integration/camp.test.ts +++ b/backend/tests/integration/camp.test.ts @@ -225,8 +225,6 @@ describe('/api/v1/camps', () => { expect(status).toBe(200); - console.log(body.data); - expect(body).toHaveProperty('data'); expect(body.data.length).toBe(2); }); From cf30bd001b0a6dcf1f54bdc0977631da649d0c31 Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Wed, 9 Oct 2024 06:08:04 +0200 Subject: [PATCH 07/13] wip: program planner --- backend/prisma/schema.prisma | 2 +- .../controllers/program-event.controller.ts | 6 +- .../api/v1/camps/program-event.routes.ts | 19 +- .../src/services/program-planner.service.ts | 8 +- .../validations/program-event.validation.ts | 26 +- common/src/entities/ProgramEvent.ts | 5 +- frontend/package.json | 1 + .../programPlanner/CalendarDayItem.vue | 2 +- .../programPlanner/CalendarItem.vue | 37 +- .../programPlanner/ProgramCalendar.vue | 76 +-- .../dialogs/ProgramEventAddDialog.vue | 490 ++++++++++++++++++ .../campManagement/ProgramPlannerPage.vue | 6 +- frontend/src/stores/program-planner-store.ts | 34 +- package-lock.json | 10 + 14 files changed, 627 insertions(+), 95 deletions(-) create mode 100644 frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventAddDialog.vue diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 77b75680..6603039a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -198,7 +198,7 @@ model ProgramEvent { date String? duration Int? time String? - color String? @map("background_color") + color String? @map("color") side String? camp Camp? @relation(fields: [campId], references: [id], onDelete: Cascade) diff --git a/backend/src/controllers/program-event.controller.ts b/backend/src/controllers/program-event.controller.ts index 2c4d5966..c602790b 100644 --- a/backend/src/controllers/program-event.controller.ts +++ b/backend/src/controllers/program-event.controller.ts @@ -22,6 +22,7 @@ const index = catchRequestAsync(async (req, res) => { const store = catchRequestAsync(async (req, res) => { const { campId } = req.params; const data = req.body; + const event = await programPlannerService.createProgramEvent(campId, { title: data.title, details: data.details, @@ -36,9 +37,10 @@ const store = catchRequestAsync(async (req, res) => { }); const update = catchRequestAsync(async (req, res) => { - const { roomId } = req.params; + const { programEventId: id } = req.params; const data = req.body; - const event = await programPlannerService.updateProgramEventById(roomId, { + + const event = await programPlannerService.updateProgramEventById(id, { title: data.title, details: data.details, location: data.location, diff --git a/backend/src/routes/api/v1/camps/program-event.routes.ts b/backend/src/routes/api/v1/camps/program-event.routes.ts index 3a040a73..24036439 100644 --- a/backend/src/routes/api/v1/camps/program-event.routes.ts +++ b/backend/src/routes/api/v1/camps/program-event.routes.ts @@ -11,40 +11,45 @@ const router = express.Router({ mergeParams: true }); router.param( 'programEventId', - catchParamAsync(async (req, res, next, id) => { + catchParamAsync(async (req, res, id) => { const camp = routeModel(req.models.camp); const event = await programPlannerService.getProgramEventById(camp.id, id); req.models.programEvent = verifyModelExists(event); - next(); }), ); router.get( '/', auth(), - guard([campManager]), + guard(campManager), validate(programEventValidation.index), programEventController.index, ); router.get( '/:programEventId', auth(), - guard([campManager]), + guard(campManager), validate(programEventValidation.show), programEventController.show, ); -router.post('/', auth(), guard([campManager]), programEventController.store); +router.post( + '/', + auth(), + guard(campManager), + validate(programEventValidation.store), + programEventController.store, +); router.put( '/:programEventId', auth(), - guard([campManager]), + guard(campManager), validate(programEventValidation.update), programEventController.update, ); router.delete( '/:programEventId', auth(), - guard([campManager]), + guard(campManager), validate(programEventValidation.destroy), programEventController.destroy, ); diff --git a/backend/src/services/program-planner.service.ts b/backend/src/services/program-planner.service.ts index 84ec2e3c..859f1f15 100644 --- a/backend/src/services/program-planner.service.ts +++ b/backend/src/services/program-planner.service.ts @@ -28,12 +28,12 @@ const createProgramEvent = async ( }; const updateProgramEventById = async ( - roomId: string, - updateBody: Omit, + id: string, + data: Omit, ) => { return prisma.programEvent.update({ - where: { id: roomId }, - data: updateBody, + where: { id: id }, + data, }); }; diff --git a/backend/src/validations/program-event.validation.ts b/backend/src/validations/program-event.validation.ts index cf533bcc..a6a1c769 100644 --- a/backend/src/validations/program-event.validation.ts +++ b/backend/src/validations/program-event.validation.ts @@ -8,7 +8,7 @@ const translatableSchema = Joi.alternatives() .try(Joi.string(), Joi.object().pattern(Joi.string(), Joi.string())) .required(); const timeSchema = Joi.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/); -const dateSchema = extendedJoi.date().format('YYYY-MM-DD'); +const dateSchema = extendedJoi.date().format('YYYY-MM-DD').raw(); const show = { params: Joi.object({ @@ -29,13 +29,13 @@ const store = { }), body: Joi.object({ title: translatableSchema.required(), - details: translatableSchema.optional(), - location: translatableSchema.optional(), + details: translatableSchema.optional().allow(null), + location: translatableSchema.optional().allow(null), date: dateSchema.optional(), - time: timeSchema.optional(), - duration: Joi.number().min(0).optional(), - color: Joi.string().required(), - side: Joi.string().optional(), + time: timeSchema.optional().allow(null), + duration: Joi.number().min(0).optional().allow(null), + color: Joi.string().optional().allow(null), + side: Joi.string().optional().allow(null), }), }; @@ -46,13 +46,13 @@ const update = { }), body: Joi.object({ title: translatableSchema.optional(), - details: translatableSchema.optional(), - location: translatableSchema.optional(), + details: translatableSchema.optional().allow(null), + location: translatableSchema.optional().allow(null), date: dateSchema.optional(), - time: timeSchema.optional(), - duration: Joi.number().min(0).optional(), - color: Joi.string().optional(), - side: Joi.string().optional(), + time: timeSchema.optional().allow(null), + duration: Joi.number().min(0).optional().allow(null), + color: Joi.string().optional().allow(null), + side: Joi.string().optional().allow(null), }), }; diff --git a/common/src/entities/ProgramEvent.ts b/common/src/entities/ProgramEvent.ts index 7bd0ddac..aa1669b5 100644 --- a/common/src/entities/ProgramEvent.ts +++ b/common/src/entities/ProgramEvent.ts @@ -12,6 +12,9 @@ export interface ProgramEvent extends Identifiable { side: 'left' | 'right' | 'auto' | null; } -export type ProgramEventCreateData = Omit; +export type ProgramEventCreateData = Partial< + Omit +> & + Pick; export type ProgramEventUpdateData = Partial; diff --git a/frontend/package.json b/frontend/package.json index 1b0af6bb..e87d480f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "dependencies": { "@camp-registration/common": "*", "@quasar/extras": "^1.16.12", + "@quasar/quasar-ui-qcalendar": "^4.0.0-beta.16", "apexcharts": "^3.50.0", "axios": "^1.7.4", "dom-to-image": "^2.6.0", diff --git a/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue b/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue index 8d05f246..480c794f 100644 --- a/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue +++ b/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue @@ -21,7 +21,7 @@ const props = defineProps(); const { to } = useObjectTranslation(); const backgroundColor = computed(() => { - return props.event.backgroundColor ?? '#0000ff'; + return props.event.color ?? '#0000ff'; }); const badgeStyles = computed(() => { diff --git a/frontend/src/components/campManagement/programPlanner/CalendarItem.vue b/frontend/src/components/campManagement/programPlanner/CalendarItem.vue index 503fa35c..de0a9019 100644 --- a/frontend/src/components/campManagement/programPlanner/CalendarItem.vue +++ b/frontend/src/components/campManagement/programPlanner/CalendarItem.vue @@ -1,13 +1,17 @@ + + + + +title: 'Add Event' + +field: + details: + label: 'Details' + end: + label: 'End time' + rule: + later: 'End must be after start time' + fullDay: + label: 'Full Day' + location: + label: 'Location' + side: + left: 'Left' + auto: 'Auto' + right: 'Right' + start: + label: 'Start time' + title: + label: 'Title' + rule: + required: 'The title is required' + +action: + cancel: 'Cancel' + close: 'Close' + ok: 'Create' + + + +title: 'Ereignis hinzufügen' + +field: + details: + label: 'Details' + end: + label: 'Endzeit' + rule: + later: 'Das Ende muss nach der Startzeit liegen' + fullDay: + label: 'Ganztägig' + location: + label: 'Ort' + side: + left: 'Links' + auto: 'Automatisch' + right: 'Rechts' + start: + label: 'Startzeit' + title: + label: 'Titel' + rule: + required: 'Der Titel ist erforderlich' + +action: + cancel: 'Abbrechen' + close: 'Schließen' + ok: 'Erstellen' + + + +title: 'Ajouter un événement' + +field: + details: + label: 'Détails' + end: + label: 'Heure de fin' + rule: + later: "La fin doit être après l'heure de début" + fullDay: + label: 'Journée entière' + location: + label: 'Lieu' + side: + left: 'Gauche' + auto: 'Automatique' + right: 'Droit' + start: + label: 'Heure de début' + title: + label: 'Titre' + rule: + required: 'Le titre est requis' + +action: + cancel: 'Annuler' + close: 'Fermer' + ok: 'Créer' + + + diff --git a/frontend/src/pages/campManagement/ProgramPlannerPage.vue b/frontend/src/pages/campManagement/ProgramPlannerPage.vue index bd3df29c..a703645b 100644 --- a/frontend/src/pages/campManagement/ProgramPlannerPage.vue +++ b/frontend/src/pages/campManagement/ProgramPlannerPage.vue @@ -22,8 +22,8 @@ import { useCampDetailsStore } from 'stores/camp-details-store'; import ProgramCalendar from 'components/campManagement/programPlanner/ProgramCalendar.vue'; import { storeToRefs } from 'pinia'; import type { - ProgramEvent, ProgramEventCreateData, + ProgramEventUpdateData, } from '@camp-registration/common/entities'; import { useProgramPlannerStore } from 'stores/program-planner-store'; @@ -41,7 +41,7 @@ const loading = computed(() => { return campDetailsStore.isLoading || programPlannerStore.isLoading; }); -const error = computed(() => { +const error = computed(() => { return campDetailsStore.error ?? programPlannerStore.error; }); @@ -49,7 +49,7 @@ function onEventAdd(event: ProgramEventCreateData) { programPlannerStore.createEntry(event); } -function onEventUpdate(id: string, eventUpdate: Partial) { +function onEventUpdate(id: string, eventUpdate: ProgramEventUpdateData) { programPlannerStore.updateEntry(id, eventUpdate); } diff --git a/frontend/src/stores/program-planner-store.ts b/frontend/src/stores/program-planner-store.ts index 6b9b3f6c..e0a8fa4f 100644 --- a/frontend/src/stores/program-planner-store.ts +++ b/frontend/src/stores/program-planner-store.ts @@ -22,10 +22,12 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { invalidate, withErrorNotification, lazyFetch, - asyncAction, checkNotNullWithError, } = useServiceHandler('programPlanner'); + // TODO Force fetch on update error after all pending requests finished + // --> Set loading to true while waiting + // TODO Add translations authBus.on('logout', () => { @@ -51,18 +53,16 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { // Generate a temporary ID and replace it later const tmpId = `#${crypto.randomUUID()}`; - asyncAction(() => - withErrorNotification('create', async () => { - const result = await apiService.createProgramEvent(campId, event); + withErrorNotification('create', async () => { + const result = await apiService.createProgramEvent(campId, event); - // Add item to data - data.value = data.value?.map((value) => - value.id === tmpId ? result : value, - ); + // Add item to data + data.value = data.value?.map((value) => + value.id === tmpId ? result : value, + ); - return result; - }), - ); + return result; + }); // Optimistic update const tmpEvent = { @@ -87,10 +87,8 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { }); } - asyncAction(() => - withErrorNotification('update', () => - apiService.updateProgramEvent(campId, id, event), - ), + withErrorNotification('update', () => + apiService.updateProgramEvent(campId, id, event), ); // Optimistic update @@ -117,10 +115,8 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { }); } - asyncAction(() => - withErrorNotification('delete', () => - apiService.deleteProgramEvent(campId, id), - ), + withErrorNotification('delete', () => + apiService.deleteProgramEvent(campId, id), ); data.value = data.value?.filter((event) => event.id === id); diff --git a/package-lock.json b/package-lock.json index ab8f7dd0..6e34fddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -258,6 +258,7 @@ "dependencies": { "@camp-registration/common": "*", "@quasar/extras": "^1.16.12", + "@quasar/quasar-ui-qcalendar": "^4.0.0-beta.16", "apexcharts": "^3.50.0", "axios": "^1.7.4", "dom-to-image": "^2.6.0", @@ -2963,6 +2964,15 @@ } } }, + "node_modules/@quasar/quasar-ui-qcalendar": { + "version": "4.0.0-beta.16", + "resolved": "https://registry.npmjs.org/@quasar/quasar-ui-qcalendar/-/quasar-ui-qcalendar-4.0.0-beta.16.tgz", + "integrity": "sha512-KVbFJD1HQp91tiklv+6XsG7bq8FKK6mhhnoVzmjgoyhUAEb9csfbDPbpegy1/FzXy3o0wITe6mmRZ8nbaiMEZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/hawkeye64" + } + }, "node_modules/@quasar/render-ssr-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@quasar/render-ssr-error/-/render-ssr-error-1.0.3.tgz", From 3f70db88ff4c6e45ad6cf288a8eea2e755488753 Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Thu, 10 Oct 2024 05:16:32 +0200 Subject: [PATCH 08/13] wip: program planner --- .../controllers/program-event.controller.ts | 9 +- .../programPlanner/CalendarDayItem.vue | 28 +++- .../programPlanner/CalendarItem.vue | 24 +++- .../programPlanner/CalendarItemPopup.vue | 129 ++++++++++++++++++ .../programPlanner/CalendarNavigationBar.vue | 37 ++++- .../programPlanner/ProgramCalendar.vue | 73 +++++++++- frontend/src/stores/program-planner-store.ts | 11 +- 7 files changed, 287 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/campManagement/programPlanner/CalendarItemPopup.vue diff --git a/backend/src/controllers/program-event.controller.ts b/backend/src/controllers/program-event.controller.ts index c602790b..20ed54f5 100644 --- a/backend/src/controllers/program-event.controller.ts +++ b/backend/src/controllers/program-event.controller.ts @@ -13,6 +13,7 @@ const show = catchRequestAsync(async (req, res) => { const index = catchRequestAsync(async (req, res) => { const { campId } = req.params; + const events = await programPlannerService.queryProgramEvent(campId); const resources = events.map((value) => programEventResource(value)); @@ -33,6 +34,7 @@ const store = catchRequestAsync(async (req, res) => { color: data.color, side: data.side, }); + res.status(httpStatus.CREATED).json(resource(programEventResource(event))); }); @@ -50,12 +52,15 @@ const update = catchRequestAsync(async (req, res) => { color: data.color, side: data.side, }); + res.json(resource(programEventResource(event))); }); const destroy = catchRequestAsync(async (req, res) => { - const { roomId } = req.params; - await programPlannerService.deleteProgramEventById(roomId); + const { programEventId: id } = req.params; + + await programPlannerService.deleteProgramEventById(id); + res.status(httpStatus.NO_CONTENT).send(); }); diff --git a/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue b/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue index 480c794f..4d35cfe0 100644 --- a/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue +++ b/frontend/src/components/campManagement/programPlanner/CalendarDayItem.vue @@ -1,9 +1,17 @@ @@ -11,12 +19,16 @@ import type { ProgramEvent } from '@camp-registration/common/entities'; import { computed, StyleValue } from 'vue'; import { useObjectTranslation } from 'src/composables/objectTranslation'; +import CalendarItemPopup from 'components/campManagement/programPlanner/CalendarItemPopup.vue'; -interface Props { +const props = defineProps<{ event: ProgramEvent; -} +}>(); -const props = defineProps(); +const emit = defineEmits<{ + (e: 'edit'): void; + (e: 'delete'): void; +}>(); const { to } = useObjectTranslation(); @@ -29,6 +41,14 @@ const badgeStyles = computed(() => { backgroundColor: backgroundColor.value, }; }); + +function onDelete() { + emit('delete'); +} + +function onEdit() { + emit('edit'); +} diff --git a/frontend/src/components/campManagement/programPlanner/CalendarItem.vue b/frontend/src/components/campManagement/programPlanner/CalendarItem.vue index de0a9019..06728977 100644 --- a/frontend/src/components/campManagement/programPlanner/CalendarItem.vue +++ b/frontend/src/components/campManagement/programPlanner/CalendarItem.vue @@ -15,6 +15,12 @@ {{ to(props.event.details) }} + +
@@ -22,14 +28,18 @@ import type { ProgramEvent } from '@camp-registration/common/entities'; import { computed, StyleValue } from 'vue'; import { useObjectTranslation } from 'src/composables/objectTranslation'; +import CalendarItemPopup from 'components/campManagement/programPlanner/CalendarItemPopup.vue'; -interface Props { +const props = defineProps<{ event: ProgramEvent; timeStartPosition?: (time?: string) => number; timeDurationHeight?: (duration?: number) => number; -} +}>(); -const props = defineProps(); +const emit = defineEmits<{ + (e: 'edit'): void; + (e: 'delete'): void; +}>(); const { to } = useObjectTranslation(); @@ -65,6 +75,14 @@ const badgeStyles = computed(() => { height, }; }); + +function onDelete() { + emit('delete'); +} + +function onEdit() { + emit('edit'); +} diff --git a/frontend/src/components/campManagement/programPlanner/CalendarNavigationBar.vue b/frontend/src/components/campManagement/programPlanner/CalendarNavigationBar.vue index 4d9dbd6b..f37fc55d 100644 --- a/frontend/src/components/campManagement/programPlanner/CalendarNavigationBar.vue +++ b/frontend/src/components/campManagement/programPlanner/CalendarNavigationBar.vue @@ -5,12 +5,14 @@
(); const emit = defineEmits<{ - (e: 'update:modelValue', val: number); + (e: 'update:modelValue', val: number): void; (e: 'next'): void; (e: 'previous'): void; }>(); onMounted(() => { - daysRange.value = maxDays.value; + if (daysRange.value > maxDays.value) { + daysRange.value = maxDays.value; + } }); -const daysRange = computed({ +const daysRange = computed({ get: () => props.modelValue, - set: (val: number) => emit('update:modelValue', val), + set: (val) => emit('update:modelValue', val), +}); + +const prevDisabled = computed(() => { + const startDate = new Date(props.start); + startDate.setHours(0, 0); + const currentDate = new Date(props.current); + + return startDate.getTime() >= currentDate.getTime(); +}); + +const DAY_IN_MY = 24 * 60 * 60 * 1000; +const nextDisabled = computed(() => { + const endDate = new Date(props.end); + endDate.setHours(0, 0); + const currentDate = new Date(props.current); + + return ( + endDate.getTime() <= + currentDate.getTime() + (daysRange.value - 1) * DAY_IN_MY + ); }); function next() { diff --git a/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue index a38a4fc9..af0ba782 100644 --- a/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue +++ b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue @@ -1,10 +1,14 @@ @@ -62,6 +68,8 @@ :time-duration-height="timeDurationHeight" :draggable="true" @dragstart="onDragStart($event, event)" + @edit="onEventEdit(event)" + @delete="onEventDelete(event)" /> @@ -111,9 +119,9 @@ const emit = defineEmits<{ const { t, locale } = useI18n(); const quasar = useQuasar(); +const calendarRef = ref(null); const selectedDate = ref(initialSelectedDate()); - -const range = ref(1); +const range = ref(initialRange()); onMounted(() => { // TODO Find better solution @@ -162,8 +170,22 @@ function updateIntervalHeight() { intervalHeight.value = height / intervalCount.value; } +function initialRange(): number { + switch (quasar.screen.name) { + case 'xs': + return 1; + case 'sm': + return 3; + case 'md': + return 5; + case 'lg': + case 'xl': + return 7; + } +} + function initialSelectedDate(): string { - return props.camp.startAt.split('T')[0]; + return formatDate(new Date(props.camp.startAt)); } const eventsMap = computed>(() => { @@ -235,6 +257,15 @@ function onTimeEventAdd({ scope }: CalendarEvent) { }); } +function onEventEdit(event: ProgramEvent) { + // TODO +} + +function onEventDelete(event: ProgramEvent) { + // TODO Maybe add conform + emit('delete', event.id); +} + function onDragStart(e: DragEvent, event: ProgramEvent): void { if (!e.dataTransfer) { return; @@ -298,12 +329,14 @@ function onDrop(e: DragEvent, type: string, scope: DragAndDropScope): boolean { eventUpdate = { date: scope.timestamp.date, time: null, + duration: null, }; break; default: eventUpdate = { date: null, time: null, + duration: null, }; } @@ -324,12 +357,38 @@ function onWeekdayClass({ scope }: { scope: DragAndDropScope }) { }; } +const DAY_IN_MY = 24 * 60 * 60 * 1000; + function onNextNavigation() { - // TODO How to shift + const endDate = new Date(props.camp.endAt); + endDate.setHours(0, 0); + + const endTime = endDate.getTime(); + const currentTime = new Date(selectedDate.value).getTime(); + + const rangeTime = range.value * DAY_IN_MY; + const maxTime = endTime - (rangeTime - DAY_IN_MY); + const updateMs = Math.min(maxTime, currentTime + rangeTime); + + selectedDate.value = formatDate(new Date(updateMs)); } function onPreciousNavigation() { - // TODO How to shift + const startDate = new Date(props.camp.startAt); + startDate.setHours(0, 0); + + const startTime = startDate.getTime(); + const currentTime = new Date(selectedDate.value).getTime(); + + const rangeTime = range.value * DAY_IN_MY; + const minTime = currentTime - rangeTime; + const updateMs = Math.max(startTime, minTime); + + selectedDate.value = formatDate(new Date(updateMs)); +} + +function formatDate(date: Date): string { + return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; } diff --git a/frontend/src/stores/program-planner-store.ts b/frontend/src/stores/program-planner-store.ts index e0a8fa4f..25e4b5bc 100644 --- a/frontend/src/stores/program-planner-store.ts +++ b/frontend/src/stores/program-planner-store.ts @@ -65,9 +65,16 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { }); // Optimistic update - const tmpEvent = { + const tmpEvent: ProgramEvent = { id: tmpId, ...event, + details: event.details ?? null, + location: event.location ?? null, + date: event.date ?? null, + time: event.time ?? null, + duration: event.duration ?? null, + color: event.color ?? null, + side: event.side ?? null, }; data.value?.push(tmpEvent); @@ -119,7 +126,7 @@ export const useProgramPlannerStore = defineStore('program-planner', () => { apiService.deleteProgramEvent(campId, id), ); - data.value = data.value?.filter((event) => event.id === id); + data.value = data.value?.filter((event) => event.id !== id); } return { From 78d6705b23335db80c252f29538500c5750b9dec Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Sat, 12 Oct 2024 05:43:15 +0200 Subject: [PATCH 09/13] feat: Accept null for empty translated input --- .../common/inputs/TranslatedInput.vue | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/common/inputs/TranslatedInput.vue b/frontend/src/components/common/inputs/TranslatedInput.vue index 223f9ec5..95a81cc6 100644 --- a/frontend/src/components/common/inputs/TranslatedInput.vue +++ b/frontend/src/components/common/inputs/TranslatedInput.vue @@ -95,8 +95,10 @@ type Translations = Record; const { t } = useI18n(); +type ModelValue = Translations | string | number | undefined | null; + interface Props { - modelValue: undefined | string | number | Translations; + modelValue: ModelValue; modelModifiers?: Record; label?: string; locales?: string[]; @@ -106,14 +108,12 @@ interface Props { const props = withDefaults(defineProps(), { modelModifiers: undefined, label: '', - locales: undefined, // TODO Why cant I make it an empty array? + locales: undefined, always: false, }); + const emit = defineEmits<{ - ( - e: 'update:modelValue', - value: string | number | Translations | undefined, - ): void; + (e: 'update:modelValue', value: ModelValue): void; }>(); const useTranslations = ref(defaultUseTranslations()); @@ -131,7 +131,11 @@ function defaultUseTranslations(): boolean { function defaultValue(): string | number { // If the model value if an object and there is only one locale, we assume that the object is a translation and // contains a translation for the given locale - if (props.locales?.length === 1 && typeof props.modelValue === 'object') { + if ( + props.locales?.length === 1 && + props.modelValue && + typeof props.modelValue === 'object' + ) { return props.modelValue[props.locales[0]]; } @@ -142,7 +146,9 @@ function defaultValue(): string | number { } function defaultTranslations(): Translations { - return typeof props.modelValue === 'object' ? props.modelValue : {}; + return props.modelValue && typeof props.modelValue === 'object' + ? props.modelValue + : {}; } watch( @@ -167,7 +173,7 @@ watch( if (typeof newValue === 'string' || typeof newValue === 'number') { value.value = newValue; useTranslations.value = false; - } else if (typeof newValue === 'object') { + } else if (newValue && typeof newValue === 'object') { translations.value = newValue; useTranslations.value = true; } From 1b85e8e87b390085b0a0783a0dc269cf97382b8c Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Sat, 12 Oct 2024 05:44:40 +0200 Subject: [PATCH 10/13] wip: Add program event edit dialog --- .../programPlanner/CalendarItemPopup.vue | 1 + .../programPlanner/ProgramCalendar.vue | 23 +- .../dialogs/ProgramEventEditDialog.vue | 495 ++++++++++++++++++ .../ProgramEventModificationDialog.vue | 129 ----- 4 files changed, 512 insertions(+), 136 deletions(-) create mode 100644 frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventEditDialog.vue delete mode 100644 frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventModificationDialog.vue diff --git a/frontend/src/components/campManagement/programPlanner/CalendarItemPopup.vue b/frontend/src/components/campManagement/programPlanner/CalendarItemPopup.vue index 364ea01c..4689c676 100644 --- a/frontend/src/components/campManagement/programPlanner/CalendarItemPopup.vue +++ b/frontend/src/components/campManagement/programPlanner/CalendarItemPopup.vue @@ -12,6 +12,7 @@ size="sm" flat rounded + @click="onEdit" /> (); -const { t, locale } = useI18n(); +const { locale } = useI18n(); const quasar = useQuasar(); const calendarRef = ref(null); @@ -211,11 +213,7 @@ function getFullDayEvents(date: string) { } function getEvents(date: string) { - const events = eventsMap.value[date] || []; - - // TODO Apply side when side is auto - - return events; + return eventsMap.value[date] || []; } interface CalendarEvent { @@ -258,7 +256,18 @@ function onTimeEventAdd({ scope }: CalendarEvent) { } function onEventEdit(event: ProgramEvent) { - // TODO + quasar + .dialog({ + component: ProgramEventEditDialog, + componentProps: { + event, + dateTimeMin: props.camp.startAt, + dateTimeMax: props.camp.endAt, + }, + }) + .onOk((programEvent: ProgramEventCreateData) => { + emit('update', event.id, programEvent); + }); } function onEventDelete(event: ProgramEvent) { diff --git a/frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventEditDialog.vue b/frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventEditDialog.vue new file mode 100644 index 00000000..3215296e --- /dev/null +++ b/frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventEditDialog.vue @@ -0,0 +1,495 @@ + + + + + + + +title: 'Edit Event' + +field: + details: + label: 'Details' + end: + label: 'End time' + rule: + later: 'End must be after start time' + fullDay: + label: 'Full Day' + location: + label: 'Location' + side: + left: 'Left' + auto: 'Auto' + right: 'Right' + start: + label: 'Start time' + title: + label: 'Title' + rule: + required: 'The title is required' + +action: + cancel: 'Cancel' + close: 'Close' + ok: 'Save' + + + +title: 'Ereignis bearbeiten' + +field: + details: + label: 'Details' + end: + label: 'Endzeit' + rule: + later: 'Das Ende muss nach der Startzeit liegen' + fullDay: + label: 'Ganztägig' + location: + label: 'Ort' + side: + left: 'Links' + auto: 'Automatisch' + right: 'Rechts' + start: + label: 'Startzeit' + title: + label: 'Titel' + rule: + required: 'Der Titel ist erforderlich' + +action: + cancel: 'Abbrechen' + close: 'Schließen' + ok: 'Speichern' + + + +title: "Modifier l'événement" + +field: + details: + label: 'Détails' + end: + label: 'Heure de fin' + rule: + later: "La fin doit être après l'heure de début" + fullDay: + label: 'Journée entière' + location: + label: 'Lieu' + side: + left: 'Gauche' + auto: 'Automatique' + right: 'Droit' + start: + label: 'Heure de début' + title: + label: 'Titre' + rule: + required: 'Le titre est requis' + +action: + cancel: 'Annuler' + close: 'Fermer' + ok: 'Enregistrer' + + + diff --git a/frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventModificationDialog.vue b/frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventModificationDialog.vue deleted file mode 100644 index b56b3938..00000000 --- a/frontend/src/components/campManagement/programPlanner/dialogs/ProgramEventModificationDialog.vue +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - -title: 'Calendar Settings' - -fields: - dayStart: - label: 'Day start' - hint: '' - dayEnd: - label: 'Day end' - hint: '' - timeInterval: - label: 'Time interval' - hint: 'How many minutes should one interval have' - -actions: - save: 'Save' - cancel: 'Cancel' - - - From 6e9f6e1acc7119bc02777a42a020b2ce4acba44d Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Sat, 12 Oct 2024 05:44:57 +0200 Subject: [PATCH 11/13] fix: Accept empty string --- backend/src/validations/program-event.validation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/validations/program-event.validation.ts b/backend/src/validations/program-event.validation.ts index a6a1c769..061008e0 100644 --- a/backend/src/validations/program-event.validation.ts +++ b/backend/src/validations/program-event.validation.ts @@ -6,7 +6,7 @@ const extendedJoi = Joi.extend(JoiDate); const translatableSchema = Joi.alternatives() .try(Joi.string(), Joi.object().pattern(Joi.string(), Joi.string())) - .required(); + .allow(''); const timeSchema = Joi.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/); const dateSchema = extendedJoi.date().format('YYYY-MM-DD').raw(); From 3db3e328f99512d4bd28ba2a8180a7854b7882f2 Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Sat, 12 Oct 2024 05:50:35 +0200 Subject: [PATCH 12/13] fix lint errors --- .../campManagement/programPlanner/ProgramCalendar.vue | 1 - frontend/src/stores/room-planner-store.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue index dd4c1436..f05acb9f 100644 --- a/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue +++ b/frontend/src/components/campManagement/programPlanner/ProgramCalendar.vue @@ -94,7 +94,6 @@ import CalendarNavigationBar from 'components/campManagement/programPlanner/Cale import CalendarItem from 'components/campManagement/programPlanner/CalendarItem.vue'; import CalendarDayItem from 'components/campManagement/programPlanner/CalendarDayItem.vue'; import { DragAndDropScope } from 'components/campManagement/programPlanner/DragAndDropScope'; -import PointerEvent from 'happy-dom/lib/event/events/PointerEvent'; import ProgramEventAddDialog from 'components/campManagement/programPlanner/dialogs/ProgramEventAddDialog.vue'; import ProgramEventEditDialog from 'components/campManagement/programPlanner/dialogs/ProgramEventEditDialog.vue'; diff --git a/frontend/src/stores/room-planner-store.ts b/frontend/src/stores/room-planner-store.ts index 7955892e..e81a66ba 100644 --- a/frontend/src/stores/room-planner-store.ts +++ b/frontend/src/stores/room-planner-store.ts @@ -31,7 +31,7 @@ export const useRoomPlannerStore = defineStore('room-planner', () => { withProgressNotification, withErrorNotification, lazyFetch, - asyncAction, + asyncUpdate, requestPending, checkNotNullWithError, checkNotNullWithNotification, @@ -125,7 +125,7 @@ export const useRoomPlannerStore = defineStore('room-planner', () => { const bedId = room.beds[position].id; const registrationId = person?.id ?? null; - asyncAction(() => { + asyncUpdate(() => { return withErrorNotification('update-bed', () => { return apiService.updateBed(campId, roomId, bedId, registrationId); }); From 9c7e3cbb8c98c4cbaa61ad711c1ce13e4d37e3ab Mon Sep 17 00:00:00 2001 From: marvin-wtt <31454580+marvin-wtt@users.noreply.github.com> Date: Mon, 14 Oct 2024 07:49:06 +0200 Subject: [PATCH 13/13] Merge main into feat/program-planner --- backend/src/app/camp/camp.routes.ts | 2 ++ .../programEvent}/program-event.controller.ts | 14 +++++++------- .../programEvent}/program-event.resource.ts | 0 .../{camp => programEvent}/program-event.routes.ts | 6 +++--- .../programEvent/program-event.service.ts} | 0 .../programEvent}/program-event.validation.ts | 0 backend/src/controllers/index.ts | 0 backend/src/resources/index.ts | 0 backend/src/routes/api/v1/camps/index.ts | 0 backend/src/services/index.ts | 0 backend/src/validations/index.ts | 0 11 files changed, 12 insertions(+), 10 deletions(-) rename backend/src/{controllers => app/programEvent}/program-event.controller.ts (77%) rename backend/src/{resources => app/programEvent}/program-event.resource.ts (100%) rename backend/src/app/{camp => programEvent}/program-event.routes.ts (87%) rename backend/src/{services/program-planner.service.ts => app/programEvent/program-event.service.ts} (100%) rename backend/src/{validations => app/programEvent}/program-event.validation.ts (100%) delete mode 100644 backend/src/controllers/index.ts delete mode 100644 backend/src/resources/index.ts delete mode 100644 backend/src/routes/api/v1/camps/index.ts delete mode 100644 backend/src/services/index.ts delete mode 100644 backend/src/validations/index.ts diff --git a/backend/src/app/camp/camp.routes.ts b/backend/src/app/camp/camp.routes.ts index d4e49dfa..43f88eca 100644 --- a/backend/src/app/camp/camp.routes.ts +++ b/backend/src/app/camp/camp.routes.ts @@ -13,6 +13,7 @@ import registrationRoutes from 'app/registration/registration.routes'; import tableTemplateRoutes from 'app/tableTemplate/table-template.routes'; import roomRoutes from 'app/room/room.routes'; import campFileRoutes from './camp-files.routes'; +import programEventRoutes from 'app/programEvent/program-event.routes'; import { CampCreateData, CampQuery } from '@camp-registration/common/entities'; const router = express.Router(); @@ -51,6 +52,7 @@ router.use('/:campId/templates', tableTemplateRoutes); router.use('/:campId/managers', managerRoutes); router.use('/:campId/rooms', roomRoutes); router.use('/:campId/files', campFileRoutes); +router.use('/:campId/program-events', programEventRoutes); router.get( '/', diff --git a/backend/src/controllers/program-event.controller.ts b/backend/src/app/programEvent/program-event.controller.ts similarity index 77% rename from backend/src/controllers/program-event.controller.ts rename to backend/src/app/programEvent/program-event.controller.ts index 20ed54f5..6bdcc1ee 100644 --- a/backend/src/controllers/program-event.controller.ts +++ b/backend/src/app/programEvent/program-event.controller.ts @@ -1,8 +1,8 @@ import { catchRequestAsync } from 'utils/catchAsync'; import httpStatus from 'http-status'; -import { collection, resource } from 'resources/resource'; -import { programPlannerService } from 'services'; -import { programEventResource } from 'resources'; +import { collection, resource } from 'app/resource'; +import programEventService from './program-event.service'; +import programEventResource from './program-event.resource'; import { routeModel } from 'utils/verifyModel'; const show = catchRequestAsync(async (req, res) => { @@ -14,7 +14,7 @@ const show = catchRequestAsync(async (req, res) => { const index = catchRequestAsync(async (req, res) => { const { campId } = req.params; - const events = await programPlannerService.queryProgramEvent(campId); + const events = await programEventService.queryProgramEvent(campId); const resources = events.map((value) => programEventResource(value)); res.json(collection(resources)); @@ -24,7 +24,7 @@ const store = catchRequestAsync(async (req, res) => { const { campId } = req.params; const data = req.body; - const event = await programPlannerService.createProgramEvent(campId, { + const event = await programEventService.createProgramEvent(campId, { title: data.title, details: data.details, location: data.location, @@ -42,7 +42,7 @@ const update = catchRequestAsync(async (req, res) => { const { programEventId: id } = req.params; const data = req.body; - const event = await programPlannerService.updateProgramEventById(id, { + const event = await programEventService.updateProgramEventById(id, { title: data.title, details: data.details, location: data.location, @@ -59,7 +59,7 @@ const update = catchRequestAsync(async (req, res) => { const destroy = catchRequestAsync(async (req, res) => { const { programEventId: id } = req.params; - await programPlannerService.deleteProgramEventById(id); + await programEventService.deleteProgramEventById(id); res.status(httpStatus.NO_CONTENT).send(); }); diff --git a/backend/src/resources/program-event.resource.ts b/backend/src/app/programEvent/program-event.resource.ts similarity index 100% rename from backend/src/resources/program-event.resource.ts rename to backend/src/app/programEvent/program-event.resource.ts diff --git a/backend/src/app/camp/program-event.routes.ts b/backend/src/app/programEvent/program-event.routes.ts similarity index 87% rename from backend/src/app/camp/program-event.routes.ts rename to backend/src/app/programEvent/program-event.routes.ts index 24036439..78d4a1a1 100644 --- a/backend/src/app/camp/program-event.routes.ts +++ b/backend/src/app/programEvent/program-event.routes.ts @@ -3,9 +3,9 @@ import { auth, guard, validate } from 'middlewares'; import { campManager } from 'guards'; import { routeModel, verifyModelExists } from 'utils/verifyModel'; import { catchParamAsync } from 'utils/catchAsync'; -import { programPlannerService } from 'services'; -import { programEventController } from 'controllers'; -import { programEventValidation } from 'validations'; +import programPlannerService from './program-event.service'; +import programEventController from './program-event.controller'; +import programEventValidation from './program-event.validation'; const router = express.Router({ mergeParams: true }); diff --git a/backend/src/services/program-planner.service.ts b/backend/src/app/programEvent/program-event.service.ts similarity index 100% rename from backend/src/services/program-planner.service.ts rename to backend/src/app/programEvent/program-event.service.ts diff --git a/backend/src/validations/program-event.validation.ts b/backend/src/app/programEvent/program-event.validation.ts similarity index 100% rename from backend/src/validations/program-event.validation.ts rename to backend/src/app/programEvent/program-event.validation.ts diff --git a/backend/src/controllers/index.ts b/backend/src/controllers/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/resources/index.ts b/backend/src/resources/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/routes/api/v1/camps/index.ts b/backend/src/routes/api/v1/camps/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/validations/index.ts b/backend/src/validations/index.ts deleted file mode 100644 index e69de29b..00000000