feat(#9): Nuxt 4 migration

This commit is contained in:
2025-11-02 18:46:46 +01:00
parent 763b2f7b7f
commit 6d79c710a2
54 changed files with 2904 additions and 1416 deletions

View File

@@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'green',
neutral: 'zinc'
}
}
})

View File

@@ -0,0 +1,42 @@
<template>
<UApp>
<NuxtLoadingIndicator />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<ServerConnectionOverlay />
</UApp>
</template>
<script setup lang="ts">
const colorMode = useColorMode()
const color = computed(() => (colorMode.value === 'dark' ? '#111827' : 'white'))
useHead({
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ key: 'theme-color', name: 'theme-color', content: color }
],
link: [{ rel: 'icon', href: '/favicon.ico' }],
htmlAttrs: {
lang: 'en'
}
})
const title = 'LegalConsentHub'
const description = 'Das Tool für die Einführung von mitbestimmungspflichtigen digitalen Lösungen.'
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogImage: 'https://dashboard-template.nuxt.dev/social-card.png',
twitterImage: 'https://dashboard-template.nuxt.dev/social-card.png',
twitterCard: 'summary_large_image'
})
</script>

View File

@@ -0,0 +1,16 @@
@import 'tailwindcss' theme(static);
@import '@nuxt/ui-pro';
@theme static {
--color-green-50: #effdf5;
--color-green-100: #d9fbe8;
--color-green-200: #b3f5d1;
--color-green-300: #75edae;
--color-green-400: #00dc82;
--color-green-500: #00c16a;
--color-green-600: #00a155;
--color-green-700: #007f45;
--color-green-800: #016538;
--color-green-900: #0a5331;
--color-green-950: #052e16;
}

View File

@@ -0,0 +1,25 @@
<template>
<UModal :open="isOpen" title="Mitbestimmungsantrag löschen" @update:open="$emit('update:isOpen', $event)">
<template #body>
Möchten Sie wirklich den Mitbestimmungsantrag <strong>{{ applicationFormToDelete.name }}</strong> löschen?
</template>
<template #footer>
<UButton label="Abbrechen" color="neutral" variant="outline" @click="$emit('update:isOpen', false)" />
<UButton label="Löschen" color="neutral" @click="$emit('delete', applicationFormToDelete.id)" />
</template>
</UModal>
</template>
<script setup lang="ts">
import type { ApplicationFormDto } from '~~/.api-client'
defineEmits<{
(e: 'delete', id: string): void
(e: 'update:isOpen', value: boolean): void
}>()
defineProps<{
applicationFormToDelete: ApplicationFormDto
isOpen: boolean
}>()
</script>

View File

