1422 lines
49 KiB
Vue
1422 lines
49 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"
|
|
|
|
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 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" },
|
|
{ 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 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 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 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 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: { 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) {
|
|
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 activeQuickEntryConfig.value.color
|
|
return resolveEventColor(event.eventtype)
|
|
}
|
|
|
|
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: resolveEventTitle(event, projectsById),
|
|
start: event.startDate,
|
|
end: event.endDate,
|
|
resourceIds,
|
|
backgroundColor: resolveRenderedEventColor(event),
|
|
borderColor: resolveRenderedEventColor(event),
|
|
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: 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
|
|
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
|
|
|
|
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,
|
|
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 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="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>
|
|
|
|
<UModal v-model:open="isQuickConfigModalOpen">
|
|
<template #content>
|
|
<UCard class="mx-auto w-full max-w-2xl" :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
|
<template #header>
|
|
<div class="flex items-center justify-between gap-4">
|
|
<div>
|
|
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
|
Quick-Einträge
|
|
</h3>
|
|
<p class="text-sm text-muted">
|
|
Name und Farbe für neu gezogene Quick-Einträge in der Plantafel.
|
|
</p>
|
|
</div>
|
|
<UButton
|
|
color="gray"
|
|
variant="ghost"
|
|
icon="i-heroicons-x-mark-20-solid"
|
|
class="-my-1"
|
|
@click="isQuickConfigModalOpen = false"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-4 p-1">
|
|
<div v-if="quickEntryPresets.length" class="space-y-2">
|
|
<div class="text-xs font-medium text-muted">Vorlagen</div>
|
|
<div 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>
|
|
</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 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>
|
|
</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>
|
|
|
|
<UModal v-model:open="isProfileDetailsModalOpen">
|
|
<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">
|
|
{{ getProfileLabel(selectedProfile) }}
|
|
</h3>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
Mitarbeiterdetails
|
|
</p>
|
|
</div>
|
|
<UButton
|
|
color="gray"
|
|
variant="ghost"
|
|
icon="i-heroicons-x-mark-20-solid"
|
|
class="-my-1"
|
|
@click="isProfileDetailsModalOpen = false"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="selectedProfile" 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>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end">
|
|
<UButton color="gray" variant="soft" @click="isProfileDetailsModalOpen = false">
|
|
Schließen
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
</template>
|
|
</UModal>
|
|
</div>
|
|
</template>
|