diff --git a/backend/db/migrations/0039_events_repeat_interval.sql b/backend/db/migrations/0039_events_repeat_interval.sql new file mode 100644 index 0000000..9a4337d --- /dev/null +++ b/backend/db/migrations/0039_events_repeat_interval.sql @@ -0,0 +1 @@ +ALTER TABLE "events" ADD COLUMN "repeatInterval" text DEFAULT 'Keine Wiederholung' NOT NULL; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index cb288c3..00763b1 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1779158400000, "tag": "0038_events_state", "breakpoints": true + }, + { + "idx": 39, + "version": "7", + "when": 1779840000000, + "tag": "0039_events_repeat_interval", + "breakpoints": true } ] } diff --git a/backend/db/schema/events.ts b/backend/db/schema/events.ts index 16e8e0a..cc81026 100644 --- a/backend/db/schema/events.ts +++ b/backend/db/schema/events.ts @@ -34,6 +34,7 @@ export const events = pgTable( quick: boolean("quick").notNull().default(false), state: text("state").notNull().default("Final"), color: text("color"), + repeatInterval: text("repeatInterval").notNull().default("Keine Wiederholung"), project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists diff --git a/frontend/pages/calendar/[mode].vue b/frontend/pages/calendar/[mode].vue index ed17d4b..80a4a67 100644 --- a/frontend/pages/calendar/[mode].vue +++ b/frontend/pages/calendar/[mode].vue @@ -6,6 +6,7 @@ import timeGridPlugin from "@fullcalendar/timegrid" import resourceTimelinePlugin from "@fullcalendar/resource-timeline"; import interactionPlugin from "@fullcalendar/interaction"; import dayjs from "dayjs"; +import { expandRecurringEvent } from "~/utils/eventRecurrence" //TODO BACKEND CHANGE COLOR IN TENANT FOR RENDERING @@ -33,6 +34,124 @@ const selectedEvent = ref({}) const selectedResources = ref([]) const events = ref([]) +const sourceEvents = ref([]) +const sourceAbsenceRequests = ref([]) +const sourceProjects = ref([]) +const sourceProfiles = ref([]) +const sourceVehicles = ref([]) +const sourceInventoryItems = ref([]) +const sourceInventoryItemGroups = ref([]) + +const buildEventTitle = (event) => { + if (event.name) return event.name + if (event.project) { + const project = sourceProjects.value.find((item) => item.id === event.project) + return project?.name || "" + } + return "" +} + +const buildEventColor = (event) => + profileStore.ownTenant.calendarConfig.eventTypes.find(type => type.label === event.eventtype)?.color || "black" + +const expandEventsForRange = (rangeStart, rangeEnd) => { + const gridEvents = sourceEvents.value.flatMap((event) => + expandRecurringEvent(event, rangeStart, rangeEnd, (occurrenceStart, occurrenceEnd, occurrenceIndex) => { + const eventColor = buildEventColor(event) + + return { + ...event, + start: occurrenceStart.toISOString(), + end: occurrenceEnd ? occurrenceEnd.toISOString() : null, + title: buildEventTitle(event), + borderColor: eventColor, + textColor: eventColor, + backgroundColor: "black", + entrytype: "event", + eventId: event.id, + occurrenceIndex + } + }) + ) + + const absenceEvents = sourceAbsenceRequests.value.map(absence => ({ + id: absence.id, + resourceId: absence.user, + resourceType: "person", + title: `${absence.reason} - ${absence.profile.fullName}`, + start: dayjs(absence.startDate).toDate(), + end: dayjs(absence.endDate).add(1, 'day').toDate(), + allDay: true, + absencerequestId: absence.id, + entrytype: "absencerequest", + })) + + calendarOptionsGrid.value.initialEvents = [ + ...gridEvents, + ...absenceEvents + ] + + const timelineEvents = sourceEvents.value.flatMap((event) => { + const eventColor = buildEventColor(event) + const title = buildEventTitle(event) + + return expandRecurringEvent(event, rangeStart, rangeEnd, (occurrenceStart, occurrenceEnd) => { + const returnData = { + title, + borderColor: eventColor, + textColor: "white", + backgroundColor: eventColor, + start: occurrenceStart.toISOString(), + end: occurrenceEnd ? occurrenceEnd.toISOString() : null, + resourceIds: [], + entrytype: "event", + eventId: event.id + } + + if(event.profiles.length > 0) { + event.profiles.forEach(profile => { + returnData.resourceIds.push(`P-${profile}`) + }) + } + + if(event.vehicles.length > 0) { + event.vehicles.forEach(vehicle => { + returnData.resourceIds.push(`F-${vehicle}`) + }) + } + + if(event.inventoryitems.length > 0) { + event.inventoryitems.forEach(inventoryitem => { + returnData.resourceIds.push(`I-${inventoryitem}`) + }) + } + + if(event.inventoryitemgroups.length > 0) { + event.inventoryitemgroups.forEach(inventoryitemgroup => { + returnData.resourceIds.push(`G-${inventoryitemgroup}`) + }) + } + + return returnData + }) + }) + + sourceAbsenceRequests.value.forEach(absencerequest => { + timelineEvents.push({ + title: `${absencerequest.reason}`, + backgroundColor: "red", + borderColor: "red", + start: absencerequest.startDate, + end: absencerequest.endDate, + resourceIds: [`P-${absencerequest.profile.id}`], + entrytype: "absencerequest", + allDay: true, + absencerequestId: absencerequest.id + }) + }) + + calendarOptionsTimeline.value.initialEvents = timelineEvents +} const calendarOptionsGrid = computed(() => { return { locale: deLocale, @@ -62,6 +181,9 @@ const calendarOptionsGrid = computed(() => { } }, + datesSet: function(info) { + expandEventsForRange(info.startStr, info.endStr) + } } }) @@ -93,6 +215,9 @@ const calendarOptionsTimeline = ref({ router.push(`/standardEntity/events/show/${info.event.extendedProps.eventId}`) } }, + datesSet: function(info) { + expandEventsForRange(info.startStr, info.endStr) + }, resourceGroupField: "type", resourceOrder: "-type", resources: [], @@ -127,75 +252,37 @@ const calendarOptionsTimeline = ref({ const loaded = ref(false) const setupPage = async () => { - let tempData = (await useEntities("events").select()).filter(i => !i.archived) - let absencerequests = (await useEntities("absencerequests").select("*, profile(*)")).filter(i => !i.archived) - let projects = (await useEntities("projects").select( "*")).filter(i => !i.archived) - let inventoryitems = (await useEntities("inventoryitems").select()).filter(i => !i.archived) - let inventoryitemgroups = (await useEntities("inventoryitemgroups").select()).filter(i => !i.archived) - let profiles = (await useEntities("profiles").select()).filter(i => !i.archived) - let vehicles = (await useEntities("vehicles").select()).filter(i => !i.archived) - - calendarOptionsGrid.value.initialEvents = [ - ...tempData.map(event => { - let eventColor = profileStore.ownTenant.calendarConfig.eventTypes.find(type => type.label === event.eventtype).color - - let title = "" - if (event.name) { - title = event.name - } else if (event.project) { - title = projects.find(i => i.id === event.project) ? projects.find(i => i.id === event.project).name : "" - } - - return { - ...event, - start: event.startDate, - end: event.endDate, - title: title, - borderColor: eventColor, - textColor: eventColor, - backgroundColor: "black", - entrytype: "event", - eventId: event.id - } - }), - ...absencerequests.map(absence => { - return { - id: absence.id, - resourceId: absence.user, - resourceType: "person", - title: `${absence.reason} - ${absence.profile.fullName}`, - start: dayjs(absence.startDate).toDate(), - end: dayjs(absence.endDate).add(1, 'day').toDate(), - allDay: true, - absencerequestId: absence.id, - entrytype: "absencerequest", - } - }) - ] + sourceEvents.value = (await useEntities("events").select()).filter(i => !i.archived) + sourceAbsenceRequests.value = (await useEntities("absencerequests").select("*, profile(*)")).filter(i => !i.archived) + sourceProjects.value = (await useEntities("projects").select( "*")).filter(i => !i.archived) + sourceInventoryItems.value = (await useEntities("inventoryitems").select()).filter(i => !i.archived) + sourceInventoryItemGroups.value = (await useEntities("inventoryitemgroups").select()).filter(i => !i.archived) + sourceProfiles.value = (await useEntities("profiles").select()).filter(i => !i.archived) + sourceVehicles.value = (await useEntities("vehicles").select()).filter(i => !i.archived) calendarOptionsTimeline.value.resources = [ - ...profiles.filter(i => i.tenant === profileStore.currentTenant).map(profile => { + ...sourceProfiles.value.filter(i => i.tenant === profileStore.currentTenant).map(profile => { return { type: 'Mitarbeiter', title: profile.fullName, id: `P-${profile.id}` } }), - ...vehicles.map(vehicle => { + ...sourceVehicles.value.map(vehicle => { return { type: 'Fahrzeug', title: vehicle.licensePlate, id: `F-${vehicle.id}` } }), - ...inventoryitems.filter(i=> i.usePlanning).map(item => { + ...sourceInventoryItems.value.filter(i=> i.usePlanning).map(item => { return { type: 'Inventar', title: item.name, id: `I-${item.id}` } }), - ...inventoryitemgroups.filter(i=> i.usePlanning).map(item => { + ...sourceInventoryItemGroups.value.filter(i=> i.usePlanning).map(item => { return { type: 'Inventargruppen', title: item.name, @@ -234,83 +321,7 @@ const setupPage = async () => { ] */ - let tempEvents = [] - - tempData.forEach(event => { - console.log(event) - let eventColor = profileStore.ownTenant.calendarConfig.eventTypes.find(type => type.label === event.eventtype).color - - let title = "" - if (event.name) { - title = event.name - } else if (event.project) { - projects.find(i => i.id === event.project) ? projects.find(i => i.id === event.project).name : "" - } - - let returnData = { - title: title, - borderColor: eventColor, - textColor: "white", - backgroundColor: eventColor, - start: event.startDate, - end: event.endDate, - resourceIds: [], - entrytype: "event", - eventId: event.id - } - - - if(event.profiles.length > 0) { - event.profiles.forEach(profile => { - returnData.resourceIds.push(`P-${profile}`) - }) - } - - if(event.vehicles.length > 0) { - event.vehicles.forEach(vehicle => { - returnData.resourceIds.push(`F-${vehicle}`) - }) - } - - if(event.inventoryitems.length > 0) { - event.inventoryitems.forEach(inventoryitem => { - returnData.resourceIds.push(`I-${inventoryitem}`) - }) - } - - if(event.inventoryitemgroups.length > 0) { - event.inventoryitemgroups.forEach(inventoryitemgroup => { - returnData.resourceIds.push(`G-${inventoryitemgroup}`) - }) - } - - console.log(returnData) - - tempEvents.push(returnData) - - }) - - absencerequests.forEach(absencerequest => { - let returnData = { - title: `${absencerequest.reason}`, - backgroundColor: "red", - borderColor: "red", - start: absencerequest.startDate, - end: absencerequest.endDate, - resourceIds: [`P-${absencerequest.profile.id}`], - entrytype: "absencerequest", - allDay: true, - absencerequestId: absencerequest.id - } - - tempEvents.push(returnData) - }) - - console.log(tempEvents) - - calendarOptionsTimeline.value.initialEvents = tempEvents - - console.log(calendarOptionsTimeline.value) + expandEventsForRange(dayjs().startOf("month").toISOString(), dayjs().endOf("month").toISOString()) loaded.value = true @@ -385,4 +396,4 @@ const convertResourceIds = () => { \ No newline at end of file + diff --git a/frontend/pages/organisation/plantafel.vue b/frontend/pages/organisation/plantafel.vue index 0545db3..9f33883 100644 --- a/frontend/pages/organisation/plantafel.vue +++ b/frontend/pages/organisation/plantafel.vue @@ -5,6 +5,7 @@ import interactionPlugin from "@fullcalendar/interaction" import resourceTimelinePlugin from "@fullcalendar/resource-timeline" import { parseDate } from "@internationalized/date" import { useDraggable } from "@vueuse/core" +import { expandRecurringEvent } from "~/utils/eventRecurrence" const router = useRouter() const auth = useAuthStore() @@ -534,7 +535,7 @@ function buildResources({ profiles, inventoryitems }) { function buildEvents({ rawEvents, projectsById }) { const mappedEvents = rawEvents .filter((event) => !event.archived) - .map((event) => { + .flatMap((event) => { const resourceIds = [ ...(profiles.value .filter((profile) => (event.profiles || []).includes(profile.id)) @@ -542,20 +543,26 @@ function buildEvents({ rawEvents, projectsById }) { ...(event.inventoryitems || []).map((itemId) => `I-${itemId}`) ] - return { - title: resolveDisplayedEventTitle(event, projectsById), - start: event.startDate, - end: event.endDate, - resourceIds, - color: event.color || null, - state: event.state || "Final", - backgroundColor: resolveRenderedEventColor(event), - borderColor: resolveRenderedEventColor(event), - textColor: "#ffffff", - classNames: event.state === "Entwurf" ? ["planning-board-draft-event"] : [], - entrytype: "event", - eventId: event.id - } + return expandRecurringEvent( + event, + `${visibleRange.value.from}T00:00:00`, + `${visibleRange.value.to}T23:59:59`, + (occurrenceStart, occurrenceEnd, occurrenceIndex) => ({ + title: resolveDisplayedEventTitle(event, projectsById), + start: occurrenceStart.toISOString(), + end: occurrenceEnd ? occurrenceEnd.toISOString() : null, + resourceIds, + color: event.color || null, + state: event.state || "Final", + backgroundColor: resolveRenderedEventColor(event), + borderColor: resolveRenderedEventColor(event), + textColor: "#ffffff", + classNames: event.state === "Entwurf" ? ["planning-board-draft-event"] : [], + entrytype: "event", + eventId: event.id, + occurrenceIndex + }) + ) }) .filter((event) => event.resourceIds.length > 0) diff --git a/frontend/stores/data.js b/frontend/stores/data.js index c355530..b330f66 100644 --- a/frontend/stores/data.js +++ b/frontend/stores/data.js @@ -3096,6 +3096,13 @@ export const useDataStore = defineStore('data', () => { selectManualOptions: ["Entwurf", "Final"], sortable: true }, + { + key: "repeatInterval", + label: "Wiederholung", + inputType: "select", + selectManualOptions: ["Keine Wiederholung", "Täglich", "Wöchentlich", "2-wöchentlich", "Monatlich", "Jährlich"], + sortable: true + }, { key: "color", label: "Farbe", diff --git a/frontend/utils/eventRecurrence.ts b/frontend/utils/eventRecurrence.ts new file mode 100644 index 0000000..60aacb0 --- /dev/null +++ b/frontend/utils/eventRecurrence.ts @@ -0,0 +1,78 @@ +import dayjs from "dayjs" + +export const EVENT_REPEAT_INTERVALS = [ + "Keine Wiederholung", + "Täglich", + "Wöchentlich", + "2-wöchentlich", + "Monatlich", + "Jährlich" +] as const + +const DEFAULT_REPEAT_INTERVAL = "Keine Wiederholung" +const MAX_OCCURRENCES = 500 + +const addInterval = (date: dayjs.Dayjs, repeatInterval: string) => { + switch (repeatInterval) { + case "Täglich": + return date.add(1, "day") + case "Wöchentlich": + return date.add(1, "week") + case "2-wöchentlich": + return date.add(2, "week") + case "Monatlich": + return date.add(1, "month") + case "Jährlich": + return date.add(1, "year") + default: + return null + } +} + +export const normalizeRepeatInterval = (value?: string | null) => + EVENT_REPEAT_INTERVALS.includes(value as typeof EVENT_REPEAT_INTERVALS[number]) + ? value as typeof EVENT_REPEAT_INTERVALS[number] + : DEFAULT_REPEAT_INTERVAL + +export const expandRecurringEvent = ( + event: { + startDate: string | Date + endDate?: string | Date | null + repeatInterval?: string | null + }, + rangeStart: string | Date, + rangeEnd: string | Date, + buildOccurrence: (occurrenceStart: dayjs.Dayjs, occurrenceEnd: dayjs.Dayjs | null, occurrenceIndex: number) => T +) => { + const repeatInterval = normalizeRepeatInterval(event.repeatInterval) + const baseStart = dayjs(event.startDate) + const baseEnd = event.endDate ? dayjs(event.endDate) : null + const visibleStart = dayjs(rangeStart) + const visibleEnd = dayjs(rangeEnd) + const durationMs = baseEnd ? Math.max(baseEnd.diff(baseStart), 0) : 0 + + if (repeatInterval === DEFAULT_REPEAT_INTERVAL) { + const occurrenceEnd = baseEnd ? baseStart.add(durationMs, "millisecond") : null + if (baseStart.isAfter(visibleEnd) || (occurrenceEnd && occurrenceEnd.isBefore(visibleStart))) return [] + return [buildOccurrence(baseStart, occurrenceEnd, 0)] + } + + const occurrences: T[] = [] + let occurrenceStart = baseStart + let occurrenceIndex = 0 + + while (occurrenceIndex < MAX_OCCURRENCES && occurrenceStart.isBefore(visibleEnd.add(1, "day"))) { + const occurrenceEnd = baseEnd ? occurrenceStart.add(durationMs, "millisecond") : null + + if (!occurrenceStart.isAfter(visibleEnd) && !(occurrenceEnd && occurrenceEnd.isBefore(visibleStart))) { + occurrences.push(buildOccurrence(occurrenceStart, occurrenceEnd, occurrenceIndex)) + } + + const nextStart = addInterval(occurrenceStart, repeatInterval) + if (!nextStart || nextStart.isSame(occurrenceStart)) break + occurrenceStart = nextStart + occurrenceIndex += 1 + } + + return occurrences +}