feat(#9): Nuxt 4 migration
This commit is contained in:
8
legalconsenthub/app/app.config.ts
Normal file
8
legalconsenthub/app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'green',
|
||||
neutral: 'zinc'
|
||||
}
|
||||
}
|
||||
})
|
||||
42
legalconsenthub/app/app.vue
Normal file
42
legalconsenthub/app/app.vue
Normal 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>
|
||||
16
legalconsenthub/app/assets/css/main.css
Normal file
16
legalconsenthub/app/assets/css/main.css
Normal 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;
|
||||
}
|
||||
25
legalconsenthub/app/components/DeleteModal.vue
Normal file
25
legalconsenthub/app/components/DeleteModal.vue
Normal 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>
|
||||
110
legalconsenthub/app/components/FormEngine.vue
Normal file
110
legalconsenthub/app/components/FormEngine.vue
Normal 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>
|
||||
92
legalconsenthub/app/components/NotificationsSlideover.vue
Normal file
92
legalconsenthub/app/components/NotificationsSlideover.vue
Normal 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>
|
||||
55
legalconsenthub/app/components/ServerConnectionOverlay.vue
Normal file
55
legalconsenthub/app/components/ServerConnectionOverlay.vue
Normal 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>
|
||||
63
legalconsenthub/app/components/TheComment.vue
Normal file
63
legalconsenthub/app/components/TheComment.vue
Normal 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>
|
||||
187
legalconsenthub/app/components/UserMenu.vue
Normal file
187
legalconsenthub/app/components/UserMenu.vue
Normal 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>
|
||||
@@ -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>
|
||||
28
legalconsenthub/app/components/formelements/TheCheckbox.vue
Normal file
28
legalconsenthub/app/components/formelements/TheCheckbox.vue
Normal 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>
|
||||
29
legalconsenthub/app/components/formelements/TheInput.vue
Normal file
29
legalconsenthub/app/components/formelements/TheInput.vue
Normal 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>
|
||||
@@ -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>
|
||||
32
legalconsenthub/app/components/formelements/TheSelect.vue
Normal file
32
legalconsenthub/app/components/formelements/TheSelect.vue
Normal 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>
|
||||
28
legalconsenthub/app/components/formelements/TheSwitch.vue
Normal file
28
legalconsenthub/app/components/formelements/TheSwitch.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Element unimplemented:</div>
|
||||
</template>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
47
legalconsenthub/app/composables/comment/useCommentApi.ts
Normal file
47
legalconsenthub/app/composables/comment/useCommentApi.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
47
legalconsenthub/app/composables/complianceMap.ts
Normal file
47
legalconsenthub/app/composables/complianceMap.ts
Normal 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
|
||||
]
|
||||
4
legalconsenthub/app/composables/index.ts
Normal file
4
legalconsenthub/app/composables/index.ts
Normal 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'
|
||||
144
legalconsenthub/app/composables/notification/useNotification.ts
Normal file
144
legalconsenthub/app/composables/notification/useNotification.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
47
legalconsenthub/app/composables/useFormElementManagement.ts
Normal file
47
legalconsenthub/app/composables/useFormElementManagement.ts
Normal 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
|
||||
}
|
||||
}
|
||||
57
legalconsenthub/app/composables/useFormStepper.ts
Normal file
57
legalconsenthub/app/composables/useFormStepper.ts
Normal 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
|
||||
}
|
||||
}
|
||||
152
legalconsenthub/app/composables/usePermissions.ts
Normal file
152
legalconsenthub/app/composables/usePermissions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
92
legalconsenthub/app/composables/useServerHealth.ts
Normal file
92
legalconsenthub/app/composables/useServerHealth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
24
legalconsenthub/app/error.vue
Normal file
24
legalconsenthub/app/error.vue
Normal 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>
|
||||
5
legalconsenthub/app/layouts/auth.vue
Normal file
5
legalconsenthub/app/layouts/auth.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="h-screen flex items-center justify-center overlay">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
66
legalconsenthub/app/layouts/default.vue
Normal file
66
legalconsenthub/app/layouts/default.vue
Normal 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>
|
||||
14
legalconsenthub/app/middleware/auth.global.ts
Normal file
14
legalconsenthub/app/middleware/auth.global.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
8
legalconsenthub/app/middleware/permissions.global.ts
Normal file
8
legalconsenthub/app/middleware/permissions.global.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { canWriteApplicationForms } = usePermissions()
|
||||
|
||||
if (to.path === '/create' && !canWriteApplicationForms.value) {
|
||||
return navigateTo('/', { replace: true })
|
||||
}
|
||||
})
|
||||
|
||||
83
legalconsenthub/app/middleware/refreshToken.global.ts
Normal file
83
legalconsenthub/app/middleware/refreshToken.global.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
19
legalconsenthub/app/pages/callback.vue
Normal file
19
legalconsenthub/app/pages/callback.vue
Normal 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>
|
||||
168
legalconsenthub/app/pages/create.vue
Normal file
168
legalconsenthub/app/pages/create.vue
Normal 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>
|
||||
174
legalconsenthub/app/pages/index.vue
Normal file
174
legalconsenthub/app/pages/index.vue
Normal 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>
|
||||
42
legalconsenthub/app/pages/login.vue
Normal file
42
legalconsenthub/app/pages/login.vue
Normal 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>
|
||||
9
legalconsenthub/app/plugins/error-handler.ts
Normal file
9
legalconsenthub/app/plugins/error-handler.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
13
legalconsenthub/app/plugins/server-health.client.ts
Normal file
13
legalconsenthub/app/plugins/server-health.client.ts
Normal 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)
|
||||
// })
|
||||
}
|
||||
})
|
||||
10
legalconsenthub/app/utils/date.ts
Normal file
10
legalconsenthub/app/utils/date.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
60
legalconsenthub/app/utils/wrappedFetch.ts
Normal file
60
legalconsenthub/app/utils/wrappedFetch.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user