Plantafel um Mitarbeiterdetails mit Resturlaub erweitern
This commit is contained in:
@@ -30,10 +30,14 @@ const events = ref([])
|
|||||||
const profiles = ref([])
|
const profiles = ref([])
|
||||||
const inventoryitems = ref([])
|
const inventoryitems = ref([])
|
||||||
const savingQuickConfig = ref(false)
|
const savingQuickConfig = ref(false)
|
||||||
|
const isQuickConfigModalOpen = ref(false)
|
||||||
|
const showQuickConfigEditor = ref(true)
|
||||||
|
const showQuickPresetManagement = ref(false)
|
||||||
const quickEntryConfig = reactive({
|
const quickEntryConfig = reactive({
|
||||||
name: "Quick-Eintrag",
|
name: "Quick-Eintrag",
|
||||||
color: "#2563eb"
|
color: "#2563eb"
|
||||||
})
|
})
|
||||||
|
const newQuickPresetName = ref("")
|
||||||
const quickEntryColorOptions = [
|
const quickEntryColorOptions = [
|
||||||
{ label: "Blau", value: "#2563eb" },
|
{ label: "Blau", value: "#2563eb" },
|
||||||
{ label: "Grün", value: "#16a34a" },
|
{ label: "Grün", value: "#16a34a" },
|
||||||
@@ -46,6 +50,11 @@ const quickEntryColorOptions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const isAbsenceModalOpen = ref(false)
|
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({
|
const absenceForm = reactive({
|
||||||
mode: "create",
|
mode: "create",
|
||||||
entry: null,
|
entry: null,
|
||||||
@@ -142,8 +151,14 @@ const absenceModalTitle = computed(() =>
|
|||||||
: (absenceForm.type === "sick" ? "Krank eintragen" : "Urlaub eintragen")
|
: (absenceForm.type === "sick" ? "Krank eintragen" : "Urlaub eintragen")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tenantCalendarConfig = computed(() =>
|
||||||
|
auth.activeTenantData?.calendarConfig
|
||||||
|
|| profileStore.ownTenant?.calendarConfig
|
||||||
|
|| {}
|
||||||
|
)
|
||||||
|
|
||||||
const resolvedQuickEntryConfig = computed(() => {
|
const resolvedQuickEntryConfig = computed(() => {
|
||||||
const config = profileStore.ownTenant?.calendarConfig?.quickEntry || {}
|
const config = tenantCalendarConfig.value?.quickEntry || {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: config.name || "Quick-Eintrag",
|
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(() => {
|
const visibleResources = computed(() => {
|
||||||
if (selectedType.value === "all") return resources.value
|
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)))
|
const visibleResourceIds = computed(() => new Set(visibleResources.value.map((resource) => resource.id)))
|
||||||
@@ -188,6 +267,28 @@ const calendarOptions = computed(() => ({
|
|||||||
headerContent: "Ressource"
|
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: {
|
views: {
|
||||||
resourceTimelineDay: {
|
resourceTimelineDay: {
|
||||||
type: "resourceTimeline",
|
type: "resourceTimeline",
|
||||||
@@ -245,17 +346,17 @@ const calendarOptions = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
function resolveEventColor(eventType) {
|
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) {
|
function resolveEventTitle(event, projectsById) {
|
||||||
if (event.name) return event.name
|
if (event.name) return event.name
|
||||||
if (event.project && projectsById.has(event.project)) return projectsById.get(event.project).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) {
|
function resolveRenderedEventColor(event) {
|
||||||
if (event?.quick) return resolvedQuickEntryConfig.value.color
|
if (event?.quick) return activeQuickEntryConfig.value.color
|
||||||
return resolveEventColor(event.eventtype)
|
return resolveEventColor(event.eventtype)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +375,11 @@ function getAbsenceColor(type) {
|
|||||||
|
|
||||||
function getTeamLabel(team) {
|
function getTeamLabel(team) {
|
||||||
if (!team) return "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) {
|
function getProfileResourceIds(profile) {
|
||||||
@@ -305,8 +410,10 @@ function normalizeSelectedResourceIds(resourceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildResources({ profiles, inventoryitems }) {
|
function buildResources({ profiles, inventoryitems }) {
|
||||||
|
const branchResources = []
|
||||||
const teamResources = []
|
const teamResources = []
|
||||||
const profileResources = []
|
const profileResources = []
|
||||||
|
const topLevelTeamGroupId = "TEAM-UNBOUND"
|
||||||
|
|
||||||
profiles
|
profiles
|
||||||
.filter((profile) => !profile.archived)
|
.filter((profile) => !profile.archived)
|
||||||
@@ -317,17 +424,40 @@ function buildResources({ profiles, inventoryitems }) {
|
|||||||
profileResources.push({
|
profileResources.push({
|
||||||
id: `P-${profile.id}`,
|
id: `P-${profile.id}`,
|
||||||
type: "Profile",
|
type: "Profile",
|
||||||
title: getProfileLabel(profile)
|
title: getProfileLabel(profile),
|
||||||
|
resourceKind: "profile",
|
||||||
|
profileId: profile.id
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assignedTeams.forEach((team) => {
|
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}`)) {
|
if (!teamResources.find((resource) => resource.id === `T-${team.id}`)) {
|
||||||
teamResources.push({
|
teamResources.push({
|
||||||
id: `T-${team.id}`,
|
id: `T-${team.id}`,
|
||||||
type: "Team",
|
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}`,
|
id: `T-${team.id}:P-${profile.id}`,
|
||||||
parentId: `T-${team.id}`,
|
parentId: `T-${team.id}`,
|
||||||
type: "Profile",
|
type: "Profile",
|
||||||
title: getProfileLabel(profile)
|
title: getProfileLabel(profile),
|
||||||
|
resourceKind: "profile",
|
||||||
|
profileId: profile.id
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -348,7 +480,7 @@ function buildResources({ profiles, inventoryitems }) {
|
|||||||
title: item.name
|
title: item.name
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return [...teamResources, ...profileResources, ...inventoryResources]
|
return [...branchResources, ...teamResources, ...profileResources, ...inventoryResources]
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEvents({ rawEvents, projectsById }) {
|
function buildEvents({ rawEvents, projectsById }) {
|
||||||
@@ -443,19 +575,83 @@ function moveCalendarToday() {
|
|||||||
api.today()
|
api.today()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openQuickConfig() {
|
function formatProfileDate(value) {
|
||||||
quickEntryConfig.name = resolvedQuickEntryConfig.value.name
|
if (!value) return "Nicht hinterlegt"
|
||||||
quickEntryConfig.color = resolvedQuickEntryConfig.value.color
|
return $dayjs(value).isValid() ? $dayjs(value).format("DD.MM.YYYY") : "Nicht hinterlegt"
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveQuickConfig() {
|
function formatProfileNumber(value) {
|
||||||
if (savingQuickConfig.value) return
|
if (value === null || value === undefined || value === "") return "Nicht hinterlegt"
|
||||||
|
|
||||||
const name = quickEntryConfig.name?.trim()
|
const numericValue = Number(value)
|
||||||
if (!name) {
|
return Number.isFinite(numericValue)
|
||||||
toast.add({ title: "Name fehlt", description: "Bitte einen Namen für Quick-Einträge angeben.", color: "orange" })
|
? new Intl.NumberFormat("de-DE", { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(numericValue)
|
||||||
return
|
: 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
|
savingQuickConfig.value = true
|
||||||
|
|
||||||
@@ -463,10 +659,7 @@ async function saveQuickConfig() {
|
|||||||
const currentTenantData = auth.activeTenantData || {}
|
const currentTenantData = auth.activeTenantData || {}
|
||||||
const nextCalendarConfig = {
|
const nextCalendarConfig = {
|
||||||
...(currentTenantData.calendarConfig || {}),
|
...(currentTenantData.calendarConfig || {}),
|
||||||
quickEntry: {
|
quickEntryPresets: nextPresets
|
||||||
name,
|
|
||||||
color: quickEntryConfig.color || "#2563eb"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTenant = await $api(`/api/tenant/other/${auth.activeTenant}`, {
|
const updatedTenant = await $api(`/api/tenant/other/${auth.activeTenant}`, {
|
||||||
@@ -480,25 +673,49 @@ async function saveQuickConfig() {
|
|||||||
|
|
||||||
auth.activeTenantData = updatedTenant
|
auth.activeTenantData = updatedTenant
|
||||||
profileStore.ownTenant = updatedTenant
|
profileStore.ownTenant = updatedTenant
|
||||||
toast.add({ title: "Quick-Einträge gespeichert", color: "green" })
|
|
||||||
await loadPlanningBoard()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("saveQuickConfig failed", error)
|
console.error("persistQuickPresets failed", error)
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Quick-Konfiguration konnte nicht gespeichert werden",
|
title: "Vorlagen konnten nicht gespeichert werden",
|
||||||
description: error?.message || "Bitte erneut versuchen.",
|
description: error?.message || "Bitte erneut versuchen.",
|
||||||
color: "red"
|
color: "red"
|
||||||
})
|
})
|
||||||
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
savingQuickConfig.value = false
|
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) {
|
async function createQuickEvent(info) {
|
||||||
const resourceIds = normalizeSelectedResourceIds(info.resource?.id)
|
const resourceIds = normalizeSelectedResourceIds(info.resource?.id)
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: resolvedQuickEntryConfig.value.name,
|
name: activeQuickEntryConfig.value.name,
|
||||||
quick: true,
|
quick: true,
|
||||||
startDate: info.startStr,
|
startDate: info.startStr,
|
||||||
endDate: info.endStr,
|
endDate: info.endStr,
|
||||||
@@ -720,7 +937,6 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template #right>
|
<template #right>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<UPopover>
|
|
||||||
<UButton
|
<UButton
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -729,69 +945,6 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
Quick-Einträge
|
Quick-Einträge
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
<template #content>
|
|
||||||
<div class="w-[320px] space-y-4 p-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-semibold text-highlighted">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>
|
|
||||||
|
|
||||||
<UFormField label="Name">
|
|
||||||
<UInput v-model="quickEntryConfig.name" 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">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<UButton
|
|
||||||
color="primary"
|
|
||||||
:loading="savingQuickConfig"
|
|
||||||
@click="saveQuickConfig"
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UPopover>
|
|
||||||
<UButton
|
<UButton
|
||||||
color="amber"
|
color="amber"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
@@ -832,6 +985,174 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</UDashboardPanelContent>
|
</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">
|
<UModal v-model:open="isAbsenceModalOpen">
|
||||||
<template #content>
|
<template #content>
|
||||||
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
||||||
@@ -955,5 +1276,146 @@ onMounted(() => {
|
|||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user