This commit is contained in:
529
frontend/pages/organisation/plantafel.vue
Normal file
529
frontend/pages/organisation/plantafel.vue
Normal file
@@ -0,0 +1,529 @@
|
||||
<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="rose"
|
||||
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="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>
|
||||
Reference in New Issue
Block a user