@@ -0,0 +1,110 @@
<template>
<template v-for="(formElement, index) in props.modelValue" :key="formElement.id">
<div class="flex py-3 lg:py-4">
<div class="group flex-auto">
<p v-if="formElement.title" class="font-semibold">{{ formElement.title }}</p>
<p v-if="formElement.description" class="text-dimmed pb-3">{{ formElement.description }}</p>
<component
:is="getResolvedComponent(formElement)"
:form-options="formElement.options"
:disabled="props.disabled"
@update:form-options="updateFormOptions($event, index)"
/>
<TheComment
v-if="applicationFormId && activeFormElement === formElement.id"
:form-element-id="formElement.id"
:application-form-id="applicationFormId"
:comments="comments?.[formElement.id]"
/>
</div>
<div>
<UDropdownMenu :items="getDropdownItems(formElement.id, index)" :content="{ align: 'end' }">
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
</UDropdownMenu>
</div>
</div>
<USeparator />
</template>
</template>
<script setup lang="ts">
import type { FormElementDto, FormOptionDto } from '~~/.api-client'
import { resolveComponent } from 'vue'
import TheComment from '~/components/TheComment.vue'
import type { DropdownMenuItem } from '@nuxt/ui'
import { useCommentStore } from '~~/stores/useCommentStore'
const props = defineProps<{
modelValue: FormElementDto[]
applicationFormId?: string
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', formElementDto: FormElementDto[]): void
(e: 'click:comments', formElementId: string): void
(e: 'add:input-form', position: number): void
}>()
const commentStore = useCommentStore()
const { load: loadComments } = commentStore
const { comments } = storeToRefs(commentStore)
if (props.applicationFormId) {
console.log('Loading comments for application form:', props.applicationFormId)
await loadComments(props.applicationFormId)
}
const activeFormElement = ref('')
function getResolvedComponent(formElement: FormElementDto) {
switch (formElement.type) {
case 'CHECKBOX':
return resolveComponent('TheCheckbox')
case 'SELECT':
return resolveComponent('TheSelect')
case 'RADIOBUTTON':
return resolveComponent('TheRadioGroup')
case 'SWITCH':
return resolveComponent('TheSwitch')
case 'TEXTFIELD':
return resolveComponent('TheInput')
case 'TITLE_BODY_TEXTFIELDS':
return resolveComponent('TheTitleBodyInput')
default:
return resolveComponent('Unimplemented')
}
}
function getDropdownItems(formElementId: string, formElementPosition: number): DropdownMenuItem[] {
return [
[
{
label: 'Comments',
icon: 'i-lucide-message-square-more',
onClick: () => toggleComments(formElementId)
},
{
label: 'Add input field below',
icon: 'i-lucide-list-plus',
onClick: () => emit('add:input-form', formElementPosition)
}
]
]
}
function updateFormOptions(formOptions: FormOptionDto[], formElementIndex: number) {
const updatedModelValue = [...props.modelValue]
updatedModelValue[formElementIndex] = { ...updatedModelValue[formElementIndex], options: formOptions }
emit('update:modelValue', updatedModelValue)
}
function toggleComments(formElementId: string) {
if (activeFormElement.value === formElementId) {
activeFormElement.value = ''
return
}
activeFormElement.value = formElementId
emit('click:comments', formElementId)
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<USlideover v-model:open="isOpen" title="Benachrichtigungen">
<template #body>
<div v-if="notifications.length === 0" class="text-center py-8 text-muted">
<UIcon name="i-heroicons-bell-slash" class="h-8 w-8 mx-auto mb-2" />
<p>Keine Benachrichtigungen</p>
</div>
<NuxtLink
v-for="notification in notifications"
:key="notification.id"
:to="notification.clickTarget"
class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3"
@click="onNotificationClick(notification)"
>
<UChip
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'primary'"
:show="!notification.isRead"
inset
>
<UIcon
:name="
notification.type === 'ERROR'
? 'i-heroicons-x-circle'
: notification.type === 'WARNING'
? 'i-heroicons-exclamation-triangle'
: 'i-heroicons-information-circle'
"
class="h-6 w-6"
/>
</UChip>
<div class="text-sm flex-1">
<p class="flex items-center justify-between">
<span class="text-highlighted font-medium">{{ notification.title }}</span>
<time
:datetime="notification.createdAt.toISOString()"
class="text-muted text-xs"
v-text="formatTimeAgo(notification.createdAt)"
/>
</p>
<p class="text-dimmed">
{{ notification.message }}
</p>
<div class="flex items-center gap-2 mt-1">
<UBadge
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'info'"
variant="subtle"
size="xs"
>
{{ notification.type }}
</UBadge>
</div>
</div>
</NuxtLink>
</template>
</USlideover>
</template>
<script setup lang="ts">
import { formatTimeAgo } from '@vueuse/core'
import type { NotificationDto } from '~~/.api-client'
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const props = defineProps<{
modelValue: boolean
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const { notifications, fetchNotifications, handleNotificationClick } = useNotification()
watch(isOpen, async (newValue) => {
if (newValue) {
await fetchNotifications()
}
})
function onNotificationClick(notification: NotificationDto) {
handleNotificationClick(notification)
emit('update:modelValue', false)
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<Teleport to="body">
<div
v-if="!isServerAvailable"
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center"
@click.prevent
@keydown.prevent
>
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl p-8 max-w-md mx-4 text-center">
<!-- Loading Spinner -->
<div class="mb-6 flex justify-center">
<UIcon name="i-heroicons-arrow-path" class="w-12 h-12 text-primary-500 animate-spin" />
</div>
<!-- Title -->
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
{{ t('serverConnection.title') }}
</h2>
<!-- Description -->
<p class="text-gray-600 dark:text-gray-400 mb-6 leading-relaxed">
{{ t('serverConnection.message') }}
</p>
<!-- Status Information -->
<div class="space-y-2 text-sm text-gray-500 dark:text-gray-400">
<div v-if="isChecking" class="flex items-center justify-center gap-2">
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 animate-spin" />
<span>{{ t('serverConnection.checking') }}</span>
</div>
<div v-if="lastCheckTime" class="text-xs">
{{ t('serverConnection.lastCheck') }}:
{{ new Date(lastCheckTime).toLocaleTimeString() }}
</div>
<div class="text-xs">
{{ t('serverConnection.retryInfo') }}
</div>
</div>
<!-- Optional: Manual retry button -->
<UButton v-if="!isChecking" variant="ghost" size="sm" class="mt-4" @click="void checkServerHealth()">
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 mr-1" />
{{ t('serverConnection.retryNow') }}
</UButton>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
const { t } = useI18n()
const { isServerAvailable, isChecking, lastCheckTime, checkServerHealth } = useServerHealth()
</script>

View File

@@ -0,0 +1,63 @@
<template>
<template v-if="comments && comments.length > 0">
<UChatMessages :auto-scroll="false" :should-scroll-to-bottom="false">
<UChatMessage
v-for="comment in comments"
:id="comment.id"
:key="comment.id"
:avatar="{ icon: 'i-lucide-bot' }"
:content="comment.message"
role="user"
:side="isCommentByUser(comment) ? 'right' : 'left'"
variant="subtle"
:actions="createChatMessageActions(comment)"
>
<template #leading="{ avatar }">
<div class="flex flex-col">
<UAvatar icon="i-lucide-bot" />
<p class="text-sm">{{ comment.createdBy.name }}</p>
</div>
</template>
</UChatMessage>
</UChatMessages>
</template>
<UTextarea v-model="commentTextAreaValue" class="w-full" />
<UButton v-if="!isEditingComment" class="my-3 lg:my-4" @click="submitComment(formElementId)"> Submit </UButton>
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="updateEditComment"> Edit comment </UButton>
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="cancelEditComment"> Cancel </UButton>
</template>
<script setup lang="ts">
import type { CommentDto } from '~~/.api-client'
import { useCommentTextarea } from '~/composables/comment/useCommentTextarea'
const props = defineProps<{
formElementId: string
applicationFormId: string
comments?: CommentDto[]
}>()
const commentActions = useCommentTextarea(props.applicationFormId)
const {
submitComment,
updateEditComment,
cancelEditComment,
editComment,
isEditingComment,
isCommentByUser,
commentTextAreaValue
} = commentActions
function createChatMessageActions(comment: CommentDto) {
const chatMessageActions = []
if (isCommentByUser(comment)) {
chatMessageActions.push({
label: 'Edit',
icon: 'i-lucide-pencil',
onClick: () => editComment(comment)
})
}
return chatMessageActions
}
</script>

View File

@@ -0,0 +1,187 @@
<template>
<UDropdownMenu
:items="items"
:content="{ align: 'center', collisionPadding: 12 }"
:ui="{ content: collapsed ? 'w-48' : 'w-(--reka-dropdown-menu-trigger-width)' }"
>
<UButton
v-bind="{
...user,
label: collapsed ? undefined : user?.name,
trailingIcon: collapsed ? undefined : 'i-lucide-chevrons-up-down'
}"
color="neutral"
variant="ghost"
block
:square="collapsed"
class="data-[state=open]:bg-(--ui-bg-elevated)"
:ui="{
trailingIcon: 'text-(--ui-text-dimmed)'
}"
/>
<template #chip-leading="{ item }">
<span
:style="{ '--chip': `var(--color-${(item as any).chip}-400)` }"
class="ms-0.5 size-2 rounded-full bg-(--chip)"
/>
</template>
</UDropdownMenu>
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
import { useUserStore } from '~~/stores/useUserStore'
defineProps<{
collapsed?: boolean
}>()
const colorMode = useColorMode()
const appConfig = useAppConfig()
const colors = [
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink',
'rose'
]
const neutrals = ['slate', 'gray', 'zinc', 'neutral', 'stone']
const userStore = useUserStore()
const { user: keyCloakUser } = storeToRefs(userStore)
const user = ref({
name: keyCloakUser.value.name,
avatar: {
alt: keyCloakUser.value.name
}
})
const items = computed<DropdownMenuItem[][]>(() => [
[
{
type: 'label',
label: user.value.name,
avatar: user.value.avatar
}
],
[
{
label: 'Profile',
icon: 'i-lucide-user'
},
{
label: 'Administration',
icon: 'i-lucide-user',
to: '/administration'
},
{
label: 'Settings',
icon: 'i-lucide-settings',
to: '/settings'
}
],
[
{
label: 'Theme',
icon: 'i-lucide-palette',
children: [
{
label: 'Primary',
slot: 'chip',
chip: appConfig.ui.colors.primary,
content: {
align: 'center',
collisionPadding: 16
},
children: colors.map((color) => ({
label: color,
chip: color,
slot: 'chip',
checked: appConfig.ui.colors.primary === color,
type: 'checkbox',
onSelect: (e) => {
e.preventDefault()
appConfig.ui.colors.primary = color
}
}))
},
{
label: 'Neutral',
slot: 'chip',
chip: appConfig.ui.colors.neutral,
content: {
align: 'end',
collisionPadding: 16
},
children: neutrals.map((color) => ({
label: color,
chip: color,
slot: 'chip',
type: 'checkbox',
checked: appConfig.ui.colors.neutral === color,
onSelect: (e) => {
e.preventDefault()
appConfig.ui.colors.neutral = color
}
}))
}
]
},
{
label: 'Appearance',
icon: 'i-lucide-sun-moon',
children: [
{
label: 'Light',
icon: 'i-lucide-sun',
type: 'checkbox',
checked: colorMode.value === 'light',
onSelect(e: Event) {
e.preventDefault()
colorMode.preference = 'light'
}
},
{
label: 'Dark',
icon: 'i-lucide-moon',
type: 'checkbox',
checked: colorMode.value === 'dark',
onUpdateChecked(checked: boolean) {
if (checked) {
colorMode.preference = 'dark'
}
},
onSelect(e: Event) {
e.preventDefault()
}
}
]
}
],
[
{
label: 'Log out',
icon: 'i-lucide-log-out',
async onSelect(e: Event) {
e.preventDefault()
await navigateTo('/auth/logout', { external: true })
}
}
]
])
</script>

View File

@@ -0,0 +1,52 @@
<template>
<!-- <component :is="getResolvedComponent(formElement)" :model-value="input" @update:model-value="update($event, index)" /> -->
</template>
<script setup lang="ts">
// import { FormElementType, type FormOptionDto, type FormElementDto } from '~~/.api-client'
// import { resolveComponent } from 'vue'
// const props = defineProps<{
// formElementType: FormElementType
// modelValue: FormOptionDto[]
// }>()
// const emit = defineEmits<{
// (e: 'update:modelValue', value: FormOptionDto[]): void
// }>()
// // TODO: Lazy loading?
// function getResolvedComponent() {
// switch (props.formElementType) {
// case 'CHECKBOX':
// case 'DROPDOWN':
// case 'RADIOBUTTON':
// case 'SWITCH':
// return resolveComponent('TheSwitch')
// case 'TEXTFIELD':
// return resolveComponent('TheInput')
// default:
// return resolveComponent('Unimplemented')
// }
// }
// const input = computed<FormOptionDto | FormOptionDto[]>({
// get: () => {
// if (props.formElementType === FormElementType.Switch) {
// return props.modelValue[0]
// } else {
// return props.modelValue
// }
// },
// set: (val) => {
// // TODO
// if (Array.isArray(val)) {
// const updatedModelValue = [...props.modelValue]
// updatedModelValue[0] = { ...updatedModelValue[0], value: val.toString() }
// emit('update:modelValue', updatedModelValue)
// } else {
// emit('update:modelValue', val)
// }
// }
// })
</script>

View File

@@ -0,0 +1,28 @@
<template>
<UCheckbox v-model="modelValue" :label="label" />
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~~/.api-client'
const props = defineProps<{
formOptions: FormOptionDto[]
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const modelValue = computed({
get: () => props.formOptions?.[0].value === 'true',
set: (val) => {
if (props.formOptions?.[0]) {
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...updatedModelValue[0], value: val.toString() }
emit('update:formOptions', updatedModelValue)
}
}
})
const label = computed(() => props.formOptions?.[0].label ?? '')
</script>

View File

@@ -0,0 +1,29 @@
<template>
<UFormField :label="label">
<UInput v-model="modelValue" />
</UFormField>
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~~/.api-client'
const props = defineProps<{
label?: string
formOptions: FormOptionDto[]
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const modelValue = computed({
get: () => props.formOptions?.[0].value ?? '',
set: (val) => {
if (val && props.formOptions?.[0].value) {
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...updatedModelValue[0], value: val.toString() }
emit('update:formOptions', updatedModelValue)
}
}
})
</script>

View File

@@ -0,0 +1,32 @@
<template>
<URadioGroup v-model="modelValue" :items="items" />
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~~/.api-client'
const props = defineProps<{
label?: string
formOptions: FormOptionDto[]
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
// Our "label" is the "value" of the radio button
const items = computed(() => props.formOptions.map((option) => ({ label: option.label, value: option.label })))
const modelValue = computed({
get: () => props.formOptions.find((option) => option.value === 'true')?.label,
set: (val) => {
if (val) {
const updatedModelValue = [...props.formOptions]
updatedModelValue.forEach((option) => {
option.value = option.label === val ? 'true' : 'false'
})
emit('update:formOptions', updatedModelValue)
}
}
})
</script>

View File

@@ -0,0 +1,32 @@
<template>
<USelect v-model="modelValue" placeholder="Select status" :items="items" />
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~~/.api-client'
const props = defineProps<{
label?: string
formOptions: FormOptionDto[]
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
// Our "label" is the "value" of the select
const items = computed(() => props.formOptions.map((option) => ({ label: option.label, value: option.label })))
const modelValue = computed({
get: () => props.formOptions.find((option) => option.value === 'true')?.label,
set: (val) => {
if (val) {
const updatedModelValue = [...props.formOptions]
updatedModelValue.forEach((option) => {
option.value = option.label === val ? 'true' : 'false'
})
emit('update:formOptions', updatedModelValue)
}
}
})
</script>

View File

@@ -0,0 +1,28 @@
<template>
<USwitch v-model="modelValue" :label="label" />
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~~/.api-client'
const props = defineProps<{
formOptions: FormOptionDto[]
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const modelValue = computed({
get: () => props.formOptions?.[0].value === 'true',
set: (val) => {
if (props.formOptions?.[0]) {
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...updatedModelValue[0], value: val.toString() }
emit('update:formOptions', updatedModelValue)
}
}
})
const label = computed(() => props.formOptions?.[0].label ?? '')
</script>

View File

@@ -0,0 +1,66 @@
<template>
<UFormField label="Titel">
<UInput v-model="title" class="w-full" :disabled="props.disabled" />
</UFormField>
<UFormField label="Text">
<UTextarea v-model="body" class="w-full" autoresize :disabled="props.disabled" />
</UFormField>
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~~/.api-client'
const props = defineProps<{
formOptions: FormOptionDto[]
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const SEPARATOR = '|||'
const title = computed({
get: () => {
const currentValue = props.formOptions?.[0]?.value ?? ''
return splitValue(currentValue).title
},
set: (newTitle: string) => {
const currentValue = props.formOptions?.[0]?.value ?? ''
const { body: currentBody } = splitValue(currentValue)
const combinedValue = joinValue(newTitle, currentBody)
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...updatedModelValue[0], value: combinedValue }
emit('update:formOptions', updatedModelValue)
}
})
const body = computed({
get: () => {
const currentValue = props.formOptions?.[0]?.value ?? ''
return splitValue(currentValue).body
},
set: (newBody: string) => {
const currentValue = props.formOptions?.[0]?.value ?? ''
const { title: currentTitle } = splitValue(currentValue)
const combinedValue = joinValue(currentTitle, newBody)
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...updatedModelValue[0], value: combinedValue }
emit('update:formOptions', updatedModelValue)
}
})
function splitValue(value: string): { title: string; body: string } {
const parts = value.split(SEPARATOR)
return {
title: parts[0] || '',
body: parts[1] || ''
}
}
function joinValue(title: string, body: string): string {
return `${title}${SEPARATOR}${body}`
}
</script>

View File

@@ -0,0 +1,3 @@
<template>
<div>Element unimplemented:</div>
</template>

View File

@@ -0,0 +1,111 @@
import type {
CreateApplicationFormDto,
CreateFormElementDto,
ApplicationFormDto,
PagedApplicationFormDto
} from '~~/.api-client'
import { useApplicationFormApi } from './useApplicationFormApi'
export function useApplicationForm() {
const applicationFormApi = useApplicationFormApi()
async function createApplicationForm(
createApplicationFormDto: CreateApplicationFormDto
): Promise<ApplicationFormDto> {
try {
return await applicationFormApi.createApplicationForm(createApplicationFormDto)
} catch (e: unknown) {
console.error('Failed creating application form:', e)
return Promise.reject(e)
}
}
async function getAllApplicationForms(organizationId: string): Promise<PagedApplicationFormDto> {
try {
return await applicationFormApi.getAllApplicationForms(organizationId)
} catch (e: unknown) {
console.error('Failed retrieving application forms:', e, JSON.stringify(e))
return Promise.reject(e)
}
}
async function getApplicationFormById(id: string): Promise<ApplicationFormDto> {
try {
return await applicationFormApi.getApplicationFormById(id)
} catch (e: unknown) {
console.error(`Failed retrieving application form with ID ${id}:`, e)
return Promise.reject(e)
}
}
async function updateApplicationForm(
id?: string,
applicationFormDto?: ApplicationFormDto
): Promise<ApplicationFormDto> {
if (!id || !applicationFormDto) {
return Promise.reject(new Error('ID or application form DTO missing'))
}
try {
return await applicationFormApi.updateApplicationForm(id, applicationFormDto)
} catch (e: unknown) {
console.error(`Failed updating application form with ID ${id}:`, e)
return Promise.reject(e)
}
}
async function deleteApplicationFormById(id: string): Promise<void> {
try {
return await applicationFormApi.deleteApplicationFormById(id)
} catch (e: unknown) {
console.error(`Failed deleting application form with ID ${id}:`, e)
return Promise.reject(e)
}
}
async function submitApplicationForm(id: string): Promise<ApplicationFormDto> {
if (!id) {
return Promise.reject(new Error('ID missing'))
}
try {
return await applicationFormApi.submitApplicationForm(id)
} catch (e: unknown) {
console.error(`Failed submitting application form with ID ${id}:`, e)
return Promise.reject(e)
}
}
async function addFormElementToSection(
applicationFormId: string,
sectionId: string,
createFormElementDto: CreateFormElementDto,
position: number
): Promise<ApplicationFormDto> {
if (!applicationFormId || !sectionId) {
return Promise.reject(new Error('Application form ID or section ID missing'))
}
try {
return await applicationFormApi.addFormElementToSection(
applicationFormId,
sectionId,
createFormElementDto,
position
)
} catch (e: unknown) {
console.error(`Failed adding form element to section ${sectionId}:`, e)
return Promise.reject(e)
}
}
return {
createApplicationForm,
getAllApplicationForms,
getApplicationFormById,
updateApplicationForm,
deleteApplicationFormById,
submitApplicationForm,
addFormElementToSection
}
}

View File

@@ -0,0 +1,80 @@
import {
ApplicationFormApi,
Configuration,
type CreateApplicationFormDto,
type CreateFormElementDto,
type ApplicationFormDto,
type PagedApplicationFormDto
} from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useApplicationFormApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(
import.meta.client
? appBaseUrl + clientProxyBasePath
: useRequestURL().origin + clientProxyBasePath + serverApiBasePath
)
)
const applicationFormApiClient = new ApplicationFormApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function createApplicationForm(
createApplicationFormDto: CreateApplicationFormDto
): Promise<ApplicationFormDto> {
return applicationFormApiClient.createApplicationForm({ createApplicationFormDto })
}
async function getAllApplicationForms(organizationId: string): Promise<PagedApplicationFormDto> {
return applicationFormApiClient.getAllApplicationForms({ organizationId })
}
async function getApplicationFormById(id: string): Promise<ApplicationFormDto> {
return applicationFormApiClient.getApplicationFormById({ id })
}
async function updateApplicationForm(
id: string,
applicationFormDto: ApplicationFormDto
): Promise<ApplicationFormDto> {
return applicationFormApiClient.updateApplicationForm({ id, applicationFormDto })
}
async function deleteApplicationFormById(id: string): Promise<void> {
return applicationFormApiClient.deleteApplicationForm({ id })
}
async function submitApplicationForm(id: string): Promise<ApplicationFormDto> {
return applicationFormApiClient.submitApplicationForm({ id })
}
async function addFormElementToSection(
applicationFormId: string,
sectionId: string,
createFormElementDto: CreateFormElementDto,
position: number
): Promise<ApplicationFormDto> {
return applicationFormApiClient.addFormElementToSection({
applicationFormId,
sectionId,
createFormElementDto,
position
})
}
return {
createApplicationForm,
getAllApplicationForms,
getApplicationFormById,
updateApplicationForm,
deleteApplicationFormById,
submitApplicationForm,
addFormElementToSection
}
}

