Files
FEDEO/frontend/pages/organisation/plantafel.vue

1643 lines
57 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"
import { parseDate } from "@internationalized/date"
import { useDraggable } from "@vueuse/core"
const router = useRouter()
const auth = useAuthStore()
const profileStore = useProfileStore()
const toast = useToast()
const { $api, $dayjs } = useNuxtApp()
const { create: createEvent } = useEntities("events")
const { list: listStaffTimeSpans, createEntry, update: updateStaffTimeEntry } = useStaffTime()
const loading = ref(true)
const savingAbsence = ref(false)
const selectedType = ref("all")
const calendarRef = ref(null)
const calendarView = ref("resourceTimelineWeek")
const calendarCurrentDate = ref($dayjs().format("YYYY-MM-DD"))
const calendarTitle = ref("")
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 isDraftModeActive = ref(false)
const isFinalizeDraftsModalOpen = ref(false)
const finalizingDrafts = ref(false)
const savingQuickConfig = ref(false)
const isQuickConfigModalOpen = ref(false)
const quickConfigWindowEl = ref(null)
const profileDetailsWindowEl = ref(null)
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" },
{ label: "Orange", value: "#ea580c" },
{ label: "Rot", value: "#dc2626" },
{ label: "Pink", value: "#db2777" },
{ label: "Türkis", value: "#0f766e" },
{ label: "Grau", value: "#4b5563" },
{ label: "Schwarz", value: "#111827" }
]
const { style: quickConfigWindowStyle } = useDraggable(quickConfigWindowEl, {
initialValue: { x: 120, y: 100 }
})
const { style: profileDetailsWindowStyle } = useDraggable(profileDetailsWindowEl, {
initialValue: { x: 220, y: 120 }
})
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,
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 startDateValue = computed({
get: () => {
if (!absenceForm.startDate) return null
try {
return parseDate(absenceForm.startDate)
} catch {
return null
}
},
set: (value) => {
absenceForm.startDate = value ? value.toString() : ""
}
})
const endDateValue = computed({
get: () => {
if (!absenceForm.endDate) return null
try {
return parseDate(absenceForm.endDate)
} catch {
return null
}
},
set: (value) => {
absenceForm.endDate = value ? value.toString() : ""
}
})
const resourceTypeOptions = [
{ label: "Alle Ressourcen", value: "all" },
{ label: "Teams", value: "Team" },
{ label: "Profile", value: "Profile" },
{ label: "Inventarartikel", value: "Inventarartikel" }
]
const calendarViewOptions = [
{ label: "Tag", value: "resourceTimelineDay" },
{ label: "Woche", value: "resourceTimelineWeek" },
{ label: "Monat", value: "resourceTimelineMonth" }
]
const absenceTypeOptions = [
{ label: "Urlaub", value: "vacation" },
{ label: "Krank", value: "sick" }
]
const calendarPickerValue = computed({
get: () => {
if (!calendarCurrentDate.value) return null
try {
return parseDate(calendarCurrentDate.value)
} catch {
return null
}
},
set: (value) => {
calendarCurrentDate.value = value ? value.toString() : ""
if (value) {
const api = calendarRef.value?.getApi?.()
api?.gotoDate(value.toString())
}
}
})
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 tenantCalendarConfig = computed(() =>
auth.activeTenantData?.calendarConfig
|| profileStore.ownTenant?.calendarConfig
|| {}
)
const resolvedPlanningBoardConfig = computed(() => {
const config = tenantCalendarConfig.value?.planningBoard || {}
const normalizedStartTime = /^\d{2}:\d{2}$/.test(String(config.startTime || "")) ? String(config.startTime) : "06:00"
const normalizedEndTime = /^\d{2}:\d{2}$/.test(String(config.endTime || "")) ? String(config.endTime) : "21:00"
const normalizedSlotMinutes = Number(config.slotMinutes)
const allowedSlotMinutes = [15, 30, 60, 120, 180]
return {
startTime: normalizedStartTime,
endTime: normalizedEndTime,
slotMinutes: allowedSlotMinutes.includes(normalizedSlotMinutes) ? normalizedSlotMinutes : 180
}
})
const resolvedQuickEntryConfig = computed(() => {
const config = tenantCalendarConfig.value?.quickEntry || {}
return {
name: config.name || "Quick-Eintrag",
color: config.color || "#2563eb"
}
})
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 selectedProfileAvailabilityNote = computed(() =>
selectedProfile.value?.availability_note?.trim() || "Keine Verfügbarkeitshinweise hinterlegt"
)
const visibleResources = computed(() => {
if (selectedType.value === "all") return resources.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)))
const visibleEvents = computed(() => {
if (selectedType.value === "all") return events.value
return events.value.filter((event) =>
(event.resourceIds || []).some((resourceId) => visibleResourceIds.value.has(resourceId))
)
})
const visibleDraftEventIds = computed(() =>
[...new Set(
visibleEvents.value
.filter((event) => event.state === "Entwurf" && event.entrytype === "event" && event.eventId)
.map((event) => event.eventId)
)]
)
const calendarOptions = computed(() => ({
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
locale: deLocale,
plugins: [resourceTimelinePlugin, interactionPlugin],
initialView: calendarView.value,
initialDate: calendarCurrentDate.value,
headerToolbar: false,
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"
}
],
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",
duration: { days: 1 },
buttonText: "Tag",
slotDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
snapDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
slotMinTime: `${resolvedPlanningBoardConfig.value.startTime}:00`,
slotMaxTime: `${resolvedPlanningBoardConfig.value.endTime}:00`
},
resourceTimelineWeek: {
type: "resourceTimeline",
duration: { days: 7 },
buttonText: "Woche",
slotDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
snapDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
weekends: false,
slotMinTime: `${resolvedPlanningBoardConfig.value.startTime}:00`,
slotMaxTime: `${resolvedPlanningBoardConfig.value.endTime}:00`
},
resourceTimelineMonth: {
type: "resourceTimeline",
duration: { months: 1 },
buttonText: "Monat"
}
},
select(info) {
createQuickEvent(info)
},
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/edit/${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}`
calendarView.value = info.view.type
calendarCurrentDate.value = $dayjs(info.view.currentStart).format("YYYY-MM-DD")
calendarTitle.value = info.view.title
if (nextKey === lastRangeKey.value) return
lastRangeKey.value = nextKey
visibleRange.value = { from: nextFrom, to: nextTo }
loadPlanningBoard()
}
}))
function resolveEventColor(eventType) {
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 ? activeQuickEntryConfig.value.name : "Planung"
}
function resolveRenderedEventColor(event) {
if (event?.quick) return event?.color || activeQuickEntryConfig.value.color
return resolveEventColor(event.eventtype)
}
function resolveDisplayedEventTitle(event, projectsById) {
const baseTitle = resolveEventTitle(event, projectsById)
return event?.state === "Entwurf" ? `[Entwurf] ${baseTitle}` : baseTitle
}
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 getTeamLabel(team) {
if (!team) return "Team"
return team.name
}
function getBranchResourceId(branchId) {
return `B-${branchId}`
}
function getProfileResourceIds(profile) {
const assignedTeams = Array.isArray(profile?.teams) ? profile.teams.filter((team) => team?.id) : []
if (!assignedTeams.length) {
return [`P-${profile.id}`]
}
return assignedTeams.map((team) => `T-${team.id}:P-${profile.id}`)
}
function normalizeSelectedResourceIds(resourceId) {
if (!resourceId) return []
if (resourceId.startsWith("T-") && resourceId.includes(":P-")) {
return [`P-${resourceId.split(":P-")[1]}`]
}
if (resourceId.startsWith("T-")) {
const teamId = Number(resourceId.replace("T-", ""))
return profiles.value
.filter((profile) => (profile.teams || []).some((team) => team?.id === teamId))
.map((profile) => `P-${profile.id}`)
}
return [resourceId]
}
function buildResources({ profiles, inventoryitems }) {
const branchResources = []
const teamResources = []
const profileResources = []
const topLevelTeamGroupId = "TEAM-UNBOUND"
profiles
.filter((profile) => !profile.archived)
.forEach((profile) => {
const assignedTeams = Array.isArray(profile?.teams) ? profile.teams.filter((team) => team?.id) : []
if (!assignedTeams.length) {
profileResources.push({
id: `P-${profile.id}`,
type: "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),
parentId: team?.branch?.id ? getBranchResourceId(team.branch.id) : topLevelTeamGroupId
})
}
profileResources.push({
id: `T-${team.id}:P-${profile.id}`,
parentId: `T-${team.id}`,
type: "Profile",
title: getProfileLabel(profile),
resourceKind: "profile",
profileId: profile.id
})
})
})
const inventoryResources = inventoryitems
.filter((item) => !item.archived && item.usePlanning)
.map((item) => ({
id: `I-${item.id}`,
type: "Inventarartikel",
title: item.name
}))
return [...branchResources, ...teamResources, ...profileResources, ...inventoryResources]
}
function buildEvents({ rawEvents, projectsById }) {
const mappedEvents = rawEvents
.filter((event) => !event.archived)
.map((event) => {
const resourceIds = [
...(profiles.value
.filter((profile) => (event.profiles || []).includes(profile.id))
.flatMap((profile) => getProfileResourceIds(profile))),
...(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
}
})
.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: getProfileResourceIds(profile),
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 getCalendarApi() {
return calendarRef.value?.getApi?.()
}
function changeCalendarView(view) {
const api = getCalendarApi()
if (!api || !view) return
calendarView.value = view
api.changeView(view)
}
function moveCalendar(direction) {
const api = getCalendarApi()
if (!api) return
if (direction === "prev") api.prev()
if (direction === "next") api.next()
}
function moveCalendarToday() {
const api = getCalendarApi()
if (!api) return
api.today()
}
function formatProfileDate(value) {
if (!value) return "Nicht hinterlegt"
return $dayjs(value).isValid() ? $dayjs(value).format("DD.MM.YYYY") : "Nicht hinterlegt"
}
function formatProfileNumber(value) {
if (value === null || value === undefined || value === "") return "Nicht hinterlegt"
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
const requestId = profileVacationRequestId.value + 1
profileVacationRequestId.value = requestId
if (!profile.user_id) {
loadingProfileVacation.value = false
return
}
loadingProfileVacation.value = true
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
try {
const currentTenantData = auth.activeTenantData || {}
const nextCalendarConfig = {
...(currentTenantData.calendarConfig || {}),
quickEntryPresets: nextPresets
}
const updatedTenant = await $api(`/api/tenant/other/${auth.activeTenant}`, {
method: "PUT",
body: {
data: {
calendarConfig: nextCalendarConfig
}
}
})
auth.activeTenantData = updatedTenant
profileStore.ownTenant = updatedTenant
} catch (error) {
console.error("persistQuickPresets failed", error)
toast.add({
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: activeQuickEntryConfig.value.name,
quick: true,
state: isDraftModeActive.value ? "Entwurf" : "Final",
color: activeQuickEntryConfig.value.color,
startDate: info.startStr,
endDate: info.endStr,
profiles: resourceIds
.filter((resourceId) => resourceId.startsWith("P-"))
.map((resourceId) => resourceId.replace("P-", "")),
inventoryitems: resourceIds
.filter((resourceId) => resourceId.startsWith("I-"))
.map((resourceId) => Number(resourceId.replace("I-", ""))),
inventoryitemgroups: [],
vehicles: [],
notes: "",
link: "",
project: null,
customer: null,
vendor: null,
}
try {
await createEvent(payload, true)
toast.add({ title: "Quick-Eintrag angelegt", color: "green" })
await loadPlanningBoard()
} catch (error) {
console.error("createQuickEvent failed", error)
toast.add({
title: "Quick-Eintrag konnte nicht angelegt werden",
description: error?.message || "Bitte erneut versuchen.",
color: "red"
})
}
}
function toggleDraftMode() {
if (isDraftModeActive.value) {
if (!visibleDraftEventIds.value.length) {
isDraftModeActive.value = false
toast.add({ title: "Entwurfsmodus deaktiviert", color: "green" })
return
}
isFinalizeDraftsModalOpen.value = true
return
}
isDraftModeActive.value = true
toast.add({ title: "Entwurfsmodus aktiviert", description: "Neue Schichten werden als Entwurf angelegt.", color: "green" })
}
async function finalizeVisibleDrafts() {
if (finalizingDrafts.value || !visibleDraftEventIds.value.length) return
finalizingDrafts.value = true
const draftCount = visibleDraftEventIds.value.length
try {
await Promise.all(
visibleDraftEventIds.value.map((eventId) =>
$api(`/api/resource/events/${eventId}`, {
method: "PUT",
body: { state: "Final" }
})
)
)
isDraftModeActive.value = false
isFinalizeDraftsModalOpen.value = false
toast.add({ title: "Entwürfe finalisiert", description: `${draftCount} Termine wurden finalisiert.`, color: "green" })
await loadPlanningBoard()
} catch (error) {
console.error("finalizeVisibleDrafts failed", error)
toast.add({
title: "Entwürfe konnten nicht finalisiert werden",
description: error?.message || "Bitte erneut versuchen.",
color: "red"
})
} finally {
finalizingDrafts.value = false
}
}
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, profileResponse, inventoryItemRows] = await Promise.all([
useEntities("events").select(),
useEntities("projects").select(),
useNuxtApp().$api("/api/tenant/profiles"),
useEntities("inventoryitems").select()
])
const profileRows = profileResponse?.data || []
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"
:items="resourceTypeOptions"
value-key="value"
label-key="label"
:clearable="false"
class="min-w-[220px]"
/>
<USelectMenu
v-model="calendarView"
:items="calendarViewOptions"
value-key="value"
label-key="label"
:clearable="false"
class="min-w-[160px]"
@update:model-value="changeCalendarView"
/>
<UPopover>
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-calendar-days"
class="min-w-[180px] justify-between"
>
{{ calendarCurrentDate ? $dayjs(calendarCurrentDate).format("DD.MM.YYYY") : "Datum wählen" }}
</UButton>
<template #content>
<div class="p-2">
<UCalendar v-model="calendarPickerValue" />
<div class="flex justify-end border-t border-default pt-2">
<UButton color="neutral" variant="ghost" size="sm" @click="moveCalendarToday">
Heute
</UButton>
</div>
</div>
</template>
</UPopover>
<div class="flex items-center gap-1">
<UButton color="neutral" variant="ghost" icon="i-heroicons-chevron-left" @click="moveCalendar('prev')" />
<UButton color="neutral" variant="ghost" icon="i-heroicons-chevron-right" @click="moveCalendar('next')" />
</div>
<span class="text-sm font-medium text-highlighted">
{{ calendarTitle }}
</span>
</div>
</template>
<template #right>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-cog-6-tooth"
@click="openQuickConfig"
>
Quick-Einträge
</UButton>
<UButton
:color="isDraftModeActive ? 'amber' : 'neutral'"
:variant="isDraftModeActive ? 'solid' : 'outline'"
icon="i-heroicons-document-duplicate"
@click="toggleDraftMode"
>
{{ isDraftModeActive ? "Entwurfsmodus aktiv" : "Entwurfsmodus" }}
</UButton>
<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
ref="calendarRef"
:options="calendarOptions"
/>
</UDashboardPanelContent>
<div
v-if="isQuickConfigModalOpen"
ref="quickConfigWindowEl"
:style="quickConfigWindowStyle"
class="fixed z-[999] flex h-[720px] w-[980px] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl resize dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3 select-none dark:border-gray-800 dark:bg-gray-800/50">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-cog-6-tooth" class="text-gray-500" />
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Quick-Einträge
</h3>
<p class="text-xs text-muted">
Name und Farbe für neu gezogene Quick-Einträge in der Plantafel.
</p>
</div>
</div>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
size="sm"
@click="isQuickConfigModalOpen = false"
/>
</div>
<div class="flex-1 overflow-auto p-4">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div class="space-y-4">
<div class="rounded border border-default p-3">
<div class="space-y-3">
<div>
<p class="text-sm font-medium text-highlighted">Vorlagen</p>
<p class="text-xs text-muted">Gespeicherte Kombinationen direkt für neue Quick-Einträge anwenden.</p>
</div>
<div v-if="quickEntryPresets.length" class="flex flex-wrap gap-2">
<button
v-for="preset in quickEntryPresets"
:key="preset.name"
type="button"
class="flex items-center gap-2 rounded border border-default px-2 py-1 text-xs transition hover:border-primary"
@click="applyQuickPreset(preset)"
>
<span
class="h-4 w-4 rounded-full border border-white/40"
:style="{ backgroundColor: preset.color }"
/>
{{ preset.name }}
</button>
</div>
<p v-else class="text-sm text-muted">
Es sind noch keine Vorlagen gespeichert.
</p>
</div>
</div>
<div class="rounded border border-default p-3">
<button
type="button"
class="flex w-full items-center justify-between text-left"
@click="showQuickPresetManagement = !showQuickPresetManagement"
>
<div>
<p class="text-sm font-medium text-highlighted">Vorlagen verwalten</p>
<p class="text-xs text-muted">Tenantweite Vorlagen löschen oder aufräumen.</p>
</div>
<UIcon
:name="showQuickPresetManagement ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
class="text-muted"
/>
</button>
<div v-if="showQuickPresetManagement" class="mt-4 space-y-4 border-t border-default pt-4">
<div v-if="quickEntryPresets.length" class="space-y-2">
<p class="text-xs font-medium text-muted">Gespeicherte Vorlagen</p>
<div
v-for="preset in quickEntryPresets"
:key="`${preset.name}-${preset.color}`"
class="flex items-center justify-between gap-3 rounded border border-default px-3 py-2"
>
<button
type="button"
class="flex min-w-0 items-center gap-2 text-left"
@click="applyQuickPreset(preset)"
>
<span
class="h-4 w-4 shrink-0 rounded-full border border-white/40"
:style="{ backgroundColor: preset.color }"
/>
<span class="truncate text-sm">{{ preset.name }}</span>
</button>
<UButton
color="error"
variant="ghost"
icon="i-heroicons-trash"
@click="deleteQuickPreset(preset.name)"
/>
</div>
</div>
</div>
</div>
</div>
<div class="rounded border border-default p-3">
<button
type="button"
class="flex w-full items-center justify-between text-left"
@click="showQuickConfigEditor = !showQuickConfigEditor"
>
<div>
<p class="text-sm font-medium text-highlighted">Konfiguration</p>
<p class="text-xs text-muted">Aktuellen Titel und Farbe für Quick-Einträge anpassen.</p>
</div>
<UIcon
:name="showQuickConfigEditor ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
class="text-muted"
/>
</button>
<div v-if="showQuickConfigEditor" class="mt-4 space-y-3 border-t border-default pt-4">
<UFormField label="Titel">
<UInput v-model="quickEntryConfig.name" class="w-full" />
</UFormField>
<UFormField label="Vorlagenname">
<UInput v-model="newQuickPresetName" class="w-full" />
</UFormField>
<UFormField label="Farbe">
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<button
v-for="option in quickEntryColorOptions"
:key="option.value"
type="button"
class="flex items-center gap-2 rounded border px-2 py-1 text-xs transition"
:class="quickEntryConfig.color === option.value ? 'border-primary ring-1 ring-primary' : 'border-default'"
@click="quickEntryConfig.color = option.value"
>
<span
class="h-4 w-4 rounded-full border border-white/40"
:style="{ backgroundColor: option.value }"
/>
{{ option.label }}
</button>
</div>
<div class="flex items-center gap-3">
<input
v-model="quickEntryConfig.color"
type="color"
class="h-10 w-14 cursor-pointer rounded border border-default bg-transparent p-1"
>
<UInput v-model="quickEntryConfig.color" class="flex-1" />
</div>
</div>
</UFormField>
<div class="rounded border border-default p-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs text-muted">Vorschau</p>
<div class="mt-2 inline-flex rounded px-3 py-1 text-sm font-medium text-white" :style="{ backgroundColor: quickEntryConfig.color }">
{{ quickEntryConfig.name || "Quick-Eintrag" }}
</div>
</div>
<UButton
color="primary"
:loading="savingQuickConfig"
@click="saveQuickPreset"
>
Vorlage speichern
</UButton>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="absolute bottom-0 right-0 h-4 w-4 cursor-se-resize opacity-50">
<UIcon name="i-heroicons-arrows-pointing-out" class="h-3 w-3 rotate-90 text-gray-400" />
</div>
</div>
<UModal v-model:open="isFinalizeDraftsModalOpen">
<template #content>
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<template #header>
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Entwürfe finalisieren
</h3>
<p class="text-sm text-muted">
Beim Beenden des Entwurfsmodus können die aktuell sichtbaren Entwürfe finalisiert werden.
</p>
</div>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="isFinalizeDraftsModalOpen = false"
/>
</div>
</template>
<div class="space-y-4">
<UAlert
color="amber"
variant="soft"
icon="i-heroicons-exclamation-triangle"
:title="`${visibleDraftEventIds.length} sichtbare Entwürfe gefunden`"
description="Diese Termine werden beim Finalisieren auf den Status Final gesetzt."
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="isFinalizeDraftsModalOpen = false">
Abbrechen
</UButton>
<UButton color="primary" :loading="finalizingDrafts" @click="finalizeVisibleDrafts">
Entwürfe finalisieren
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
<UModal v-model:open="isAbsenceModalOpen">
<template #content>
<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">
<UFormField label="Profil">
<USelectMenu
v-model="absenceForm.userId"
:items="profileOptions"
value-key="value"
label-key="label"
class="w-full"
:search-input="{ placeholder: 'Profil suchen...' }"
:filter-fields="['label']"
/>
</UFormField>
<UFormField label="Typ">
<USelectMenu
v-model="absenceForm.type"
:items="absenceTypeOptions"
value-key="value"
label-key="label"
class="w-full"
/>
</UFormField>
<div class="grid grid-cols-2 gap-4">
<UFormField label="Start">
<div class="flex items-center gap-2">
<UPopover>
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-calendar-days"
class="min-w-0 flex-1 justify-between"
>
<span class="truncate text-left">
{{ absenceForm.startDate ? $dayjs(absenceForm.startDate).format("DD.MM.YYYY") : "Kein Datum" }}
</span>
</UButton>
<template #content>
<div class="p-2">
<UCalendar v-model="startDateValue" />
<div class="flex justify-end border-t border-default pt-2">
<UButton color="neutral" variant="ghost" size="sm" @click="setAbsenceDateToToday('startDate')">
Heute
</UButton>
</div>
</div>
</template>
</UPopover>
<UButton color="neutral" variant="soft" label="Heute" @click="setAbsenceDateToToday('startDate')" />
</div>
</UFormField>
<UFormField label="Ende">
<div class="flex items-center gap-2">
<UPopover>
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-calendar-days"
class="min-w-0 flex-1 justify-between"
>
<span class="truncate text-left">
{{ absenceForm.endDate ? $dayjs(absenceForm.endDate).format("DD.MM.YYYY") : "Kein Datum" }}
</span>
</UButton>
<template #content>
<div class="p-2">
<UCalendar v-model="endDateValue" />
<div class="flex justify-end border-t border-default pt-2">
<UButton color="neutral" variant="ghost" size="sm" @click="setAbsenceDateToToday('endDate')">
Heute
</UButton>
</div>
</div>
</template>
</UPopover>
<UButton color="neutral" variant="soft" label="Heute" @click="setAbsenceDateToToday('endDate')" />
</div>
</UFormField>
</div>
<UFormField label="Notiz">
<UTextarea
v-model="absenceForm.description"
:placeholder="absenceForm.type === 'sick' ? 'z. B. Krankmeldung eingegangen' : 'z. B. Sommerurlaub'"
/>
</UFormField>
</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>
</template>
</UModal>
<div
v-if="isProfileDetailsModalOpen"
ref="profileDetailsWindowEl"
:style="profileDetailsWindowStyle"
class="fixed z-[999] flex h-[760px] w-[980px] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl resize dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3 select-none dark:border-gray-800 dark:bg-gray-800/50">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-user-circle" class="text-gray-500" />
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ getProfileLabel(selectedProfile) }}
</h3>
<p class="text-xs text-muted">
Mitarbeiterdetails
</p>
</div>
</div>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
size="sm"
@click="isProfileDetailsModalOpen = false"
/>
</div>
<div v-if="selectedProfile" class="flex-1 overflow-auto p-4">
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-lg border border-default p-4">
<p class="text-xs font-medium uppercase tracking-wide text-muted">Kontakt</p>
<dl class="mt-3 space-y-3 text-sm">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">E-Mail</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.email || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Mobil</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.mobile_tel || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Festnetz</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.fixed_tel || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Geburtstag</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileDate(selectedProfile.birthday) }}</dd>
</div>
</dl>
</div>
<div class="rounded-lg border border-default p-4">
<p class="text-xs font-medium uppercase tracking-wide text-muted">Beschäftigung</p>
<dl class="mt-3 space-y-3 text-sm">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">MA-Nummer</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.employee_number || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Position</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.position || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Vertragsart</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.contract_type || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Eintrittsdatum</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileDate(selectedProfile.entry_date) }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Wochenstunden</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileNumber(selectedProfile.weekly_working_hours) }}</dd>
</div>
</dl>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-lg border border-default p-4">
<p class="text-xs font-medium uppercase tracking-wide text-muted">Zuordnung</p>
<dl class="mt-3 space-y-3 text-sm">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Niederlassung</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfileBranchesLabel }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Teams</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfileTeamsLabel }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Status</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.active ? "Aktiv" : "Inaktiv" }}</dd>
</div>
</dl>
</div>
<div class="rounded-lg border border-default p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-muted">Urlaub {{ currentVacationYear }}</p>
<p class="mt-1 text-sm text-muted">Anzeige auf Basis der aktuellen Zeitauswertung.</p>
</div>
<UBadge color="amber" variant="subtle">
Resturlaub
</UBadge>
</div>
<div v-if="loadingProfileVacation" class="mt-4 space-y-2">
<USkeleton class="h-6 w-full" />
<USkeleton class="h-6 w-4/5" />
<USkeleton class="h-6 w-3/5" />
</div>
<dl v-else class="mt-4 space-y-3 text-sm">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Urlaubsanspruch</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileNumber(selectedProfileAnnualLeaveDays) }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Bereits verplant/genehmigt</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileNumber(selectedProfileVacationDaysTaken) }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Resturlaub</dt>
<dd class="text-right text-base font-semibold text-highlighted">
{{ selectedProfileRemainingVacationDays === null ? "Nicht hinterlegt" : formatProfileNumber(selectedProfileRemainingVacationDays) }}
</dd>
</div>
</dl>
</div>
</div>
<div class="rounded-lg border border-default p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-muted">Verfügbarkeitshinweis</p>
<p class="mt-1 text-sm text-muted">Z. B. bevorzugte Einsatzzeiten oder bekannte Einschränkungen.</p>
</div>
<UBadge color="sky" variant="subtle">
Verfügbarkeit
</UBadge>
</div>
<p class="mt-4 whitespace-pre-wrap text-sm font-medium text-highlighted">
{{ selectedProfileAvailabilityNote }}
</p>
</div>
</div>
</div>
<div class="flex justify-end gap-2 border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/30">
<UButton
color="primary"
variant="soft"
icon="i-heroicons-pencil-square"
@click="selectedProfile?.id && router.push(`/staff/profiles/${selectedProfile.id}`)"
>
Bearbeiten
</UButton>
<UButton color="gray" variant="soft" @click="isProfileDetailsModalOpen = false">
Schließen
</UButton>
</div>
<div class="absolute bottom-0 right-0 h-4 w-4 cursor-se-resize opacity-50">
<UIcon name="i-heroicons-arrows-pointing-out" class="h-3 w-3 rotate-90 text-gray-400" />
</div>
</div>
</div>
</template>
<style scoped>
:deep(.planning-board-draft-event) {
opacity: 0.7;
}
:deep(.planning-board-draft-event .fc-timeline-event) {
border-style: dashed;
}
</style>