feat(#9): Nuxt 4 migration
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user