View File

@@ -0,0 +1,97 @@
import {
type CreateApplicationFormDto,
type ApplicationFormDto,
type PagedApplicationFormDto,
ResponseError
} from '~~/.api-client'
import { useApplicationFormTemplateApi } from './useApplicationFormTemplateApi'
const currentApplicationForm: Ref<ApplicationFormDto | undefined> = ref()
export async function useApplicationFormTemplate() {
const applicationFormApi = await useApplicationFormTemplateApi()
async function createApplicationFormTemplate(
createApplicationFormDto: CreateApplicationFormDto
): Promise<ApplicationFormDto> {
try {
currentApplicationForm.value = await applicationFormApi.createApplicationFormTemplate(createApplicationFormDto)
return currentApplicationForm.value
} catch (e: unknown) {
if (e instanceof ResponseError) {
console.error('Failed creating application form:', e.response)
} else {
console.error('Failed creating application form:', e)
}
return Promise.reject(e)
}
}
async function getAllApplicationFormTemplates(): Promise<PagedApplicationFormDto> {
try {
return await applicationFormApi.getAllApplicationFormTemplates()
} catch (e: unknown) {
if (e instanceof ResponseError) {
console.error('Failed retrieving application forms:', e.response)
} else {
console.error('Failed retrieving application forms:', e)
}
return Promise.reject(e)
}
}
async function getApplicationFormTemplateById(id: string): Promise<ApplicationFormDto> {
try {
return await applicationFormApi.getApplicationFormTemplateById(id)
} catch (e: unknown) {
if (e instanceof ResponseError) {
console.error(`Failed retrieving application form with ID ${id}:`, e.response)
} else {
console.error(`Failed retrieving application form with ID ${id}:`, e)
}
return Promise.reject(e)
}
}
async function updateApplicationFormTemplate(
id?: string,
applicationFormDto?: ApplicationFormDto
): Promise<ApplicationFormDto> {
if (!id || !applicationFormDto) {
return Promise.reject(new Error('ID or application form DTO missing'))
}
try {
currentApplicationForm.value = await applicationFormApi.updateApplicationFormTemplate(id, applicationFormDto)
return currentApplicationForm.value
} catch (e: unknown) {
if (e instanceof ResponseError) {
console.error(`Failed updating application form with ID ${id}:`, e.response)
} else {
console.error(`Failed updating application form with ID ${id}:`, e)
}
return Promise.reject(e)
}
}
async function deleteApplicationFormTemplateById(id: string): Promise<void> {
try {
return await applicationFormApi.deleteApplicationFormTemplateById(id)
} catch (e: unknown) {
if (e instanceof ResponseError) {
console.error(`Failed deleting application form with ID ${id}:`, e.response)
} else {
console.error(`Failed deleting application form with ID ${id}:`, e)
}
return Promise.reject(e)
}
}
return {
createApplicationFormTemplate,
getAllApplicationFormTemplates,
getApplicationFormTemplateById,
updateApplicationFormTemplate,
deleteApplicationFormTemplateById
}
}

