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
+}