From cfc5efb556ab86fbbc68ede85ed339be4bcc6b18 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 29 Apr 2026 16:12:13 +0200 Subject: [PATCH] Plantafel um Mitarbeiterdetails mit Resturlaub erweitern --- frontend/pages/organisation/plantafel.vue | 662 ++++++++++++++++++---- 1 file changed, 562 insertions(+), 100 deletions(-) diff --git a/frontend/pages/organisation/plantafel.vue b/frontend/pages/organisation/plantafel.vue index ed06d9c..3ac6dfc 100644 --- a/frontend/pages/organisation/plantafel.vue +++ b/frontend/pages/organisation/plantafel.vue @@ -30,10 +30,14 @@ const events = ref([]) const profiles = ref([]) const inventoryitems = ref([]) const savingQuickConfig = ref(false) +const isQuickConfigModalOpen = ref(false) +const showQuickConfigEditor = ref(true) +const showQuickPresetManagement = ref(false) const quickEntryConfig = reactive({ name: "Quick-Eintrag", color: "#2563eb" }) +const newQuickPresetName = ref("") const quickEntryColorOptions = [ { label: "Blau", value: "#2563eb" }, { label: "Grün", value: "#16a34a" }, @@ -46,6 +50,11 @@ const quickEntryColorOptions = [ ] const isAbsenceModalOpen = ref(false) +const isProfileDetailsModalOpen = ref(false) +const loadingProfileVacation = ref(false) +const selectedProfile = ref(null) +const selectedProfileVacationSummary = ref(null) +const profileVacationRequestId = ref(0) const absenceForm = reactive({ mode: "create", entry: null, @@ -142,8 +151,14 @@ const absenceModalTitle = computed(() => : (absenceForm.type === "sick" ? "Krank eintragen" : "Urlaub eintragen") ) +const tenantCalendarConfig = computed(() => + auth.activeTenantData?.calendarConfig + || profileStore.ownTenant?.calendarConfig + || {} +) + const resolvedQuickEntryConfig = computed(() => { - const config = profileStore.ownTenant?.calendarConfig?.quickEntry || {} + const config = tenantCalendarConfig.value?.quickEntry || {} return { name: config.name || "Quick-Eintrag", @@ -151,9 +166,73 @@ const resolvedQuickEntryConfig = computed(() => { } }) +const activeQuickEntryConfig = computed(() => ({ + name: quickEntryConfig.name?.trim() || resolvedQuickEntryConfig.value.name, + color: quickEntryConfig.color || resolvedQuickEntryConfig.value.color +})) + +const quickEntryPresets = computed(() => { + const rawPresets = tenantCalendarConfig.value?.quickEntryPresets + + if (!Array.isArray(rawPresets)) return [] + + return rawPresets + .filter((preset) => preset?.name && preset?.color) + .map((preset) => ({ + name: String(preset.name), + color: String(preset.color) + })) +}) + +const resourcesById = computed(() => new Map(resources.value.map((resource) => [resource.id, resource]))) +const currentVacationYear = computed(() => $dayjs().year()) +const selectedProfileTeamsLabel = computed(() => + (selectedProfile.value?.teams || []) + .map((team) => team?.name) + .filter(Boolean) + .join(", ") || "Keine Teams zugeordnet" +) +const selectedProfileBranchesLabel = computed(() => { + const profile = selectedProfile.value + if (!profile) return "Keine Niederlassung" + + const labels = [ + profile.branch?.name, + ...(profile.branches || []).map((branch) => branch?.name) + ].filter(Boolean) + + return [...new Set(labels)].join(", ") || "Keine Niederlassung" +}) +const selectedProfileVacationDaysTaken = computed(() => + Number(selectedProfileVacationSummary.value?.sumVacationDays || 0) +) +const selectedProfileAnnualLeaveDays = computed(() => { + const value = selectedProfile.value?.annual_paid_leave_days + return value === null || value === undefined || value === "" ? null : Number(value) +}) +const selectedProfileRemainingVacationDays = computed(() => { + if (selectedProfileAnnualLeaveDays.value === null) return null + + return Number((selectedProfileAnnualLeaveDays.value - selectedProfileVacationDaysTaken.value).toFixed(2)) +}) + const visibleResources = computed(() => { if (selectedType.value === "all") return resources.value - return resources.value.filter((resource) => resource.type === selectedType.value) + + const visibleMap = new Map() + + resources.value + .filter((resource) => resource.type === selectedType.value) + .forEach((resource) => { + let current = resource + + while (current) { + visibleMap.set(current.id, current) + current = current.parentId ? resourcesById.value.get(current.parentId) : null + } + }) + + return resources.value.filter((resource) => visibleMap.has(resource.id)) }) const visibleResourceIds = computed(() => new Set(visibleResources.value.map((resource) => resource.id))) @@ -188,6 +267,28 @@ const calendarOptions = computed(() => ({ headerContent: "Ressource" } ], + resourceLabelDidMount(info) { + const { resource } = info + const isProfileResource = resource.extendedProps?.resourceKind === "profile" && resource.extendedProps?.profileId + + if (!isProfileResource) return + + info.el.classList.add("cursor-pointer") + info.el.setAttribute("role", "button") + info.el.setAttribute("tabindex", "0") + info.el.setAttribute("title", "Mitarbeiterdetails öffnen") + + const openProfile = () => openProfileDetails(resource.extendedProps.profileId) + const onKeydown = (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + openProfile() + } + } + + info.el.addEventListener("click", openProfile) + info.el.addEventListener("keydown", onKeydown) + }, views: { resourceTimelineDay: { type: "resourceTimeline", @@ -245,17 +346,17 @@ const calendarOptions = computed(() => ({ })) function resolveEventColor(eventType) { - return profileStore.ownTenant?.calendarConfig?.eventTypes?.find((item) => item.label === eventType)?.color || "#111827" + return tenantCalendarConfig.value?.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 event.quick ? resolvedQuickEntryConfig.value.name : "Planung" + return event.quick ? activeQuickEntryConfig.value.name : "Planung" } function resolveRenderedEventColor(event) { - if (event?.quick) return resolvedQuickEntryConfig.value.color + if (event?.quick) return activeQuickEntryConfig.value.color return resolveEventColor(event.eventtype) } @@ -274,7 +375,11 @@ function getAbsenceColor(type) { function getTeamLabel(team) { if (!team) return "Team" - return team.branch?.name ? `${team.name} (${team.branch.name})` : team.name + return team.name +} + +function getBranchResourceId(branchId) { + return `B-${branchId}` } function getProfileResourceIds(profile) { @@ -305,8 +410,10 @@ function normalizeSelectedResourceIds(resourceId) { } function buildResources({ profiles, inventoryitems }) { + const branchResources = [] const teamResources = [] const profileResources = [] + const topLevelTeamGroupId = "TEAM-UNBOUND" profiles .filter((profile) => !profile.archived) @@ -317,17 +424,40 @@ function buildResources({ profiles, inventoryitems }) { profileResources.push({ id: `P-${profile.id}`, type: "Profile", - title: getProfileLabel(profile) + title: getProfileLabel(profile), + resourceKind: "profile", + profileId: profile.id }) return } assignedTeams.forEach((team) => { + if (team?.branch?.id) { + const branchResourceId = getBranchResourceId(team.branch.id) + + if (!branchResources.find((resource) => resource.id === branchResourceId)) { + branchResources.push({ + id: branchResourceId, + type: "Niederlassung", + title: team.branch.name + }) + } + } + + if (!team?.branch?.id && !branchResources.find((resource) => resource.id === topLevelTeamGroupId)) { + branchResources.push({ + id: topLevelTeamGroupId, + type: "Niederlassung", + title: "Übergreifende Teams" + }) + } + if (!teamResources.find((resource) => resource.id === `T-${team.id}`)) { teamResources.push({ id: `T-${team.id}`, type: "Team", - title: getTeamLabel(team) + title: getTeamLabel(team), + parentId: team?.branch?.id ? getBranchResourceId(team.branch.id) : topLevelTeamGroupId }) } @@ -335,7 +465,9 @@ function buildResources({ profiles, inventoryitems }) { id: `T-${team.id}:P-${profile.id}`, parentId: `T-${team.id}`, type: "Profile", - title: getProfileLabel(profile) + title: getProfileLabel(profile), + resourceKind: "profile", + profileId: profile.id }) }) }) @@ -348,7 +480,7 @@ function buildResources({ profiles, inventoryitems }) { title: item.name })) - return [...teamResources, ...profileResources, ...inventoryResources] + return [...branchResources, ...teamResources, ...profileResources, ...inventoryResources] } function buildEvents({ rawEvents, projectsById }) { @@ -443,19 +575,83 @@ function moveCalendarToday() { api.today() } -function openQuickConfig() { - quickEntryConfig.name = resolvedQuickEntryConfig.value.name - quickEntryConfig.color = resolvedQuickEntryConfig.value.color +function formatProfileDate(value) { + if (!value) return "Nicht hinterlegt" + return $dayjs(value).isValid() ? $dayjs(value).format("DD.MM.YYYY") : "Nicht hinterlegt" } -async function saveQuickConfig() { - if (savingQuickConfig.value) return +function formatProfileNumber(value) { + if (value === null || value === undefined || value === "") return "Nicht hinterlegt" - const name = quickEntryConfig.name?.trim() - if (!name) { - toast.add({ title: "Name fehlt", description: "Bitte einen Namen für Quick-Einträge angeben.", color: "orange" }) - return + const numericValue = Number(value) + return Number.isFinite(numericValue) + ? new Intl.NumberFormat("de-DE", { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(numericValue) + : String(value) +} + +async function openProfileDetails(profileId) { + const profile = profiles.value.find((item) => item.id === profileId) + if (!profile) return + + selectedProfile.value = profile + selectedProfileVacationSummary.value = null + isProfileDetailsModalOpen.value = true + loadingProfileVacation.value = true + + const requestId = profileVacationRequestId.value + 1 + profileVacationRequestId.value = requestId + + try { + const from = `${currentVacationYear.value}-01-01` + const to = `${currentVacationYear.value}-12-31` + const response = await $api(`/api/staff/time/evaluation?${new URLSearchParams({ + from, + to, + targetUserId: profile.user_id + }).toString()}`) + + if (profileVacationRequestId.value !== requestId) return + + selectedProfileVacationSummary.value = response?.summary || null + } catch (error) { + console.error("openProfileDetails failed", error) + + if (profileVacationRequestId.value !== requestId) return + + selectedProfileVacationSummary.value = null + toast.add({ + title: "Resturlaub konnte nicht geladen werden", + description: "Die Mitarbeiterdetails sind sichtbar, aber die Urlaubsauswertung konnte nicht abgerufen werden.", + color: "orange" + }) + } finally { + if (profileVacationRequestId.value === requestId) { + loadingProfileVacation.value = false + } } +} + +function openQuickConfig() { + newQuickPresetName.value = quickEntryConfig.name + showQuickConfigEditor.value = true + showQuickPresetManagement.value = false + isQuickConfigModalOpen.value = true +} + +function applyQuickPreset(preset) { + if (!preset) return + quickEntryConfig.name = preset.name + quickEntryConfig.color = preset.color + newQuickPresetName.value = preset.name +} + +watch(resolvedQuickEntryConfig, (config) => { + if (!quickEntryConfig.name?.trim()) quickEntryConfig.name = config.name + if (!quickEntryConfig.color) quickEntryConfig.color = config.color +}, { immediate: true }) + +async function persistQuickPresets(nextPresets) { + if (savingQuickConfig.value) return savingQuickConfig.value = true @@ -463,10 +659,7 @@ async function saveQuickConfig() { const currentTenantData = auth.activeTenantData || {} const nextCalendarConfig = { ...(currentTenantData.calendarConfig || {}), - quickEntry: { - name, - color: quickEntryConfig.color || "#2563eb" - } + quickEntryPresets: nextPresets } const updatedTenant = await $api(`/api/tenant/other/${auth.activeTenant}`, { @@ -480,25 +673,49 @@ async function saveQuickConfig() { auth.activeTenantData = updatedTenant profileStore.ownTenant = updatedTenant - toast.add({ title: "Quick-Einträge gespeichert", color: "green" }) - await loadPlanningBoard() } catch (error) { - console.error("saveQuickConfig failed", error) + console.error("persistQuickPresets failed", error) toast.add({ - title: "Quick-Konfiguration konnte nicht gespeichert werden", + title: "Vorlagen konnten nicht gespeichert werden", description: error?.message || "Bitte erneut versuchen.", color: "red" }) + throw error } finally { savingQuickConfig.value = false } } +async function saveQuickPreset() { + const presetName = newQuickPresetName.value?.trim() || quickEntryConfig.name?.trim() + if (!presetName) { + toast.add({ title: "Name fehlt", description: "Bitte einen Namen für die Vorlage angeben.", color: "orange" }) + return + } + + const nextPresets = [ + ...quickEntryPresets.value.filter((preset) => preset.name !== presetName), + { + name: presetName, + color: quickEntryConfig.color || "#2563eb" + } + ] + + await persistQuickPresets(nextPresets) + toast.add({ title: "Vorlage gespeichert", color: "green" }) +} + +async function deleteQuickPreset(presetName) { + const nextPresets = quickEntryPresets.value.filter((preset) => preset.name !== presetName) + await persistQuickPresets(nextPresets) + toast.add({ title: "Vorlage gelöscht", color: "green" }) +} + async function createQuickEvent(info) { const resourceIds = normalizeSelectedResourceIds(info.resource?.id) const payload = { - name: resolvedQuickEntryConfig.value.name, + name: activeQuickEntryConfig.value.name, quick: true, startDate: info.startStr, endDate: info.endStr, @@ -720,78 +937,14 @@ onMounted(() => {