View File

@@ -0,0 +1,54 @@
import { ApplicationFormTemplateApi, Configuration } from '../../../.api-client'
import type { CreateApplicationFormDto, ApplicationFormDto, PagedApplicationFormDto } from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export async function useApplicationFormTemplateApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(
import.meta.client
? appBaseUrl + clientProxyBasePath
: useRequestURL().origin + clientProxyBasePath + serverApiBasePath
)
)
const applicationFormApiClient = new ApplicationFormTemplateApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function createApplicationFormTemplate(
createApplicationFormDto: CreateApplicationFormDto
): Promise<ApplicationFormDto> {
return applicationFormApiClient.createApplicationFormTemplate({ createApplicationFormDto })
}
async function getAllApplicationFormTemplates(): Promise<PagedApplicationFormDto> {
return applicationFormApiClient.getAllApplicationFormTemplates()
}
async function getApplicationFormTemplateById(id: string): Promise<ApplicationFormDto> {
return applicationFormApiClient.getApplicationFormTemplateById({ id })
}
async function updateApplicationFormTemplate(
id: string,
applicationFormDto: ApplicationFormDto
): Promise<ApplicationFormDto> {
return applicationFormApiClient.updateApplicationFormTemplate({ id, applicationFormDto })
}
async function deleteApplicationFormTemplateById(id: string): Promise<void> {
return applicationFormApiClient.deleteApplicationFormTemplate({ id })
}
return {
createApplicationFormTemplate,
getAllApplicationFormTemplates,
getApplicationFormTemplateById,
updateApplicationFormTemplate,
deleteApplicationFormTemplateById
}
}

View File

@@ -0,0 +1,47 @@
import { CommentApi, Configuration, type CommentDto, type CreateCommentDto, type PagedCommentDto } from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useCommentApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(
import.meta.client
? appBaseUrl + clientProxyBasePath
: useRequestURL().origin + clientProxyBasePath + serverApiBasePath
)
)
const commentApiClient = new CommentApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function createComment(
applicationFormId: string,
formElementId: string,
createCommentDto: CreateCommentDto
): Promise<CommentDto> {
return commentApiClient.createComment({ applicationFormId, formElementId, createCommentDto })
}
async function getCommentsByApplicationFormId(applicationFormId: string): Promise<PagedCommentDto> {
return commentApiClient.getCommentsByApplicationFormId({ applicationFormId })
}
async function updateComment(id: string, commentDto: CommentDto): Promise<CommentDto> {
return commentApiClient.updateComment({ id, commentDto })
}
async function deleteCommentById(id: string): Promise<void> {
return commentApiClient.deleteComment({ id })
}
return {
createComment,
getCommentsByApplicationFormId,
updateComment,
deleteCommentById
}
}

View File

@@ -0,0 +1,69 @@
import type { CreateCommentDto, CommentDto } from '~~/.api-client'
import { useCommentStore } from '~~/stores/useCommentStore'
import { useUserStore } from '~~/stores/useUserStore'
export function useCommentTextarea(applicationFormId: string) {
const commentStore = useCommentStore()
const { createComment, updateComment } = commentStore
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const isEditingComment = ref(false)
const currentEditedComment = ref<CommentDto | null>(null)
const commentTextAreaValue = ref('')
const toast = useToast()
async function submitComment(formElementId: string) {
const newCommentDto: CreateCommentDto = {
message: commentTextAreaValue.value
}
try {
await createComment(applicationFormId, formElementId, newCommentDto)
commentTextAreaValue.value = ''
toast.add({ title: 'Comment created successfully', color: 'success' })
} catch (e) {
toast.add({ title: 'Error creating comment', color: 'error' })
console.error('Error creating comment:', e)
}
}
async function updateEditComment() {
if (!currentEditedComment.value) return
const updatedComment: CommentDto = { ...currentEditedComment.value, message: commentTextAreaValue.value }
try {
await updateComment(currentEditedComment.value.id, updatedComment)
commentTextAreaValue.value = ''
currentEditedComment.value = null
isEditingComment.value = false
toast.add({ title: 'Comment updated successfully', color: 'success' })
} catch (e) {
toast.add({ title: 'Error updating comment', color: 'error' })
console.error('Error updating comment:', e)
}
}
function editComment(comment: CommentDto) {
isEditingComment.value = true
currentEditedComment.value = comment
commentTextAreaValue.value = comment.message || ''
}
function cancelEditComment() {
isEditingComment.value = false
currentEditedComment.value = null
commentTextAreaValue.value = ''
}
function isCommentByUser(comment: CommentDto) {
return comment.createdBy.keycloakId === user.value?.keycloakId
}
return {
commentTextAreaValue,
submitComment,
updateEditComment,
editComment,
cancelEditComment,
isEditingComment,
isCommentByUser
}
}

View File

@@ -0,0 +1,47 @@
import { ComplianceStatus, EmployeeDataCategory, FormElementType, ProcessingPurpose } from '~~/.api-client'
export const complianceMap = new Map<ProcessingPurpose, Map<EmployeeDataCategory, ComplianceStatus>>([
[
ProcessingPurpose.SystemOperation,
new Map([
[EmployeeDataCategory.None, ComplianceStatus.NonCritical],
[EmployeeDataCategory.NonCritical, ComplianceStatus.NonCritical],
[EmployeeDataCategory.ReviewRequired, ComplianceStatus.NonCritical],
[EmployeeDataCategory.Sensitive, ComplianceStatus.NonCritical]
])
],
[
ProcessingPurpose.BusinessProcess,
new Map([
[EmployeeDataCategory.None, ComplianceStatus.NonCritical],
[EmployeeDataCategory.NonCritical, ComplianceStatus.NonCritical],
[EmployeeDataCategory.ReviewRequired, ComplianceStatus.Warning],
[EmployeeDataCategory.Sensitive, ComplianceStatus.Critical]
])
],
[
ProcessingPurpose.DataAnalysis,
new Map([
[EmployeeDataCategory.None, ComplianceStatus.NonCritical],
[EmployeeDataCategory.NonCritical, ComplianceStatus.Warning],
[EmployeeDataCategory.ReviewRequired, ComplianceStatus.Warning],
[EmployeeDataCategory.Sensitive, ComplianceStatus.Critical]
])
],
[
ProcessingPurpose.None,
new Map([
[EmployeeDataCategory.None, ComplianceStatus.NonCritical],
[EmployeeDataCategory.NonCritical, ComplianceStatus.NonCritical],
[EmployeeDataCategory.ReviewRequired, ComplianceStatus.NonCritical],
[EmployeeDataCategory.Sensitive, ComplianceStatus.NonCritical]
])
]
])
export const complianceCheckableElementTypes: FormElementType[] = [
FormElementType.Switch,
FormElementType.Select,
FormElementType.Checkbox,
FormElementType.Radiobutton
]

View File

@@ -0,0 +1,4 @@
export { useApplicationFormTemplate } from './applicationFormTemplate/useApplicationFormTemplate'
export { useApplicationForm } from './applicationForm/useApplicationForm'
export { useNotification } from './notification/useNotification'
export { useNotificationApi } from './notification/useNotificationApi'

View File

@@ -0,0 +1,144 @@
import type { NotificationDto } from '~~/.api-client'
import { useUserStore } from '~~/stores/useUserStore'
export const useNotification = () => {
const {
getNotifications,
getUnreadNotifications,
getUnreadNotificationCount,
markAllNotificationsAsRead,
markNotificationAsRead
} = useNotificationApi()
const userStore = useUserStore()
const organizationId = computed(() => userStore.selectedOrganization?.id)
const { user } = useUserSession()
const userId = computed(() => user.value?.keycloakId)
const notifications = ref<NotificationDto[]>([])
const unreadNotifications = ref<NotificationDto[]>([])
const unreadCount = ref<number>(0)
const isLoading = ref(false)
const fetchNotifications = async (page: number = 0, size: number = 20) => {
if (!organizationId.value) {
console.warn('No organization selected')
return
}
isLoading.value = true
try {
const response = await getNotifications(organizationId.value, page, size)
notifications.value = response.content || []
return response
} catch (error) {
console.error('Failed to fetch notifications:', error)
throw error
} finally {
isLoading.value = false
}
}
const fetchUnreadNotifications = async () => {
if (!organizationId.value) {
console.warn('No organization selected')
return
}
try {
const response = await getUnreadNotifications(organizationId.value)
unreadNotifications.value = response || []
return response
} catch (error) {
console.error('Failed to fetch unread notifications:', error)
throw error
}
}
const fetchUnreadCount = async () => {
if (!userId.value || !organizationId.value) {
console.warn('No user or organization selected')
return
}
try {
const count = await getUnreadNotificationCount(userId.value, organizationId.value)
unreadCount.value = count || 0
return count
} catch (error) {
console.error('Failed to fetch unread count:', error)
throw error
}
}
const markAllAsRead = async () => {
if (!organizationId.value) {
console.warn('No organization selected')
return
}
try {
await markAllNotificationsAsRead(organizationId.value)
unreadCount.value = 0
unreadNotifications.value = []
notifications.value = notifications.value.map((n) => ({ ...n, isRead: true }))
} catch (error) {
console.error('Failed to mark all as read:', error)
throw error
}
}
const markAsRead = async (notificationId: string) => {
if (!organizationId.value) {
console.warn('No organization selected')
return
}
try {
await markNotificationAsRead(notificationId, organizationId.value)
const index = notifications.value.findIndex((n) => n.id === notificationId)
if (index !== -1) {
notifications.value[index].isRead = true
}
// Remove from unread notifications
unreadNotifications.value = unreadNotifications.value.filter((n) => n.id !== notificationId)
if (unreadCount.value > 0) {
unreadCount.value--
}
} catch (error) {
console.error('Failed to mark notification as read:', error)
throw error
}
}
const handleNotificationClick = async (notification: NotificationDto) => {
if (!notification.isRead) {
await markAsRead(notification.id)
}
if (notification.clickTarget) {
await navigateTo(notification.clickTarget)
}
}
const startPeriodicRefresh = (intervalMs: number = 30000) => {
const interval = setInterval(() => {
void fetchUnreadCount()
}, intervalMs)
onUnmounted(() => {
clearInterval(interval)
})
return interval
}
return {
notifications,
unreadNotifications,
unreadCount,
isLoading,
fetchNotifications,
fetchUnreadNotifications,
fetchUnreadCount,
markAllAsRead,
markAsRead,
handleNotificationClick,
startPeriodicRefresh
}
}

