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,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>