Files
FEDEO/frontend/pages/staff/profiles/[id].vue

733 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const admin = useAdmin()
const { $api } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const id = route.params.id as string
const profile = ref<any>(null)
const branches = ref<any[]>([])
const teams = ref<any[]>([])
const pending = ref(true)
const saving = ref(false)
const creatingLinkedUser = ref(false)
const createLinkedUserModalOpen = ref(false)
const createdLinkedUserPassword = ref("")
const generatingCalendarSubscription = ref(false)
const createLinkedUserForm = reactive({
email: "",
})
const selectMenuUi = {
base: 'w-full',
content: 'min-w-[min(32rem,90vw)] w-max max-w-[90vw]'
}
const canCreateLinkedUser = computed(() => Boolean(auth.user?.is_admin && profile.value && !profile.value.user_id))
const linkedUserStatusLabel = computed(() => profile.value?.user_id ? "Benutzer verknüpft" : "Kein Benutzer verknüpft")
const linkedUserStatusColor = computed(() => profile.value?.user_id ? "green" : "orange")
const calendarSubscriptionHttpUrl = computed(() => {
const token = profile.value?.calendar_subscription_token
if (!token) return ""
const path = profile.value?.calendar_subscription_path || `/api/public/calendar/subscriptions/${token}.ics`
const apiBase = String(runtimeConfig.public.apiBase || "")
if (/^https?:\/\//i.test(apiBase)) {
const apiUrl = new URL(apiBase)
return new URL(path, `${apiUrl.protocol}//${apiUrl.host}`).toString()
}
if (process.client) {
return `${window.location.origin}${path}`
}
return path
})
const calendarSubscriptionWebcalUrl = computed(() =>
calendarSubscriptionHttpUrl.value
? calendarSubscriptionHttpUrl.value.replace(/^https?/i, "webcal")
: ""
)
async function fetchBranches() {
try {
branches.value = await useEntities("branches").select()
} catch (err) {
console.error('[fetchBranches]', err)
branches.value = []
}
}
async function fetchTeams() {
try {
teams.value = await useEntities("teams").select()
} catch (err) {
console.error('[fetchTeams]', err)
teams.value = []
}
}
/** Profil laden **/
async function fetchProfile() {
pending.value = true
try {
profile.value = await $api(`/api/profiles/${id}`)
ensureWorkingHoursStructure()
ensureBranchStructure()
ensureTeamStructure()
} catch (err: any) {
console.error('[fetchProfile]', err)
toast.add({
title: 'Fehler beim Laden',
description: err?.data?.error || err?.message || 'Unbekannter Fehler',
color: 'red'
})
} finally {
pending.value = false
}
}
function ensureBranchStructure() {
if (!profile.value) return
profile.value.branch_id = profile.value.branch_id ?? profile.value.branch?.id ?? null
if (!Array.isArray(profile.value.branch_ids)) {
if (Array.isArray(profile.value.branches)) {
profile.value.branch_ids = profile.value.branches
.map((entry: any) => entry?.id ?? entry)
.filter((entry: any) => entry != null)
} else {
profile.value.branch_ids = []
}
}
if (profile.value.branch_id && !profile.value.branch_ids.includes(profile.value.branch_id)) {
profile.value.branch_ids = [...profile.value.branch_ids, profile.value.branch_id]
}
}
function ensureTeamStructure() {
if (!profile.value) return
if (!Array.isArray(profile.value.team_ids)) {
if (Array.isArray(profile.value.teams)) {
profile.value.team_ids = profile.value.teams
.map((entry: any) => entry?.id ?? entry)
.filter((entry: any) => entry != null)
} else {
profile.value.team_ids = []
}
}
}
const updatePrimaryBranch = (value: number | null) => {
if (!profile.value) return
profile.value.branch_id = value
if (value && !profile.value.branch_ids.includes(value)) {
profile.value.branch_ids = [...profile.value.branch_ids, value]
}
}
const updateBranchMemberships = (values: number[]) => {
if (!profile.value) return
profile.value.branch_ids = values || []
if (profile.value.branch_id && !profile.value.branch_ids.includes(profile.value.branch_id)) {
profile.value.branch_id = null
}
}
const updateTeamMemberships = (values: number[]) => {
if (!profile.value) return
profile.value.team_ids = values || []
}
/** Profil speichern **/
async function saveProfile() {
if (saving.value) return
saving.value = true
try {
await $api(`/api/profiles/${id}`, {
method: 'PUT',
body: profile.value
})
toast.add({ title: 'Profil gespeichert', color: 'green' })
fetchProfile()
} catch (err: any) {
console.error('[saveProfile]', err)
toast.add({
title: 'Fehler beim Speichern',
description: err?.data?.error || err?.message || 'Unbekannter Fehler',
color: 'red'
})
} finally {
saving.value = false
}
}
function openCreateLinkedUserModal() {
if (!profile.value) return
createLinkedUserForm.email = profile.value.email || ""
createLinkedUserModalOpen.value = true
}
async function createLinkedUser() {
if (!profile.value || creatingLinkedUser.value) return
const email = createLinkedUserForm.email.trim().toLowerCase()
if (!email) {
toast.add({
title: 'E-Mail fehlt',
description: 'Bitte eine E-Mail-Adresse für den neuen Benutzer angeben.',
color: 'orange'
})
return
}
creatingLinkedUser.value = true
try {
const response = await admin.createUserForProfile(profile.value.id, { email })
createdLinkedUserPassword.value = response?.initialPassword || ""
createLinkedUserModalOpen.value = false
createLinkedUserForm.email = ""
toast.add({
title: 'Benutzer angelegt',
description: createdLinkedUserPassword.value ? `Initialpasswort: ${createdLinkedUserPassword.value}` : undefined,
color: 'green'
})
await fetchProfile()
} catch (err: any) {
console.error('[createLinkedUser]', err)
toast.add({
title: 'Benutzer konnte nicht angelegt werden',
description: err?.data?.error || err?.message || 'Unbekannter Fehler',
color: 'red'
})
} finally {
creatingLinkedUser.value = false
}
}
async function generateCalendarSubscription() {
if (!profile.value || generatingCalendarSubscription.value) return
generatingCalendarSubscription.value = true
try {
profile.value = await $api(`/api/profiles/${id}/calendar-subscription-token`, {
method: "POST"
})
ensureWorkingHoursStructure()
ensureBranchStructure()
ensureTeamStructure()
toast.add({
title: "Kalender-Abo erstellt",
description: "Der abonnierbare Kalender-Link wurde generiert.",
color: "green"
})
} catch (err: any) {
console.error("[generateCalendarSubscription]", err)
toast.add({
title: "Kalender-Abo konnte nicht erstellt werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red"
})
} finally {
generatingCalendarSubscription.value = false
}
}
async function copyCalendarSubscriptionUrl(value: string, successTitle: string) {
if (!value) return
try {
await navigator.clipboard.writeText(value)
toast.add({
title: successTitle,
color: "green"
})
} catch (err) {
console.error("[copyCalendarSubscriptionUrl]", err)
toast.add({
title: "Link konnte nicht kopiert werden",
color: "red"
})
}
}
const weekdays = [
{ key: '1', label: 'Montag' },
{ key: '2', label: 'Dienstag' },
{ key: '3', label: 'Mittwoch' },
{ key: '4', label: 'Donnerstag' },
{ key: '5', label: 'Freitag' },
{ key: '6', label: 'Samstag' },
{ key: '7', label: 'Sonntag' }
]
const bundeslaender = [
{ code: 'DE-BW', name: 'Baden-Württemberg' },
{ code: 'DE-BY', name: 'Bayern' },
{ code: 'DE-BE', name: 'Berlin' },
{ code: 'DE-BB', name: 'Brandenburg' },
{ code: 'DE-HB', name: 'Bremen' },
{ code: 'DE-HH', name: 'Hamburg' },
{ code: 'DE-HE', name: 'Hessen' },
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
{ code: 'DE-NI', name: 'Niedersachsen' },
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
{ code: 'DE-SL', name: 'Saarland' },
{ code: 'DE-SN', name: 'Sachsen' },
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
{ code: 'DE-TH', name: 'Thüringen' }
]
// Sicherstellen, dass das JSON-Feld existiert
function ensureWorkingHoursStructure() {
if (!profile.value.weekly_regular_working_hours) {
profile.value.weekly_regular_working_hours = {}
}
for (const { key } of weekdays) {
if (profile.value.weekly_regular_working_hours[key] == null) {
profile.value.weekly_regular_working_hours[key] = 0
}
}
}
function recalculateWeeklyHours() {
if (!profile.value?.weekly_regular_working_hours) return
const total = Object.values(profile.value.weekly_regular_working_hours).reduce(
(sum: number, val: any) => {
const num = parseFloat(val)
return sum + (isNaN(num) ? 0 : num)
},
0
)
profile.value.weekly_working_hours = Number(total.toFixed(2))
}
const getToday = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const setProfileDate = (field: 'birthday' | 'entry_date') => {
if (!profile.value) return
profile.value[field] = getToday()
}
const checkZip = async () => {
const zipData = await useFunctions().useZipCheck(profile.value.address_zip)
if (zipData) {
profile.value.address_zip = zipData.zip || profile.value.address_zip
profile.value.address_city = zipData.short
profile.value.state_code = zipData.state_code
}
}
onMounted(async () => {
await Promise.all([fetchBranches(), fetchTeams(), fetchProfile()])
})
</script>
<template>
<!-- Haupt-Navigation -->
<UDashboardNavbar title="Mitarbeiter">
<template #left>
<UButton
color="primary"
variant="outline"
@click="navigateTo(`/staff/profiles`)"
icon="i-heroicons-chevron-left"
>
Mitarbeiter
</UButton>
</template>
<template #center>
<h1 class="text-xl font-medium truncate">
Mitarbeiter bearbeiten: {{ profile?.full_name || '' }}
</h1>
</template>
</UDashboardNavbar>
<!-- Toolbar -->
<UDashboardToolbar>
<template #right>
<div class="flex items-center gap-2">
<UButton
v-if="canCreateLinkedUser"
icon="i-heroicons-user-plus"
color="neutral"
variant="outline"
@click="openCreateLinkedUserModal"
>
Benutzer anlegen
</UButton>
<UButton
icon="i-mdi-content-save"
color="primary"
:loading="saving"
@click="saveProfile"
>
Speichern
</UButton>
</div>
</template>
</UDashboardToolbar>
<!-- Inhalt -->
<UDashboardPanelContent>
<UCard v-if="!pending && profile">
<div class="flex items-center gap-4 mb-6">
<UAvatar size="xl" :alt="profile.full_name" />
<div>
<h2 class="text-xl font-semibold text-gray-900">{{ profile.full_name }}</h2>
<p class="text-sm text-gray-500">{{ profile.employee_number || '' }}</p>
</div>
<UBadge :color="linkedUserStatusColor" variant="subtle">
{{ linkedUserStatusLabel }}
</UBadge>
</div>
<USeparator label="Persönliche Daten" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormField label="Vorname" class="w-full">
<UInput v-model="profile.first_name" class="w-full" />
</UFormField>
<UFormField label="Nachname" class="w-full">
<UInput v-model="profile.last_name" class="w-full" />
</UFormField>
<UFormField label="E-Mail" class="w-full">
<UInput v-model="profile.email" class="w-full" />
</UFormField>
<UFormField label="Telefon (Mobil)" class="w-full">
<UInput v-model="profile.mobile_tel" class="w-full" />
</UFormField>
<UFormField label="Telefon (Festnetz)" class="w-full">
<UInput v-model="profile.fixed_tel" class="w-full" />
</UFormField>
<UFormField label="Geburtstag" class="w-full">
<div class="flex items-center gap-2">
<UInput type="date" v-model="profile.birthday" class="flex-1 w-full" />
<UButton color="gray" variant="soft" label="Heute" @click="setProfileDate('birthday')" />
</div>
</UFormField>
</UForm>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Vertragsinformationen" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormField label="Vertragsart" class="w-full">
<UInput v-model="profile.contract_type" class="w-full"/>
</UFormField>
<UFormField label="Status" class="w-full">
<UInput v-model="profile.status" class="w-full"/>
</UFormField>
<UFormField label="Position" class="w-full">
<UInput v-model="profile.position" class="w-full"/>
</UFormField>
<UFormField label="Qualifikation" class="w-full">
<UInput v-model="profile.qualification" class="w-full"/>
</UFormField>
<UFormField label="Eintrittsdatum" class="w-full">
<div class="flex items-center gap-2">
<UInput type="date" v-model="profile.entry_date" class="flex-1 w-full" />
<UButton color="gray" variant="soft" label="Heute" @click="setProfileDate('entry_date')" />
</div>
</UFormField>
<UFormField label="Wöchentliche Arbeitszeit (Std)" class="w-full">
<UInput type="number" v-model="profile.weekly_working_hours" class="w-full" />
</UFormField>
<UFormField label="Bezahlte Urlaubstage (Jahr)" class="w-full">
<UInput type="number" v-model="profile.annual_paid_leave_days" class="w-full" />
</UFormField>
<UFormField label="Aktiv" class="w-full">
<div class="flex items-center gap-3">
<USwitch v-model="profile.active" color="primary" />
<span class="text-sm text-gray-600">
</span>
</div>
</UFormField>
</UForm>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Niederlassungen" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormField label="Primäre Niederlassung" class="w-full">
<USelectMenu
:model-value="profile.branch_id"
:items="branches"
label-key="name"
value-key="id"
class="w-full"
:ui="selectMenuUi"
@update:model-value="updatePrimaryBranch"
/>
</UFormField>
<UFormField label="Weitere Niederlassungen" class="w-full">
<USelectMenu
:model-value="profile.branch_ids"
:items="branches"
label-key="name"
value-key="id"
multiple
class="w-full"
:ui="selectMenuUi"
@update:model-value="updateBranchMemberships"
/>
</UFormField>
</UForm>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Teams" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormField label="Team-Zuordnung" class="w-full">
<USelectMenu
:model-value="profile.team_ids"
:items="teams"
label-key="name"
value-key="id"
multiple
class="w-full"
:ui="selectMenuUi"
@update:model-value="updateTeamMemberships"
/>
</UFormField>
<UFormField label="Hinweis" class="w-full">
<div class="text-sm text-gray-500 pt-2">
Teams können in den Stammdaten gepflegt und optional einer Niederlassung zugeordnet werden.
</div>
</UFormField>
</UForm>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Kalender-Abo" />
<div class="mt-4 space-y-4">
<p class="text-sm text-gray-500">
Hier kann ein abonnierbarer Kalender-Link für Handy-Kalender erzeugt werden. Das Abo nutzt einen persönlichen Backend-Link und kann in vielen Kalender-Apps direkt als `webcal` oder `ics` eingebunden werden.
</p>
<div class="flex flex-wrap gap-2">
<UButton
icon="i-heroicons-link"
color="primary"
:loading="generatingCalendarSubscription"
@click="generateCalendarSubscription"
>
{{ profile.calendar_subscription_token ? 'Link neu generieren' : 'Link generieren' }}
</UButton>
<UButton
v-if="calendarSubscriptionHttpUrl"
icon="i-heroicons-clipboard-document"
color="neutral"
variant="outline"
@click="copyCalendarSubscriptionUrl(calendarSubscriptionHttpUrl, 'ICS-Link kopiert')"
>
ICS kopieren
</UButton>
<UButton
v-if="calendarSubscriptionWebcalUrl"
icon="i-heroicons-device-phone-mobile"
color="neutral"
variant="outline"
@click="copyCalendarSubscriptionUrl(calendarSubscriptionWebcalUrl, 'Webcal-Link kopiert')"
>
Webcal kopieren
</UButton>
</div>
<UForm :state="profile" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="ICS-Link" class="w-full">
<UInput :model-value="calendarSubscriptionHttpUrl" readonly class="w-full" />
</UFormField>
<UFormField label="Webcal-Link" class="w-full">
<UInput :model-value="calendarSubscriptionWebcalUrl" readonly class="w-full" />
</UFormField>
</UForm>
</div>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Adresse & Standort" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormField label="Straße und Hausnummer" class="w-full">
<UInput v-model="profile.address_street" class="w-full"/>
</UFormField>
<UFormField label="PLZ" class="w-full">
<UInput type="text" v-model="profile.address_zip" class="w-full" @focusout="checkZip"/>
</UFormField>
<UFormField label="Ort" class="w-full">
<UInput v-model="profile.address_city" class="w-full"/>
</UFormField>
<UFormField label="Bundesland" class="w-full">
<USelectMenu
v-model="profile.state_code"
:options="bundeslaender"
value-attribute="code"
option-attribute="name"
placeholder="Bundesland auswählen"
class="w-full"
:ui="selectMenuUi"
/>
</UFormField>
</UForm>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Wöchentliche Arbeitsstunden" />
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="day in weekdays"
:key="day.key"
:class="[...profile.weekly_regular_working_hours[day.key] === 0 ? ['bg-gray-100'] : ['bg-gray-100','border-green-400'], 'flex items-center justify-between border rounded-lg p-3 bg-gray-50']"
>
<span class="font-medium text-gray-700">{{ day.label }}</span>
<div class="flex items-center gap-2">
<UInput
type="number"
size="sm"
min="0"
max="24"
step="0.25"
v-model.number="profile.weekly_regular_working_hours[day.key]"
placeholder="0"
class="w-24"
@change="recalculateWeeklyHours"
/>
<span class="text-gray-400 text-sm">Std</span>
</div>
</div>
</div>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Sonstiges" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormField label="Kleidergröße (Oberteil)" class="w-full">
<UInput v-model="profile.clothing_size_top" class="w-full" />
</UFormField>
<UFormField label="Kleidergröße (Hose)" class="w-full">
<UInput v-model="profile.clothing_size_bottom" class="w-full" />
</UFormField>
<UFormField label="Schuhgröße" class="w-full">
<UInput v-model="profile.clothing_size_shoe" class="w-full" />
</UFormField>
<UFormField label="Token-ID" class="w-full">
<UInput v-model="profile.token_id" class="w-full" />
</UFormField>
<UFormField label="Verfügbarkeitshinweis" class="w-full md:col-span-2">
<UTextarea
v-model="profile.availability_note"
class="w-full"
:rows="4"
placeholder="z. B. kann nur vormittags eingeplant werden, bevorzugt Außendienst, nicht dienstags verfügbar"
/>
</UFormField>
</UForm>
</UCard>
<USkeleton v-if="pending" height="300px" />
<div v-if="createdLinkedUserPassword" class="mt-4">
<UAlert
title="Initialpasswort für den neuen Benutzer"
:description="createdLinkedUserPassword"
color="amber"
variant="soft"
close-button
@close="createdLinkedUserPassword = ''"
/>
</div>
</UDashboardPanelContent>
<UModal v-model:open="createLinkedUserModalOpen">
<template #content>
<UCard>
<template #header>
<div>
<div class="text-lg font-semibold">Benutzer zum Profil anlegen</div>
<p class="mt-1 text-sm text-gray-500">
Es wird automatisch ein zufälliges Initialpasswort erzeugt und der neue Benutzer direkt mit diesem Mitarbeiterprofil verknüpft.
</p>
</div>
</template>
<UForm :state="createLinkedUserForm" class="space-y-4" @submit.prevent="createLinkedUser">
<UFormField label="E-Mail">
<UInput v-model="createLinkedUserForm.email" type="email" autocomplete="email" />
</UFormField>
<div class="flex justify-end gap-3 pt-2">
<UButton color="gray" variant="soft" @click="createLinkedUserModalOpen = false">
Abbrechen
</UButton>
<UButton type="submit" color="primary" :loading="creatingLinkedUser">
Benutzer anlegen
</UButton>
</div>
</UForm>
</UCard>
</template>
</UModal>
</template>