View File

@@ -0,0 +1,64 @@
import {
NotificationApi,
Configuration,
type NotificationDto,
type PagedNotificationDto,
type CreateNotificationDto
} from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useNotificationApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(
import.meta.client
? appBaseUrl + clientProxyBasePath
: useRequestURL().origin + clientProxyBasePath + serverApiBasePath
)
)
const notificationApiClient = new NotificationApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function createNotification(createNotificationDto: CreateNotificationDto): Promise<NotificationDto> {
return notificationApiClient.createNotification({ createNotificationDto })
}
async function getNotifications(organizationId: string, page?: number, size?: number): Promise<PagedNotificationDto> {
return notificationApiClient.getNotifications({ organizationId, page, size })
}
async function getUnreadNotifications(organizationId: string): Promise<NotificationDto[]> {
return notificationApiClient.getUnreadNotifications({ organizationId })
}
async function getUnreadNotificationCount(userId: string, organizationId: string): Promise<number> {
return notificationApiClient.getUnreadNotificationCount({ userId, organizationId })
}
async function markAllNotificationsAsRead(organizationId: string): Promise<void> {
return notificationApiClient.markAllNotificationsAsRead({ organizationId })
}
async function markNotificationAsRead(id: string, organizationId: string): Promise<NotificationDto> {
return notificationApiClient.markNotificationAsRead({ id, organizationId })
}
async function clearAllNotifications(organizationId: string): Promise<void> {
return notificationApiClient.clearAllNotifications({ organizationId })
}
return {
createNotification,
getNotifications,
getUnreadNotifications,
getUnreadNotificationCount,
markAllNotificationsAsRead,
markNotificationAsRead,
clearAllNotifications
}
}

View File

@@ -0,0 +1,62 @@
import { ComplianceStatus, type FormElementDto } from '~~/.api-client'
import { complianceCheckableElementTypes, complianceMap } from './complianceMap'
import type { FormElementId } from '~~/types/formElement'
const formElementComplianceMap = ref(new Map<FormElementId, ComplianceStatus>())
export function useApplicationFormValidator() {
function getHighestComplianceStatus(): ComplianceStatus {
const complianceStatusValues = Array.from(formElementComplianceMap.value.values())
const highestComplianceNumber = Math.max(
...complianceStatusValues.map((complianceStatus) => Object.values(ComplianceStatus).indexOf(complianceStatus))
)
return Object.values(ComplianceStatus)[highestComplianceNumber]
}
function validateFormElements(formElements: FormElementDto[]): Map<FormElementId, ComplianceStatus> {
formElementComplianceMap.value.clear()
formElements.forEach((formElement) => {
if (!complianceCheckableElementTypes.includes(formElement.type)) return
// Reset any previously set compliance status when all options are false
const hasAtLeastOneOptionSet = formElement.options.some((option) => option.value && option.value !== 'false')
if (!hasAtLeastOneOptionSet) {
// No value set, continue with next form element
return
}
formElement.options.forEach((option) => {
if (!option.value) {
console.log(`Value missing for ${formElement.type}`)
return
}
// Value not set to true, continue with next option
if (option.value === 'false') {
return
}
const currentHighestComplianceStatus =
complianceMap?.get(option.processingPurpose)?.get(option.employeeDataCategory) ?? ComplianceStatus.NonCritical
const currentHighestComplianceStatusPos =
Object.values(ComplianceStatus).indexOf(currentHighestComplianceStatus)
if (formElementComplianceMap.value.has(formElement.id)) {
const newComplianceStatus = formElementComplianceMap.value.get(formElement.id)!
const newComplianceStatusPos = Object.values(ComplianceStatus).indexOf(newComplianceStatus)
if (newComplianceStatusPos > currentHighestComplianceStatusPos) {
formElementComplianceMap.value.set(formElement.id, newComplianceStatus)
}
} else {
formElementComplianceMap.value.set(formElement.id, currentHighestComplianceStatus)
}
})
})
return formElementComplianceMap.value
}
return { getHighestComplianceStatus, validateFormElements }
}

View File

@@ -0,0 +1,47 @@
import type { ApplicationFormDto, CreateFormElementDto, FormElementSectionDto } from '~~/.api-client'
import type { MaybeRefOrGetter } from 'vue'
export function useFormElementManagement(
currentFormElementSection: MaybeRefOrGetter<FormElementSectionDto | undefined>,
applicationFormId?: string
) {
const { addFormElementToSection } = useApplicationForm()
async function addInputFormToApplicationForm(position: number): Promise<ApplicationFormDto | undefined> {
const section = toValue(currentFormElementSection)
if (!section) return
const { formElements } = section
const inputFormElement: CreateFormElementDto = {
title: 'Formular ergänzen',
description: 'Bitte fügen Sie hier Ihre Ergänzungen ein.',
options: [
{
value: '|||',
label: '',
processingPurpose: 'NONE',
employeeDataCategory: 'NONE'
}
],
type: 'TITLE_BODY_TEXTFIELDS'
}
if (applicationFormId) {
try {
return await addFormElementToSection(applicationFormId, section.id, inputFormElement, position + 1)
} catch (error) {
console.error('Failed to add form element:', error)
throw error
}
} else {
// @ts-expect-error Add CreateFormElementDto to formElements array. ID will be generated by the backend.
formElements.splice(position + 1, 0, inputFormElement)
return undefined
}
}
return {
addInputFormToApplicationForm
}
}

View File

@@ -0,0 +1,57 @@
import type { FormElementSectionDto } from '~~/.api-client'
import type { StepperItem } from '@nuxt/ui'
import type { MaybeRefOrGetter } from 'vue'
interface Stepper {
hasPrev: boolean
hasNext: boolean
next: () => void
prev: () => void
}
export function useFormStepper(
formElementSections: MaybeRefOrGetter<FormElementSectionDto[] | undefined>,
options?: {
onNavigate?: (direction: 'forward' | 'backward', newIndex: number) => void | Promise<void>
}
) {
const stepper = useTemplateRef<Stepper>('stepper')
const activeStepperItemIndex = ref<number>(0)
const sections = computed(() => toValue(formElementSections) ?? [])
const stepperItems = computed(() => {
const items: StepperItem[] = []
sections.value.forEach((section: FormElementSectionDto) => {
items.push({
title: section.shortTitle,
description: section.description
})
})
return items
})
const currentFormElementSection = computed<FormElementSectionDto | undefined>(
() => sections.value[activeStepperItemIndex.value]
)
async function navigateStepper(direction: 'forward' | 'backward') {
if (direction === 'forward') {
stepper.value?.next()
} else {
stepper.value?.prev()
}
if (options?.onNavigate) {
await options.onNavigate(direction, activeStepperItemIndex.value)
}
}
return {
stepper,
activeStepperItemIndex,
stepperItems,
currentFormElementSection,
navigateStepper
}
}

View File

