Files
FEDEO/frontend/pages/organisation/plantafel.vue
2026-03-21 22:13:19 +01:00

530 lines
17 KiB
Vue

<script setup>
import deLocale from "@fullcalendar/core/locales/de"
import FullCalendar from "@fullcalendar/vue3"
import interactionPlugin from "@fullcalendar/interaction"
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
const router = useRouter()
const profileStore = useProfileStore()
const toast = useToast()
const { $dayjs } = useNuxtApp()
const { list: listStaffTimeSpans, createEntry, update: updateStaffTimeEntry } = useStaffTime()
const loading = ref(true)
const savingAbsence = ref(false)
const selectedType = ref("all")
const visibleRange = ref({
from: $dayjs().startOf("month").format("YYYY-MM-DD"),
to: $dayjs().endOf("month").format("YYYY-MM-DD")
})
const lastRangeKey = ref("")
const resources = ref([])
const events = ref([])
const profiles = ref([])
const inventoryitems = ref([])
const isAbsenceModalOpen = ref(false)
const absenceForm = reactive({
mode: "create",
entry: null,
userId: "",
type: "vacation",
startDate: $dayjs().format("YYYY-MM-DD"),
endDate: $dayjs().format("YYYY-MM-DD"),
description: ""
})
const setAbsenceDateToToday = (field) => {
absenceForm[field] = $dayjs().format("YYYY-MM-DD")
}
const resourceTypeOptions = [
{ label: "Alle Ressourcen", value: "all" },
{ label: "Profile", value: "Profile" },
{ label: "Inventarartikel", value: "Inventarartikel" }
]
const absenceTypeOptions = [
{ label: "Urlaub", value: "vacation" },
{ label: "Krank", value: "sick" }
]
const profileOptions = computed(() =>
profiles.value
.filter((profile) => !profile.archived && profile.user_id)
.map((profile) => ({
label: profile.full_name || profile.fullName || [profile.first_name, profile.last_name].filter(Boolean).join(" ") || profile.email,
value: profile.user_id
}))
)
const absenceModalTitle = computed(() =>
absenceForm.mode === "edit"
? (absenceForm.type === "sick" ? "Krankmeldung bearbeiten" : "Urlaub bearbeiten")
: (absenceForm.type === "sick" ? "Krank eintragen" : "Urlaub eintragen")
)
const visibleResources = computed(() => {
if (selectedType.value === "all") return resources.value
return resources.value.filter((resource) => resource.type === selectedType.value)
})
const visibleResourceIds = computed(() => new Set(visibleResources.value.map((resource) => resource.id)))
const visibleEvents = computed(() => {
if (selectedType.value === "all") return events.value
return events.value.filter((event) =>
(event.resourceIds || []).some((resourceId) => visibleResourceIds.value.has(resourceId))
)
})
const calendarOptions = computed(() => ({
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
locale: deLocale,
plugins: [resourceTimelinePlugin, interactionPlugin],
initialView: "resourceTimelineWeek",
headerToolbar: {
left: "prev,next today",
center: "title",
right: "resourceTimelineDay,resourceTimelineWeek,resourceTimelineMonth"
},
resourceAreaWidth: "280px",
resourceGroupField: "type",
resourceOrder: "type,title",
resources: visibleResources.value,
events: visibleEvents.value,
nowIndicator: true,
selectable: true,
height: "calc(100vh - 250px)",
slotMinWidth: 80,
resourceAreaColumns: [
{
field: "title",
headerContent: "Ressource"
}
],
views: {
resourceTimelineDay: {
type: "resourceTimeline",
duration: { days: 1 },
buttonText: "Tag",
slotDuration: { hours: 1 }
},
resourceTimelineWeek: {
type: "resourceTimeline",
duration: { days: 7 },
buttonText: "Woche",
slotDuration: { hours: 3 },
weekends: false,
slotMinTime: "06:00:00",
slotMaxTime: "21:00:00"
},
resourceTimelineMonth: {
type: "resourceTimeline",
duration: { months: 1 },
buttonText: "Monat"
}
},
select(info) {
const resourceIds = info.resource?.id ? [info.resource.id] : []
router.push(`/standardEntity/events/create?startDate=${encodeURIComponent(info.startStr)}&endDate=${encodeURIComponent(info.endStr)}&resources=${encodeURIComponent(JSON.stringify(resourceIds))}&source=timeline`)
},
eventClick(info) {
if (info.event.extendedProps.entrytype === "staff-absence") {
openAbsenceModal(info.event.extendedProps.absenceType, {
entry: info.event.extendedProps.absenceEntry,
userId: info.event.extendedProps.absenceEntry?.user_id || "",
startDate: $dayjs(info.event.extendedProps.absenceEntry?.started_at).format("YYYY-MM-DD"),
endDate: $dayjs(info.event.extendedProps.absenceEntry?.stopped_at || info.event.extendedProps.absenceEntry?.started_at).format("YYYY-MM-DD"),
description: info.event.extendedProps.absenceEntry?.description || ""
})
return
}
router.push(`/standardEntity/events/show/${info.event.extendedProps.eventId}`)
},
datesSet(info) {
const nextFrom = $dayjs(info.start).format("YYYY-MM-DD")
const nextTo = $dayjs(info.end).subtract(1, "day").format("YYYY-MM-DD")
const nextKey = `${nextFrom}:${nextTo}`
if (nextKey === lastRangeKey.value) return
lastRangeKey.value = nextKey
visibleRange.value = { from: nextFrom, to: nextTo }
loadPlanningBoard()
}
}))
function resolveEventColor(eventType) {
return profileStore.ownTenant?.calendarConfig?.eventTypes?.find((item) => item.label === eventType)?.color || "#111827"
}
function resolveEventTitle(event, projectsById) {
if (event.name) return event.name
if (event.project && projectsById.has(event.project)) return projectsById.get(event.project).name
return "Planung"
}
function getProfileLabel(profile) {
return profile?.full_name || profile?.fullName || [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") || profile?.email || `Profil ${profile?.id || ""}`.trim()
}
function getAbsenceTitle(entry) {
const baseLabel = entry.type === "sick" ? "Krank" : "Urlaub"
return entry.description ? `${baseLabel}: ${entry.description}` : baseLabel
}
function getAbsenceColor(type) {
return type === "sick" ? "#dc2626" : "#d97706"
}
function buildResources({ profiles, inventoryitems }) {
return [
...profiles
.filter((profile) => !profile.archived)
.map((profile) => ({
id: `P-${profile.id}`,
type: "Profile",
title: getProfileLabel(profile)
})),
...inventoryitems
.filter((item) => !item.archived && item.usePlanning)
.map((item) => ({
id: `I-${item.id}`,
type: "Inventarartikel",
title: item.name
}))
]
}
function buildEvents({ rawEvents, projectsById }) {
const mappedEvents = rawEvents
.filter((event) => !event.archived)
.map((event) => {
const resourceIds = [
...(event.profiles || []).map((profileId) => `P-${profileId}`),
...(event.inventoryitems || []).map((itemId) => `I-${itemId}`)
]
return {
title: resolveEventTitle(event, projectsById),
start: event.startDate,
end: event.endDate,
resourceIds,
backgroundColor: resolveEventColor(event.eventtype),
borderColor: resolveEventColor(event.eventtype),
textColor: "#ffffff",
entrytype: "event",
eventId: event.id
}
})
.filter((event) => event.resourceIds.length > 0)
return mappedEvents
}
function buildAbsenceEvents(spansByUserId) {
const profileByUserId = new Map(
profiles.value
.filter((profile) => profile.user_id)
.map((profile) => [profile.user_id, profile])
)
return spansByUserId.flatMap(({ userId, spans }) => {
const profile = profileByUserId.get(userId)
if (!profile) return []
return (spans || [])
.filter((entry) => ["vacation", "sick"].includes(entry.type) && entry.state !== "rejected")
.map((entry) => ({
title: getAbsenceTitle(entry),
start: $dayjs(entry.started_at).startOf("day").toISOString(),
end: $dayjs(entry.stopped_at || entry.started_at).add(1, "day").startOf("day").toISOString(),
resourceIds: [`P-${profile.id}`],
allDay: true,
backgroundColor: getAbsenceColor(entry.type),
borderColor: getAbsenceColor(entry.type),
textColor: "#ffffff",
entrytype: "staff-absence",
absenceType: entry.type,
absenceEntry: entry
}))
})
}
function resetAbsenceForm() {
absenceForm.mode = "create"
absenceForm.entry = null
absenceForm.userId = profileOptions.value[0]?.value || ""
absenceForm.type = "vacation"
absenceForm.startDate = $dayjs().format("YYYY-MM-DD")
absenceForm.endDate = $dayjs().format("YYYY-MM-DD")
absenceForm.description = ""
}
function openAbsenceModal(type = "vacation", preset = {}) {
absenceForm.mode = preset.entry ? "edit" : "create"
absenceForm.entry = preset.entry || null
absenceForm.type = type
absenceForm.userId = preset.userId || profileOptions.value[0]?.value || ""
absenceForm.startDate = preset.startDate || $dayjs().format("YYYY-MM-DD")
absenceForm.endDate = preset.endDate || preset.startDate || $dayjs().format("YYYY-MM-DD")
absenceForm.description = preset.description || ""
isAbsenceModalOpen.value = true
}
async function saveAbsence() {
if (!absenceForm.userId) {
toast.add({ title: "Profil fehlt", description: "Bitte ein Profil auswählen.", color: "orange" })
return
}
if (!absenceForm.startDate || !absenceForm.endDate) {
toast.add({ title: "Zeitraum fehlt", description: "Bitte Start- und Enddatum angeben.", color: "orange" })
return
}
if ($dayjs(absenceForm.endDate).isBefore($dayjs(absenceForm.startDate))) {
toast.add({ title: "Zeitraum ungültig", description: "Das Enddatum muss am oder nach dem Startdatum liegen.", color: "orange" })
return
}
savingAbsence.value = true
try {
const payload = {
start: $dayjs(absenceForm.startDate).startOf("day").toISOString(),
end: $dayjs(absenceForm.endDate).endOf("day").toISOString(),
type: absenceForm.type,
description: absenceForm.description || "",
user_id: absenceForm.userId
}
if (absenceForm.mode === "edit" && absenceForm.entry) {
await updateStaffTimeEntry(absenceForm.entry, payload)
toast.add({ title: "Abwesenheit aktualisiert", color: "green" })
} else {
await createEntry(payload)
toast.add({ title: absenceForm.type === "sick" ? "Krankmeldung eingetragen" : "Urlaub eingetragen", color: "green" })
}
isAbsenceModalOpen.value = false
await loadPlanningBoard()
} catch (error) {
console.error("saveAbsence failed", error)
toast.add({
title: "Abwesenheit konnte nicht gespeichert werden",
description: error?.message || "Bitte Eingaben prüfen und erneut versuchen.",
color: "red"
})
} finally {
savingAbsence.value = false
}
}
async function loadPlanningBoard() {
loading.value = true
try {
const [rawEvents, projects, profileRows, inventoryItemRows] = await Promise.all([
useEntities("events").select(),
useEntities("projects").select(),
useEntities("profiles").select(),
useEntities("inventoryitems").select()
])
const absenceSpansByUserId = await Promise.all(
(profileRows || [])
.filter((profile) => profile.user_id)
.map(async (profile) => ({
userId: profile.user_id,
spans: await listStaffTimeSpans({
user_id: profile.user_id,
from: visibleRange.value.from,
to: visibleRange.value.to
})
}))
)
const projectsById = new Map((projects || []).map((project) => [project.id, project]))
profiles.value = profileRows || []
inventoryitems.value = inventoryItemRows || []
if (!absenceForm.userId) {
absenceForm.userId = profileOptions.value[0]?.value || ""
}
resources.value = buildResources({
profiles: profiles.value,
inventoryitems: inventoryitems.value
})
events.value = buildEvents({
rawEvents: rawEvents || [],
projectsById
}).concat(buildAbsenceEvents(absenceSpansByUserId))
} catch (error) {
console.error("loadPlanningBoard failed", error)
toast.add({
title: "Plantafel konnte nicht geladen werden",
description: "Die Ressourcen oder Planungen konnten nicht abgerufen werden.",
color: "red"
})
} finally {
loading.value = false
}
}
onMounted(() => {
resetAbsenceForm()
loadPlanningBoard()
})
</script>
<template>
<div>
<UDashboardNavbar title="Plantafel">
<template #right>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-arrow-path"
:loading="loading"
@click="loadPlanningBoard"
>
Aktualisieren
</UButton>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<div class="flex flex-wrap items-center gap-3">
<USelectMenu
v-model="selectedType"
:options="resourceTypeOptions"
value-attribute="value"
option-attribute="label"
:clearable="false"
class="min-w-[220px]"
/>
</div>
</template>
<template #right>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="amber"
variant="soft"
icon="i-heroicons-sun"
@click="openAbsenceModal('vacation')"
>
Urlaub
</UButton>
<UButton
color="error"
variant="soft"
icon="i-heroicons-heart"
@click="openAbsenceModal('sick')"
>
Krank
</UButton>
</div>
</template>
</UDashboardToolbar>
<UDashboardPanelContent class="p-5">
<div v-if="loading" class="space-y-3">
<USkeleton class="h-12 w-full" />
<USkeleton class="h-[70vh] w-full" />
</div>
<UAlert
v-else-if="visibleResources.length === 0"
icon="i-heroicons-calendar-days"
title="Keine planbaren Ressourcen vorhanden"
description="Lege Profile an oder aktiviere die Plantafel-Nutzung bei Inventarartikeln, damit hier Ressourcen erscheinen."
/>
<FullCalendar
v-else
:options="calendarOptions"
/>
</UDashboardPanelContent>
<UModal v-model:open="isAbsenceModalOpen">
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
{{ absenceModalTitle }}
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="isAbsenceModalOpen = false"
/>
</div>
</template>
<div class="space-y-4">
<UFormGroup label="Profil">
<USelectMenu
v-model="absenceForm.userId"
:options="profileOptions"
value-attribute="value"
option-attribute="label"
searchable
/>
</UFormGroup>
<UFormGroup label="Typ">
<USelectMenu
v-model="absenceForm.type"
:options="absenceTypeOptions"
value-attribute="value"
option-attribute="label"
/>
</UFormGroup>
<div class="grid grid-cols-2 gap-4">
<UFormGroup label="Start">
<div class="flex items-center gap-2">
<UInput v-model="absenceForm.startDate" type="date" class="flex-1" />
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('startDate')" />
</div>
</UFormGroup>
<UFormGroup label="Ende">
<div class="flex items-center gap-2">
<UInput v-model="absenceForm.endDate" type="date" class="flex-1" />
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('endDate')" />
</div>
</UFormGroup>
</div>
<UFormGroup label="Notiz">
<UTextarea
v-model="absenceForm.description"
:placeholder="absenceForm.type === 'sick' ? 'z. B. Krankmeldung eingegangen' : 'z. B. Sommerurlaub'"
/>
</UFormGroup>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="isAbsenceModalOpen = false">
Abbrechen
</UButton>
<UButton color="primary" :loading="savingAbsence" @click="saveAbsence">
Speichern
</UButton>
</div>
</template>
</UCard>
</UModal>
</div>
</template>