KI-AGENT: Ergänze wiederholende Termine für Kalender und Plantafel

This commit is contained in:
2026-05-19 12:27:17 +02:00
parent ea392af094
commit 58c47fa8f7
7 changed files with 254 additions and 142 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "events" ADD COLUMN "repeatInterval" text DEFAULT 'Keine Wiederholung' NOT NULL;

View File

@@ -274,6 +274,13 @@
"when": 1779158400000, "when": 1779158400000,
"tag": "0038_events_state", "tag": "0038_events_state",
"breakpoints": true "breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1779840000000,
"tag": "0039_events_repeat_interval",
"breakpoints": true
} }
] ]
} }

View File

@@ -34,6 +34,7 @@ export const events = pgTable(
quick: boolean("quick").notNull().default(false), quick: boolean("quick").notNull().default(false),
state: text("state").notNull().default("Final"), state: text("state").notNull().default("Final"),
color: text("color"), color: text("color"),
repeatInterval: text("repeatInterval").notNull().default("Keine Wiederholung"),
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists

View File

@@ -6,6 +6,7 @@ import timeGridPlugin from "@fullcalendar/timegrid"
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"; import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
import interactionPlugin from "@fullcalendar/interaction"; import interactionPlugin from "@fullcalendar/interaction";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { expandRecurringEvent } from "~/utils/eventRecurrence"
//TODO BACKEND CHANGE COLOR IN TENANT FOR RENDERING //TODO BACKEND CHANGE COLOR IN TENANT FOR RENDERING
@@ -33,6 +34,124 @@ const selectedEvent = ref({})
const selectedResources = ref([]) const selectedResources = ref([])
const events = 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(() => { const calendarOptionsGrid = computed(() => {
return { return {
locale: deLocale, 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}`) router.push(`/standardEntity/events/show/${info.event.extendedProps.eventId}`)
} }
}, },
datesSet: function(info) {
expandEventsForRange(info.startStr, info.endStr)
},
resourceGroupField: "type", resourceGroupField: "type",
resourceOrder: "-type", resourceOrder: "-type",
resources: [], resources: [],
@@ -127,75 +252,37 @@ const calendarOptionsTimeline = ref({
const loaded = ref(false) const loaded = ref(false)
const setupPage = async () => { const setupPage = async () => {
let tempData = (await useEntities("events").select()).filter(i => !i.archived) sourceEvents.value = (await useEntities("events").select()).filter(i => !i.archived)
let absencerequests = (await useEntities("absencerequests").select("*, profile(*)")).filter(i => !i.archived) sourceAbsenceRequests.value = (await useEntities("absencerequests").select("*, profile(*)")).filter(i => !i.archived)
let projects = (await useEntities("projects").select( "*")).filter(i => !i.archived) sourceProjects.value = (await useEntities("projects").select( "*")).filter(i => !i.archived)
let inventoryitems = (await useEntities("inventoryitems").select()).filter(i => !i.archived) sourceInventoryItems.value = (await useEntities("inventoryitems").select()).filter(i => !i.archived)
let inventoryitemgroups = (await useEntities("inventoryitemgroups").select()).filter(i => !i.archived) sourceInventoryItemGroups.value = (await useEntities("inventoryitemgroups").select()).filter(i => !i.archived)
let profiles = (await useEntities("profiles").select()).filter(i => !i.archived) sourceProfiles.value = (await useEntities("profiles").select()).filter(i => !i.archived)
let vehicles = (await useEntities("vehicles").select()).filter(i => !i.archived) sourceVehicles.value = (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",
}
})
]
calendarOptionsTimeline.value.resources = [ calendarOptionsTimeline.value.resources = [
...profiles.filter(i => i.tenant === profileStore.currentTenant).map(profile => { ...sourceProfiles.value.filter(i => i.tenant === profileStore.currentTenant).map(profile => {
return { return {
type: 'Mitarbeiter', type: 'Mitarbeiter',
title: profile.fullName, title: profile.fullName,
id: `P-${profile.id}` id: `P-${profile.id}`
} }
}), }),
...vehicles.map(vehicle => { ...sourceVehicles.value.map(vehicle => {
return { return {
type: 'Fahrzeug', type: 'Fahrzeug',
title: vehicle.licensePlate, title: vehicle.licensePlate,
id: `F-${vehicle.id}` id: `F-${vehicle.id}`
} }
}), }),
...inventoryitems.filter(i=> i.usePlanning).map(item => { ...sourceInventoryItems.value.filter(i=> i.usePlanning).map(item => {
return { return {
type: 'Inventar', type: 'Inventar',
title: item.name, title: item.name,
id: `I-${item.id}` id: `I-${item.id}`
} }
}), }),
...inventoryitemgroups.filter(i=> i.usePlanning).map(item => { ...sourceInventoryItemGroups.value.filter(i=> i.usePlanning).map(item => {
return { return {
type: 'Inventargruppen', type: 'Inventargruppen',
title: item.name, title: item.name,
@@ -234,83 +321,7 @@ const setupPage = async () => {
] ]
*/ */
let tempEvents = [] expandEventsForRange(dayjs().startOf("month").toISOString(), dayjs().endOf("month").toISOString())
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)
loaded.value = true loaded.value = true
@@ -385,4 +396,4 @@ const convertResourceIds = () => {
<style scoped> <style scoped>
</style> </style>

View File

@@ -5,6 +5,7 @@ import interactionPlugin from "@fullcalendar/interaction"
import resourceTimelinePlugin from "@fullcalendar/resource-timeline" import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
import { parseDate } from "@internationalized/date" import { parseDate } from "@internationalized/date"
import { useDraggable } from "@vueuse/core" import { useDraggable } from "@vueuse/core"
import { expandRecurringEvent } from "~/utils/eventRecurrence"
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
@@ -534,7 +535,7 @@ function buildResources({ profiles, inventoryitems }) {
function buildEvents({ rawEvents, projectsById }) { function buildEvents({ rawEvents, projectsById }) {
const mappedEvents = rawEvents const mappedEvents = rawEvents
.filter((event) => !event.archived) .filter((event) => !event.archived)
.map((event) => { .flatMap((event) => {
const resourceIds = [ const resourceIds = [
...(profiles.value ...(profiles.value
.filter((profile) => (event.profiles || []).includes(profile.id)) .filter((profile) => (event.profiles || []).includes(profile.id))
@@ -542,20 +543,26 @@ function buildEvents({ rawEvents, projectsById }) {
...(event.inventoryitems || []).map((itemId) => `I-${itemId}`) ...(event.inventoryitems || []).map((itemId) => `I-${itemId}`)
] ]
return { return expandRecurringEvent(
title: resolveDisplayedEventTitle(event, projectsById), event,
start: event.startDate, `${visibleRange.value.from}T00:00:00`,
end: event.endDate, `${visibleRange.value.to}T23:59:59`,
resourceIds, (occurrenceStart, occurrenceEnd, occurrenceIndex) => ({
color: event.color || null, title: resolveDisplayedEventTitle(event, projectsById),
state: event.state || "Final", start: occurrenceStart.toISOString(),
backgroundColor: resolveRenderedEventColor(event), end: occurrenceEnd ? occurrenceEnd.toISOString() : null,
borderColor: resolveRenderedEventColor(event), resourceIds,
textColor: "#ffffff", color: event.color || null,
classNames: event.state === "Entwurf" ? ["planning-board-draft-event"] : [], state: event.state || "Final",
entrytype: "event", backgroundColor: resolveRenderedEventColor(event),
eventId: event.id 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) .filter((event) => event.resourceIds.length > 0)

View File

@@ -3096,6 +3096,13 @@ export const useDataStore = defineStore('data', () => {
selectManualOptions: ["Entwurf", "Final"], selectManualOptions: ["Entwurf", "Final"],
sortable: true 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", key: "color",
label: "Farbe", label: "Farbe",

View File

@@ -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 = <T>(
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
}