@@ -0,0 +1,152 @@
export type Permission =
| 'application-form:read'
| 'application-form:write'
| 'application-form:sign'
| 'application-form-template:add'
| 'application-form-template:edit'
| 'application-form-template:delete'
| 'comment:add'
| 'comment:edit'
| 'comment:delete'
export type Role =
| 'CHIEF_EXECUTIVE_OFFICER'
| 'BUSINESS_DEPARTMENT'
| 'IT_DEPARTMENT'
| 'HUMAN_RESOURCES'
| 'HEAD_OF_WORKS_COUNCIL'
| 'WORKS_COUNCIL'
| 'EMPLOYEE'
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
CHIEF_EXECUTIVE_OFFICER: [
'application-form:read',
'application-form:write',
'application-form:sign',
'application-form-template:add',
'application-form-template:edit',
'application-form-template:delete',
'comment:add',
'comment:edit',
'comment:delete'
],
HEAD_OF_WORKS_COUNCIL: [
'application-form:read',
'application-form:write',
'application-form:sign',
'application-form-template:add',
'application-form-template:edit',
'application-form-template:delete',
'comment:add',
'comment:edit',
'comment:delete'
],
BUSINESS_DEPARTMENT: [
'application-form:read',
'application-form:write',
'application-form-template:add',
'application-form-template:edit',
'application-form-template:delete',
'comment:add',
'comment:edit',
'comment:delete'
],
IT_DEPARTMENT: [
'application-form:read',
'application-form:write',
'application-form-template:add',
'application-form-template:edit',
'application-form-template:delete',
'comment:add',
'comment:edit',
'comment:delete'
],
HUMAN_RESOURCES: [
'application-form:read',
'application-form:write',
'application-form-template:add',
'application-form-template:edit',
'application-form-template:delete',
'comment:add',
'comment:edit',
'comment:delete'
],
WORKS_COUNCIL: [
'application-form:read',
'application-form:write',
'application-form-template:add',
'application-form-template:edit',
'application-form-template:delete',
'comment:add',
'comment:edit',
'comment:delete'
],
EMPLOYEE: ['application-form:read', 'comment:add', 'comment:edit']
}
export const usePermissions = () => {
const { user } = useUserSession()
const userRoles = computed<Role[]>(() => {
return (user.value?.roles ?? []) as Role[]
})
const userPermissions = computed<Permission[]>(() => {
const permissions = new Set<Permission>()
userRoles.value.forEach((role) => {
const rolePermissions = ROLE_PERMISSIONS[role] ?? []
rolePermissions.forEach((permission) => permissions.add(permission))
})
return Array.from(permissions)
})
const hasPermission = (permission: Permission): boolean => {
return userPermissions.value.includes(permission)
}
const hasAnyPermission = (permissions: Permission[]): boolean => {
return permissions.some((permission) => hasPermission(permission))
}
const hasAllPermissions = (permissions: Permission[]): boolean => {
return permissions.every((permission) => hasPermission(permission))
}
const hasRole = (role: Role): boolean => {
return userRoles.value.includes(role)
}
const hasAnyRole = (roles: Role[]): boolean => {
return roles.some((role) => hasRole(role))
}
const canReadApplicationForms = computed(() => hasPermission('application-form:read'))
const canWriteApplicationForms = computed(() => hasPermission('application-form:write'))
const canSignApplicationForms = computed(() => hasPermission('application-form:sign'))
const canAddTemplate = computed(() => hasPermission('application-form-template:add'))
const canEditTemplate = computed(() => hasPermission('application-form-template:edit'))
const canDeleteTemplate = computed(() => hasPermission('application-form-template:delete'))
const canAddComment = computed(() => hasPermission('comment:add'))
const canEditComment = computed(() => hasPermission('comment:edit'))
const canDeleteComment = computed(() => hasPermission('comment:delete'))
return {
userRoles,
userPermissions,
hasPermission,
hasAnyPermission,
hasAllPermissions,
hasRole,
hasAnyRole,
canReadApplicationForms,
canWriteApplicationForms,
canSignApplicationForms,
canAddTemplate,
canEditTemplate,
canDeleteTemplate,
canAddComment,
canEditComment,
canDeleteComment
}
}

View File

@@ -0,0 +1,92 @@
export const isServerAvailable = ref(true)
export const isChecking = ref(false)
export const lastCheckTime = ref<Date | null>(null)
export function useServerHealth() {
const checkInterval = ref<NodeJS.Timeout | null>(null)
const healthCheckUrl = '/api/actuator/health'
async function checkServerHealth(): Promise<boolean> {
if (isChecking.value) return isServerAvailable.value
isChecking.value = true
lastCheckTime.value = new Date()
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const response = await fetch(healthCheckUrl, {
method: 'GET',
signal: controller.signal,
headers: {
'Content-Type': 'application/json'
}
})
clearTimeout(timeoutId)
const wasAvailable = isServerAvailable.value
isServerAvailable.value = response.ok
if (!wasAvailable && isServerAvailable.value) {
console.info('Server is back online')
}
if (wasAvailable && !isServerAvailable.value) {
console.warn('Server is no longer available')
}
return isServerAvailable.value
} catch (error) {
const wasAvailable = isServerAvailable.value
isServerAvailable.value = false
if (wasAvailable) {
console.warn('Server health check failed:', error)
}
return false
} finally {
isChecking.value = false
}
}
async function startPeriodicHealthCheck(intervalMs: number = 60000) {
if (checkInterval.value) {
clearInterval(checkInterval.value)
}
checkServerHealth()
checkInterval.value = setInterval(() => {
checkServerHealth()
}, intervalMs)
onUnmounted(() => {
if (checkInterval.value) {
clearInterval(checkInterval.value)
checkInterval.value = null
}
})
return checkInterval.value
}
const stopHealthCheck = () => {
if (checkInterval.value) {
clearInterval(checkInterval.value)
checkInterval.value = null
}
}
return {
isServerAvailable,
isChecking,
lastCheckTime,
healthCheckUrl,
checkServerHealth,
startPeriodicHealthCheck,
stopHealthCheck
}
}

View File

@@ -0,0 +1,24 @@
<template>
<UApp>
<UError :error="error" />
</UApp>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app'
defineProps<{
error: NuxtError
}>()
useSeoMeta({
title: 'Page not found',
description: 'We are sorry but this page could not be found.'
})
useHead({
htmlAttrs: {
lang: 'en'
}
})
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div class="h-screen flex items-center justify-center overlay">
<slot />
</div>
</template>

View File

@@ -0,0 +1,66 @@
<template>
<UDashboardGroup>
<UDashboardSearch />
<UDashboardSidebar
v-model:open="open"
collapsible
resizable
class="bg-(--ui-bg-elevated)/25"
:ui="{ footer: 'lg:border-t lg:border-(--ui-border)' }"
>
<template #header>
<NuxtLink to="/">
<img src="@@/public/favicon.ico" alt="Logo" />
</NuxtLink>
</template>
<template #default="{ collapsed }">
<UDashboardSearchButton :collapsed="collapsed" class="bg-transparent ring-(--ui-border)" />
<UNavigationMenu :collapsed="collapsed" :items="links[0]" orientation="vertical" />
<UNavigationMenu :collapsed="collapsed" :items="links[1]" orientation="vertical" class="mt-auto" />
</template>
<template #footer="{ collapsed }">
<UserMenu :collapsed="collapsed" />
<UButton @click="copyAccessTokenToClipboard">📋</UButton>
</template>
</UDashboardSidebar>
<slot />
<NotificationsSlideover v-model="isNotificationsSlideoverOpen" />
</UDashboardGroup>
</template>
<script setup lang="ts">
const links = [[], []]
const open = ref(false)
const isNotificationsSlideoverOpen = ref(false)
const { unreadCount, fetchUnreadCount, startPeriodicRefresh } = useNotification()
onMounted(async () => {
await fetchUnreadCount()
startPeriodicRefresh()
})
provide('notificationState', {
isNotificationsSlideoverOpen,
unreadCount
})
async function copyAccessTokenToClipboard() {
const { session } = useUserSession()
console.log('Access Token :', session.value?.jwt?.accessToken)
const accessToken = session.value?.jwt?.accessToken
if (accessToken) {
navigator.clipboard.writeText(accessToken)
console.log('Access token copied to clipboard')
} else {
console.warn('No access token found in session')
}
}
</script>

View File

@@ -0,0 +1,14 @@
import type { RouteLocationNormalized } from '#vue-router'
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
// https://github.com/WaldemarEnns/nuxtui-github-auth/blob/7e3110f933d5d0445d3ac89d6c84c48052b49041/middleware/auth.global.ts
const { loggedIn } = useUserSession()
if (to.meta.auth === false) {
return
}
if (!loggedIn.value) {
return navigateTo('/login')
}
})

View File

@@ -0,0 +1,8 @@
export default defineNuxtRouteMiddleware((to) => {
const { canWriteApplicationForms } = usePermissions()
if (to.path === '/create' && !canWriteApplicationForms.value) {
return navigateTo('/', { replace: true })
}
})

View File

@@ -0,0 +1,83 @@
// Copied from https://github.com/atinux/nuxt-auth-utils/issues/91#issuecomment-2476019136
import { appendResponseHeader } from 'h3'
import { parse, parseSetCookie, serialize } from 'cookie-es'
import { jwtDecode, type JwtPayload } from 'jwt-decode'
export default defineNuxtRouteMiddleware(async (to, from) => {
const nuxtApp = useNuxtApp()
// Don't run on client hydration when server rendered
if (import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) return
console.log('🔍 Middleware: refreshToken.global.ts')
console.log(` from: ${from.fullPath} to: ${to.fullPath}`)
const { session, clear: clearSession, fetch: fetchSession } = useUserSession()
// Ignore if no tokens
if (!session.value?.jwt) return
const serverEvent = useRequestEvent()
const runtimeConfig = useRuntimeConfig()
const { accessToken, refreshToken } = session.value.jwt
const accessPayload = jwtDecode(accessToken)
const refreshPayload = jwtDecode(refreshToken)
// Both tokens expired, clearing session
if (isExpired(accessPayload) && isExpired(refreshPayload)) {
console.info('both tokens expired, clearing session')
await clearSession()
return navigateTo('/login')
} else if (isExpired(accessPayload)) {
console.info('access token expired, refreshing')
await useRequestFetch()('/api/jwt/refresh', {
method: 'POST',
onResponse({ response: { headers } }) {
// Forward the Set-Cookie header to the main server event
if (import.meta.server && serverEvent) {
for (const setCookie of headers.getSetCookie()) {
appendResponseHeader(serverEvent, 'Set-Cookie', setCookie)
// Update session cookie for next fetch requests
const { name, value } = parseSetCookie(setCookie)
if (name === runtimeConfig.session.name) {
console.log('updating headers.cookie to', value)
const cookies = parse(serverEvent.headers.get('cookie') || '')
// set or overwrite existing cookie
cookies[name] = value
// update cookie event header for future requests
serverEvent.headers.set(
'cookie',
Object.entries(cookies)
.map(([name, value]) => serialize(name, value))
.join('; ')
)
// Also apply to serverEvent.node.req.headers
if (serverEvent.node?.req?.headers) {
serverEvent.node.req.headers['cookie'] = serverEvent.headers.get('cookie') || ''
}
}
}
}
},
onError() {
console.error('🔍 Middleware: Token refresh failed')
const { loggedIn } = useUserSession()
if (!loggedIn.value) {
console.log('🔍 Middleware: User not logged in, redirecting to /login')
return navigateTo('/login')
}
}
})
// Refresh the session
await fetchSession()
}
})
function isExpired(payload: JwtPayload) {
return payload?.exp && payload.exp < Date.now() / 1000
}

View File

@@ -0,0 +1,156 @@
<template>
<UDashboardPanel id="home">
<template #header>
<UDashboardNavbar title="Home" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right>
<UDropdownMenu :items="items">
<UButton icon="i-lucide-plus" size="md" class="rounded-full" />
</UDropdownMenu>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #right>
<UButton
icon="i-lucide-file-text"
size="md"
color="primary"
variant="solid"
target="_blank"
:to="`/api/application-forms/${applicationForm.id}/pdf`"
>PDF Vorschau</UButton
>
</template>
</UDashboardToolbar>
</template>
<template #body>
<div class="flex flex-col w-full lg:max-w-4xl mx-auto">
<UStepper ref="stepper" v-model="activeStepperItemIndex" :items="stepperItems" class="w-full" />
<h1 v-if="currentFormElementSection?.title" class="text-xl text-pretty font-bold text-highlighted">
{{ currentFormElementSection.title }}
</h1>
<UCard variant="subtle">
<FormEngine
v-if="applicationForm && currentFormElementSection?.formElements"
v-model="currentFormElementSection.formElements"
:application-form-id="applicationForm.id"
:disabled="isReadOnly"
@add:input-form="handleAddInputForm"
/>
<div class="flex gap-2 justify-between mt-4">
<UButton
leading-icon="i-lucide-arrow-left"
:disabled="!stepper?.hasPrev"
@click="navigateStepper('backward')"
>
Prev
</UButton>
<UButton
v-if="stepper?.hasNext"
trailing-icon="i-lucide-arrow-right"
:disabled="!stepper?.hasNext"
@click="navigateStepper('forward')"
>
Next
</UButton>
<div v-if="!stepper?.hasNext" class="flex flex-wrap items-center gap-1.5">
<UButton trailing-icon="i-lucide-save" :disabled="isReadOnly" variant="outline" @click="onSave">
Save
</UButton>
<UButton trailing-icon="i-lucide-send-horizontal" :disabled="isReadOnly" @click="onSubmit">
Submit
</UButton>
</div>
</div>
</UCard>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import type { ApplicationFormDto } from '~~/.api-client'
import { useUserStore } from '~~/stores/useUserStore'
const { getApplicationFormById, updateApplicationForm, submitApplicationForm } = useApplicationForm()
const route = useRoute()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const toast = useToast()
definePageMeta({
// Prevent whole page from re-rendering when navigating between sections to keep state
key: (route) => `${route.params.id}`
})
const items = [
[
{
label: 'Neuer Mitbestimmungsantrag',
icon: 'i-lucide-send',
to: '/create'
}
]
]
const { data, error } = await useAsyncData<ApplicationFormDto>(`application-form-${route.params.id}`, async () => {
console.log('Fetching application form with ID:', route.params.id)
return await getApplicationFormById(Array.isArray(route.params.id) ? route.params.id[0] : route.params.id)
})
if (error.value) {
throw createError({ statusText: error.value.message })
}
const applicationForm = computed<ApplicationFormDto>(() => data?.value as ApplicationFormDto)
const isReadOnly = computed(() => {
return applicationForm.value?.createdBy.keycloakId !== user.value?.keycloakId
})
const { stepper, activeStepperItemIndex, stepperItems, currentFormElementSection, navigateStepper } = useFormStepper(
computed(() => applicationForm.value?.formElementSections),
{
onNavigate: async () => {
await navigateTo(`/application-forms/${route.params.id}/${activeStepperItemIndex.value}`)
}
}
)
const { addInputFormToApplicationForm } = useFormElementManagement(currentFormElementSection, applicationForm.value?.id)
async function handleAddInputForm(position: number) {
const updatedForm = await addInputFormToApplicationForm(position)
if (updatedForm) {
data.value = updatedForm
}
}
onMounted(() => {
const sectionIndex = parseInt(route.params.sectionIndex[0])
activeStepperItemIndex.value = !isNaN(sectionIndex) ? sectionIndex : 0
})
async function onSave() {
if (data?.value) {
await updateApplicationForm(data.value.id, data.value)
toast.add({ title: 'Success', description: 'Application form saved', color: 'success' })
}
}
async function onSubmit() {
if (data?.value) {
await submitApplicationForm(data.value.id)
await navigateTo('/')
toast.add({ title: 'Success', description: 'Application form submitted', color: 'success' })
}
}
</script>

View File

@@ -0,0 +1,19 @@
<template>
<h1>Authentication callback processing...</h1>
</template>
<script setup lang="ts">
import { useKeycloak } from '~/composables/useKeycloak'
const { userManager } = useKeycloak()
onMounted(async () => {
try {
const user = await userManager.signinRedirectCallback()
console.log('User logged in', user)
await navigateTo('/')
} catch (e) {
console.error('Error during login', e)
}
})
</script>

View File

@@ -0,0 +1,168 @@
<template>
<UDashboardPanel id="home">
<template #header>
<UDashboardNavbar title="Home" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right />
</UDashboardNavbar>
<UDashboardToolbar>
<template #left />
</UDashboardToolbar>
</template>
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-4xl mx-auto">
<div v-if="!canWriteApplicationForms" class="text-center py-12">
<UIcon name="i-lucide-shield-x" class="w-16 h-16 mx-auto text-red-400 mb-4" />
<h2 class="text-2xl font-semibold text-gray-700 mb-2">Keine Berechtigung</h2>
<p class="text-gray-500 mb-4">Sie haben keine Berechtigung zum Erstellen von Anträgen.</p>
<UButton to="/" class="mt-4"> Zurück zur Übersicht </UButton>
</div>
<div v-else>
<UPageCard title="Ampelstatus" variant="naked" orientation="horizontal" class="mb-4">
{{ trafficLightStatusEmoji }}
</UPageCard>
<UPageCard variant="subtle">
<UForm class="space-y-4" :state="{}" @submit="onSubmit">
<UFormField label="Name">
<UInput v-if="applicationFormTemplate" v-model="applicationFormTemplate.name" />
</UFormField>
<UStepper ref="stepper" v-model="activeStepperItemIndex" :items="stepperItems" class="w-full" />
<h1 v-if="currentFormElementSection?.title" class="text-xl text-pretty font-bold text-highlighted">
{{ currentFormElementSection.title }}
</h1>
<FormEngine
v-if="currentFormElementSection?.formElements"
v-model="currentFormElementSection.formElements"
@add:input-form="addInputFormToApplicationForm"
/>
<div class="flex gap-2 justify-between mt-4">
<UButton
leading-icon="i-lucide-arrow-left"
:disabled="!stepper?.hasPrev"
@click="navigateStepper('backward')"
>
Prev
</UButton>
<UButton
v-if="stepper?.hasNext"
trailing-icon="i-lucide-arrow-right"
:disabled="!stepper?.hasNext"
@click="navigateStepper('forward')"
>
Next
</UButton>
<div v-if="!stepper?.hasNext" class="flex flex-wrap items-center gap-1.5">
<UButton trailing-icon="i-lucide-save" variant="outline" @click="onSave"> Save </UButton>
<UButton trailing-icon="i-lucide-send-horizontal" @click="onSubmit"> Submit </UButton>
</div>
</div>
</UForm>
</UPageCard>
</div>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { ComplianceStatus, type PagedApplicationFormDto } from '~~/.api-client'
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
import type { FormElementId } from '~~/types/formElement'
import { useUserStore } from '~~/stores/useUserStore'
const { getAllApplicationFormTemplates } = await useApplicationFormTemplate()
const { createApplicationForm, submitApplicationForm } = useApplicationForm()
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
const { canWriteApplicationForms } = usePermissions()
const userStore = useUserStore()
const { selectedOrganization } = storeToRefs(userStore)
const toast = useToast()
const { data, error } = await useAsyncData<PagedApplicationFormDto>('create-application-form', async () => {
return await getAllApplicationFormTemplates()
})
if (error.value) {
throw createError({ statusText: error.value.message })
}
const applicationFormTemplate = computed(
// TODO: Don't select always the first item, allow user to select a template
() => data?.value?.content[0] ?? undefined
)
const { stepper, activeStepperItemIndex, stepperItems, currentFormElementSection, navigateStepper } = useFormStepper(
computed(() => applicationFormTemplate.value?.formElementSections)
)
const { addInputFormToApplicationForm } = useFormElementManagement(currentFormElementSection)
const formElements = computed({
get: () => currentFormElementSection?.value?.formElements ?? [],
set: (val) => {
if (val && applicationFormTemplate.value) {
if (!currentFormElementSection.value) return
currentFormElementSection.value.formElements = val
}
}
})
const validationMap = ref<Map<FormElementId, ComplianceStatus> | undefined>()
const validationStatus = ref<ComplianceStatus>(ComplianceStatus.NonCritical)
watch(
() => formElements,
(updatedFormElements) => {
validationMap.value = validateFormElements(updatedFormElements.value)
validationStatus.value = getHighestComplianceStatus()
},
{ deep: true }
)
const trafficLightStatusEmoji = computed(() => {
switch (validationStatus.value) {
case ComplianceStatus.Critical:
return '🔴'
case ComplianceStatus.Warning:
return '🟡'
case ComplianceStatus.NonCritical:
return '🟢'
default:
return '🟢'
}
})
async function onSave() {
const applicationForm = await prepareAndCreateApplicationForm()
if (applicationForm) {
toast.add({ title: 'Success', description: 'Application form saved', color: 'success' })
}
}
async function onSubmit() {
const applicationForm = await prepareAndCreateApplicationForm()
if (applicationForm) {
await submitApplicationForm(applicationForm.id)
await navigateTo('/')
toast.add({ title: 'Success', description: 'Application form submitted', color: 'success' })
}
}
async function prepareAndCreateApplicationForm() {
if (!applicationFormTemplate.value) {
console.error('Application form data is undefined')
return null
}
console.log('selectedOrganization', selectedOrganization.value)
applicationFormTemplate.value.organizationId = selectedOrganization.value?.id ?? ''
return await createApplicationForm(applicationFormTemplate.value)
}
</script>

View File

@@ -0,0 +1,174 @@
<template>
<UDashboardPanel id="home">
<template #header>
<UDashboardNavbar title="Home" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right>
Aktuelle Organisation
<USelect
v-model="selectedOrganizationId"
:items="organizations"
value-key="id"
label-key="name"
size="lg"
:ui="{
trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200'
}"
class="w-48"
/>
<UTooltip text="Notifications" :shortcuts="['N']">
<UButton color="neutral" variant="ghost" square @click="isNotificationsSlideoverOpen = true">
<UChip :show="unreadCount > 0" color="error" inset>
<UIcon name="i-lucide-bell" class="size-5 shrink-0" />
<span v-if="unreadCount > 0" class="ml-1 text-xs">{{ unreadCount }}</span>
</UChip>
</UButton>
</UTooltip>
<UDropdownMenu :items="items">
<UButton icon="i-lucide-plus" size="md" class="rounded-full" />
</UDropdownMenu>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left> toolbar left </template>
</UDashboardToolbar>
</template>
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-4xl mx-auto">
<div
v-for="(applicationFormElem, index) in applicationForms"
:key="applicationFormElem.id"
class="flex justify-between items-center p-4 bg-white rounded-lg shadow-md"
@click="navigateTo(`application-forms/${applicationFormElem.id}/0`)"
>
<div>
<p class="font-medium text-(--ui-text-highlighted) text-base">
#{{ index }} {{ applicationFormElem.name }}
</p>
<p class="text-(--ui-text-muted) text-sm">
Zuletzt bearbeitet von {{ applicationFormElem.lastModifiedBy.name }} am
{{ formatDate(applicationFormElem.modifiedAt) }}
</p>
<p class="text-(--ui-text-muted) text-sm">
Erstellt von {{ applicationFormElem.createdBy.name }} am {{ formatDate(applicationFormElem.createdAt) }}
</p>
<p class="text-(--ui-text-muted) text-sm">Status: {{ applicationFormElem.status }}</p>
</div>
<div>
<UPageLinks :links="getLinksForApplicationForm(applicationFormElem)" />
</div>
</div>
</div>
</template>
<DeleteModal
v-if="isDeleteModalOpen && applicationFormNameToDelete"
v-model:is-open="isDeleteModalOpen"
:application-form-to-delete="applicationFormNameToDelete"
@delete="deleteApplicationForm($event)"
/>
</UDashboardPanel>
</template>
<script setup lang="ts">
import type { ApplicationFormDto, PagedApplicationFormDto } from '~~/.api-client'
import type { Organization } from '~~/types/keycloak'
import { useUserStore } from '~~/stores/useUserStore'
const { getAllApplicationForms, deleteApplicationFormById } = useApplicationForm()
const route = useRoute()
const userStore = useUserStore()
const { organizations, selectedOrganization } = storeToRefs(userStore)
// Inject notification state from layout
const { isNotificationsSlideoverOpen, unreadCount } = inject('notificationState', {
isNotificationsSlideoverOpen: ref(false),
unreadCount: ref(0)
})
const { data } = await useAsyncData<PagedApplicationFormDto>(
async () => {
if (!selectedOrganization.value) {
throw new Error('No organization selected')
}
return await getAllApplicationForms(selectedOrganization.value.id)
},
{ watch: [selectedOrganization] }
)
const isDeleteModalOpen = computed<boolean>({
get: () => 'delete' in route.query,
set: (isOpen: boolean) => {
if (isOpen) return
navigateTo({ path: route.path, query: {} })
}
})
const applicationFormNameToDelete = computed(() => {
return data?.value?.content.find((appForm) => appForm.id === route.query.id)
})
const selectedOrganizationId = computed({
get() {
return selectedOrganization.value?.id
},
set(item) {
// TODO: USelect triggers multiple times after single selection
selectedOrganization.value = organizations.value.find((i: Organization) => i.id === item) ?? null
}
})
const { canWriteApplicationForms } = usePermissions()
const items = computed(() => [
[
{
label: 'Neuer Mitbestimmungsantrag',
icon: 'i-lucide-send',
to: '/create',
disabled: !canWriteApplicationForms.value
}
]
])
const applicationForms = computed({
get: () => data?.value?.content ?? [],
set: (val) => {
if (val && data.value) {
data.value.content = val
}
}
})
function getLinksForApplicationForm(applicationForm: ApplicationFormDto) {
return [
{
label: 'Bearbeiten',
icon: 'i-lucide-file-pen',
to: `/application-forms/${applicationForm.id}`,
disabled: !canWriteApplicationForms.value
},
{
label: 'Löschen',
icon: 'i-lucide-trash',
to: `?delete&id=${applicationForm.id}`,
disabled: !canWriteApplicationForms.value
}
]
}
async function deleteApplicationForm(applicationFormId: string) {
await deleteApplicationFormById(applicationFormId)
data.value?.content.splice(
data.value?.content.findIndex((appForm) => appForm.id === applicationFormId),
1
)
isDeleteModalOpen.value = false
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<UCard variant="subtle">
<template #header>
<div class="text-center">
<UIcon name="i-lucide-lock" class="mx-auto h-16 w-16 text-primary-500 mb-6" />
<h1 class="text-3xl font-bold text-gray-900 mb-2">
Welcome
</h1>
<p class="text-gray-600">
You will be redirected to Keycloak to authenticate
</p>
</div>
</template>
<div class="text-center">
<UButton
color="primary"
size="xl"
icon="i-lucide-log-in"
@click="handleSignIn"
>
Sign in with Keycloak
</UButton>
</div>
<template #footer>
<div class="text-center text-xs text-gray-500">
By signing in, you agree to our terms of service
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
definePageMeta({ auth: false, layout: 'auth' })
useSeoMeta({ title: 'Login' })
function handleSignIn() {
navigateTo('/auth/keycloak', { external: true })
}
</script>

View File

@@ -0,0 +1,9 @@
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('vue:error', (error, instance, info) => {
console.error('Vue error:', error, 'Instance:', instance, 'Info:', info)
})
nuxtApp.hook('app:error', (error) => {
console.error('App error:', error)
})
})

View File

@@ -0,0 +1,13 @@
export default defineNuxtPlugin(() => {
// This plugin runs only on the client side to avoid issues with server-side rendering
if (import.meta.client) {
// Initialize server health monitoring as soon as the client is ready
const { startPeriodicHealthCheck } = useServerHealth()
// Start the health check with a 1-minute interval
// This ensures the health check starts even if app.vue's onMounted hasn't fired yet
// nextTick(() => {
// startPeriodicHealthCheck(60000)
// })
}
})

View File

@@ -0,0 +1,10 @@
export function formatDate(date: Date): string {
return date.toLocaleString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}

View File

@@ -0,0 +1,60 @@
import type { HTTPMethod } from 'h3'
// Custom OpenAPI fetch client that wraps useRequestFetch. This ensures that authentication headers
// are forwarded correctly during SSR. Unlike fetch, useRequestFetch returns data directly,
// so we need to wrap it to mimic the Response object.
export const wrappedFetchWrap = (requestFetch: ReturnType<typeof useRequestFetch>) =>
async function wrappedFetch(url: string, init?: RequestInit): Promise<Response> {
try {
// Convert RequestInit to $fetch options
const fetchOptions: Parameters<typeof $fetch>[1] = {
method: (init?.method || 'GET') as HTTPMethod,
headers: init?.headers as Record<string, string>
}
if (init?.body) {
fetchOptions.body = init.body
}
// Use $fetch to get the data with proper header forwarding
const data = await requestFetch(url, fetchOptions)
// Create a proper Response object
return new Response(JSON.stringify(data), {
status: 200,
statusText: 'OK',
headers: {
'Content-Type': 'application/json'
}
})
} catch (error: unknown) {
console.error('Fetch error:', error)
// Check if it's a FetchError from ofetch
if (error && typeof error === 'object' && 'status' in error) {
const fetchError = error as { status?: number; statusText?: string; data?: unknown; message?: string }
const status = fetchError.status || 500
const statusText = fetchError.statusText || fetchError.message || 'Internal Server Error'
const errorData = fetchError.data || fetchError.message || 'Unknown error'
return new Response(JSON.stringify(errorData), {
status,
statusText,
headers: {
'Content-Type': 'application/json'
}
})
} else {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
statusText: 'Internal Server Error',
headers: {
'Content-Type': 'application/json'
}
})
}
}
}