major: Rename legalconsenthub to gremiumhub
All checks were successful
CI/CD Pipeline / frontend (push) Successful in 5m52s
CI/CD Pipeline / backend (push) Successful in 7m58s
CI/CD Pipeline / deploy (push) Successful in 1s

This commit is contained in:
2026-03-16 10:28:32 +01:00
parent 52fe6b6392
commit afec157b35
326 changed files with 566 additions and 1004 deletions

View File

@@ -1,15 +0,0 @@
node_modules
.nuxt
.output
.git
.gitignore
README.md
.api-client
.api-client-middleware
*.log
.DS_Store
coverage
.vscode
.idea
dist

View File

@@ -1 +0,0 @@
shamefully-hoist=true

View File

@@ -1 +0,0 @@
22

View File

@@ -1,7 +0,0 @@
# Ignore artifacts:
build
coverage
.nuxt
.output
.api-client
pnpm-lock.yaml

View File

@@ -1,6 +0,0 @@
{
"trailingComma": "none",
"semi": false,
"singleQuote": true,
"printWidth": 120
}

View File

@@ -1,84 +0,0 @@
# Frontend - AI Context
## Tech Stack
- **Framework**: Nuxt 4.2.0
- **UI Library**: Nuxt UI 4.3.0 (built on Vue 3)
- **State Management**: Pinia
- **Language**: TypeScript
- **i18n**: Multilingual support (de/en)
- **Auth**: nuxt-auth-utils (OAuth2/JWT integration)
- **API Client**: Auto-generated from OpenAPI spec
---
## Composables
| Composable | Purpose |
| ----------------------------- | ---------------------------------------- |
| `useFormElementVisibility` | Evaluate visibility conditions |
| `useSectionSpawning` | Process spawn triggers, clone templates |
| `useFormElementDuplication` | Clone elements with reference generation |
| `useFormElementValueClearing` | Clear values when elements become hidden |
| `useTableCrossReferences` | Resolve table column/row references |
| `useApplicationFormValidator` | Form validation rules |
| `useLogger` | Consola logger instance |
---
## Development
```bash
# Install dependencies
pnpm install
# Run dev server (port 3001)
pnpm run dev
# Build for production
pnpm run build
# Generate API client (after OpenAPI spec changes)
pnpm run api:generate
```
---
## Key Files
| File | Purpose |
| ----------------- | ------------------------------ |
| `composables/` | Reusable composition functions |
| `locales/de.json` | German translations |
| `locales/en.json` | English translations |
| `stores/` | Pinia state stores |
| `pages/` | Nuxt page components |
| `components/` | Vue components |
---
## Rules for AI
1. **No hardcoded UI strings** - Always use i18n. Add keys to both `de.json` and `en.json`
2. **Nuxt UI 4** - For any UI-related questions, use the Nuxt UI MCP server to get current component docs and examples
3. **Type safety** - Leverage TypeScript for all components and composables
4. **Composables pattern** - Use composables for shared logic (visibility, spawning, validation, etc.)
5. **VueUse first** - Prefer composables from [VueUse](https://vueuse.org/) over custom implementations when available. Check VueUse before writing custom utility composables. Use context7 to learn more about the available composables.
6. **State management** - Use Pinia stores for cross-component state
7. **API client** - Never manually write API calls. Use auto-generated client from OpenAPI spec
### Component Guidelines
- Keep components focused and single-responsibility
- Extract complex logic into composables
- Use TypeScript interfaces for props and emits
- Follow Vue 3 Composition API patterns
- Use Nuxt UI components for consistent design
- API calls are wrapped in composables or Pinia actions. The `/composables/applicationFormTemplate` are a good reference.
### i18n Best Practices
- Use semantic keys: `form.submit`, `error.validation.required`
- Never skip German translations (primary language)
- Keep English translations synchronized
- Use interpolation for dynamic values: `{count}`, `{name}`

View File

@@ -1,37 +0,0 @@
FROM node:22.16.0-alpine AS builder
WORKDIR /app
RUN apk add --no-cache openjdk21-jre
RUN npm install -g pnpm@10.13.1
RUN mkdir -p ../api
COPY api/legalconsenthub.yml ../api/
COPY legalconsenthub/package.json legalconsenthub/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY legalconsenthub/ .
RUN pnpm build
FROM node:22.16.0-alpine AS runner
WORKDIR /app
RUN npm install -g pnpm@10.13.1
COPY --from=builder /app/.output /app/.output
COPY --from=builder /app/package.json /app/package.json
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -1,5 +0,0 @@
# Legal Consent Hub
## Pipeline Triggering
Trigger count: 12

View File

@@ -1,23 +0,0 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'teal',
neutral: 'zinc'
},
// Card customizations with vibrant hover effect
card: {
slots: {
root: 'hover:ring-2 hover:ring-teal-400/50 dark:hover:ring-teal-500/40 hover:shadow-lg hover:shadow-teal-500/10 transition-all duration-200'
}
},
// Dashboard navbar with glass morphism
dashboardNavbar: {
slots: {
root: 'bg-white/90 dark:bg-gray-950/90 backdrop-blur-xl border-b border-gray-200/50 dark:border-gray-800/50 transition-all duration-300',
title: 'font-semibold text-gray-900 dark:text-white'
}
}
}
})

View File

@@ -1,48 +0,0 @@
<template>
<UApp :locale="locales[locale]">
<NuxtLoadingIndicator />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<ServerConnectionOverlay />
</UApp>
</template>
<script setup lang="ts">
import * as locales from '@nuxt/ui/locale'
const { locale } = useI18n()
const colorMode = useColorMode()
const color = computed(() => (colorMode.value === 'dark' ? '#111827' : 'white'))
const lang = computed(() => locales[locale.value].code)
const dir = computed(() => locales[locale.value].dir)
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.svg', type: 'image/svg+xml' }],
htmlAttrs: {
lang,
dir
}
})
const title = 'LegalConsentHub'
const description = 'Das Tool für die Einführung von mitbestimmungspflichtigen digitalen Lösungen.'
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogImage: 'https://dashboard-template.nuxt.dev/social-card.png',
twitterImage: 'https://dashboard-template.nuxt.dev/social-card.png',
twitterCard: 'summary_large_image'
})
</script>

View File

@@ -1,136 +0,0 @@
@import 'tailwindcss' theme(static);
@import '@nuxt/ui';
@theme static {
/* Primary color - Teal (matching landing page) */
--color-teal-50: #f0fdfa;
--color-teal-100: #ccfbf1;
--color-teal-200: #99f6e4;
--color-teal-300: #5eead4;
--color-teal-400: #2dd4bf;
--color-teal-500: #14b8a6;
--color-teal-600: #0d9488;
--color-teal-700: #0f766e;
--color-teal-800: #115e59;
--color-teal-900: #134e4a;
--color-teal-950: #042f2e;
/* Cyan for accent highlights */
--color-cyan-50: #ecfeff;
--color-cyan-100: #cffafe;
--color-cyan-200: #a5f3fc;
--color-cyan-300: #67e8f9;
--color-cyan-400: #22d3ee;
--color-cyan-500: #06b6d4;
--color-cyan-600: #0891b2;
--color-cyan-700: #0e7490;
--color-cyan-800: #155e75;
--color-cyan-900: #164e63;
--color-cyan-950: #083344;
/* Violet color palette (for Tailwind classes and hover effects) */
--color-violet-50: #f5f3ff;
--color-violet-100: #ede9fe;
--color-violet-200: #ddd6fe;
--color-violet-300: #c4b5fd;
--color-violet-400: #a78bfa;
--color-violet-500: #8b5cf6;
--color-violet-600: #7c3aed;
--color-violet-700: #6d28d9;
--color-violet-800: #5b21b6;
--color-violet-900: #4c1d95;
--color-violet-950: #2e1065;
}
/* ============================================
FOCUS & ACCESSIBILITY
============================================ */
/* Global focus styles for elements without built-in focus styling.
Nuxt UI components (inputs, buttons, selects, etc.) have their own
focus ring styling, so we exclude them to avoid double borders. */
:focus-visible:not(input):not(textarea):not(select):not(button):not([role='combobox']):not([role='listbox']):not(
[role='option']
):not([role='checkbox']):not([role='radio']):not([role='switch']):not([role='slider']):not([role='tab']):not(
[role='menuitem']
) {
outline: 2px solid var(--color-teal-500);
outline-offset: 2px;
}
/* ============================================
HEADER STYLING
============================================ */
/* Header logo accent with gradient */
.header-logo-accent {
background: linear-gradient(135deg, var(--color-teal-500), var(--color-cyan-400));
box-shadow: 0 4px 6px -1px rgba(20, 184, 166, 0.2);
}
/* ============================================
NAVIGATION BUTTON GROUP
============================================ */
.nav-button-group {
display: flex;
gap: 0.75rem;
align-items: center;
}
@media (max-width: 640px) {
.nav-button-group {
flex-direction: column;
width: 100%;
}
.nav-button-group > * {
width: 100%;
justify-content: center;
}
}
/* Navigation separator line */
.nav-separator {
height: 1px;
background: linear-gradient(90deg, transparent, var(--color-gray-200), transparent);
margin-bottom: 1.5rem;
}
.dark .nav-separator {
background: linear-gradient(90deg, transparent, var(--color-gray-700), transparent);
}
/* ============================================
FORM ELEMENT VISIBILITY TRANSITIONS
============================================ */
/* Subtle fade + slide animation for form elements appearing/disappearing */
.form-element-enter-active {
transition:
opacity 200ms ease-out,
transform 200ms ease-out;
}
.form-element-leave-active {
transition:
opacity 200ms ease-out,
transform 200ms ease-out;
/* Take leaving elements out of flow so remaining elements reposition smoothly */
position: absolute;
width: 100%;
}
.form-element-enter-from {
opacity: 0;
transform: translateY(-8px);
}
.form-element-leave-to {
opacity: 0;
transform: translateY(8px);
}
/* Smooth repositioning when elements are added/removed */
.form-element-move {
transition: transform 200ms ease-out;
}

View File

@@ -1,58 +0,0 @@
<template>
<div class="bg-white dark:bg-white rounded-md border border-gray-200 dark:border-gray-200 overflow-hidden">
<UEditor
v-slot="{ editor }"
v-model="content"
content-type="html"
:editable="!props.disabled"
:placeholder="props.placeholder"
:ui="{
content: 'bg-white dark:bg-white',
base: 'min-h-[200px] p-3 bg-white dark:bg-white'
}"
class="w-full"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-muted sticky top-0 inset-x-0 px-3 py-2 z-50 bg-default overflow-x-auto"
/>
</UEditor>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: string
disabled?: boolean
placeholder?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const content = computed({
get: () => props.modelValue,
set: (newValue: string) => {
emit('update:modelValue', newValue)
}
})
const toolbarItems = [
[
{ kind: 'undo', icon: 'i-lucide-undo' },
{ kind: 'redo', icon: 'i-lucide-redo' }
],
[
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' }
],
[
{ kind: 'bulletList', icon: 'i-lucide-list' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
],
[{ kind: 'link', icon: 'i-lucide-link' }]
]
</script>

View File

@@ -1,32 +0,0 @@
<template>
<UModal :open="isOpen" :title="$t('applicationForms.deleteTitle')" @update:open="$emit('update:isOpen', $event)">
<template #body>
{{ $t('applicationForms.deleteConfirm', { name: applicationFormToDelete.name }) }}
</template>
<template #footer>
<UButton :label="$t('common.cancel')" color="neutral" variant="outline" @click="$emit('update:isOpen', false)" />
<UButton :label="$t('common.delete')" color="neutral" :disabled="!applicationFormToDelete.id" @click="onDelete" />
</template>
</UModal>
</template>
<script setup lang="ts">
import type { ApplicationFormDto } from '~~/.api-client'
const emit = defineEmits<{
(e: 'delete', id: string): void
(e: 'update:isOpen', value: boolean): void
}>()
const props = defineProps<{
applicationFormToDelete: ApplicationFormDto
isOpen: boolean
}>()
function onDelete() {
if (!props.applicationFormToDelete.id) {
return
}
emit('delete', props.applicationFormToDelete.id)
}
</script>

View File

@@ -1,258 +0,0 @@
<template>
<TransitionGroup name="form-element" tag="div" class="relative" @before-enter="applyCascadingEntryDelay">
<div
v-for="(formElementItem, visibleIndex) in visibleFormElements"
:key="getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)"
:data-index="visibleIndex"
>
<div class="group flex py-3 lg:py-4">
<div class="flex-auto min-w-0">
<p
v-if="formElementItem.formElement.title"
:class="['font-semibold', formElementItem.formElement.description ? '' : 'pb-3']"
>
{{ formElementItem.formElement.title }}
</p>
<p v-if="formElementItem.formElement.description" class="text-dimmed pb-3">
{{ formElementItem.formElement.description }}
</p>
<component
:is="getResolvedComponent(formElementItem.formElement)"
:form-options="formElementItem.formElement.options"
:disabled="props.disabled"
:all-form-elements="props.allFormElements"
:table-row-preset="formElementItem.formElement.tableRowPreset"
:application-form-id="props.applicationFormId"
:form-element-reference="formElementItem.formElement.reference"
@update:form-options="updateFormOptions($event, formElementItem)"
/>
<div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3">
<UButton
variant="outline"
size="sm"
leading-icon="i-lucide-copy-plus"
@click="handleCloneElement(formElementItem.formElement, formElementItem.indexInSubsection)"
>
{{ $t('applicationForms.formElements.addAnother') }}
</UButton>
</div>
<TheComment
v-if="
applicationFormId &&
formElementItem.formElement.id &&
activeFormElement === formElementItem.formElement.id
"
:form-element-id="formElementItem.formElement.id"
:application-form-id="applicationFormId"
:comments="comments?.[formElementItem.formElement.id]"
:total-count="
commentCounts?.[formElementItem.formElement.id] ?? comments?.[formElementItem.formElement.id]?.length ?? 0
"
@close="activeFormElement = ''"
/>
</div>
<div class="flex items-start gap-1">
<div class="min-w-9">
<UButton
v-if="
applicationFormId &&
formElementItem.formElement.id &&
(commentCounts?.[formElementItem.formElement.id] ?? 0) > 0
"
color="neutral"
variant="soft"
size="xs"
icon="i-lucide-message-square"
class="w-full justify-center"
@click="toggleComments(formElementItem.formElement.id)"
>
{{ commentCounts?.[formElementItem.formElement.id] ?? 0 }}
</UButton>
</div>
<div
:class="[
'transition-opacity duration-200',
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
? 'opacity-100'
: 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-within:opacity-100'
]"
>
<UDropdownMenu
:items="
getDropdownItems(
formElementItem.formElement,
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
formElementItem.indexInSubsection
)
"
:content="{ align: 'end' }"
@update:open="
(isOpen: boolean) =>
handleDropdownToggle(
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
isOpen
)
"
>
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
</UDropdownMenu>
</div>
</div>
</div>
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
</div>
</TransitionGroup>
</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[]
visibilityMap: Map<string, boolean>
applicationFormId?: string
disabled?: boolean
allFormElements?: FormElementDto[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', formElementDto: FormElementDto[]): void
(e: 'click:comments', formElementId: string): void
(e: 'add:input-form', position: number): void
(e: 'clone:element', element: FormElementDto, position: number): void
}>()
const commentStore = useCommentStore()
const { loadInitial: loadCommentsInitial } = commentStore
const { commentsByApplicationFormId, countsByApplicationFormId } = storeToRefs(commentStore)
const comments = computed(() => {
if (!props.applicationFormId) return {}
return commentsByApplicationFormId.value[props.applicationFormId] ?? {}
})
const commentCounts = computed(() => {
if (!props.applicationFormId) return {}
return countsByApplicationFormId.value[props.applicationFormId] ?? {}
})
const route = useRoute()
const activeFormElement = ref('')
const openDropdownId = ref<string | null>(null)
type VisibleFormElement = { formElement: FormElementDto; indexInSubsection: number }
function getElementKey(formElement: FormElementDto, indexInSubsection: number): string {
return formElement.id || formElement.reference || `element-${indexInSubsection}`
}
const visibleFormElements = computed<VisibleFormElement[]>(() => {
return props.modelValue
.map((formElement, indexInSubsection) => ({ formElement, indexInSubsection }))
.filter(({ formElement }) => {
const key = formElement.id || formElement.reference
return key ? props.visibilityMap.get(key) !== false : true
})
})
function handleDropdownToggle(formElementId: string, isOpen: boolean) {
openDropdownId.value = isOpen ? formElementId : null
}
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 'TEXTAREA':
return resolveComponent('TheTextarea')
case 'RICH_TEXT':
return resolveComponent('TheEditor')
case 'DATE':
return resolveComponent('TheDate')
case 'TABLE':
return resolveComponent('TheTable')
case 'FILE_UPLOAD':
return resolveComponent('TheFileUpload')
default:
return resolveComponent('Unimplemented')
}
}
function getDropdownItems(
formElement: FormElementDto,
formElementKey: string,
formElementPosition: number
): DropdownMenuItem[] {
const { t: $t } = useI18n()
const items = []
if (route.path !== '/create') {
items.push({
label: $t('applicationForms.formElements.comments'),
icon: 'i-lucide-message-square-more',
onClick: () => toggleComments(formElement.id ?? formElementKey)
})
}
items.push({
label: $t('applicationForms.formElements.addInputBelow'),
icon: 'i-lucide-list-plus',
onClick: () => emit('add:input-form', formElementPosition)
})
return [items]
}
function updateFormOptions(formOptions: FormOptionDto[], target: VisibleFormElement) {
const targetElement = target.formElement
const targetIndex = target.indexInSubsection
const updatedModelValue = props.modelValue.map((element) => {
if (targetElement.id && element.id === targetElement.id) {
return { ...element, options: formOptions }
}
if (targetElement.reference && element.reference === targetElement.reference) {
return { ...element, options: formOptions }
}
// Newly added form input element
if (!targetElement.id && !targetElement.reference && props.modelValue[targetIndex] === element) {
return { ...element, options: formOptions }
}
return element
})
emit('update:modelValue', updatedModelValue)
}
async function toggleComments(formElementId: string) {
if (activeFormElement.value === formElementId) {
activeFormElement.value = ''
return
}
activeFormElement.value = formElementId
if (props.applicationFormId) {
await loadCommentsInitial(props.applicationFormId, formElementId)
}
emit('click:comments', formElementId)
}
function handleCloneElement(formElement: FormElementDto, position: number) {
emit('clone:element', formElement, position)
}
function applyCascadingEntryDelay(el: Element) {
const index = parseInt((el as HTMLElement).dataset.index ?? '0', 10)
;(el as HTMLElement).style.transitionDelay = `${index * 30}ms`
}
</script>

View File

@@ -1,513 +0,0 @@
<template>
<div class="flex flex-col w-full">
<div class="w-full p-4">
<div class="flex flex-col gap-4 sm:gap-6 w-full">
<div class="lch-stepper relative">
<div
ref="stepperScrollEl"
:class="['lch-stepper-scroll overflow-x-auto overflow-y-visible scroll-smooth', cursorClass]"
>
<UStepper
ref="stepper"
v-model="activeStepperItemIndex"
:items="stepperItems"
:ui="stepperUi"
:linear="false"
class="w-full"
/>
</div>
<div
v-if="canScrollLeft"
class="pointer-events-none absolute inset-y-0 left-0 w-20 bg-linear-to-r from-default to-transparent"
/>
<div
v-if="canScrollRight"
class="pointer-events-none absolute inset-y-0 right-0 w-20 bg-linear-to-l from-default to-transparent"
/>
<UButton
v-if="canScrollLeft"
icon="i-lucide-chevron-left"
color="neutral"
variant="ghost"
size="md"
class="absolute left-1 top-1/2 -translate-y-1/2"
aria-label="Scroll steps left"
@click="scrollStepperBy(-1)"
/>
<UButton
v-if="canScrollRight"
icon="i-lucide-chevron-right"
color="neutral"
variant="ghost"
size="md"
class="absolute right-1 top-1/2 -translate-y-1/2"
aria-label="Scroll steps right"
@click="scrollStepperBy(1)"
/>
</div>
</div>
</div>
<div class="w-full p-4">
<div class="flex flex-col gap-4 sm:gap-6 w-full lg:max-w-4xl mx-auto">
<slot />
<h1 v-if="currentFormElementSection?.title" class="text-xl text-pretty font-bold text-highlighted">
{{ currentFormElementSection.title }}
</h1>
<TransitionGroup name="form-element" tag="div" class="relative">
<UCard
v-for="{ subsection, sectionIndex } in visibleSubsections"
:key="getSubsectionKey(currentFormElementSection, sectionIndex, subsection)"
variant="subtle"
class="mb-6"
>
<div class="mb-4">
<h2 class="text-lg font-semibold text-highlighted">{{ subsection.title }}</h2>
<p v-if="subsection.subtitle" class="text-sm text-dimmed">{{ subsection.subtitle }}</p>
</div>
<FormEngine
:model-value="subsection.formElements"
:visibility-map="visibilityMap"
:application-form-id="applicationFormId"
:disabled="disabled"
:all-form-elements="allFormElements"
@update:model-value="
(elements) =>
handleFormElementUpdate(
elements,
getSubsectionKey(currentFormElementSection, sectionIndex, subsection)
)
"
@add:input-form="
(position) =>
handleAddInputForm(position, getSubsectionKey(currentFormElementSection, sectionIndex, subsection))
"
@clone:element="
(element, position) =>
handleCloneElement(
element,
position,
getSubsectionKey(currentFormElementSection, sectionIndex, subsection)
)
"
/>
</UCard>
</TransitionGroup>
<UCard v-if="visibleSubsections.length === 0" variant="subtle" class="mb-6">
<div class="text-center py-8 text-dimmed">
<UIcon name="i-lucide-eye-off" class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{{ $t('applicationForms.noVisibleElements') }}</p>
</div>
</UCard>
<!-- Navigation Section -->
<div class="mt-8">
<div class="nav-separator" />
<div class="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center">
<!-- Previous Button -->
<UButton
leading-icon="i-lucide-arrow-left"
:disabled="!stepper?.hasPrev"
variant="soft"
color="neutral"
size="lg"
class="order-2 sm:order-1 rounded-xl"
@click="handleNavigate('backward')"
>
{{ $t('applicationForms.navigation.previous') }}
</UButton>
<!-- Primary Actions -->
<div class="nav-button-group order-1 sm:order-2">
<UButton
trailing-icon="i-lucide-save"
:disabled="disabled"
variant="outline"
size="lg"
class="rounded-xl font-semibold"
@click="emit('save')"
>
{{ $t('applicationForms.navigation.save') }}
</UButton>
<UButton
v-if="stepper?.hasNext"
trailing-icon="i-lucide-arrow-right"
size="lg"
class="bg-gradient-to-br from-teal-500 to-cyan-500 text-white font-semibold rounded-xl shadow-lg shadow-teal-500/25 hover:from-cyan-500 hover:to-violet-500 hover:shadow-xl hover:shadow-violet-500/30 transition-all duration-200"
@click="handleNavigate('forward')"
>
{{ $t('applicationForms.navigation.next') }}
</UButton>
<UButton
v-if="!stepper?.hasNext"
trailing-icon="i-lucide-send-horizontal"
:disabled="disabled"
size="lg"
class="bg-gradient-to-br from-teal-500 to-cyan-500 text-white font-semibold rounded-xl shadow-lg shadow-teal-500/25 hover:from-cyan-500 hover:to-violet-500 hover:shadow-xl hover:shadow-violet-500/30 transition-all duration-200"
@click="emit('submit')"
>
{{ $t('applicationForms.navigation.submit') }}
</UButton>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { usePointerSwipe } from '@vueuse/core'
import type {
ApplicationFormDto,
FormElementSectionDto,
FormElementDto,
FormElementSubSectionDto
} from '~~/.api-client'
const props = defineProps<{
formElementSections: FormElementSectionDto[]
initialSectionIndex?: number
applicationFormId?: string
disabled?: boolean
}>()
const emit = defineEmits<{
save: []
submit: []
'add-input-form': [updatedForm: ApplicationFormDto | undefined]
'update:formElementSections': [sections: FormElementSectionDto[]]
navigate: [{ direction: 'forward' | 'backward'; index: number }]
}>()
const { stepper, activeStepperItemIndex, stepperItems, currentFormElementSection, navigateStepper } = useFormStepper(
computed(() => props.formElementSections)
)
const stepperScrollEl = ref<HTMLElement | null>(null)
const initialScrollLeft = ref(0)
const initialPointerX = ref(0)
const canScrollLeft = ref(false)
const canScrollRight = ref(false)
const hasOverflow = ref(false)
const { evaluateFormElementVisibility } = useFormElementVisibility()
const { clearHiddenFormElementValues } = useFormElementValueClearing()
const { processSpawnTriggers } = useSectionSpawning()
const { cloneElement } = useFormElementDuplication()
const { isSwiping } = usePointerSwipe(stepperScrollEl, {
threshold: 0,
disableTextSelect: true,
onSwipeStart: (e: PointerEvent) => {
// Capture initial scroll position and pointer position when drag starts
initialScrollLeft.value = stepperScrollEl.value?.scrollLeft ?? 0
initialPointerX.value = e.clientX
},
onSwipe: (e: PointerEvent) => {
// Calculate how far the pointer has moved from initial position
const deltaX = initialPointerX.value - e.clientX
// Update scroll position with direct 1:1 tracking
const el = stepperScrollEl.value
if (el) {
el.scrollLeft = initialScrollLeft.value + deltaX
}
}
})
const previousVisibilityMap = ref<Map<string, boolean>>(new Map())
const allFormElements = computed(() => {
const nonTemplateSections = props.formElementSections.filter((section) => section.isTemplate !== true)
return nonTemplateSections.flatMap(
(section) => section.formElementSubSections?.flatMap((subsection) => subsection.formElements) ?? []
)
})
const visibilityMap = computed(() => {
return evaluateFormElementVisibility(allFormElements.value)
})
const cursorClass = computed(
() =>
isSwiping.value
? 'cursor-grabbing select-none' // During drag: closed fist + no selection
: 'cursor-grab' // Ready to drag: open hand
)
const stepperUi = computed(() => ({
header: hasOverflow.value ? 'flex w-full flex-nowrap' : 'flex w-full flex-nowrap justify-center',
item: 'w-auto shrink-0 flex-[0_0_calc(100%/6)] min-w-[14rem]'
}))
const visibleSubsections = computed(() => {
if (!currentFormElementSection.value?.formElementSubSections) {
return []
}
return currentFormElementSection.value.formElementSubSections
.map((subsection) => ({ subsection, sectionIndex: currentSectionIndex.value }))
.filter(({ subsection }) => {
return subsection.formElements.some((element) => {
const key = element.id || element.reference
return key ? visibilityMap.value.get(key) !== false : true
})
})
})
const currentSectionIndex = computed(() => {
if (!currentFormElementSection.value) return -1
return props.formElementSections.indexOf(currentFormElementSection.value)
})
onMounted(() => {
if (props.initialSectionIndex !== undefined) {
activeStepperItemIndex.value = props.initialSectionIndex
}
previousVisibilityMap.value = visibilityMap.value
stepperScrollEl.value?.addEventListener('scroll', updateStepperOverflowIndicators, { passive: true })
updateStepperOverflowIndicators()
window.addEventListener('resize', scrollToActiveStep)
})
onUnmounted(() => {
stepperScrollEl.value?.removeEventListener('scroll', updateStepperOverflowIndicators)
window.removeEventListener('resize', scrollToActiveStep)
})
watch(
() => activeStepperItemIndex.value,
async (newIdx, oldIdx) => {
await nextTick()
scrollToActiveStep()
// Emit navigate event when stepper index changes (from direct clicks)
// Note: navigateStepper() emits via its onNavigate callback, so button clicks are covered
if (newIdx !== oldIdx && oldIdx !== undefined) {
emit('navigate', { direction: newIdx > oldIdx ? 'forward' : 'backward', index: newIdx })
}
}
)
watch(
() => stepperItems.value.length,
async () => {
await nextTick()
updateStepperOverflowIndicators()
}
)
function getActiveStepElement(): HTMLElement | null {
const root = stepperScrollEl.value
if (!root) return null
const selectors = [
// Nuxt UI / Reka often exposes state via data attributes
'[data-state="active"]',
'[data-active="true"]',
// ARIA patterns
'[aria-current="step"]',
'[aria-selected="true"]'
]
for (const selector of selectors) {
const el = root.querySelector(selector) as HTMLElement | null
if (!el) continue
// Prefer the list item (stable width) if present
const li = el.closest('li') as HTMLElement | null
return li ?? el
}
return null
}
function scrollToActiveStep() {
const scroller = stepperScrollEl.value
if (!scroller) return
const activeIndex = activeStepperItemIndex.value
if (activeIndex < 3) {
scroller.scrollTo({ left: 0, behavior: 'smooth' })
return
}
const activeEl = getActiveStepElement()
if (!activeEl) return
const scrollerRect = scroller.getBoundingClientRect()
const activeRect = activeEl.getBoundingClientRect()
const activeCenterInScroller = activeRect.left - scrollerRect.left + activeRect.width / 2 + scroller.scrollLeft
const targetScrollLeft = activeCenterInScroller - scroller.clientWidth / 2
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth)
const clamped = Math.max(0, Math.min(maxScrollLeft, targetScrollLeft))
scroller.scrollTo({ left: clamped, behavior: 'smooth' })
}
function updateStepperOverflowIndicators() {
const scroller = stepperScrollEl.value
if (!scroller) {
canScrollLeft.value = false
canScrollRight.value = false
hasOverflow.value = false
return
}
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth)
hasOverflow.value = maxScrollLeft > 0
canScrollLeft.value = scroller.scrollLeft > 1
canScrollRight.value = scroller.scrollLeft < maxScrollLeft - 1
}
function scrollStepperBy(direction: -1 | 1) {
const scroller = stepperScrollEl.value
if (!scroller) return
const delta = Math.max(200, Math.round(scroller.clientWidth * 0.6))
scroller.scrollBy({ left: direction * delta, behavior: 'smooth' })
}
async function handleAddInputForm(position: number, subsectionKey: string) {
const foundSubsection = findSubsectionByKey(subsectionKey)
if (!foundSubsection) return
const { addInputFormElement } = useFormElementManagement()
const updatedElements = addInputFormElement(foundSubsection.formElements, position)
const updatedSections = updateSubsectionElements(props.formElementSections, subsectionKey, updatedElements)
emit('update:formElementSections', updatedSections)
}
function findSubsectionByKey(subsectionKey: string): FormElementSubSectionDto | undefined {
for (let sectionIdx = 0; sectionIdx < props.formElementSections.length; sectionIdx++) {
const section = props.formElementSections[sectionIdx]
if (!section) continue
for (let i = 0; i < section.formElementSubSections.length; i++) {
const subsection = section.formElementSubSections[i]
if (subsection && getSubsectionKey(section, sectionIdx, subsection) === subsectionKey) {
return subsection
}
}
}
return undefined
}
async function handleNavigate(direction: 'forward' | 'backward') {
await navigateStepper(direction)
}
function handleCloneElement(element: FormElementDto, position: number, subsectionKey: string) {
const clonedElement = cloneElement(element, allFormElements.value)
const updatedSections = props.formElementSections.map((section, sectionIdx) => ({
...section,
formElementSubSections: section.formElementSubSections.map((subsection) => {
if (getSubsectionKey(section, sectionIdx, subsection) === subsectionKey) {
const newFormElements = [...subsection.formElements]
newFormElements.splice(position + 1, 0, clonedElement as FormElementDto)
return { ...subsection, formElements: newFormElements }
}
return subsection
})
}))
emit('update:formElementSections', updatedSections)
}
function handleFormElementUpdate(updatedFormElements: FormElementDto[], subsectionKey: string) {
let updatedSections = updateSubsectionElements(props.formElementSections, subsectionKey, updatedFormElements)
updatedSections = processSpawnTriggers(updatedSections, updatedFormElements)
updatedSections = clearNewlyHiddenFormElements(updatedSections)
emit('update:formElementSections', updatedSections)
}
function clearNewlyHiddenFormElements(sections: FormElementSectionDto[]): FormElementSectionDto[] {
// Only evaluate visibility for non-template sections to avoid duplicate reference issues
const nonTemplateSections = sections.filter((section) => section.isTemplate !== true)
const allElements = nonTemplateSections.flatMap(
(section) => section.formElementSubSections?.flatMap((subsection) => subsection.formElements) ?? []
)
const newVisibilityMap = evaluateFormElementVisibility(allElements)
// Only clear values in non-template sections, preserve template sections unchanged
const clearedNonTemplateSections = clearHiddenFormElementValues(
nonTemplateSections,
previousVisibilityMap.value,
newVisibilityMap
)
previousVisibilityMap.value = newVisibilityMap
// Create a map of cleared sections by their identity for lookup
const clearedSectionMap = new Map<FormElementSectionDto, FormElementSectionDto>()
nonTemplateSections.forEach((original, index) => {
clearedSectionMap.set(original, clearedNonTemplateSections[index]!)
})
// Preserve original order: replace non-template sections with cleared versions, keep templates unchanged
return sections.map((section) => {
if (section.isTemplate === true) {
return section
}
return clearedSectionMap.get(section) ?? section
})
}
function getSubsectionKey(
section: FormElementSectionDto | undefined,
sectionIndex: number,
subsection: FormElementSubSectionDto
): string {
if (subsection.id) {
return subsection.id
}
const spawnedFrom = section?.spawnedFromElementReference ?? 'root'
const templateRef = section?.templateReference ?? 'no_tmpl'
const title = subsection.title ?? ''
return `spawned:${spawnedFrom}|tmpl:${templateRef}|sec:${sectionIndex}|title:${title}`
}
function updateSubsectionElements(
sections: FormElementSectionDto[],
subsectionKey: string,
updatedFormElements: FormElementDto[]
): FormElementSectionDto[] {
return sections.map((section, sectionIdx) => ({
...section,
formElementSubSections: section.formElementSubSections.map((subsection) => {
if (getSubsectionKey(section, sectionIdx, subsection) === subsectionKey) {
return { ...subsection, formElements: updatedFormElements }
}
return subsection
})
}))
}
</script>
<style scoped>
.lch-stepper-scroll {
-webkit-overflow-scrolling: touch;
}
.lch-stepper-scroll::-webkit-scrollbar {
height: 0px;
}
.lch-stepper-scroll {
scrollbar-width: none;
}
</style>

View File

@@ -1,200 +0,0 @@
<template>
<div class="flex items-center gap-2">
<div class="relative flex items-center justify-center">
<svg class="shield-ring" :class="ringClass" width="48" height="48" viewBox="0 0 48 48">
<circle
class="ring-background"
cx="24"
cy="24"
r="20"
fill="none"
stroke="currentColor"
stroke-width="3"
opacity="0.1"
/>
<circle
class="ring-progress"
cx="24"
cy="24"
r="20"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="progressOffset"
transform="rotate(-90 24 24)"
/>
</svg>
<Transition name="shield-pulse" mode="out-in">
<UIcon :key="status" :name="shieldIcon" class="absolute shield-icon" :class="iconClass" />
</Transition>
</div>
<UBadge :label="statusLabel" :color="badgeColor" size="md" variant="subtle" class="status-badge" />
</div>
</template>
<script setup lang="ts">
import { ComplianceStatus } from '~~/.api-client'
const props = defineProps<{
status: ComplianceStatus
}>()
const circumference = 2 * Math.PI * 20
const shieldIcon = computed(() => {
switch (props.status) {
case ComplianceStatus.Critical:
return 'i-lucide-shield-alert'
case ComplianceStatus.Warning:
return 'i-lucide-shield-x'
case ComplianceStatus.NonCritical:
return 'i-lucide-shield-check'
default:
return 'i-lucide-shield-check'
}
})
const { t: $t } = useI18n()
const statusLabel = computed(() => {
switch (props.status) {
case ComplianceStatus.Critical:
return $t('compliance.critical')
case ComplianceStatus.Warning:
return $t('compliance.warning')
case ComplianceStatus.NonCritical:
return $t('compliance.nonCritical')
default:
return $t('compliance.nonCritical')
}
})
const badgeColor = computed(() => {
switch (props.status) {
case ComplianceStatus.Critical:
return 'error' as const
case ComplianceStatus.Warning:
return 'warning' as const
case ComplianceStatus.NonCritical:
return 'success' as const
default:
return 'success' as const
}
})
const ringClass = computed(() => {
switch (props.status) {
case ComplianceStatus.Critical:
return 'text-red-500'
case ComplianceStatus.Warning:
return 'text-yellow-500'
case ComplianceStatus.NonCritical:
return 'text-green-500'
default:
return 'text-green-500'
}
})
const iconClass = computed(() => {
switch (props.status) {
case ComplianceStatus.Critical:
return 'text-red-500 w-6 h-6'
case ComplianceStatus.Warning:
return 'text-yellow-500 w-6 h-6'
case ComplianceStatus.NonCritical:
return 'text-green-500 w-6 h-6'
default:
return 'text-green-500 w-6 h-6'
}
})
const progressValue = computed(() => {
switch (props.status) {
case ComplianceStatus.Critical:
return 100
case ComplianceStatus.Warning:
return 60
case ComplianceStatus.NonCritical:
return 30
default:
return 30
}
})
const progressOffset = computed(() => {
return circumference - (progressValue.value / 100) * circumference
})
watch(
() => props.status,
() => {
const element = document.querySelector('.shield-ring')
if (element) {
element.classList.add('status-change-pulse')
setTimeout(() => {
element.classList.remove('status-change-pulse')
}, 600)
}
}
)
</script>
<style scoped>
.shield-ring {
transition: color 300ms ease-in-out;
}
.ring-progress {
transition: stroke-dashoffset 500ms cubic-bezier(0.4, 0, 0.2, 1);
}
.shield-icon {
transition: color 300ms ease-in-out;
}
.status-badge {
transition: all 300ms ease-in-out;
}
@keyframes pulse-ring {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.status-change-pulse {
animation: pulse-ring 600ms ease-in-out;
}
.shield-pulse-enter-active,
.shield-pulse-leave-active {
transition: all 300ms ease-in-out;
}
.shield-pulse-enter-from {
opacity: 0;
transform: scale(0.8);
}
.shield-pulse-leave-to {
opacity: 0;
transform: scale(1.2);
}
.shield-icon {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
</style>

View File

@@ -1,147 +0,0 @@
<template>
<USlideover v-model:open="isOpen" :title="$t('notifications.title')">
<template #body>
<!-- Action buttons at the top -->
<div v-if="notifications.length > 0" class="flex gap-2 mb-4 -mt-2">
<UButton
:label="$t('notifications.markAllRead')"
icon="i-lucide-check-check"
color="neutral"
variant="outline"
size="sm"
:disabled="!hasUnreadNotifications"
@click="onMarkAllAsRead"
/>
<UButton
:label="$t('notifications.deleteAll')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
size="sm"
@click="onDeleteAll"
/>
</div>
<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>{{ $t('notifications.empty') }}</p>
</div>
<div
v-for="notification in notifications"
:key="notification.id"
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 group"
>
<NuxtLink
:to="notification.clickTarget"
class="flex items-center gap-3 flex-1"
@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>
<!-- Delete button for individual notification -->
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="xs"
square
class="opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
:aria-label="$t('notifications.delete')"
@click.stop.prevent="onDeleteNotification(notification.id)"
/>
</div>
</template>
</USlideover>
</template>
<script setup lang="ts">
import { formatTimeAgo } from '@vueuse/core'
import type { NotificationDto } from '~~/.api-client'
import { useNotificationStore } from '~~/stores/useNotificationStore'
const { t: $t } = useI18n()
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 notificationStore = useNotificationStore()
const { notifications } = storeToRefs(notificationStore)
const hasUnreadNotifications = computed(() => notifications.value.some((n) => !n.isRead))
watch(isOpen, async (newValue) => {
if (newValue) {
await notificationStore.fetchNotifications()
}
})
function onNotificationClick(notification: NotificationDto) {
notificationStore.handleNotificationClick(notification)
emit('update:modelValue', false)
}
async function onMarkAllAsRead() {
await notificationStore.markAllAsRead()
}
async function onDeleteAll() {
await notificationStore.deleteAllNotifications()
}
async function onDeleteNotification(notificationId: string) {
await notificationStore.deleteSingleNotification(notificationId)
}
</script>

View File

@@ -1,31 +0,0 @@
<template>
<UModal :open="open" :title="$t('versions.restoreTitle')" @update:open="$emit('update:open', $event)">
<template #body>
<div class="space-y-2">
<p>
{{ $t('versions.restoreConfirm', { number: versionNumber }) }}
</p>
<p class="text-sm text-gray-600">
{{ $t('versions.restoreDescription') }}
</p>
</div>
</template>
<template #footer>
<UButton :label="$t('common.cancel')" color="neutral" variant="outline" @click="$emit('update:open', false)" />
<UButton :label="$t('versions.restore')" color="primary" :loading="loading" @click="$emit('confirm')" />
</template>
</UModal>
</template>
<script setup lang="ts">
defineProps<{
open: boolean
versionNumber: number
loading?: boolean
}>()
defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'confirm'): void
}>()
</script>

View File

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

View File

@@ -1,356 +0,0 @@
<template>
<div class="mt-4 lg:mt-6">
<UCard variant="subtle" :ui="{ body: 'p-4 sm:p-5', header: 'p-4 sm:p-5', footer: 'p-4 sm:p-5' }">
<template #header>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
{{ $t('comments.title') }}
</p>
<p class="text-xs text-muted">
{{ $t('comments.count', { count: commentCount }) }}
</p>
</div>
<UButton color="neutral" variant="ghost" size="sm" icon="i-lucide-x" @click="$emit('close')" />
</div>
</template>
<div v-if="comments && comments.length > 0" class="relative">
<UProgress
v-if="isLoadingMore"
indeterminate
size="xs"
class="absolute top-0 inset-x-0 z-10"
:ui="{ base: 'bg-default' }"
/>
<UScrollArea ref="scrollAreaRef" class="max-h-96" :ui="{ viewport: 'space-y-4 pe-2' }">
<div v-for="comment in comments" :key="comment.id" class="space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<UAvatar icon="i-lucide-user" size="2xs" />
<div class="min-w-0">
<p class="text-sm text-highlighted truncate">
{{ comment.createdBy.name }}
</p>
<p class="text-xs text-muted">
{{ comment.createdAt ? formatDate(comment.createdAt) : '-' }}
</p>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<UDropdownMenu
v-if="isCommentByUser(comment)"
:items="[createCommentActions(comment)]"
:content="{ align: 'end' }"
>
<UButton icon="i-lucide-ellipsis" color="neutral" variant="ghost" size="xs" square />
</UDropdownMenu>
</div>
</div>
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
<!-- Edit comment form -->
<template v-if="editingCommentId === comment.id">
<UEditor
v-slot="{ editor }"
v-model="editingCommentEditorContent"
content-type="json"
:editable="true"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'min-h-[120px] p-3 bg-transparent'
}"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-default px-3 py-2 bg-default/50 overflow-x-auto"
/>
</UEditor>
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end p-3 border-t border-default">
<UButton color="neutral" variant="outline" @click="cancelEditComment">
{{ $t('common.cancel') }}
</UButton>
<UButton @click="saveEditedComment(comment)">
{{ $t('comments.saveChanges') }}
</UButton>
</div>
</template>
<!-- Display comment content when not editing -->
<UEditor
v-else
:key="`${comment.id}:${comment.modifiedAt?.toISOString?.() ?? ''}`"
:model-value="getCommentEditorModelValue(comment)"
content-type="json"
:editable="false"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'p-3 bg-transparent'
}"
/>
</div>
</div>
</UScrollArea>
</div>
<UEmpty v-else :title="$t('comments.empty')" icon="i-lucide-message-square" class="py-6" />
<template #footer>
<div class="space-y-3">
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
<UEditor
v-slot="{ editor }"
v-model="newCommentEditorContent"
content-type="json"
:editable="true"
:placeholder="$t('comments.placeholder')"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'min-h-[120px] p-3 bg-transparent'
}"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-default px-3 py-2 bg-transparent overflow-x-auto"
/>
</UEditor>
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end">
<UButton @click="submitComment(formElementId)">
{{ $t('comments.submit') }}
</UButton>
</div>
</div>
</template>
</UCard>
</div>
</template>
<script setup lang="ts">
import type { CommentDto } from '~~/.api-client'
import type { JSONContent } from '@tiptap/vue-3'
import type { DropdownMenuItem } from '@nuxt/ui'
import { useInfiniteScroll } from '@vueuse/core'
import { useCommentStore } from '~~/stores/useCommentStore'
import { useUserStore } from '~~/stores/useUserStore'
const props = defineProps<{
formElementId: string
applicationFormId: string
comments?: CommentDto[]
totalCount?: number
}>()
defineEmits<{
(e: 'close'): void
}>()
const commentStore = useCommentStore()
const { loadMore } = commentStore
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const toast = useToast()
const { t: $t } = useI18n()
const scrollAreaRef = useTemplateRef('scrollAreaRef')
const scrollContainerEl = ref<HTMLElement | null>(null)
const commentCount = computed(() => props.totalCount ?? props.comments?.length ?? 0)
const commentCursorSate = computed(
() => commentStore.nextCursorByApplicationFormId[props.applicationFormId]?.[props.formElementId]
)
const canLoadMore = computed(() => commentCursorSate.value?.hasMore === true)
const isLoadingMore = computed(() => commentCursorSate.value?.isLoading === true)
const newCommentValue = ref<string>('')
const newCommentEditorContent = computed<JSONContent>({
get: () => toEditorJson(newCommentValue.value),
set: (newValue) => {
newCommentValue.value = JSON.stringify(newValue)
}
})
const editingCommentId = ref<string | null>(null)
const editingCommentValue = ref<string>('')
const editingCommentEditorContent = computed<JSONContent>({
get: () => toEditorJson(editingCommentValue.value),
set: (newValue) => {
editingCommentValue.value = JSON.stringify(newValue)
}
})
watch(
() => scrollAreaRef.value,
async (scrollAreaComponent) => {
if (!scrollAreaComponent) {
scrollContainerEl.value = null
return
}
await nextTick()
const rootEl = scrollAreaComponent.$el as HTMLElement | undefined
if (!rootEl) return
scrollContainerEl.value = rootEl
// Wait another tick for content to be measured, then scroll to bottom
await nextTick()
// Use requestAnimationFrame to ensure layout is complete
requestAnimationFrame(() => {
rootEl.scrollTop = rootEl.scrollHeight
})
},
{ immediate: true }
)
useInfiniteScroll(
scrollContainerEl,
async () => {
const scrollEl = scrollContainerEl.value
if (!scrollEl) return
const previousScrollHeight = scrollEl.scrollHeight
await loadMore(props.applicationFormId, props.formElementId)
// Maintain scroll position after prepending older comments
await nextTick()
const newScrollHeight = scrollEl.scrollHeight
scrollEl.scrollTop = newScrollHeight - previousScrollHeight + scrollEl.scrollTop
},
{
direction: 'top',
distance: 100,
canLoadMore: () => canLoadMore.value && !isLoadingMore.value
}
)
async function submitComment(formElementId: string) {
if (!newCommentValue.value.trim()) {
return
}
try {
await commentStore.createComment(props.applicationFormId, formElementId, { message: newCommentValue.value })
newCommentValue.value = ''
toast.add({ title: $t('comments.created'), color: 'success' })
} catch {
toast.add({ title: $t('comments.createError'), color: 'error' })
}
}
function startEditComment(comment: CommentDto) {
editingCommentId.value = comment.id
editingCommentValue.value = comment.message || ''
}
function cancelEditComment() {
editingCommentId.value = null
editingCommentValue.value = ''
}
async function saveEditedComment(comment: CommentDto) {
try {
const updatedComment: CommentDto = {
...comment,
message: editingCommentValue.value,
modifiedAt: new Date()
}
await commentStore.updateComment(comment.id, updatedComment)
cancelEditComment()
toast.add({ title: $t('comments.updated'), color: 'success' })
} catch {
toast.add({ title: $t('comments.updateError'), color: 'error' })
}
}
function isCommentByUser(comment: CommentDto) {
return comment.createdBy.keycloakId === user.value?.keycloakId
}
function createCommentActions(comment: CommentDto): DropdownMenuItem[] {
return [
{
label: $t('comments.editAction'),
icon: 'i-lucide-pencil',
onClick: () => startEditComment(comment)
}
]
}
function getCommentEditorModelValue(comment: CommentDto): JSONContent {
return toEditorJson(comment.message)
}
function toEditorJson(rawValue: string | undefined): JSONContent {
const raw = (rawValue ?? '').trim()
if (raw) {
try {
if (raw.startsWith('{')) {
return JSON.parse(raw) as JSONContent
}
} catch {
// fall through to plain text
}
return wrapPlainTextAsDoc(raw)
}
return wrapPlainTextAsDoc('')
}
function wrapPlainTextAsDoc(text: string): JSONContent {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: text
? [
{
type: 'text',
text
}
]
: []
}
]
}
}
const toolbarItems = [
[
{ kind: 'undo', icon: 'i-lucide-undo' },
{ kind: 'redo', icon: 'i-lucide-redo' }
],
[
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', label: 'H1' },
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', label: 'H2' },
{ kind: 'heading', level: 3, icon: 'i-lucide-heading-3', label: 'H3' }
],
[
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' }
],
[
{ kind: 'bulletList', icon: 'i-lucide-list' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
],
[
{ kind: 'blockquote', icon: 'i-lucide-quote' },
{ kind: 'codeBlock', icon: 'i-lucide-code' }
],
[{ kind: 'link', icon: 'i-lucide-link' }]
]
</script>

View File

@@ -1,76 +0,0 @@
<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-elevated"
:ui="{
trailingIcon: 'text-(--ui-text-dimmed)'
}"
/>
</UDropdownMenu>
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
import { useUserStore } from '~~/stores/useUserStore'
defineProps<{
collapsed?: boolean
}>()
const userStore = useUserStore()
const { user: keyCloakUser } = storeToRefs(userStore)
const user = ref({
name: keyCloakUser.value?.name ?? 'UNKNOWN',
avatar: {
alt: keyCloakUser.value?.name ?? 'UNKNOWN'
}
})
const { t: $t } = useI18n()
const items = computed<DropdownMenuItem[][]>(() => [
[
{
type: 'label',
label: user.value.name,
avatar: user.value.avatar
}
],
[
{
label: $t('user.administration'),
icon: 'i-lucide-shield',
to: '/administration'
},
{
label: $t('user.settings'),
icon: 'i-lucide-settings',
to: '/settings'
}
],
[
{
label: $t('user.logout'),
icon: 'i-lucide-log-out',
async onSelect(e: Event) {
e.preventDefault()
await navigateTo('/auth/logout', { external: true })
}
}
]
])
</script>

View File

@@ -1,335 +0,0 @@
<template>
<UModal :open="open" :title="$t('versions.compare')" @update:open="$emit('update:open', $event)">
<template #header>
<h3 class="text-lg font-semibold">{{ $t('versions.comparisonTitle', { number: versionNumber }) }}</h3>
</template>
<template #body>
<div v-if="loading" class="flex justify-center py-8">
<UIcon name="i-lucide-loader-circle" class="animate-spin h-8 w-8" />
</div>
<div v-else-if="error" class="text-red-500">{{ $t('versions.comparisonError') }}: {{ error }}</div>
<div v-else-if="valueDiff && hasChanges" class="space-y-4">
<!-- Summary Alert -->
<UAlert
:title="$t('versions.changesSummary', { count: totalChanges })"
:icon="totalChanges > 0 ? 'i-lucide-file-diff' : 'i-lucide-check'"
color="info"
variant="subtle"
/>
<!-- Changes grouped by section -->
<UAccordion
v-if="sectionChanges.length > 0"
:items="accordionItems"
type="multiple"
:default-value="accordionItems.map((_, i) => String(i))"
>
<template #body="{ item }">
<div class="space-y-3">
<div
v-for="(change, changeIdx) in item.changes"
:key="changeIdx"
class="rounded-lg border border-default bg-elevated/50 p-3"
>
<!-- Element title with type indicator -->
<div class="flex items-center gap-2 mb-2">
<UIcon v-if="change.elementType === 'TABLE'" name="i-lucide-table" class="h-4 w-4 text-muted" />
<span class="font-medium text-sm">{{
change.elementTitle || $t('versions.elementWithoutTitle')
}}</span>
<!-- Change type badge -->
<UBadge v-if="isNewAnswer(change)" color="success" variant="subtle" size="xs">
{{ $t('versions.newAnswer') }}
</UBadge>
<UBadge v-else-if="isClearedAnswer(change)" color="warning" variant="subtle" size="xs">
{{ $t('versions.clearedAnswer') }}
</UBadge>
<UBadge v-else color="info" variant="subtle" size="xs">
{{ $t('versions.changedAnswer') }}
</UBadge>
</div>
<!-- Table diff display -->
<div v-if="change.elementType === 'TABLE' && change.tableDiff" class="mt-3">
<!-- Table summary -->
<div class="flex items-center gap-4 text-xs text-muted mb-2">
<span v-if="change.tableDiff.addedCount > 0" class="text-success">
+{{ change.tableDiff.addedCount }} {{ $t('versions.tableRowsAdded') }}
</span>
<span v-if="change.tableDiff.removedCount > 0" class="text-error">
-{{ change.tableDiff.removedCount }} {{ $t('versions.tableRowsRemoved') }}
</span>
<span v-if="change.tableDiff.modifiedCount > 0" class="text-warning">
~{{ change.tableDiff.modifiedCount }} {{ $t('versions.tableRowsModified') }}
</span>
</div>
<!-- Scrollable table container -->
<div class="overflow-x-auto border border-default rounded-lg">
<table class="min-w-full text-xs">
<thead class="bg-elevated">
<tr>
<th
class="px-3 py-2 text-left font-medium text-muted whitespace-nowrap left-0 bg-elevated z-10"
>
#
</th>
<th
class="px-3 py-2 text-left font-medium text-muted whitespace-nowrap left-8 bg-elevated z-10"
>
{{ $t('versions.tableStatus') }}
</th>
<th
v-for="col in change.tableDiff.columns"
:key="col"
class="px-3 py-2 text-left font-medium text-muted whitespace-nowrap"
>
{{ col }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<template v-for="row in change.tableDiff.rows" :key="row.rowIndex">
<!-- Show only changed rows (added, removed, modified) -->
<tr v-if="row.changeType !== 'unchanged'" :class="getRowClass(row.changeType)">
<td
class="px-3 py-2 text-muted whitespace-nowrap left-0 z-10"
:class="getRowBgClass(row.changeType)"
>
{{ row.rowIndex + 1 }}
</td>
<td class="px-3 py-2 whitespace-nowrap left-8 z-10" :class="getRowBgClass(row.changeType)">
<UBadge :color="getStatusBadgeColor(row.changeType)" variant="subtle" size="xs">
{{ getStatusLabel(row.changeType) }}
</UBadge>
</td>
<td v-for="col in change.tableDiff.columns" :key="col" class="px-3 py-2 whitespace-nowrap">
<template v-if="row.changeType === 'added'">
<span class="text-success">{{ row.currentValues[col] || '-' }}</span>
</template>
<template v-else-if="row.changeType === 'removed'">
<span class="text-error line-through">{{ row.previousValues[col] || '-' }}</span>
</template>
<template v-else-if="row.changeType === 'modified'">
<div v-if="row.previousValues[col] !== row.currentValues[col]" class="space-y-0.5">
<div class="text-error line-through text-[10px]">
{{ row.previousValues[col] || '-' }}
</div>
<div class="text-success">{{ row.currentValues[col] || '-' }}</div>
</div>
<span v-else class="text-muted">{{ row.currentValues[col] || '-' }}</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Non-table Before/After display -->
<div v-else class="space-y-2 text-sm">
<!-- Previous value -->
<div class="flex items-start gap-2">
<div class="flex items-center gap-1.5 text-muted min-w-[60px] shrink-0">
<UIcon name="i-lucide-circle" class="h-3 w-3 text-muted" />
<span>{{ $t('versions.before') }}:</span>
</div>
<span :class="change.previousLabel ? 'text-default' : 'text-muted italic'">
{{ change.previousLabel || $t('versions.noValue') }}
</span>
</div>
<!-- Arrow indicator -->
<div class="flex items-center gap-2 pl-1">
<UIcon name="i-lucide-arrow-down" class="h-3 w-3 text-muted" />
</div>
<!-- Current value -->
<div class="flex items-start gap-2">
<div class="flex items-center gap-1.5 text-primary min-w-[60px] shrink-0">
<UIcon name="i-lucide-circle-dot" class="h-3 w-3" />
<span>{{ $t('versions.after') }}:</span>
</div>
<span :class="change.currentLabel ? 'text-default font-medium' : 'text-muted italic'">
{{ change.currentLabel || $t('versions.noValue') }}
</span>
</div>
</div>
</div>
</div>
</template>
</UAccordion>
</div>
<div v-else class="text-center py-8">
<UIcon name="i-lucide-check-circle" class="h-12 w-12 text-success mx-auto mb-3" />
<p class="text-muted">{{ $t('versions.noChanges') }}</p>
</div>
</template>
<template #footer>
<div class="flex justify-end">
<UButton :label="$t('common.close')" color="neutral" variant="outline" @click="$emit('update:open', false)" />
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import type { AccordionItem } from '@nuxt/ui'
import type { ApplicationFormDto, ApplicationFormVersionDto } from '~~/.api-client'
import type { FormValueDiff, ValueChange, SectionChanges, TableRowDiff } from '~~/types/formSnapshotComparison'
import { compareApplicationFormValues, groupChangesBySection } from '~/utils/formSnapshotComparison'
const props = defineProps<{
open: boolean
currentForm: ApplicationFormDto
versionNumber: number
applicationFormId: string
}>()
defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const { getVersion } = useApplicationFormVersion()
const { t: $t } = useI18n()
const loading = ref(false)
const error = ref<string | null>(null)
const valueDiff = ref<FormValueDiff | null>(null)
const versionData = ref<ApplicationFormVersionDto | null>(null)
const sectionChanges = computed<SectionChanges[]>(() => {
if (!valueDiff.value) return []
return groupChangesBySection(valueDiff.value)
})
const totalChanges = computed(() => {
if (!valueDiff.value) return 0
return (
valueDiff.value.newAnswers.length + valueDiff.value.changedAnswers.length + valueDiff.value.clearedAnswers.length
)
})
const hasChanges = computed(() => totalChanges.value > 0)
interface AccordionItemWithChanges extends AccordionItem {
changes: ValueChange[]
}
const accordionItems = computed<AccordionItemWithChanges[]>(() => {
return sectionChanges.value.map((section, index) => ({
label: `${section.sectionTitle} (${$t('versions.changesCount', { count: section.changes.length })})`,
icon: 'i-lucide-folder',
value: String(index),
changes: section.changes
}))
})
function isNewAnswer(change: ValueChange): boolean {
return !change.previousLabel && !!change.currentLabel
}
function isClearedAnswer(change: ValueChange): boolean {
return !!change.previousLabel && !change.currentLabel
}
function getRowClass(changeType: TableRowDiff['changeType']): string {
switch (changeType) {
case 'added':
return 'bg-success/5'
case 'removed':
return 'bg-error/5'
case 'modified':
return 'bg-warning/5'
default:
return ''
}
}
function getRowBgClass(changeType: TableRowDiff['changeType']): string {
switch (changeType) {
case 'added':
return 'bg-success/10'
case 'removed':
return 'bg-error/10'
case 'modified':
return 'bg-warning/10'
default:
return 'bg-elevated'
}
}
function getStatusBadgeColor(changeType: TableRowDiff['changeType']): 'success' | 'error' | 'warning' | 'neutral' {
switch (changeType) {
case 'added':
return 'success'
case 'removed':
return 'error'
case 'modified':
return 'warning'
default:
return 'neutral'
}
}
function getStatusLabel(changeType: TableRowDiff['changeType']): string {
switch (changeType) {
case 'added':
return $t('versions.rowAdded')
case 'removed':
return $t('versions.rowRemoved')
case 'modified':
return $t('versions.rowModified')
default:
return ''
}
}
// Track which version was loaded to detect when we need to reload
const loadedVersionNumber = ref<number | null>(null)
watch(
() => props.open,
async (isOpen) => {
if (isOpen) {
// Always load if version changed or no data loaded yet
if (loadedVersionNumber.value !== props.versionNumber || !versionData.value) {
await loadVersionAndCompare()
}
}
},
{ immediate: true }
)
// Also reload when version number changes while modal is open
watch(
() => props.versionNumber,
async (newVersion, oldVersion) => {
if (newVersion !== oldVersion && props.open) {
await loadVersionAndCompare()
}
}
)
async function loadVersionAndCompare() {
loading.value = true
error.value = null
valueDiff.value = null // Reset diff while loading
try {
versionData.value = await getVersion(props.applicationFormId, props.versionNumber)
valueDiff.value = compareApplicationFormValues(props.currentForm, versionData.value.snapshot)
loadedVersionNumber.value = props.versionNumber
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : $t('versions.unknownError')
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,187 +0,0 @@
<template>
<div class="space-y-4">
<div v-if="loading" class="flex justify-center py-8">
<UIcon name="i-lucide-loader-circle" class="animate-spin h-8 w-8" />
</div>
<div v-else-if="error" class="text-red-500">{{ $t('versions.loadError') }}: {{ error }}</div>
<div v-else-if="versions.length === 0" class="text-center py-8 text-gray-500">{{ $t('versions.empty') }}</div>
<div v-else class="space-y-3">
<UCard v-for="version in versions" :key="version.id" class="hover:shadow-md transition-shadow">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<UBadge variant="subtle" color="primary" class="font-semibold"> v{{ version.versionNumber }} </UBadge>
<div>
<div class="font-medium">{{ version.name }}</div>
<div class="text-sm text-gray-500">
{{ formatDate(new Date(version.createdAt)) }} · {{ version.createdBy.name }}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<UBadge :color="getStatusColor(version.status)">
{{ getStatusLabel(version.status) }}
</UBadge>
<UButton
icon="i-lucide-file-text"
size="sm"
color="neutral"
variant="outline"
:label="$t('versions.openPdf')"
:to="`/api/application-forms/${applicationFormId}/versions/${version.versionNumber}/pdf`"
target="_blank"
/>
<UButton
icon="i-lucide-git-compare"
size="sm"
color="neutral"
variant="outline"
:label="$t('versions.compare')"
@click="openComparisonModal(version)"
/>
<UButton
icon="i-lucide-undo-2"
size="sm"
color="neutral"
variant="outline"
:label="$t('versions.restore')"
@click="openRestoreModal(version)"
/>
</div>
</div>
</UCard>
</div>
<VersionComparisonModal
v-model:open="isComparisonModalOpen"
:current-form="currentForm"
:version-number="selectedVersion?.versionNumber ?? 0"
:application-form-id="applicationFormId"
/>
<RestoreVersionModal
v-model:open="isRestoreModalOpen"
:version-number="selectedVersion?.versionNumber ?? 0"
:loading="restoring"
@confirm="handleRestore"
/>
</div>
</template>
<script setup lang="ts">
import type { ApplicationFormDto, ApplicationFormVersionListItemDto, ApplicationFormStatus } from '~~/.api-client'
import { formatDate } from '~/utils/date'
const props = defineProps<{
applicationFormId: string
currentForm: ApplicationFormDto
}>()
const emit = defineEmits<{
(e: 'restored'): void
}>()
const { getVersions, restoreVersion } = useApplicationFormVersion()
const toast = useToast()
const { t: $t } = useI18n()
const versions = ref<ApplicationFormVersionListItemDto[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const isRestoreModalOpen = ref(false)
const isComparisonModalOpen = ref(false)
const selectedVersion = ref<ApplicationFormVersionListItemDto | null>(null)
const restoring = ref(false)
async function loadVersions() {
loading.value = true
error.value = null
try {
versions.value = await getVersions(props.applicationFormId)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : $t('versions.unknownError')
toast.add({
title: $t('common.error'),
description: $t('versions.loadErrorDescription'),
color: 'error'
})
} finally {
loading.value = false
}
}
function openComparisonModal(version: ApplicationFormVersionListItemDto) {
selectedVersion.value = version
isComparisonModalOpen.value = true
}
function openRestoreModal(version: ApplicationFormVersionListItemDto) {
selectedVersion.value = version
isRestoreModalOpen.value = true
}
async function handleRestore() {
if (!selectedVersion.value) return
restoring.value = true
try {
await restoreVersion(props.applicationFormId, selectedVersion.value.versionNumber)
toast.add({
title: $t('common.success'),
description: $t('versions.restored', { number: selectedVersion.value.versionNumber }),
color: 'success'
})
isRestoreModalOpen.value = false
await loadVersions()
emit('restored')
} catch {
toast.add({
title: $t('common.error'),
description: $t('versions.restoreError'),
color: 'error'
})
} finally {
restoring.value = false
}
}
function getStatusColor(status: ApplicationFormStatus) {
switch (status) {
case 'DRAFT':
return 'neutral' as const
case 'SUBMITTED':
return 'info' as const
case 'APPROVED':
return 'success' as const
case 'REJECTED':
return 'error' as const
case 'SIGNED':
return 'primary' as const
default:
return 'neutral' as const
}
}
function getStatusLabel(status: ApplicationFormStatus): string {
switch (status) {
case 'DRAFT':
return $t('applicationForms.status.draft')
case 'SUBMITTED':
return $t('applicationForms.status.submitted')
case 'APPROVED':
return $t('applicationForms.status.approved')
case 'REJECTED':
return $t('applicationForms.status.rejected')
case 'SIGNED':
return $t('applicationForms.status.signed')
default:
return status
}
}
onMounted(() => {
loadVersions()
})
</script>

View File

@@ -1,50 +0,0 @@
<template>
<UCheckboxGroup v-model="modelValue" :items="items" />
</template>
<script setup lang="ts">
import type { FormElementDto, FormOptionDto } from '~~/.api-client'
import { useFormElementVisibility } from '~/composables/useFormElementVisibility'
const props = defineProps<{
formOptions: FormOptionDto[]
allFormElements?: FormElementDto[]
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const { isFormOptionVisible } = useFormElementVisibility()
const visibleOptions = computed(() => {
if (!props.allFormElements) return props.formOptions
return props.formOptions.filter((opt) => isFormOptionVisible(opt.visibilityConditions, props.allFormElements!))
})
// Auto-clear hidden options that are still selected
watchEffect(() => {
if (!props.allFormElements) return
const hiddenSelected = props.formOptions.filter(
(opt) => opt.value === 'true' && !isFormOptionVisible(opt.visibilityConditions, props.allFormElements!)
)
if (hiddenSelected.length === 0) return
emit(
'update:formOptions',
props.formOptions.map((opt) => (hiddenSelected.includes(opt) ? { ...opt, value: 'false' } : opt))
)
})
const items = computed(() => visibleOptions.value.map((option) => ({ label: option.label, value: option.label })))
const modelValue = computed({
get: () => props.formOptions.filter((option) => option.value === 'true').map((option) => option.label),
set: (selectedLabels: string[]) => {
const updatedModelValue = props.formOptions.map((option) => ({
...option,
value: selectedLabels.includes(option.label) ? 'true' : 'false'
}))
emit('update:formOptions', updatedModelValue)
}
})
</script>

View File

@@ -1,62 +0,0 @@
<template>
<UFormField :label="label">
<UInputDate ref="inputDateRef" v-model="dateValue" :disabled="disabled">
<template #trailing>
<UPopover :reference="inputDateRef?.inputsRef[3]?.$el">
<UButton
color="neutral"
variant="link"
size="sm"
icon="i-lucide-calendar"
:aria-label="$t('applicationForms.formElements.selectDate')"
class="px-0"
/>
<template #content>
<UCalendar v-model="dateValue" class="p-2" />
</template>
</UPopover>
</template>
</UInputDate>
</UFormField>
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~~/.api-client'
import type { CalendarDate } from '@internationalized/date'
import { parseDate } from '@internationalized/date'
const props = defineProps<{
label?: string
formOptions: FormOptionDto[]
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const inputDateRef = useTemplateRef('inputDateRef')
const dateValue = computed({
get: () => {
const value = props.formOptions[0]?.value ?? ''
if (!value) return null
try {
return parseDate(value)
} catch {
return null
}
},
set: (val: CalendarDate | null) => {
const firstOption = props.formOptions[0]
if (firstOption) {
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = {
...firstOption,
value: val ? val.toString() : ''
}
emit('update:formOptions', updatedModelValue)
}
}
})
</script>

View File

@@ -1,119 +0,0 @@
<template>
<div class="bg-white dark:bg-white rounded-md border border-gray-200 dark:border-gray-200 overflow-hidden">
<UEditor
v-slot="{ editor }"
v-model="editorContent"
content-type="json"
:editable="!props.disabled"
:placeholder="t('applicationForms.formElements.richTextPlaceholder')"
:ui="{
content: 'bg-white dark:bg-white',
base: 'min-h-[200px] p-3 bg-white dark:bg-white'
}"
class="w-full"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-muted sticky top-0 inset-x-0 px-3 py-2 z-50 bg-default overflow-x-auto"
/>
<UEditorSuggestionMenu :editor="editor" :items="suggestionItems" />
<UEditorMentionMenu :editor="editor" :items="mentionItems" />
<UEditorDragHandle :editor="editor" />
</UEditor>
</div>
</template>
<script setup lang="ts">
import { EmployeeDataCategory, ProcessingPurpose, type FormOptionDto } from '~~/.api-client'
import type { JSONContent } from '@tiptap/vue-3'
const { t } = useI18n()
const props = defineProps<{
formOptions: FormOptionDto[]
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const editorContent = computed({
get: () => {
const rawValue = props.formOptions[0]?.value ?? ''
if (rawValue.trim().startsWith('{')) {
try {
return JSON.parse(rawValue) as JSONContent
} catch {
return rawValue // Fallback to HTML if JSON parse fails
}
}
return rawValue // Treat as HTML
},
set: (newValue: string | JSONContent) => {
const updatedOptions: FormOptionDto[] = [...props.formOptions]
const stringifiedValue = typeof newValue === 'string' ? newValue : JSON.stringify(newValue)
if (updatedOptions.length === 0) {
const createdOption: FormOptionDto = {
value: stringifiedValue,
label: '',
processingPurpose: ProcessingPurpose.None,
employeeDataCategory: EmployeeDataCategory.None
}
updatedOptions.push(createdOption)
} else {
const firstOption = updatedOptions[0]!
updatedOptions[0] = { ...firstOption, value: stringifiedValue }
}
emit('update:formOptions', updatedOptions)
}
})
const toolbarItems = [
[
{ kind: 'undo', icon: 'i-lucide-undo' },
{ kind: 'redo', icon: 'i-lucide-redo' }
],
[
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', label: 'H1' },
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', label: 'H2' },
{ kind: 'heading', level: 3, icon: 'i-lucide-heading-3', label: 'H3' }
],
[
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' }
],
[
{ kind: 'bulletList', icon: 'i-lucide-list' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
],
[
{ kind: 'blockquote', icon: 'i-lucide-quote' },
{ kind: 'codeBlock', icon: 'i-lucide-code' }
],
[{ kind: 'link', icon: 'i-lucide-link' }]
]
const suggestionItems = [
[
{ kind: 'heading', level: 1, label: 'Überschrift 1', icon: 'i-lucide-heading-1' },
{ kind: 'heading', level: 2, label: 'Überschrift 2', icon: 'i-lucide-heading-2' },
{ kind: 'heading', level: 3, label: 'Überschrift 3', icon: 'i-lucide-heading-3' }
],
[
{ kind: 'bulletList', label: 'Aufzählung', icon: 'i-lucide-list' },
{ kind: 'orderedList', label: 'Nummerierung', icon: 'i-lucide-list-ordered' }
],
[
{ kind: 'blockquote', label: 'Zitat', icon: 'i-lucide-quote' },
{ kind: 'codeBlock', label: 'Code Block', icon: 'i-lucide-code' },
{ kind: 'horizontalRule', label: 'Trennlinie', icon: 'i-lucide-separator-horizontal' }
]
]
const mentionItems: Array<{ label: string; avatar?: { src: string } }> = []
</script>

View File

@@ -1,234 +0,0 @@
<template>
<div class="space-y-4">
<!-- Upload Area -->
<div>
<UFileUpload
v-model="selectedFiles"
:accept="allowedFileTypes"
:multiple="true"
:disabled="isUploading || disabled"
:label="t('applicationForms.formElements.fileUpload.label')"
:description="t('applicationForms.formElements.fileUpload.allowedTypes')"
variant="area"
layout="list"
position="inside"
@change="handleFileSelect"
/>
</div>
<!-- Upload Progress -->
<div v-if="isUploading" class="space-y-2">
<UProgress :value="uploadProgress" />
<p class="text-sm text-gray-600">
{{ t('applicationForms.formElements.fileUpload.uploading') }}
</p>
</div>
<!-- Error Message -->
<UAlert
v-if="errorMessage"
color="error"
variant="soft"
:title="t('applicationForms.formElements.fileUpload.uploadError')"
:description="errorMessage"
:close-button="{ icon: 'i-ph-x', color: 'red', variant: 'link' }"
@close="errorMessage = ''"
/>
<!-- Uploaded Files List -->
<div v-if="uploadedFiles.length > 0" class="space-y-2">
<p class="text-sm font-medium">
{{ t('applicationForms.formElements.fileUpload.uploadedFiles') }}
</p>
<div class="space-y-2">
<div
v-for="file in uploadedFiles"
:key="file.fileId"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<UIcon :name="getFileIcon(file.mimeType)" class="text-xl flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ file.filename }}</p>
<p class="text-xs text-gray-500">{{ formatFileSize(file.size) }}</p>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<UButton
v-if="isViewableInBrowser(file.mimeType)"
icon="i-ph-eye"
color="neutral"
variant="ghost"
size="sm"
:title="t('applicationForms.formElements.fileUpload.view')"
@click="viewFile(file.fileId)"
/>
<UButton
icon="i-ph-download"
color="info"
variant="ghost"
size="sm"
:disabled="isDownloading"
@click="downloadFile(file.fileId, file.filename)"
/>
<UButton
icon="i-ph-trash"
color="error"
variant="ghost"
size="sm"
:disabled="disabled || isDeleting"
@click="deleteFile(file.fileId)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EmployeeDataCategory, type FormOptionDto, ProcessingPurpose } from '~~/.api-client'
import { useFile, type UploadedFileMetadata } from '~/composables/file/useFile'
import { useUserStore } from '~~/stores/useUserStore'
const props = defineProps<{
formOptions: FormOptionDto[]
disabled?: boolean
applicationFormId?: string
formElementReference?: string
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const { t } = useI18n()
const {
uploadFile: uploadFileApi,
downloadFile: downloadFileApi,
viewFile: viewFileApi,
deleteFile: deleteFileApi,
parseUploadedFiles,
createFileMetadata,
getFileIcon,
formatFileSize,
isViewableInBrowser
} = useFile()
const isUploading = ref(false)
const isDownloading = ref(false)
const isDeleting = ref(false)
const uploadProgress = ref(0)
const errorMessage = ref('')
const selectedFiles = ref<File[] | null>(null)
const allowedFileTypes = '.pdf,.docx,.doc,.odt,.jpg,.jpeg,.png,.zip'
// Parse uploaded files from formOptions
const uploadedFiles = computed<UploadedFileMetadata[]>(() => {
const values = props.formOptions.map((option) => option.value)
return parseUploadedFiles(values)
})
const userStore = useUserStore()
const organizationId = computed(() => userStore.selectedOrganization?.id)
const handleFileSelect = async () => {
if (!selectedFiles.value || selectedFiles.value.length === 0) return
const files = Array.isArray(selectedFiles.value) ? selectedFiles.value : [selectedFiles.value]
errorMessage.value = ''
for (const file of files) {
const maxFileSize = 10 * 1024 * 1024 // 10 MB
if (file.size > maxFileSize) {
errorMessage.value = t('applicationForms.formElements.fileUpload.fileTooLarge', {
filename: file.name,
maxSize: formatFileSize(maxFileSize)
})
continue
}
await uploadFile(file)
}
selectedFiles.value = null
}
const uploadFile = async (file: File) => {
if (!props.formElementReference) {
errorMessage.value = 'Missing required context: formElementReference'
return
}
isUploading.value = true
uploadProgress.value = 0
try {
const response = await uploadFileApi({
file,
applicationFormId: props.applicationFormId,
formElementReference: props.formElementReference,
organizationId: organizationId.value
})
const metadata = createFileMetadata(response)
const newOption: FormOptionDto = {
value: JSON.stringify(metadata),
label: props.formElementReference,
processingPurpose: props.formOptions[0]?.processingPurpose ?? ProcessingPurpose.None,
employeeDataCategory: props.formOptions[0]?.employeeDataCategory ?? EmployeeDataCategory.None
}
const updatedOptions = [...props.formOptions, newOption]
emit('update:formOptions', updatedOptions)
uploadProgress.value = 100
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
const downloadFile = async (fileId: string, filename: string) => {
isDownloading.value = true
try {
await downloadFileApi(fileId, filename)
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isDownloading.value = false
}
}
const viewFile = (fileId: string) => {
viewFileApi(fileId)
}
const deleteFile = async (fileId: string) => {
if (!confirm(t('common.confirmDelete'))) return
isDeleting.value = true
try {
await deleteFileApi(fileId)
// Remove from formOptions
const updatedOptions = props.formOptions.filter((option) => {
try {
const metadata = JSON.parse(option.value) as UploadedFileMetadata
return metadata.fileId !== fileId
} catch {
return true
}
})
emit('update:formOptions', updatedOptions)
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isDeleting.value = false
}
}
</script>

View File

@@ -1,30 +0,0 @@
<template>
<UFormField :label="label">
<UInput v-model="modelValue" class="w-full" />
</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) => {
const firstOption = props.formOptions[0]
if (val && firstOption) {
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...firstOption, value: val.toString() }
emit('update:formOptions', updatedModelValue)
}
}
})
</script>

View File

@@ -1,54 +0,0 @@
<template>
<URadioGroup v-model="modelValue" :items="items" />
</template>
<script setup lang="ts">
import type { FormElementDto, FormOptionDto } from '~~/.api-client'
import { useFormElementVisibility } from '~/composables/useFormElementVisibility'
const props = defineProps<{
label?: string
formOptions: FormOptionDto[]
allFormElements?: FormElementDto[]
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const { isFormOptionVisible } = useFormElementVisibility()
const visibleOptions = computed(() => {
if (!props.allFormElements) return props.formOptions
return props.formOptions.filter((opt) => isFormOptionVisible(opt.visibilityConditions, props.allFormElements!))
})
// Auto-clear selected option if it becomes hidden
watchEffect(() => {
if (!props.allFormElements) return
const selectedOption = props.formOptions.find((opt) => opt.value === 'true')
if (!selectedOption) return
if (!isFormOptionVisible(selectedOption.visibilityConditions, props.allFormElements!)) {
emit(
'update:formOptions',
props.formOptions.map((opt) => ({ ...opt, value: 'false' }))
)
}
})
// Our "label" is the "value" of the radio button
const items = computed(() => visibleOptions.value.map((option) => ({ label: option.label, value: option.label })))
const modelValue = computed({
get: () => props.formOptions.find((option) => option.value === 'true')?.label,
set: (val) => {
if (val) {
const updatedModelValue = [...props.formOptions]
updatedModelValue.forEach((option) => {
option.value = option.label === val ? 'true' : 'false'
})
emit('update:formOptions', updatedModelValue)
}
}
})
</script>

View File

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

View File

@@ -1,29 +0,0 @@
<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) => {
const firstOption = props.formOptions[0]
if (firstOption) {
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...firstOption, value: val.toString() }
emit('update:formOptions', updatedModelValue)
}
}
})
const label = computed(() => props.formOptions[0]?.label ?? '')
</script>

View File

@@ -1,412 +0,0 @@
<template>
<div class="space-y-3">
<div class="flex justify-end">
<UTooltip :text="$t('applicationForms.formElements.table.enlargeTable')">
<UButton
icon="i-lucide-maximize-2"
color="neutral"
variant="ghost"
size="xs"
:aria-label="$t('applicationForms.formElements.table.enlargeTable')"
@click="fullscreenOpen = true"
/>
</UTooltip>
</div>
<!-- Regular table -->
<TheTableContent
:table-data="tableData"
:table-columns="tableColumns"
:data-columns="dataColumns"
:form-options="formOptions"
:disabled="disabled"
:can-modify-rows="canModifyRows"
:get-column-options="getColumnOptions"
:read-only-column-indices="readOnlyColumnIndices"
:is-cell-visible="isCellVisible"
@update:cell="updateCell"
@update:cell-value="updateCellValue"
@update:checkbox-cell="updateCheckboxCell"
@add-row="addRow"
@remove-row="removeRow"
/>
<!-- Fullscreen Modal -->
<UModal
v-model:open="fullscreenOpen"
:title="$t('applicationForms.formElements.table.enlargeTable')"
class="min-w-3/4"
>
<template #content>
<div class="flex flex-col h-full">
<!-- Modal header -->
<div class="flex items-center justify-between p-4 border-b border-default">
<h2 class="text-lg font-semibold">{{ $t('applicationForms.formElements.table.editTable') }}</h2>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
:aria-label="$t('common.close')"
@click="fullscreenOpen = false"
/>
</div>
<!-- Modal body with full-width table -->
<div class="flex-1 overflow-auto p-4">
<TheTableContent
:table-data="tableData"
:table-columns="tableColumns"
:data-columns="dataColumns"
:form-options="formOptions"
:disabled="disabled"
:can-modify-rows="canModifyRows"
:get-column-options="getColumnOptions"
:read-only-column-indices="readOnlyColumnIndices"
:is-cell-visible="isCellVisible"
add-row-button-class="mt-4"
@update:cell="updateCell"
@update:cell-value="updateCellValue"
@update:checkbox-cell="updateCheckboxCell"
@add-row="addRow"
@remove-row="removeRow"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
import type { FormElementDto, FormOptionDto, TableRowPresetDto } from '~~/.api-client'
import type { TableColumn } from '@nuxt/ui'
import { useTableCrossReferences } from '~/composables/useTableCrossReferences'
import { useFormElementVisibility } from '~/composables/useFormElementVisibility'
const props = defineProps<{
formOptions: FormOptionDto[]
disabled?: boolean
allFormElements?: FormElementDto[]
tableRowPreset?: TableRowPresetDto
}>()
const { isFormOptionVisible } = useFormElementVisibility()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const { getReferencedColumnValues, getConstrainedColumnValues, applyRowPresets } = useTableCrossReferences()
const fullscreenOpen = ref(false)
const canModifyRows = computed(() => {
if (!props.tableRowPreset) return true
return props.tableRowPreset.canAddRows !== false
})
// Watch for changes in source table and apply row presets reactively
const sourceTableOptions = computed(() => {
if (!props.tableRowPreset?.sourceTableReference || !props.allFormElements) return null
const sourceTable = props.allFormElements.find(
(el) => el.reference === props.tableRowPreset?.sourceTableReference && el.type === 'TABLE'
)
return sourceTable?.options
})
watch(
sourceTableOptions,
() => {
if (!sourceTableOptions.value || !props.tableRowPreset || !props.allFormElements) return
const updatedOptions = applyRowPresets(props.tableRowPreset, props.formOptions, props.allFormElements)
const hasChanges = updatedOptions.some((opt, idx) => opt.value !== props.formOptions[idx]?.value)
if (hasChanges) {
emit('update:formOptions', updatedOptions)
}
},
{ immediate: true, deep: true }
)
type CellValue = string | string[] | boolean
type TableRowData = Record<string, CellValue>
interface DataColumn {
key: string
colIndex: number
}
// Filter columns based on visibility conditions
interface VisibleColumn {
option: FormOptionDto
originalIndex: number
}
const visibleColumns = computed<VisibleColumn[]>(() => {
return props.formOptions
.map((option, index) => ({ option, originalIndex: index }))
.filter(({ option }) => {
if (!option.visibilityConditions || !props.allFormElements) {
return true
}
return isFormOptionVisible(option.visibilityConditions, props.allFormElements)
})
})
const readOnlyColumnIndices = computed<Set<number>>(() => {
if (!props.allFormElements) return new Set()
return new Set(
props.formOptions
.map((option, index) => ({ option, index }))
.filter(({ option }) => {
const conditions = option.columnConfig?.readOnlyConditions
return conditions && isFormOptionVisible(conditions, props.allFormElements!)
})
.map(({ index }) => index)
)
})
// When columns become read-only, reset their values to the configured default
watch(
readOnlyColumnIndices,
(currentSet, previousSet) => {
const newlyReadOnlyIndices = [...currentSet].filter((i) => !previousSet?.has(i))
if (newlyReadOnlyIndices.length === 0) return
const updatedOptions = props.formOptions.map((option, colIndex) => {
if (!newlyReadOnlyIndices.includes(colIndex)) return option
const columnValues = parseColumnValues(option.value)
const defaultValue = isColumnCheckbox(colIndex) ? false : (option.columnConfig?.readOnlyDefaultValue ?? '')
const newValue = JSON.stringify(columnValues.map(() => defaultValue))
return newValue !== option.value ? { ...option, value: newValue } : option
})
if (updatedOptions.some((opt, i) => opt !== props.formOptions[i])) {
emit('update:formOptions', updatedOptions)
}
},
{ immediate: true }
)
const dataColumns = computed<DataColumn[]>(() =>
visibleColumns.value.map(({ originalIndex }) => ({
key: `col_${originalIndex}`,
colIndex: originalIndex
}))
)
const tableColumns = computed<TableColumn<TableRowData>[]>(() => {
const columns: TableColumn<TableRowData>[] = visibleColumns.value.map(({ option, originalIndex }) => ({
accessorKey: `col_${originalIndex}`,
header: option.label || ''
}))
// Only show actions column if not disabled AND rows can be modified
if (!props.disabled && canModifyRows.value) {
columns.push({
id: 'actions',
header: ''
})
}
return columns
})
const tableData = computed<TableRowData[]>(() => {
if (props.formOptions.length === 0) return []
const columnData: CellValue[][] = props.formOptions.map((option, colIndex) => {
const parsed = parseColumnValues(option.value)
// Normalize cell values based on column type
if (isColumnMultipleAllowed(colIndex)) {
return parsed.map((val) => (Array.isArray(val) ? val : []))
}
if (isColumnCheckbox(colIndex)) {
return parsed.map((val) => val === true)
}
return parsed
})
const rowCount = Math.max(...columnData.map((col) => col.length), 0)
const rows: TableRowData[] = []
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row: TableRowData = {}
props.formOptions.forEach((_, colIndex) => {
const cellValue = columnData[colIndex]?.[rowIndex]
if (isColumnMultipleAllowed(colIndex)) {
row[`col_${colIndex}`] = Array.isArray(cellValue) ? cellValue : []
} else if (isColumnCheckbox(colIndex)) {
row[`col_${colIndex}`] = cellValue === true
} else {
row[`col_${colIndex}`] = typeof cellValue === 'string' ? cellValue : ''
}
})
rows.push(row)
}
return rows
})
function isColumnMultipleAllowed(colIndex: number): boolean {
const option = props.formOptions[colIndex]
return option?.columnConfig?.isMultipleAllowed === true
}
function isColumnCheckbox(colIndex: number): boolean {
const option = props.formOptions[colIndex]
return option?.columnConfig?.isCheckbox === true
}
function getColumnOptions(colIndex: number, currentRowData?: TableRowData): string[] {
const option = props.formOptions[colIndex]
if (!option?.columnConfig || !props.allFormElements) {
return []
}
const { columnConfig } = option
const { rowConstraint } = columnConfig
// If row constraint is configured, filter values based on current row's key value
if (rowConstraint?.constraintTableReference && currentRowData) {
const currentRowAsRecord: Record<string, string> = {}
for (const [key, value] of Object.entries(currentRowData)) {
currentRowAsRecord[key] =
typeof value === 'string' ? value : Array.isArray(value) ? value.join(',') : String(value)
}
return getConstrainedColumnValues(
columnConfig,
currentRowAsRecord,
rowConstraint.constraintTableReference,
rowConstraint.constraintKeyColumnIndex ?? 0,
rowConstraint.constraintValueColumnIndex ?? 1,
props.allFormElements,
rowConstraint.currentRowKeyColumnIndex
)
}
return getReferencedColumnValues(columnConfig, props.allFormElements)
}
function updateCell(rowIndex: number, columnKey: string, value: string) {
const colIndex = parseInt(columnKey.replace('col_', ''), 10)
const updatedOptions = props.formOptions.map((option, index) => {
if (index !== colIndex) return option
const columnValues = parseColumnValues(option.value)
while (columnValues.length <= rowIndex) {
columnValues.push('')
}
columnValues[rowIndex] = value
return { ...option, value: JSON.stringify(columnValues) }
})
emit('update:formOptions', updatedOptions)
}
function updateCellValue(rowIndex: number, _columnKey: string, colIndex: number, value: string | string[]) {
const updatedOptions = props.formOptions.map((option, index) => {
if (index !== colIndex) return option
const columnValues = parseColumnValues(option.value)
const isMultiple = isColumnMultipleAllowed(colIndex)
while (columnValues.length <= rowIndex) {
columnValues.push(isMultiple ? [] : '')
}
columnValues[rowIndex] = value
return { ...option, value: JSON.stringify(columnValues) }
})
emit('update:formOptions', updatedOptions)
}
function updateCheckboxCell(rowIndex: number, colIndex: number, value: boolean) {
const updatedOptions = props.formOptions.map((option, index) => {
if (index !== colIndex) return option
const columnValues = parseColumnValues(option.value)
while (columnValues.length <= rowIndex) {
columnValues.push(false)
}
columnValues[rowIndex] = value
return { ...option, value: JSON.stringify(columnValues) }
})
emit('update:formOptions', updatedOptions)
}
function addRow() {
const updatedOptions = props.formOptions.map((option, colIndex) => {
const columnValues = parseColumnValues(option.value)
// Determine initial value based on column type
let initialValue: CellValue = ''
if (readOnlyColumnIndices.value.has(colIndex)) {
initialValue = isColumnCheckbox(colIndex) ? false : (option.columnConfig?.readOnlyDefaultValue ?? '')
} else if (isColumnMultipleAllowed(colIndex)) {
initialValue = []
} else if (isColumnCheckbox(colIndex)) {
initialValue = false
}
columnValues.push(initialValue)
return { ...option, value: JSON.stringify(columnValues) }
})
emit('update:formOptions', updatedOptions)
}
function removeRow(rowIndex: number) {
const updatedOptions = props.formOptions.map((option) => {
const columnValues = parseColumnValues(option.value)
columnValues.splice(rowIndex, 1)
return { ...option, value: JSON.stringify(columnValues) }
})
emit('update:formOptions', updatedOptions)
}
function isCellVisible(colIndex: number, rowData: TableRowData): boolean {
const option = props.formOptions[colIndex]
const rowVisibility = option?.columnConfig?.rowVisibilityCondition
if (!rowVisibility) return true
const { sourceColumnIndex, expectedValues, operator } = rowVisibility
const sourceKey = `col_${sourceColumnIndex}`
const cellValue = rowData[sourceKey]
let sourceValues: string[] = []
if (Array.isArray(cellValue)) {
sourceValues = cellValue
} else if (typeof cellValue === 'string' && cellValue) {
sourceValues = cellValue.split(',').map((v) => v.trim())
}
if (operator === 'CONTAINS' || operator === 'EQUALS') {
return (expectedValues ?? []).some((expected) =>
sourceValues.some((v) => v.toLowerCase() === expected.toLowerCase())
)
}
return true
}
function parseColumnValues(value: string | undefined): CellValue[] {
try {
const parsed = JSON.parse(value || '[]')
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
</script>

View File

@@ -1,172 +0,0 @@
<template>
<div>
<UTable :data="tableData" :columns="tableColumns" class="w-full" :ui="{ td: 'p-2' }">
<template v-for="col in dataColumns" :key="col.key" #[`${col.key}-cell`]="slotProps">
<span
v-if="
props.isCellVisible &&
!props.isCellVisible(col.colIndex, (slotProps.row as TableRow<TableRowData>).original)
"
>
-
</span>
<template v-else>
<!-- Column with cross-reference -->
<USelectMenu
v-if="hasColumnReference(col.colIndex)"
:model-value="getCellValueForSelect(slotProps.row as TableRow<TableRowData>, col.key, col.colIndex)"
:items="getColumnOptions(col.colIndex, (slotProps.row as TableRow<TableRowData>).original)"
:disabled="disabled"
:placeholder="$t('applicationForms.formElements.table.selectValue')"
:multiple="isColumnMultipleAllowed(col.colIndex)"
class="w-full min-w-32"
@update:model-value="
(val: string | string[]) =>
$emit('update:cellValue', (slotProps.row as TableRow<TableRowData>).index, col.key, col.colIndex, val)
"
/>
<!-- Read-only column -->
<span v-else-if="isColumnReadOnly(col.colIndex)" class="text-muted px-2 py-1">
{{ formatCellDisplay(slotProps.row as any, col.key, col.colIndex) }}
</span>
<!-- Checkbox column -->
<div v-else-if="isColumnCheckbox(col.colIndex)" class="flex justify-center">
<UCheckbox
:model-value="getCellValueForCheckbox(slotProps.row as TableRow<TableRowData>, col.key)"
:disabled="disabled"
@update:model-value="
(val: boolean | 'indeterminate') =>
$emit(
'update:checkboxCell',
(slotProps.row as TableRow<TableRowData>).index,
col.colIndex,
val === true
)
"
/>
</div>
<!-- Regular text input with auto-resizing textarea -->
<UTextarea
v-else
:model-value="getCellValue(slotProps.row as TableRow<TableRowData>, col.key)"
:disabled="disabled"
:rows="1"
autoresize
:maxrows="0"
class="w-full min-w-32"
@update:model-value="
(val: string | number) =>
$emit('update:cell', (slotProps.row as TableRow<TableRowData>).index, col.key, String(val))
"
/>
</template>
</template>
<template v-if="canModifyRows" #actions-cell="{ row }">
<UButton
v-if="!disabled"
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
:aria-label="$t('applicationForms.formElements.table.removeRow')"
@click="$emit('removeRow', row.index)"
/>
</template>
</UTable>
<div v-if="tableData.length === 0" class="text-center text-dimmed py-4">
{{ $t('applicationForms.formElements.table.noData') }}
</div>
<UButton
v-if="!disabled && canModifyRows"
variant="outline"
size="sm"
leading-icon="i-lucide-plus"
:class="addRowButtonClass"
@click="$emit('addRow')"
>
{{ $t('applicationForms.formElements.table.addRow') }}
</UButton>
</div>
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~~/.api-client'
import type { TableColumn, TableRow } from '@nuxt/ui'
type CellValue = string | string[] | boolean
type TableRowData = Record<string, CellValue>
interface DataColumn {
key: string
colIndex: number
}
const props = defineProps<{
tableData: TableRowData[]
tableColumns: TableColumn<TableRowData>[]
dataColumns: DataColumn[]
formOptions: FormOptionDto[]
disabled?: boolean
canModifyRows: boolean
addRowButtonClass?: string
getColumnOptions: (colIndex: number, currentRowData?: TableRowData) => string[]
readOnlyColumnIndices?: Set<number>
isCellVisible?: (colIndex: number, rowData: TableRowData) => boolean
}>()
defineEmits<{
(e: 'update:cell', rowIndex: number, columnKey: string, value: string): void
(e: 'update:cellValue', rowIndex: number, columnKey: string, colIndex: number, value: string | string[]): void
(e: 'update:checkboxCell', rowIndex: number, colIndex: number, value: boolean): void
(e: 'addRow'): void
(e: 'removeRow', rowIndex: number): void
}>()
function hasColumnReference(colIndex: number): boolean {
const option = props.formOptions[colIndex]
return !!option?.columnConfig?.sourceTableReference && !isColumnReadOnly(colIndex)
}
function isColumnReadOnly(colIndex: number): boolean {
const option = props.formOptions[colIndex]
return option?.columnConfig?.isReadOnly === true || (props.readOnlyColumnIndices?.has(colIndex) ?? false)
}
function isColumnMultipleAllowed(colIndex: number): boolean {
const option = props.formOptions[colIndex]
return option?.columnConfig?.isMultipleAllowed === true
}
function isColumnCheckbox(colIndex: number): boolean {
const option = props.formOptions[colIndex]
return option?.columnConfig?.isCheckbox === true
}
function getCellValue(row: TableRow<TableRowData>, columnKey: string): string {
const value = row.original[columnKey]
return typeof value === 'string' ? value : ''
}
function getCellValueForSelect(row: TableRow<TableRowData>, columnKey: string, colIndex: number): string | string[] {
const value = row.original[columnKey]
if (isColumnMultipleAllowed(colIndex)) {
return Array.isArray(value) ? value : []
}
return typeof value === 'string' ? value : ''
}
function getCellValueForCheckbox(row: TableRow<TableRowData>, columnKey: string): boolean {
const value = row.original[columnKey]
return value === true
}
function formatCellDisplay(row: TableRow<TableRowData>, columnKey: string, colIndex: number): string {
const value = row.original[columnKey]
if (isColumnMultipleAllowed(colIndex) && Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : '-'
}
return (typeof value === 'string' ? value : '') || '-'
}
</script>

View File

@@ -1,30 +0,0 @@
<template>
<UFormField :label="label">
<UTextarea v-model="modelValue" class="w-full" autoresize />
</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) => {
const firstOption = props.formOptions[0]
if (val && firstOption) {
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...firstOption, value: val.toString() }
emit('update:formOptions', updatedModelValue)
}
}
})
</script>

View File

@@ -1,3 +0,0 @@
<template>
<div>{{ $t('applicationForms.formElements.unimplemented') }}</div>
</template>

View File

@@ -1,154 +0,0 @@
import type { ApplicationFormDto, PagedApplicationFormDto, FormElementDto } from '~~/.api-client'
import { useApplicationFormApi } from './useApplicationFormApi'
import { useLogger } from '../useLogger'
export function useApplicationForm() {
const applicationFormApi = useApplicationFormApi()
const logger = useLogger().withTag('applicationForm')
/**
* Extract all file IDs from FILE_UPLOAD form elements
*/
function extractFileIdsFromForm(applicationFormDto: ApplicationFormDto): string[] {
const fileIds: string[] = []
applicationFormDto.formElementSections?.forEach((section) => {
section.formElementSubSections?.forEach((subsection) => {
subsection.formElements?.forEach((element) => {
if (element.type === 'FILE_UPLOAD') {
element.options?.forEach((option) => {
try {
const metadata = JSON.parse(option.value)
if (metadata.fileId) {
fileIds.push(metadata.fileId)
}
} catch {
// Ignore parsing errors
}
})
}
})
})
})
return fileIds
}
/**
* Creates an application form with atomic file association.
*
* File IDs are included in the DTO and associated atomically on the backend.
* If file association fails, the entire operation rolls back (form is not created).
*/
async function createApplicationForm(applicationFormDto: ApplicationFormDto): Promise<ApplicationFormDto> {
try {
// Extract all temporary file IDs and include them in the DTO for atomic association
const fileIds = extractFileIdsFromForm(applicationFormDto)
// Single atomic API call - backend handles form creation and file association transactionally
const createdForm = await applicationFormApi.createApplicationForm({
...applicationFormDto,
fileIds: fileIds.length > 0 ? fileIds : undefined
})
if (fileIds.length > 0) {
logger.debug(`Created form ${createdForm.id} with ${fileIds.length} files atomically associated`)
}
return createdForm
} catch (e: unknown) {
logger.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) {
logger.error('Failed retrieving application forms:', e)
return Promise.reject(e)
}
}
async function getApplicationFormById(id: string): Promise<ApplicationFormDto> {
try {
return await applicationFormApi.getApplicationFormById(id)
} catch (e: unknown) {
logger.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'))
}
logger.debug('Updating application form with ID:', id, applicationFormDto)
try {
return await applicationFormApi.updateApplicationForm(id, applicationFormDto)
} catch (e: unknown) {
logger.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) {
logger.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) {
logger.error(`Failed submitting application form with ID ${id}:`, e)
return Promise.reject(e)
}
}
async function addFormElementToSubSection(
applicationFormId: string,
subsectionId: string,
formElementDto: FormElementDto,
position: number
): Promise<ApplicationFormDto> {
if (!applicationFormId || !subsectionId) {
return Promise.reject(new Error('Application form ID or subsection ID missing'))
}
try {
return await applicationFormApi.addFormElementToSubSection(
applicationFormId,
subsectionId,
formElementDto,
position
)
} catch (e: unknown) {
logger.error(`Failed adding form element to subsection ${subsectionId}:`, e)
return Promise.reject(e)
}
}
return {
createApplicationForm,
getAllApplicationForms,
getApplicationFormById,
updateApplicationForm,
deleteApplicationFormById,
submitApplicationForm,
addFormElementToSubSection
}
}

View File

@@ -1,73 +0,0 @@
import {
ApplicationFormApi,
Configuration,
type ApplicationFormDto,
type PagedApplicationFormDto,
type FormElementDto
} 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 : clientProxyBasePath + serverApiBasePath)
)
const applicationFormApiClient = new ApplicationFormApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function createApplicationForm(applicationFormDto: ApplicationFormDto): Promise<ApplicationFormDto> {
return applicationFormApiClient.createApplicationForm({ applicationFormDto })
}
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 addFormElementToSubSection(
applicationFormId: string,
subsectionId: string,
formElementDto: FormElementDto,
position: number
): Promise<ApplicationFormDto> {
return applicationFormApiClient.addFormElementToSubSection({
applicationFormId,
subsectionId,
formElementDto,
position
})
}
return {
createApplicationForm,
getAllApplicationForms,
getApplicationFormById,
updateApplicationForm,
deleteApplicationFormById,
submitApplicationForm,
addFormElementToSubSection
}
}

View File

@@ -1,92 +0,0 @@
import { type ApplicationFormDto, type PagedApplicationFormDto, ResponseError } from '~~/.api-client'
import { useApplicationFormTemplateApi } from './useApplicationFormTemplateApi'
import { useLogger } from '../useLogger'
const currentApplicationForm: Ref<ApplicationFormDto | undefined> = ref()
export function useApplicationFormTemplate() {
const applicationFormApi = useApplicationFormTemplateApi()
const logger = useLogger().withTag('applicationFormTemplate')
async function createApplicationFormTemplate(applicationFormDto: ApplicationFormDto): Promise<ApplicationFormDto> {
try {
currentApplicationForm.value = await applicationFormApi.createApplicationFormTemplate(applicationFormDto)
return currentApplicationForm.value
} catch (e: unknown) {
if (e instanceof ResponseError) {
logger.error('Failed creating application form:', e.response)
} else {
logger.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) {
logger.error('Failed retrieving application forms:', e.response)
} else {
logger.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) {
logger.error(`Failed retrieving application form with ID ${id}:`, e.response)
} else {
logger.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) {
logger.error(`Failed updating application form with ID ${id}:`, e.response)
} else {
logger.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) {
logger.error(`Failed deleting application form with ID ${id}:`, e.response)
} else {
logger.error(`Failed deleting application form with ID ${id}:`, e)
}
return Promise.reject(e)
}
}
return {
createApplicationFormTemplate,
getAllApplicationFormTemplates,
getApplicationFormTemplateById,
updateApplicationFormTemplate,
deleteApplicationFormTemplateById
}
}

View File

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

View File

@@ -1,45 +0,0 @@
import type { ApplicationFormVersionDto, ApplicationFormVersionListItemDto, ApplicationFormDto } from '~~/.api-client'
import { useApplicationFormVersionApi } from '~/composables'
import { useLogger } from '../useLogger'
export function useApplicationFormVersion() {
const versionApi = useApplicationFormVersionApi()
const logger = useLogger().withTag('applicationFormVersion')
async function getVersions(applicationFormId: string): Promise<ApplicationFormVersionListItemDto[]> {
try {
return await versionApi.getVersions(applicationFormId)
} catch (e: unknown) {
logger.error(`Failed retrieving versions for application form ${applicationFormId}:`, e)
return Promise.reject(e)
}
}
async function getVersion(applicationFormId: string, versionNumber: number): Promise<ApplicationFormVersionDto> {
try {
return await versionApi.getVersion(applicationFormId, versionNumber)
} catch (e: unknown) {
logger.error(`Failed retrieving version ${versionNumber} for application form ${applicationFormId}:`, e)
return Promise.reject(e)
}
}
async function restoreVersion(applicationFormId: string, versionNumber: number): Promise<ApplicationFormDto> {
if (!applicationFormId) {
return Promise.reject(new Error('Application form ID missing'))
}
try {
return await versionApi.restoreVersion(applicationFormId, versionNumber)
} catch (e: unknown) {
logger.error(`Failed restoring version ${versionNumber} for application form ${applicationFormId}:`, e)
return Promise.reject(e)
}
}
return {
getVersions,
getVersion,
restoreVersion
}
}

View File

@@ -1,46 +0,0 @@
import {
ApplicationFormVersionApi,
Configuration,
type ApplicationFormVersionDto,
type ApplicationFormVersionListItemDto,
type ApplicationFormDto
} from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useApplicationFormVersionApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
)
const versionApiClient = new ApplicationFormVersionApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function getVersions(applicationFormId: string): Promise<ApplicationFormVersionListItemDto[]> {
return versionApiClient.getApplicationFormVersions({ id: applicationFormId })
}
async function getVersion(applicationFormId: string, versionNumber: number): Promise<ApplicationFormVersionDto> {
return versionApiClient.getApplicationFormVersion({
id: applicationFormId,
versionNumber
})
}
async function restoreVersion(applicationFormId: string, versionNumber: number): Promise<ApplicationFormDto> {
return versionApiClient.restoreApplicationFormVersion({
id: applicationFormId,
versionNumber
})
}
return {
getVersions,
getVersion,
restoreVersion
}
}

View File

@@ -1,67 +0,0 @@
import {
CommentApi,
Configuration,
type ApplicationFormCommentCountsDto,
type CommentDto,
type CreateCommentDto,
type CursorPagedCommentDto
} 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 : 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,
formElementId?: string,
cursorCreatedAt?: Date,
limit: number = 10
): Promise<CursorPagedCommentDto> {
return commentApiClient.getCommentsByApplicationFormId({
applicationFormId,
formElementId,
cursorCreatedAt,
limit
})
}
async function getGroupedCommentCountByApplicationFromId(
applicationFormId: string
): Promise<ApplicationFormCommentCountsDto> {
return commentApiClient.getGroupedCommentCountByApplicationFromId({ 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,
getGroupedCommentCountByApplicationFromId,
updateComment,
deleteCommentById
}
}

View File

@@ -1,72 +0,0 @@
import type { CreateCommentDto, CommentDto } from '~~/.api-client'
import { useCommentStore } from '~~/stores/useCommentStore'
import { useUserStore } from '~~/stores/useUserStore'
import { useLogger } from '../useLogger'
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()
const { t: $t } = useI18n()
const logger = useLogger().withTag('commentTextarea')
async function submitComment(formElementId: string) {
const newCommentDto: CreateCommentDto = {
message: commentTextAreaValue.value
}
try {
await createComment(applicationFormId, formElementId, newCommentDto)
commentTextAreaValue.value = ''
toast.add({ title: $t('comments.created'), color: 'success' })
} catch (e) {
toast.add({ title: $t('comments.createError'), color: 'error' })
logger.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: $t('comments.updated'), color: 'success' })
} catch (e) {
toast.add({ title: $t('comments.updateError'), color: 'error' })
logger.error('Error updating comment:', e)
}
}
function editComment(comment: CommentDto) {
isEditingComment.value = true
currentEditedComment.value = comment
commentTextAreaValue.value = comment.message || ''
}
function cancelEditComment() {
isEditingComment.value = false
currentEditedComment.value = null
commentTextAreaValue.value = ''
}
function isCommentByUser(comment: CommentDto) {
return comment.createdBy.keycloakId === user.value?.keycloakId
}
return {
commentTextAreaValue,
submitComment,
updateEditComment,
editComment,
cancelEditComment,
isEditingComment,
isCommentByUser
}
}

View File

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

View File

@@ -1,13 +0,0 @@
import { useContactApi } from './useContactApi'
export function useContact() {
const { sendContactMessage: sendContactMessageApi } = useContactApi()
async function sendContactMessage(subject: string, message: string): Promise<void> {
return sendContactMessageApi({ subject, message })
}
return {
sendContactMessage
}
}

View File

@@ -1,24 +0,0 @@
import { ContactApi, Configuration, type ContactMessageDto } from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useContactApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
)
const contactApiClient = new ContactApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function sendContactMessage(contactMessageDto: ContactMessageDto): Promise<void> {
return contactApiClient.sendContactMessage({ contactMessageDto })
}
return {
sendContactMessage
}
}

View File

@@ -1,150 +0,0 @@
import type { UploadedFileDto } from '~~/.api-client'
import { useFileApi } from './useFileApi'
import { useLogger } from '../useLogger'
export interface UploadedFileMetadata {
fileId: string
filename: string
size: number
mimeType: string
uploadedAt: string
}
export function useFile() {
const fileApi = useFileApi()
const logger = useLogger().withTag('file')
const { t } = useI18n()
async function uploadFile(params: {
file: File
applicationFormId?: string
formElementReference: string
organizationId?: string
}): Promise<UploadedFileDto> {
try {
logger.debug('Uploading file:', params.file.name)
return await fileApi.uploadFile(params)
} catch (e: unknown) {
logger.error('Failed uploading file:', e)
// Enhanced error handling with user-friendly messages
if (e && typeof e === 'object' && 'status' in e) {
const error = e as { status: number }
if (error.status === 413) {
return Promise.reject(
new Error(
t('applicationForms.formElements.fileUpload.fileTooLarge', {
filename: params.file.name,
maxSize: '10MB'
})
)
)
} else if (error.status === 415) {
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.unsupportedType')))
}
}
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.uploadFailed')))
}
}
async function downloadFile(id: string, filename: string): Promise<void> {
try {
logger.debug('Downloading file:', id)
const blob = await fileApi.downloadFileContent(id)
// Create download link
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (e: unknown) {
logger.error('Failed downloading file:', e)
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.downloadFailed')))
}
}
function isViewableInBrowser(mimeType: string): boolean {
return mimeType === 'application/pdf' || mimeType.startsWith('image/')
}
function viewFile(id: string): void {
const url = fileApi.getFileViewUrl(id)
window.open(url, '_blank')
}
async function deleteFile(id: string): Promise<void> {
try {
logger.debug('Deleting file:', id)
return await fileApi.deleteFile(id)
} catch (e: unknown) {
logger.error('Failed deleting file:', e)
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.deleteFailed')))
}
}
async function associateFilesWithApplicationForm(applicationFormId: string, fileIds: string[]): Promise<void> {
try {
logger.debug('Associating files with application form:', { applicationFormId, fileIds })
return await fileApi.associateFilesWithApplicationForm(applicationFormId, fileIds)
} catch (e: unknown) {
logger.error('Failed associating files with application form:', e)
return Promise.reject(e)
}
}
function parseUploadedFiles(formOptionsValues: string[]): UploadedFileMetadata[] {
return formOptionsValues
.map((value) => {
try {
return JSON.parse(value) as UploadedFileMetadata
} catch {
return null
}
})
.filter((file): file is UploadedFileMetadata => file !== null)
}
function createFileMetadata(response: UploadedFileDto): UploadedFileMetadata {
return {
fileId: response.id,
filename: response.originalFilename,
size: response.size,
mimeType: response.mimeType,
uploadedAt: response.uploadedAt.toISOString()
}
}
function getFileIcon(mimeType: string): string {
if (mimeType.startsWith('image/')) return 'i-ph-image'
if (mimeType === 'application/pdf') return 'i-ph-file-pdf'
if (mimeType.includes('word')) return 'i-ph-file-doc'
if (mimeType.includes('zip')) return 'i-ph-file-zip'
return 'i-ph-file'
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
return {
uploadFile,
downloadFile,
viewFile,
deleteFile,
associateFilesWithApplicationForm,
parseUploadedFiles,
createFileMetadata,
getFileIcon,
formatFileSize,
isViewableInBrowser
}
}

View File

@@ -1,64 +0,0 @@
import {
FileApi,
Configuration,
type UploadedFileDto,
type AssociateFilesWithApplicationFormRequest
} from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useFileApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
)
const fileApiClient = new FileApi(new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) }))
async function uploadFile(params: {
file: File
applicationFormId?: string
formElementReference: string
organizationId?: string
}): Promise<UploadedFileDto> {
return fileApiClient.uploadFile(params)
}
async function downloadFileContent(id: string, inline = false): Promise<Blob> {
return fileApiClient.downloadFileContent({ id, inline })
}
function getFileViewUrl(id: string): string {
return `${basePath}/files/${id}/content?inline=true`
}
async function deleteFile(id: string): Promise<void> {
return fileApiClient.deleteFile({ id })
}
async function getFilesByApplicationForm(
applicationFormId: string,
formElementReference?: string
): Promise<UploadedFileDto[]> {
return fileApiClient.getFilesByApplicationForm({ applicationFormId, formElementReference })
}
async function associateFilesWithApplicationForm(applicationFormId: string, fileIds: string[]): Promise<void> {
const associateFilesWithApplicationFormRequest: AssociateFilesWithApplicationFormRequest = { fileIds }
return fileApiClient.associateFilesWithApplicationForm({
applicationFormId,
associateFilesWithApplicationFormRequest
})
}
return {
uploadFile,
downloadFileContent,
getFileViewUrl,
deleteFile,
getFilesByApplicationForm,
associateFilesWithApplicationForm
}
}

View File

@@ -1,9 +0,0 @@
export { useApplicationFormTemplate } from './applicationFormTemplate/useApplicationFormTemplate'
export { useApplicationForm } from './applicationForm/useApplicationForm'
export { useApplicationFormVersion } from './applicationFormVersion/useApplicationFormVersion'
export { useApplicationFormVersionApi } from './applicationFormVersion/useApplicationFormVersionApi'
export { useNotificationApi } from './notification/useNotificationApi'
export { useUser } from './user/useUser'
export { useUserApi } from './user/useUserApi'
export { useContact } from './contact/useContact'
export { useContactApi } from './contact/useContactApi'

View File

@@ -1,65 +0,0 @@
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 : 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(): Promise<void> {
return notificationApiClient.clearAllNotifications()
}
async function deleteNotification(id: string): Promise<void> {
return notificationApiClient.deleteNotification({ id })
}
return {
createNotification,
getNotifications,
getUnreadNotifications,
getUnreadNotificationCount,
markAllNotificationsAsRead,
markNotificationAsRead,
clearAllNotifications,
deleteNotification
}
}

View File

@@ -1,73 +0,0 @@
import { useUserStore } from '~~/stores/useUserStore'
import { useTestDataApi } from '~/composables/testing/useTestDataApi'
/**
* TESTING-ONLY FEATURE
*
* One-click duplicator for the seeded demo application form "SAP S/4HANA".
*
* This version uses a dedicated backend endpoint to ensure reliability:
* 1. Loads the seeded YAML on the backend.
* 2. Creates a fresh ApplicationForm entity with new IDs.
* 3. Sets current user as creator.
* 4. Assigns the form to the currently selected organization.
*/
export function useSeededSapS4HanaDuplicator() {
const { t } = useI18n()
const logger = useLogger().withTag('seeded-sap-duplicator')
const toast = useToast()
const { canWriteApplicationForms } = usePermissions()
const userStore = useUserStore()
const { selectedOrganization } = storeToRefs(userStore)
const isDuplicating = ref(false)
const showButton = computed(() => canWriteApplicationForms.value)
async function duplicateSapS4HanaForTesting() {
if (isDuplicating.value) return
const organizationId = selectedOrganization.value?.id
if (!organizationId) {
toast.add({
title: t('common.error'),
description: 'Please select an organization first.',
color: 'error'
})
return
}
isDuplicating.value = true
try {
const { createTestDataApplicationForm } = useTestDataApi()
const created = await createTestDataApplicationForm(organizationId)
toast.add({
title: t('common.success'),
description: 'Created a new test application form.',
color: 'success'
})
if (created?.id) {
await navigateTo(`/application-forms/${created.id}/0`)
}
} catch (e: unknown) {
logger.error('Failed creating test application form via backend:', e)
toast.add({
title: t('common.error'),
description: 'Failed to create test application form. Check backend logs.',
color: 'error'
})
} finally {
isDuplicating.value = false
}
}
return {
showButton,
isDuplicating,
duplicateSapS4HanaForTesting
}
}

View File

@@ -1,24 +0,0 @@
import { TestDataApi, Configuration, type ApplicationFormDto } from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useTestDataApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
)
const testDataApiClient = new TestDataApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function createTestDataApplicationForm(organizationId: string): Promise<ApplicationFormDto> {
return testDataApiClient.createTestDataApplicationForm({ organizationId })
}
return {
createTestDataApplicationForm
}
}

View File

@@ -1,77 +0,0 @@
import type { ApplicationFormDto } from '~~/.api-client'
export async function useApplicationFormNavigation(applicationFormId: string) {
const { t } = useI18n()
const { getApplicationFormById } = useApplicationForm()
const { getVersions } = useApplicationFormVersion()
const applicationFormAsync = useAsyncData<ApplicationFormDto>(
`application-form-${applicationFormId}`,
async () => await getApplicationFormById(applicationFormId),
{ deep: true }
)
const versionsAsync = useAsyncData(
`application-form-${applicationFormId}-versions-nav`,
async () => await getVersions(applicationFormId),
{ deep: false }
)
await Promise.all([applicationFormAsync, versionsAsync])
if (applicationFormAsync.error.value) {
throw createError({ statusText: applicationFormAsync.error.value.message })
}
const applicationForm = computed<ApplicationFormDto>(() => applicationFormAsync.data.value as ApplicationFormDto)
const latestVersionNumber = computed<number | null>(() => {
const versions = versionsAsync.data.value ?? []
if (versions.length === 0) return null
return Math.max(...versions.map((v) => v.versionNumber ?? 0))
})
const navigationLinks = computed(() => [
[
{
label: t('applicationForms.tabs.form'),
icon: 'i-lucide-file',
to: `/application-forms/${applicationForm.value.id}/0`,
exact: true
},
{
label: t('applicationForms.tabs.versions'),
icon: 'i-lucide-file-clock',
to: `/application-forms/${applicationForm.value.id}/versions`,
exact: true
}
],
[
{
label: t('applicationForms.tabs.preview'),
icon: 'i-lucide-file-text',
to: `/api/application-forms/${applicationForm.value.id}/versions/${latestVersionNumber.value}/pdf`,
target: '_blank',
disabled: Boolean(versionsAsync.error.value) || latestVersionNumber.value === null
}
]
])
function updateApplicationForm(updatedForm: ApplicationFormDto) {
applicationFormAsync.data.value = updatedForm
// Refresh the versions list so the Preview link points to the latest version.
void versionsAsync.refresh()
}
async function refresh() {
await Promise.all([applicationFormAsync.refresh(), versionsAsync.refresh()])
}
return {
applicationForm,
navigationLinks,
refresh,
updateApplicationForm,
error: applicationFormAsync.error
}
}

View File

@@ -1,74 +0,0 @@
import { ComplianceStatus, type FormElementDto } from '~~/.api-client'
import { complianceCheckableElementTypes, complianceMap } from './complianceMap'
import type { FormElementId } from '~~/types/formElement'
import { useLogger } from './useLogger'
const formElementComplianceMap = ref(new Map<FormElementId, ComplianceStatus>())
export function useApplicationFormValidator() {
const logger = useLogger().withTag('validator')
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] ?? ComplianceStatus.NonCritical
}
function validateFormElements(
formElements: FormElementDto[],
visibilityMap?: Map<string, boolean>
): Map<FormElementId, ComplianceStatus> {
formElementComplianceMap.value.clear()
formElements.forEach((formElement, index) => {
const elementKey = formElement.id || formElement.reference || `element-${index}`
if (visibilityMap && visibilityMap.get(elementKey) === false) {
return
}
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) {
logger.debug(`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(elementKey)) {
const newComplianceStatus = formElementComplianceMap.value.get(elementKey)!
const newComplianceStatusPos = Object.values(ComplianceStatus).indexOf(newComplianceStatus)
if (newComplianceStatusPos > currentHighestComplianceStatusPos) {
formElementComplianceMap.value.set(elementKey, newComplianceStatus)
}
} else {
formElementComplianceMap.value.set(elementKey, currentHighestComplianceStatus)
}
})
})
return formElementComplianceMap.value
}
return { getHighestComplianceStatus, validateFormElements }
}

View File

@@ -1,50 +0,0 @@
import type { FormElementDto } from '~~/.api-client'
export function useFormElementDuplication() {
function cloneElement(elementToClone: FormElementDto, existingElements: FormElementDto[]): FormElementDto {
const newReference = elementToClone.reference
? generateNextReference(existingElements, elementToClone.reference)
: undefined
const isTextField = elementToClone.type === 'TEXTAREA' || elementToClone.type === 'TEXTFIELD'
const clonedElement = JSON.parse(JSON.stringify(elementToClone)) as FormElementDto
const resetOptions = clonedElement.options.map((option) => ({
...option,
value: isTextField ? '' : option.value
}))
return {
...clonedElement,
id: undefined,
formElementSubSectionId: undefined,
reference: newReference,
options: resetOptions
}
}
function generateNextReference(existingElements: FormElementDto[], baseReference: string): string {
const { base } = extractReferenceBase(baseReference)
const existingSuffixes = existingElements
.filter((el) => el.reference && el.reference.startsWith(base))
.map((el) => {
const { suffix } = extractReferenceBase(el.reference!)
return suffix
})
const maxSuffix = existingSuffixes.length > 0 ? Math.max(...existingSuffixes) : 0
return `${base}_${maxSuffix + 1}`
}
function extractReferenceBase(reference: string): { base: string; suffix: number } {
const match = reference.match(/^(.+?)_(\d+)$/)
if (match && match[1] && match[2]) {
return { base: match[1], suffix: parseInt(match[2], 10) }
}
return { base: reference, suffix: 1 }
}
return {
cloneElement
}
}

View File

@@ -1,30 +0,0 @@
import type { FormElementDto } from '~~/.api-client'
export function useFormElementManagement() {
function addInputFormElement(elements: FormElementDto[], position: number): FormElementDto[] {
const inputFormElement = createInputFormElement()
const updatedElements = [...elements]
updatedElements.splice(position + 1, 0, inputFormElement)
return updatedElements
}
function createInputFormElement(): FormElementDto {
return {
title: 'Formular ergänzen',
description: 'Bitte fügen Sie hier Ihre Ergänzungen ein.',
options: [
{
value: '',
label: '',
processingPurpose: 'NONE',
employeeDataCategory: 'NONE'
}
],
type: 'RICH_TEXT'
}
}
return {
addInputFormElement
}
}

View File

@@ -1,59 +0,0 @@
import type { FormElementDto, FormElementSectionDto } from '~~/.api-client'
export function useFormElementValueClearing() {
function clearHiddenFormElementValues(
sections: FormElementSectionDto[],
previousVisibilityMap: Map<string, boolean>,
currentVisibilityMap: Map<string, boolean>
): FormElementSectionDto[] {
const elementsToClean = findNewlyHiddenFormElements(previousVisibilityMap, currentVisibilityMap)
if (elementsToClean.size === 0) {
return sections
}
return sections.map((section) => ({
...section,
formElementSubSections: section.formElementSubSections.map((subsection) => ({
...subsection,
formElements: subsection.formElements.map((element) => {
const key = element.id || element.reference
if (key && elementsToClean.has(key)) {
return clearFormElementValue(element)
}
return element
})
}))
}))
}
function findNewlyHiddenFormElements(
previousMap: Map<string, boolean>,
currentMap: Map<string, boolean>
): Set<string> {
const newlyHidden = new Set<string>()
currentMap.forEach((isVisible, key) => {
const wasVisible = previousMap.get(key) !== false
if (wasVisible && !isVisible) {
newlyHidden.add(key)
}
})
return newlyHidden
}
function clearFormElementValue(element: FormElementDto): FormElementDto {
if (['RADIOBUTTON', 'SELECT', 'CHECKBOX'].includes(element.type)) {
return {
...element,
options: element.options.map((opt) => ({ ...opt, value: 'false' }))
}
}
return {
...element,
options: element.options.map((opt, i) => (i === 0 ? { ...opt, value: '' } : opt))
}
}
return {
clearHiddenFormElementValues
}
}

View File

@@ -1,164 +0,0 @@
import type { FormElementDto, VisibilityConditionGroup, VisibilityConditionNode } from '~~/.api-client'
import {
VisibilityConditionType as VCType,
VisibilityConditionNodeNodeTypeEnum as VCNodeTypeEnum,
VisibilityConditionGroupOperatorEnum as VCGroupOperatorEnum,
VisibilityConditionNodeGroupOperatorEnum as VCNodeGroupOperatorEnum,
VisibilityConditionOperator as VCOperator,
FormElementType
} from '~~/.api-client'
export function useFormElementVisibility() {
function evaluateFormElementVisibility(allFormElements: FormElementDto[]): Map<string, boolean> {
const formElementsByRef = buildFormElementsMap(allFormElements)
const visibilityMap = new Map<string, boolean>()
allFormElements.forEach((element) => {
const isVisible = isElementVisible(element, formElementsByRef)
const key = element.id || element.reference
if (key) {
visibilityMap.set(key, isVisible)
}
})
return visibilityMap
}
/**
* Evaluates visibility conditions for a FormOption (e.g., table column).
* Unlike evaluateFormElementVisibility which works on FormElements,
* this evaluates standalone condition groups for options/columns.
*/
function isFormOptionVisible(
conditions: VisibilityConditionGroup | undefined,
allFormElements: FormElementDto[]
): boolean {
if (!conditions || !conditions.conditions || conditions.conditions.length === 0) {
return true
}
const formElementsByRef = buildFormElementsMap(allFormElements)
return evaluateGroup(conditions, formElementsByRef)
}
function buildFormElementsMap(formElements: FormElementDto[]): Map<string, FormElementDto> {
const map = new Map<string, FormElementDto>()
formElements.forEach((element) => {
if (element.reference) {
map.set(element.reference, element)
}
})
return map
}
function isElementVisible(element: FormElementDto, formElementsByRef: Map<string, FormElementDto>): boolean {
const group = element.visibilityConditions
if (!group || !group.conditions || group.conditions.length === 0) {
return true
}
return evaluateGroup(group, formElementsByRef)
}
function evaluateGroup(group: VisibilityConditionGroup, formElementsByRef: Map<string, FormElementDto>): boolean {
if (!group.conditions || group.conditions.length === 0) {
return true
}
const results = group.conditions.map((c) => evaluateNode(c, formElementsByRef))
return group.operator === VCGroupOperatorEnum.And ? results.every(Boolean) : results.some(Boolean)
}
function evaluateNode(node: VisibilityConditionNode, formElementsByRef: Map<string, FormElementDto>): boolean {
if (node.nodeType === VCNodeTypeEnum.Group) {
return evaluateNodeGroup(node, formElementsByRef)
}
return evaluateLeafCondition(node, formElementsByRef)
}
function evaluateNodeGroup(node: VisibilityConditionNode, formElementsByRef: Map<string, FormElementDto>): boolean {
if (!node.conditions || node.conditions.length === 0) {
return true
}
const results = node.conditions.map((c) => evaluateNode(c, formElementsByRef))
return node.groupOperator === VCNodeGroupOperatorEnum.And ? results.every(Boolean) : results.some(Boolean)
}
function evaluateLeafCondition(
leaf: VisibilityConditionNode,
formElementsByRef: Map<string, FormElementDto>
): boolean {
if (!leaf.sourceFormElementReference) {
return false
}
const sourceElement = formElementsByRef.get(leaf.sourceFormElementReference)
if (!sourceElement) {
return false
}
// Special handling for CHECKBOX with multiple options
if (sourceElement.type === FormElementType.Checkbox && sourceElement.options.length > 1) {
const operator = leaf.formElementOperator || VCOperator.Equals
const conditionMet = evaluateCheckboxCondition(sourceElement, leaf.formElementExpectedValue || '', operator)
return leaf.formElementConditionType === VCType.Hide ? !conditionMet : conditionMet
}
const sourceValue = getFormElementValue(sourceElement)
const operator = leaf.formElementOperator || VCOperator.Equals
const conditionMet = evaluateCondition(sourceValue, leaf.formElementExpectedValue || '', operator)
return leaf.formElementConditionType === VCType.Hide ? !conditionMet : conditionMet
}
function getFormElementValue(element: FormElementDto): string {
if (element.type === FormElementType.Checkbox && element.options.length === 1) {
return element.options[0]?.value || ''
}
const selectedOption = element.options.find((option) => option.value === 'true')
return selectedOption?.label || ''
}
function evaluateCheckboxCondition(element: FormElementDto, expectedValue: string, operator: string): boolean {
const selectedLabels = element.options.filter((option) => option.value === 'true').map((option) => option.label)
switch (operator) {
case VCOperator.Equals:
return selectedLabels.some((label) => label.toLowerCase() === expectedValue.toLowerCase())
case VCOperator.NotEquals:
return !selectedLabels.some((label) => label.toLowerCase() === expectedValue.toLowerCase())
case VCOperator.Contains:
return selectedLabels.some((label) => label.toLowerCase() === expectedValue.toLowerCase())
case VCOperator.NotContains:
return !selectedLabels.some((label) => label.toLowerCase() === expectedValue.toLowerCase())
case VCOperator.IsEmpty:
return selectedLabels.length === 0
case VCOperator.IsNotEmpty:
return selectedLabels.length > 0
default:
return false
}
}
function evaluateCondition(actualValue: string, expectedValue: string, operator: string): boolean {
switch (operator) {
case VCOperator.Equals:
return actualValue.toLowerCase() === expectedValue.toLowerCase()
case VCOperator.NotEquals:
return actualValue.toLowerCase() !== expectedValue.toLowerCase()
case VCOperator.Contains:
return actualValue.toLowerCase().includes(expectedValue.toLowerCase())
case VCOperator.NotContains:
return !actualValue.toLowerCase().includes(expectedValue.toLowerCase())
case VCOperator.IsEmpty:
return actualValue === ''
case VCOperator.IsNotEmpty:
return actualValue !== ''
default:
return false
}
}
return {
evaluateFormElementVisibility,
isFormOptionVisible
}
}

View File

@@ -1,59 +0,0 @@
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 visibleSections = computed(() => sections.value.filter((section) => !section.isTemplate))
const stepperItems = computed(() => {
const items: StepperItem[] = []
visibleSections.value.forEach((section: FormElementSectionDto) => {
items.push({
title: section.shortTitle,
description: section.description
})
})
return items
})
const currentFormElementSection = computed<FormElementSectionDto | undefined>(
() => visibleSections.value[activeStepperItemIndex.value]
)
async function navigateStepper(direction: 'forward' | 'backward') {
if (direction === 'forward') {
stepper.value?.next()
} else {
stepper.value?.prev()
}
if (options?.onNavigate) {
await options.onNavigate(direction, activeStepperItemIndex.value)
}
}
return {
stepper,
activeStepperItemIndex,
stepperItems,
currentFormElementSection,
navigateStepper
}
}

View File

@@ -1,97 +0,0 @@
import type { ApplicationFormDto } from '~~/.api-client'
import { useLogger } from '~/composables/useLogger'
interface LocalStorageBackup {
formId: string
sectionIndex: number
timestamp: number
formData: ApplicationFormDto
}
const STORAGE_KEY_PREFIX = 'lch-form-backup-'
export function useLocalStorageBackup(formId: string) {
const storageKey = `${STORAGE_KEY_PREFIX}${formId}`
const logger = useLogger().withTag('localStorageBackup')
const hasBackup = ref(false)
const backupTimestamp = ref<Date | null>(null)
const backupSectionIndex = ref<number | null>(null)
function checkForBackup(): boolean {
if (import.meta.server) return false
try {
const storedApplicationForm = localStorage.getItem(storageKey)
if (storedApplicationForm) {
const backup = JSON.parse(storedApplicationForm) as LocalStorageBackup
hasBackup.value = true
backupTimestamp.value = new Date(backup.timestamp)
backupSectionIndex.value = backup.sectionIndex
return true
}
} catch {
clearBackup()
}
return false
}
function saveBackup(formData: ApplicationFormDto, currentSectionIndex: number): void {
if (import.meta.server) return
try {
const backup: LocalStorageBackup = {
formId,
sectionIndex: currentSectionIndex,
timestamp: Date.now(),
formData
}
localStorage.setItem(storageKey, JSON.stringify(backup))
hasBackup.value = true
backupTimestamp.value = new Date(backup.timestamp)
backupSectionIndex.value = currentSectionIndex
} catch (e) {
// localStorage might be full or unavailable
logger.error('Error saving backup', e)
}
}
function loadBackup(): ApplicationFormDto | null {
if (import.meta.server) return null
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
const backup = JSON.parse(stored) as LocalStorageBackup
return backup.formData
}
} catch {
clearBackup()
}
return null
}
function clearBackup(): void {
if (import.meta.server) return
localStorage.removeItem(storageKey)
hasBackup.value = false
backupTimestamp.value = null
backupSectionIndex.value = null
}
// Call synchronously - import.meta.server guard handles SSR safety.
// Do NOT use onMounted here: this composable is called after `await` in async setup(),
// which means Vue's active component instance is gone and lifecycle hooks cannot be registered.
checkForBackup()
return {
hasBackup: readonly(hasBackup),
backupTimestamp: readonly(backupTimestamp),
backupSectionIndex: readonly(backupSectionIndex),
saveBackup,
loadBackup,
clearBackup,
checkForBackup
}
}

View File

@@ -1,5 +0,0 @@
import type { ConsolaInstance } from 'consola'
export function useLogger(): ConsolaInstance {
return useNuxtApp().$logger
}

View File

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

View File

@@ -1,264 +0,0 @@
import type { FormElementDto, FormElementSectionDto, SectionSpawnTriggerDto } from '~~/.api-client'
import { VisibilityConditionOperator, VisibilityConditionType } from '~~/.api-client'
export function useSectionSpawning() {
function processSpawnTriggers(
sections: FormElementSectionDto[],
updatedFormElements: FormElementDto[]
): FormElementSectionDto[] {
let resultSections = sections
for (const formElement of updatedFormElements) {
const triggers = formElement.sectionSpawnTriggers
if (!triggers || triggers.length === 0 || !formElement.reference) {
continue
}
const triggerValue = getFormElementValue(formElement)
// Process each trigger independently
for (const trigger of triggers) {
resultSections = processSingleTrigger(resultSections, formElement, trigger, triggerValue, triggers)
}
}
return resultSections
}
function processSingleTrigger(
sections: FormElementSectionDto[],
formElement: FormElementDto,
trigger: SectionSpawnTriggerDto,
triggerValue: string,
allTriggersForElement: SectionSpawnTriggerDto[]
): FormElementSectionDto[] {
let resultSections = sections
const shouldSpawn = shouldSpawnSection(trigger, triggerValue)
// Find existing spawned section for this specific trigger (by template reference)
const existingSpawnedSection = findSpawnedSectionForTrigger(
resultSections,
formElement.reference!,
trigger.templateReference
)
// Handle three spawn states:
// 1. Condition met but no section spawned yet → create new section
if (shouldSpawn && !existingSpawnedSection) {
resultSections = spawnNewSection(resultSections, formElement, trigger, triggerValue)
}
// 2. Condition no longer met but section exists → remove spawned section only if no other
// trigger for the same template reference still satisfies the spawn condition (OR logic)
else if (!shouldSpawn && existingSpawnedSection) {
const otherTriggerAlsoSpawns = allTriggersForElement.some(
(t) => t !== trigger && t.templateReference === trigger.templateReference && shouldSpawnSection(t, triggerValue)
)
if (!otherTriggerAlsoSpawns) {
resultSections = removeSpawnedSectionForTrigger(
resultSections,
formElement.reference!,
trigger.templateReference
)
}
}
// 3. Condition still met and section exists → update section titles if value changed
else if (shouldSpawn && existingSpawnedSection && triggerValue) {
resultSections = updateSpawnedSectionTitles(resultSections, formElement.reference!, trigger, triggerValue)
}
return resultSections
}
function spawnNewSection(
sections: FormElementSectionDto[],
element: FormElementDto,
trigger: SectionSpawnTriggerDto,
triggerValue: string
): FormElementSectionDto[] {
const templateSection = findTemplateSection(sections, trigger.templateReference)
if (!templateSection) {
return sections
}
const newSection = spawnSectionFromTemplate(templateSection, element.reference!, triggerValue)
// Find template index
const templateIndex = sections.findIndex((s) => s.isTemplate && s.templateReference === trigger.templateReference)
if (templateIndex === -1) {
// Fallback: append if template not found (shouldn't happen)
return sections.concat(newSection as FormElementSectionDto)
}
// Find insertion position: after template and after any existing spawned sections from same template
let insertionIndex = templateIndex + 1
while (insertionIndex < sections.length) {
const section = sections[insertionIndex]!
if (section.isTemplate || section.templateReference !== trigger.templateReference) {
break
}
insertionIndex++
}
// Insert at calculated position
const result = [...sections]
result.splice(insertionIndex, 0, newSection as FormElementSectionDto)
return result
}
function updateSpawnedSectionTitles(
sections: FormElementSectionDto[],
elementReference: string,
trigger: SectionSpawnTriggerDto,
triggerValue: string
): FormElementSectionDto[] {
const template = findTemplateSection(sections, trigger.templateReference)
if (!template) {
return sections
}
const hasTitleTemplate = template.titleTemplate
const hasShortTitleTemplate = template.shortTitle?.includes('{{triggerValue}}')
const hasDescriptionTemplate = template.description?.includes('{{triggerValue}}')
if (!hasTitleTemplate && !hasShortTitleTemplate && !hasDescriptionTemplate) {
return sections
}
return sections.map((section) => {
if (section.spawnedFromElementReference === elementReference && !section.isTemplate) {
const sectionUpdate: Partial<FormElementSectionDto> = {}
if (hasTitleTemplate) {
sectionUpdate.title = interpolateTitle(template.titleTemplate!, triggerValue)
}
if (hasShortTitleTemplate && template.shortTitle) {
sectionUpdate.shortTitle = interpolateTitle(template.shortTitle, triggerValue)
}
if (hasDescriptionTemplate && template.description) {
sectionUpdate.description = interpolateTitle(template.description, triggerValue)
}
return { ...section, ...sectionUpdate }
}
return section
})
}
function findSpawnedSectionForTrigger(
sections: FormElementSectionDto[],
elementReference: string,
templateReference: string
): FormElementSectionDto | undefined {
return sections.find(
(section) =>
!section.isTemplate &&
section.spawnedFromElementReference === elementReference &&
section.templateReference === templateReference
)
}
function removeSpawnedSectionForTrigger(
sections: FormElementSectionDto[],
elementReference: string,
templateReference: string
): FormElementSectionDto[] {
return sections.filter(
(section) =>
section.isTemplate ||
section.spawnedFromElementReference !== elementReference ||
section.templateReference !== templateReference
)
}
function spawnSectionFromTemplate(
templateSection: FormElementSectionDto,
triggerElementReference: string,
triggerValue: string
): FormElementSectionDto {
const clonedSection = JSON.parse(JSON.stringify(templateSection)) as FormElementSectionDto
const title = templateSection.titleTemplate
? interpolateTitle(templateSection.titleTemplate, triggerValue)
: templateSection.title
const shortTitle = templateSection.shortTitle?.includes('{{triggerValue}}')
? interpolateTitle(templateSection.shortTitle, triggerValue)
: templateSection.shortTitle
const description = templateSection.description?.includes('{{triggerValue}}')
? interpolateTitle(templateSection.description, triggerValue)
: templateSection.description
return {
...clonedSection,
id: undefined,
applicationFormId: undefined,
title,
shortTitle,
description,
isTemplate: false,
spawnedFromElementReference: triggerElementReference,
formElementSubSections: clonedSection.formElementSubSections.map((subsection) => ({
...subsection,
id: undefined,
formElementSectionId: undefined,
formElements: subsection.formElements.map((element) => ({
...element,
id: undefined,
formElementSubSectionId: undefined
}))
}))
}
}
function shouldSpawnSection(trigger: SectionSpawnTriggerDto, triggerElementValue: string): boolean {
const operator = trigger.sectionSpawnOperator || VisibilityConditionOperator.Equals
const isConditionMet = evaluateCondition(triggerElementValue, trigger.sectionSpawnExpectedValue || '', operator)
return trigger.sectionSpawnConditionType === VisibilityConditionType.Show ? isConditionMet : !isConditionMet
}
function findTemplateSection(
sections: FormElementSectionDto[],
templateReference: string
): FormElementSectionDto | undefined {
return sections.find((section) => section.isTemplate && section.templateReference === templateReference)
}
function evaluateCondition(
actualValue: string,
expectedValue: string,
operator: VisibilityConditionOperator
): boolean {
switch (operator) {
case VisibilityConditionOperator.Equals:
return actualValue.toLowerCase() === expectedValue.toLowerCase()
case VisibilityConditionOperator.NotEquals:
return actualValue.toLowerCase() !== expectedValue.toLowerCase()
case VisibilityConditionOperator.IsEmpty:
return actualValue === ''
case VisibilityConditionOperator.IsNotEmpty:
return actualValue !== ''
default:
return false
}
}
function interpolateTitle(titleTemplate: string, triggerValue: string): string {
return titleTemplate.replace(/\{\{triggerValue\}\}/g, triggerValue)
}
function getFormElementValue(element: FormElementDto): string {
if (element.type === 'TEXTAREA' || element.type === 'TEXTFIELD') {
return element.options[0]?.value || ''
}
const selectedOption = element.options.find((option) => option.value === 'true')
return selectedOption?.label || ''
}
return {
processSpawnTriggers
}
}

View File

@@ -1,95 +0,0 @@
import { useLogger } from './useLogger'
export const isServerAvailable = ref(true)
export const isChecking = ref(false)
export const lastCheckTime = ref<Date | null>(null)
export function useServerHealth() {
const logger = useLogger().withTag('serverHealth')
const checkInterval = ref<ReturnType<typeof setInterval> | 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) {
logger.info('Server is back online')
}
if (wasAvailable && !isServerAvailable.value) {
logger.warn('Server is no longer available')
}
return isServerAvailable.value
} catch (error) {
const wasAvailable = isServerAvailable.value
isServerAvailable.value = false
if (wasAvailable) {
logger.warn('Server health check failed:', error)
}
return false
} finally {
isChecking.value = false
}
}
async function startPeriodicHealthCheck(intervalMs: number = 60000) {
if (checkInterval.value) {
clearInterval(checkInterval.value)
}
checkServerHealth()
checkInterval.value = setInterval(() => {
checkServerHealth()
}, intervalMs)
onUnmounted(() => {
if (checkInterval.value) {
clearInterval(checkInterval.value)
checkInterval.value = null
}
})
return checkInterval.value
}
const stopHealthCheck = () => {
if (checkInterval.value) {
clearInterval(checkInterval.value)
checkInterval.value = null
}
}
return {
isServerAvailable,
isChecking,
lastCheckTime,
healthCheckUrl,
checkServerHealth,
startPeriodicHealthCheck,
stopHealthCheck
}
}

View File

@@ -1,279 +0,0 @@
import type {
FormElementDto,
FormOptionDto,
TableColumnConfigDto,
TableColumnFilterDto,
TableRowPresetDto
} from '~~/.api-client'
import { VisibilityConditionOperator as VCOperator } from '~~/.api-client'
export function useTableCrossReferences() {
// Get available values for a column that references another table's column
function getReferencedColumnValues(
columnConfig: TableColumnConfigDto | undefined,
allFormElements: FormElementDto[]
): string[] {
if (!columnConfig?.sourceTableReference || columnConfig.sourceColumnIndex === undefined) {
return []
}
const sourceTable = findTableElement(columnConfig.sourceTableReference, allFormElements)
if (!sourceTable) {
// Fallback: CHECKBOX element — return all option labels as dropdown choices
const sourceCheckbox = allFormElements.find(
(el) => el.reference === columnConfig.sourceTableReference && el.type === 'CHECKBOX'
)
if (sourceCheckbox) {
return sourceCheckbox.options
.filter((opt) => opt.value === 'true')
.map((opt) => opt.label)
.filter(Boolean)
}
return []
}
const sourceColumn = sourceTable.options[columnConfig.sourceColumnIndex]
if (!sourceColumn) {
return []
}
const columnValues = parseColumnValues(sourceColumn.value)
// Apply filter if present
if (columnConfig.filterCondition) {
return filterColumnValues(columnValues, columnConfig.filterCondition, sourceTable)
}
return columnValues.filter((v) => v.trim() !== '')
}
// Get filtered values based on constraints from another table
// Used for cases like "Permission-ID can only use permissions allowed for the selected role"
function getConstrainedColumnValues(
columnConfig: TableColumnConfigDto | undefined,
currentRowData: Record<string, string>,
constraintTableReference: string,
constraintKeyColumnIndex: number,
constraintValueColumnIndex: number,
allFormElements: FormElementDto[],
currentRowKeyColumnIndex?: number
): string[] {
if (!columnConfig?.sourceTableReference) {
return []
}
const constraintTable = findTableElement(constraintTableReference, allFormElements)
if (!constraintTable) {
// No constraint found, return all values from source table column
return getReferencedColumnValues(columnConfig, allFormElements)
}
const lookupColumnIndex = currentRowKeyColumnIndex ?? constraintKeyColumnIndex
const keyValue = currentRowData[`col_${lookupColumnIndex}`]
if (!keyValue) {
// No key value to look up, return all values from source table column
return getReferencedColumnValues(columnConfig, allFormElements)
}
const allowedValuesRaw = getAllowedValuesFromConstraintTable(
constraintTable,
keyValue,
constraintKeyColumnIndex,
constraintValueColumnIndex
)
const allowedValues = allowedValuesRaw.flatMap((v) => (typeof v === 'boolean' ? String(v) : v))
// If no allowed values found, fall back to all values from source table
if (allowedValues.length === 0) {
return getReferencedColumnValues(columnConfig, allFormElements)
}
return allowedValues
}
// Apply row presets from a source table based on filter conditions
function applyRowPresets(
tableRowPreset: TableRowPresetDto | undefined,
targetOptions: FormOptionDto[],
allFormElements: FormElementDto[]
): FormOptionDto[] {
if (!tableRowPreset?.sourceTableReference) {
return targetOptions
}
const sourceTable = findTableElement(tableRowPreset.sourceTableReference, allFormElements)
if (!sourceTable) {
return targetOptions
}
// Get source table data
const sourceData = parseTableData(sourceTable.options)
// Filter rows based on filter condition
const filteredRows = tableRowPreset.filterCondition
? filterTableRows(sourceData, tableRowPreset.filterCondition, sourceTable.options)
: sourceData
// Apply column mappings to create preset rows in target
const columnMappings = tableRowPreset.columnMappings || []
const presetRowCount = filteredRows.length
return targetOptions.map((option, targetColIndex) => {
const mapping = columnMappings.find((m) => m.targetColumnIndex === targetColIndex)
// For mapped columns, use values from source
if (mapping && mapping.sourceColumnIndex !== undefined) {
const sourceColIndex = mapping.sourceColumnIndex
const presetValues = filteredRows.map((row) => String(row[sourceColIndex] ?? ''))
return {
...option,
value: JSON.stringify(presetValues)
}
}
// For non-mapped columns, ensure we have the right number of rows
const existingValues = parseColumnValues(option.value)
const isCheckboxColumn = option.columnConfig?.isCheckbox === true
// Pad or trim to match preset row count
const adjustedValues: (string | boolean)[] = []
for (let i = 0; i < presetRowCount; i++) {
if (i < existingValues.length && existingValues[i] !== undefined) {
adjustedValues.push(existingValues[i]!)
} else {
// Initialize new rows with appropriate default
adjustedValues.push(isCheckboxColumn ? false : '')
}
}
return {
...option,
value: JSON.stringify(adjustedValues)
}
})
}
function findTableElement(reference: string, allFormElements: FormElementDto[]): FormElementDto | undefined {
return allFormElements.find((el) => el.reference === reference && el.type === 'TABLE')
}
function parseColumnValues(jsonValue: string | undefined): string[] {
if (!jsonValue) return []
try {
const parsed = JSON.parse(jsonValue)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function parseTableData(options: FormOptionDto[]): (string | boolean)[][] {
const columnData = options.map((opt) => parseColumnValuesWithTypes(opt.value))
const rowCount = Math.max(...columnData.map((col) => col.length), 0)
const rows: (string | boolean)[][] = []
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row = columnData.map((col) => col[rowIndex] ?? '')
rows.push(row)
}
return rows
}
function parseColumnValuesWithTypes(jsonValue: string | undefined): (string | boolean)[] {
if (!jsonValue) return []
try {
const parsed = JSON.parse(jsonValue)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function filterColumnValues(
values: string[],
filterCondition: TableColumnFilterDto,
sourceTable: FormElementDto
): string[] {
if (filterCondition.sourceColumnIndex === undefined) {
return values
}
const filterColumn = sourceTable.options[filterCondition.sourceColumnIndex]
if (!filterColumn) {
return values
}
const filterColumnValues = parseColumnValues(filterColumn.value)
return values.filter((_, index) => {
const filterValue = filterColumnValues[index] || ''
return evaluateFilterCondition(filterValue, filterCondition)
})
}
function filterTableRows(
rows: (string | boolean)[][],
filterCondition: TableColumnFilterDto,
_options: FormOptionDto[]
): (string | boolean)[][] {
if (filterCondition.sourceColumnIndex === undefined) {
return rows
}
return rows.filter((row) => {
const filterValue = row[filterCondition.sourceColumnIndex!] ?? ''
return evaluateFilterCondition(filterValue, filterCondition)
})
}
function evaluateFilterCondition(actualValue: string | boolean, filterCondition: TableColumnFilterDto): boolean {
const expectedValue = filterCondition.expectedValue || ''
const operator = filterCondition.operator || VCOperator.Equals
// Handle boolean values (from checkbox columns)
const normalizedActual = typeof actualValue === 'boolean' ? String(actualValue) : actualValue
switch (operator) {
case VCOperator.Equals:
return normalizedActual.toLowerCase() === expectedValue.toLowerCase()
case VCOperator.NotEquals:
return normalizedActual.toLowerCase() !== expectedValue.toLowerCase()
case VCOperator.IsEmpty:
return normalizedActual.trim() === ''
case VCOperator.IsNotEmpty:
return normalizedActual.trim() !== ''
default:
return true
}
}
function getAllowedValuesFromConstraintTable(
constraintTable: FormElementDto,
keyValue: string,
keyColumnIndex: number,
valueColumnIndex: number
): (string | boolean | string[])[] {
const tableData = parseTableData(constraintTable.options)
const allowedValues: (string | boolean | string[])[] = []
tableData.forEach((row) => {
const keyCell = row[keyColumnIndex]
const keyCellStr = Array.isArray(keyCell) ? keyCell[0] : typeof keyCell === 'boolean' ? String(keyCell) : keyCell
if (keyCellStr?.toLowerCase() === keyValue.toLowerCase()) {
const value = row[valueColumnIndex]
if (value !== undefined && !allowedValues.includes(value)) {
allowedValues.push(value)
}
}
})
return allowedValues
}
return {
getReferencedColumnValues,
getConstrainedColumnValues,
applyRowPresets
}
}

View File

@@ -1,34 +0,0 @@
import type { UpdateEmailPreferencesDto, UserDto } from '~~/.api-client'
import { useUserApi } from '~/composables'
export function useUser() {
const userApi = useUserApi()
async function getUserById(userId: string): Promise<UserDto> {
return await userApi.getUserById(userId)
}
async function updateEmailPreferences(
userId: string,
email: string | null,
emailOnFormCreated: boolean,
emailOnFormSubmitted: boolean,
emailOnFormUpdated: boolean,
emailOnCommentAdded: boolean
): Promise<UserDto> {
const updateDto: UpdateEmailPreferencesDto = {
email,
emailOnFormCreated,
emailOnFormSubmitted,
emailOnFormUpdated,
emailOnCommentAdded
}
return await userApi.updateEmailPreferences(userId, updateDto)
}
return {
getUserById,
updateEmailPreferences
}
}

View File

@@ -1,30 +0,0 @@
import { UserApi, Configuration, type UserDto, type UpdateEmailPreferencesDto } from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useUserApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
)
const userApiClient = new UserApi(new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) }))
async function getUserById(id: string): Promise<UserDto> {
return userApiClient.getUserById({ id })
}
async function updateEmailPreferences(
id: string,
updateEmailPreferencesDto: UpdateEmailPreferencesDto
): Promise<UserDto> {
return userApiClient.updateUserEmailPreferences({ id, updateEmailPreferencesDto })
}
return {
getUserById,
updateEmailPreferences
}
}

View File

@@ -1,26 +0,0 @@
<template>
<UApp>
<UError :error="error" />
</UApp>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app'
defineProps<{
error: NuxtError
}>()
const { t: $t } = useI18n()
useSeoMeta({
title: $t('error.pageNotFound'),
description: $t('error.pageNotFoundDescription')
})
useHead({
htmlAttrs: {
lang: 'en'
}
})
</script>

View File

@@ -1,61 +0,0 @@
<template>
<div class="auth-layout h-screen flex items-center justify-center p-4">
<!-- Subtle gradient orbs for visual interest -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="auth-orb auth-orb-1" />
<div class="auth-orb auth-orb-2" />
<div class="auth-orb auth-orb-3" />
</div>
<div class="relative z-10 w-full max-w-md">
<slot />
</div>
</div>
</template>
<style scoped>
.auth-layout {
position: relative;
background: linear-gradient(180deg, #ffffff 0%, #f0fdfa 50%, #ecfeff 100%);
}
.dark .auth-layout {
background: linear-gradient(180deg, #0a0a0a 0%, #042f2e 50%, #083344 100%);
}
/* Subtle floating orbs */
.auth-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
}
.auth-orb-1 {
width: 400px;
height: 400px;
top: -100px;
right: -100px;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
}
.auth-orb-2 {
width: 300px;
height: 300px;
bottom: -50px;
left: -50px;
background: linear-gradient(135deg, #22d3ee, #14b8a6);
}
.auth-orb-3 {
width: 200px;
height: 200px;
bottom: -50px;
right: 20%;
background: linear-gradient(135deg, #a78bfa, #7c3aed);
opacity: 0.3;
}
.dark .auth-orb {
opacity: 0.2;
}
</style>

View File

@@ -1,128 +0,0 @@
<template>
<UDashboardGroup>
<UDashboardSearch />
<UDashboardSidebar
v-model:open="open"
collapsible
resizable
class="bg-(--ui-bg-elevated)/75 border-r border-(--ui-border)"
:ui="{ footer: 'lg:border-t lg:border-(--ui-border)' }"
>
<template #header>
<NuxtLink to="/">
<img src="/favicon.svg" alt="Logo" class="w-8 h-8" />
</NuxtLink>
</template>
<template #default="{ collapsed }">
<UDashboardSearchButton :collapsed="collapsed" class="bg-transparent ring-(--ui-border)" />
<UNavigationMenu :collapsed="collapsed" :items="links" orientation="vertical" />
</template>
<template #footer="{ collapsed }">
<UserMenu :collapsed="collapsed" />
<UButton
v-if="showSapCopyButton"
:loading="isDuplicatingSapForm"
:disabled="isDuplicatingSapForm"
icon="i-lucide-copy"
size="xs"
variant="soft"
@click="duplicateSapS4HanaForTesting"
>
🖨
</UButton>
<UButton size="xs" variant="soft" @click="copyAccessTokenToClipboard">📋</UButton>
</template>
</UDashboardSidebar>
<slot />
<NotificationsSlideover v-model="isNotificationsSlideoverOpen" />
</UDashboardGroup>
</template>
<script setup lang="ts">
import { useNotificationStore } from '~~/stores/useNotificationStore'
import { useSeededSapS4HanaDuplicator } from '~/composables/testing/useSeededSapS4HanaDuplicator'
import { usePermissions } from '~/composables/usePermissions'
const { t: $t } = useI18n()
const { hasAnyRole } = usePermissions()
const links = computed(() => {
const items = [
{
label: $t('contact.title'),
icon: 'i-lucide-mail',
to: '/contact',
exact: true
},
{
label: $t('help.title'),
icon: 'i-lucide-help-circle',
to: '/help',
exact: true
},
{
label: $t('organization.title'),
icon: 'i-lucide-building-2',
to: '/organization',
exact: true
},
{
label: $t('settings.title'),
icon: 'i-lucide-settings',
to: '/settings',
exact: true
}
]
if (hasAnyRole(['CHIEF_EXECUTIVE_OFFICER', 'IT_DEPARTMENT'])) {
items.push({
label: $t('administration.title'),
icon: 'i-lucide-shield',
to: '/administration',
exact: true
})
}
return items
})
const open = ref(false)
const logger = useLogger().withTag('layout')
const isNotificationsSlideoverOpen = ref(false)
const notificationStore = useNotificationStore()
const { hasUnread } = storeToRefs(notificationStore)
const {
showButton: showSapCopyButton,
isDuplicating: isDuplicatingSapForm,
duplicateSapS4HanaForTesting
} = useSeededSapS4HanaDuplicator()
onMounted(async () => {
await notificationStore.fetchUnreadCount()
notificationStore.startPeriodicRefresh()
})
provide('notificationState', {
isNotificationsSlideoverOpen,
hasUnread
})
async function copyAccessTokenToClipboard() {
const { session } = useUserSession()
logger.debug('Access Token :', session.value?.jwt?.accessToken)
const accessToken = session.value?.jwt?.accessToken
if (accessToken) {
navigator.clipboard.writeText(accessToken)
logger.info('Access token copied to clipboard')
} else {
logger.warn('No access token found in session')
}
}
</script>

View File

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

View File

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

View File

@@ -1,85 +0,0 @@
// 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'
import { useLogger } from '../composables/useLogger'
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
const logger = useLogger().withTag('refreshToken')
logger.debug('🔍 Middleware: refreshToken.global.ts')
logger.debug(`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)) {
logger.info('both tokens expired, clearing session')
await clearSession()
return navigateTo('/login')
} else if (isExpired(accessPayload)) {
logger.info('access token expired, refreshing')
await useRequestFetch()('/api/jwt/refresh', {
method: 'POST',
onResponse({ response: { headers } }: { response: { headers: 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) {
logger.debug('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() {
logger.error('🔍 Middleware: Token refresh failed')
const { loggedIn } = useUserSession()
if (!loggedIn.value) {
logger.debug('🔍 Middleware: User not logged in, redirecting to /login')
return navigateTo('/login')
}
}
})
// Refresh the session
await fetchSession()
}
})
function isExpired(payload: JwtPayload) {
return payload?.exp && payload.exp < Date.now() / 1000
}

View File

@@ -1,292 +0,0 @@
<template>
<UDashboardPanel id="administration">
<template #header>
<UDashboardNavbar :title="$t('templates.editorTitle')" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right>
<UButton
v-if="hasUnsavedChanges"
icon="i-lucide-rotate-ccw"
:label="$t('templates.reset')"
color="neutral"
variant="outline"
@click="resetEditor"
/>
<UButton
icon="i-lucide-file-plus"
:label="$t('templates.newTemplate')"
color="neutral"
variant="outline"
@click="createNewTemplate"
/>
<UButton
icon="i-lucide-save"
:label="$t('common.save')"
:disabled="!hasUnsavedChanges || !isValidJson"
:loading="isSaving"
@click="saveTemplate"
/>
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-4 w-full h-full p-4">
<UCard v-if="currentTemplate" class="mb-4">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-lg">{{ currentTemplate.name }}</h3>
<p class="text-sm text-muted mt-1">
{{ $t('templates.lastModified') }}
{{ currentTemplate.modifiedAt ? formatDate(new Date(currentTemplate.modifiedAt)) : '-' }}
</p>
</div>
<UBadge v-if="hasUnsavedChanges" :label="$t('templates.unsavedChanges')" color="warning" variant="subtle" />
</div>
</UCard>
<UCard v-else class="mb-4">
<div class="text-center py-4">
<UIcon name="i-lucide-info" class="size-8 text-muted mx-auto mb-2" />
<p class="text-muted">{{ $t('templates.noTemplateFound') }}</p>
</div>
</UCard>
<UCard class="flex-1">
<div class="h-[800px] overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<vue-monaco-editor
v-model:value="editorContent"
language="json"
:options="editorOptions"
:theme="colorMode.value === 'dark' ? 'vs-dark' : 'vs'"
@change="onEditorChange"
/>
</div>
</UCard>
<UAlert
v-if="!isValidJson"
color="error"
icon="i-lucide-alert-circle"
:title="$t('templates.invalidJson')"
:description="$t('templates.invalidJsonDescription')"
/>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import type { ApplicationFormDto } from '~~/.api-client'
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { formatDate } from '~/utils/date'
definePageMeta({
layout: 'default'
})
const toast = useToast()
const colorMode = useColorMode()
const { hasRole } = usePermissions()
const { getAllApplicationFormTemplates, updateApplicationFormTemplate, createApplicationFormTemplate } =
useApplicationFormTemplate()
const { t: $t } = useI18n()
const logger = useLogger().withTag('administration')
if (!hasRole('CHIEF_EXECUTIVE_OFFICER') && !hasRole('IT_DEPARTMENT')) {
await navigateTo('/')
}
const currentTemplate = ref<ApplicationFormDto | null>(null)
const editorContent = ref<string>('')
const originalJson = ref<string>('')
const hasUnsavedChanges = ref(false)
const isValidJson = ref(true)
const isSaving = ref(false)
const editorOptions = {
automaticLayout: true,
formatOnPaste: true,
formatOnType: true,
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingStrategy: 'advanced',
bracketPairColorization: {
enabled: true
},
guides: {
bracketPairs: true,
indentation: true
},
suggest: {
showKeywords: true,
showSnippets: true
}
}
const { data: templatesData } = await useAsyncData('applicationFormTemplates', async () => {
return await getAllApplicationFormTemplates()
})
onMounted(() => {
if (templatesData.value?.content && templatesData.value.content.length > 0) {
const sortedTemplates = [...templatesData.value.content].sort((a, b) => {
const dateA = new Date(a.modifiedAt || 0).getTime()
const dateB = new Date(b.modifiedAt || 0).getTime()
return dateB - dateA
})
const latestTemplate = sortedTemplates[0]
if (latestTemplate) {
currentTemplate.value = latestTemplate
editorContent.value = JSON.stringify(currentTemplate.value, null, 2)
originalJson.value = editorContent.value
}
} else {
const emptyTemplate = {
name: '',
description: '',
status: 'DRAFT',
isTemplate: true,
organizationId: '',
sections: []
}
editorContent.value = JSON.stringify(emptyTemplate, null, 2)
originalJson.value = editorContent.value
}
})
function onEditorChange() {
try {
JSON.parse(editorContent.value)
isValidJson.value = true
hasUnsavedChanges.value = editorContent.value !== originalJson.value
} catch {
isValidJson.value = false
hasUnsavedChanges.value = true
}
}
function resetEditor() {
editorContent.value = originalJson.value
hasUnsavedChanges.value = false
isValidJson.value = true
}
function createNewTemplate() {
currentTemplate.value = null
const emptyTemplate = {
name: '',
description: '',
status: 'DRAFT',
isTemplate: true,
organizationId: '',
sections: []
}
editorContent.value = JSON.stringify(emptyTemplate, null, 2)
originalJson.value = editorContent.value
hasUnsavedChanges.value = true
isValidJson.value = true
}
async function saveTemplate() {
if (!isValidJson.value) {
toast.add({
title: $t('common.error'),
description: $t('templates.invalidJsonDescription'),
color: 'error'
})
return
}
isSaving.value = true
try {
const parsedData = JSON.parse(editorContent.value)
const dataWithDates = convertDates(parsedData)
if (currentTemplate.value?.id) {
currentTemplate.value = await updateApplicationFormTemplate(
currentTemplate.value.id,
dataWithDates as ApplicationFormDto
)
toast.add({
title: $t('common.success'),
description: $t('templates.updated'),
color: 'success'
})
} else {
currentTemplate.value = await createApplicationFormTemplate(dataWithDates as ApplicationFormDto)
toast.add({
title: $t('common.success'),
description: $t('templates.created'),
color: 'success'
})
}
editorContent.value = JSON.stringify(currentTemplate.value, null, 2)
originalJson.value = editorContent.value
hasUnsavedChanges.value = false
await refreshNuxtData('applicationFormTemplates')
} catch (error) {
toast.add({
title: $t('common.error'),
description: $t('templates.saveError'),
color: 'error'
})
logger.error('Error saving template:', error)
} finally {
isSaving.value = false
}
}
onBeforeRouteLeave((to, from, next) => {
if (hasUnsavedChanges.value) {
const answer = window.confirm($t('templates.unsavedWarning'))
if (answer) {
next()
} else {
next(false)
}
} else {
next()
}
})
function convertDates(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj
if (typeof obj === 'string') {
const date = new Date(obj)
if (!isNaN(date.getTime()) && obj.includes('T') && (obj.includes('Z') || obj.includes('+'))) {
return date
}
return obj
}
if (Array.isArray(obj)) {
return obj.map((item) => convertDates(item))
}
if (typeof obj === 'object') {
const converted: Record<string, unknown> = {}
for (const key in obj as Record<string, unknown>) {
if (key === 'createdAt' || key === 'modifiedAt') {
const value = (obj as Record<string, unknown>)[key]
converted[key] = value ? new Date(value as string) : value
} else {
converted[key] = convertDates((obj as Record<string, unknown>)[key])
}
}
return converted
}
return obj
}
</script>

View File

@@ -1,294 +0,0 @@
<template>
<UDashboardPanel id="home">
<template #header>
<UDashboardNavbar :title="$t('common.home')" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right>
<UButton
icon="i-lucide-circle-plus"
:label="$t('applicationForms.createNew')"
to="/create"
:disabled="!canWriteApplicationForms"
size="xl"
class="bg-gradient-to-br from-teal-500 to-cyan-500 text-white font-semibold rounded-xl shadow-lg shadow-teal-500/25 hover:from-cyan-500 hover:to-violet-500 hover:shadow-xl hover:shadow-violet-500/30 transition-all duration-200"
/>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<UNavigationMenu :items="links" highlight class="-mx-1 flex-1" />
<USeparator orientation="vertical" class="h-8 mx-2" />
<FormValidationIndicator :status="validationStatus" />
</UDashboardToolbar>
</template>
<template #body>
<FormStepperWithNavigation
:form-element-sections="applicationForm.formElementSections"
:initial-section-index="sectionIndex"
:application-form-id="applicationForm.id ?? undefined"
:disabled="isReadOnly"
@save="onSave"
@submit="onSubmit"
@navigate="handleNavigate"
@add-input-form="handleAddInputForm"
@update:form-element-sections="handleFormElementSectionsUpdate"
>
<UFormField :label="$t('common.name')" class="mb-4">
<UInput v-model="applicationForm.name" class="w-full" :disabled="isReadOnly" />
</UFormField>
</FormStepperWithNavigation>
</template>
</UDashboardPanel>
<UModal
:open="showRecoveryModal"
:title="$t('applicationForms.recovery.title')"
@update:open="showRecoveryModal = $event"
>
<template #body>
<p class="text-sm text-(--ui-text-muted)">
{{
$t('applicationForms.recovery.message', {
timestamp: backupTimestamp?.toLocaleString()
})
}}
</p>
<p
v-if="backupSectionIndex !== null && backupSectionIndex !== sectionIndex"
class="text-sm text-(--ui-text-muted) mt-2"
>
{{
$t('applicationForms.recovery.sectionNote', {
section: (backupSectionIndex + 1).toString()
})
}}
</p>
</template>
<template #footer>
<UButton color="neutral" variant="outline" @click="handleDiscardBackup">
{{ $t('applicationForms.recovery.discard') }}
</UButton>
<UButton color="primary" @click="handleRestoreBackup">
{{ $t('applicationForms.recovery.restore') }}
</UButton>
</template>
</UModal>
</template>
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import {
ComplianceStatus,
type ApplicationFormDto,
type FormElementDto,
type FormElementSectionDto
} from '~~/.api-client'
import type { FormElementId } from '~~/types/formElement'
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
import { useLocalStorageBackup } from '~/composables/useLocalStorageBackup'
import { useUserStore } from '~~/stores/useUserStore'
import { useCommentStore } from '~~/stores/useCommentStore'
const route = useRoute()
const toast = useToast()
const { t: $t } = useI18n()
const commentStore = useCommentStore()
definePageMeta({
// Prevent whole page from re-rendering when navigating between sections to keep state
key: (route) => `${route.params.id}`
})
const applicationFormId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const {
applicationForm,
navigationLinks: links,
updateApplicationForm
} = await useApplicationFormNavigation(applicationFormId!)
if (applicationFormId) {
await commentStore.loadCounts(applicationFormId)
}
const { updateApplicationForm: updateForm, submitApplicationForm } = useApplicationForm()
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
const { evaluateFormElementVisibility } = useFormElementVisibility()
const { canWriteApplicationForms } = usePermissions()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const sectionIndex = computed(() => {
const param = route.params.sectionIndex
const index = parseInt(Array.isArray(param) ? param[0]! : (param ?? '0'))
return !isNaN(index) ? index : 0
})
// LocalStorage backup for auto-save
const { hasBackup, backupTimestamp, backupSectionIndex, saveBackup, loadBackup, clearBackup } = useLocalStorageBackup(
applicationFormId!
)
const showRecoveryModal = ref(false)
const isReadOnly = computed(() => {
return applicationForm.value?.createdBy?.keycloakId !== user.value?.keycloakId
})
// Unsaved changes tracking
const originalFormJson = ref<string>('')
onMounted(async () => {
await nextTick()
originalFormJson.value = JSON.stringify(applicationForm.value)
window.addEventListener('beforeunload', handleBeforeUnload)
// Show recovery modal if backup exists
if (hasBackup.value) {
showRecoveryModal.value = true
}
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
const hasUnsavedChanges = computed(() => {
if (!originalFormJson.value) return false
return JSON.stringify(applicationForm.value) !== originalFormJson.value
})
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (hasUnsavedChanges.value) {
e.preventDefault()
}
}
onBeforeRouteLeave((to, from, next) => {
// Allow navigation between sections of the same form
if (to.params.id === from.params.id) {
next()
return
}
if (hasUnsavedChanges.value) {
const answer = window.confirm($t('applicationForms.unsavedWarning'))
next(answer)
} else {
next()
}
})
const validationMap = ref<Map<FormElementId, ComplianceStatus> | undefined>()
const validationStatus = ref<ComplianceStatus>(ComplianceStatus.NonCritical)
const allFormElements = computed(() => {
return (
applicationForm.value?.formElementSections?.flatMap((section: FormElementSectionDto) =>
section.formElementSubSections.flatMap((subsection) => subsection.formElements)
) ?? []
)
})
const visibilityMap = computed(() => {
return evaluateFormElementVisibility(allFormElements.value)
})
watch(
() => allFormElements.value,
(updatedFormElements: FormElementDto[]) => {
validationMap.value = validateFormElements(updatedFormElements, visibilityMap.value)
validationStatus.value = getHighestComplianceStatus()
},
{ deep: true }
)
// Re-capture baseline after section navigation to absorb initialization effects
// (e.g. TheTable applying row presets, TheEditor normalizing content on mount)
watch(sectionIndex, async () => {
await nextTick()
originalFormJson.value = JSON.stringify(applicationForm.value)
})
// Auto-save to localStorage with 5 second debounce
watchDebounced(
() => applicationForm.value,
(form) => {
if (form && hasUnsavedChanges.value && !isReadOnly.value) {
saveBackup(form, sectionIndex.value)
}
},
{ debounce: 5000, deep: true }
)
function handleRestoreBackup() {
const backupData = loadBackup()
if (backupData && applicationForm.value) {
updateApplicationForm(backupData)
originalFormJson.value = JSON.stringify(backupData)
showRecoveryModal.value = false
clearBackup()
// Navigate to the section user was editing
if (backupSectionIndex.value !== null && backupSectionIndex.value !== sectionIndex.value) {
navigateTo(`/application-forms/${applicationFormId}/${backupSectionIndex.value}`)
}
toast.add({
title: $t('common.success'),
description: $t('applicationForms.recovery.restore'),
color: 'success'
})
}
}
function handleDiscardBackup() {
clearBackup()
showRecoveryModal.value = false
}
async function onSave() {
if (applicationForm.value?.id) {
const updated = await updateForm(applicationForm.value.id, applicationForm.value)
if (updated) {
updateApplicationForm(updated)
originalFormJson.value = JSON.stringify(updated)
clearBackup()
toast.add({ title: $t('common.success'), description: $t('applicationForms.saved'), color: 'success' })
}
}
}
async function onSubmit() {
if (applicationForm.value?.id) {
// Save the form first to persist any unsaved changes before submitting
const updated = await updateForm(applicationForm.value.id, applicationForm.value)
if (updated) {
updateApplicationForm(updated)
}
await submitApplicationForm(applicationForm.value.id)
await navigateTo('/')
toast.add({ title: $t('common.success'), description: $t('applicationForms.submitted'), color: 'success' })
}
}
async function handleNavigate({ index }: { direction: 'forward' | 'backward'; index: number }) {
await navigateTo(`/application-forms/${applicationFormId}/${index}`)
}
function handleAddInputForm(updatedForm: ApplicationFormDto | undefined) {
if (updatedForm) {
updateApplicationForm(updatedForm)
}
}
function handleFormElementSectionsUpdate(sections: FormElementSectionDto[]) {
if (applicationForm.value) {
applicationForm.value.formElementSections = sections
}
}
</script>

View File

@@ -1,54 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<UDashboardPanel id="versions">
<template #header>
<UDashboardNavbar :title="$t('versions.pageTitle', { name: applicationForm?.name })">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<UNavigationMenu :items="links" highlight class="-mx-1 flex-1" />
</UDashboardToolbar>
</template>
<template #body>
<div class="p-6">
<VersionHistory
v-if="applicationForm?.id"
:application-form-id="applicationForm.id"
:current-form="applicationForm"
@restored="handleRestored"
/>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { t: $t } = useI18n()
definePageMeta({
key: (route) => `${route.params.id}-versions`
})
const applicationFormId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const { applicationForm, navigationLinks: links, refresh } = await useApplicationFormNavigation(applicationFormId!)
async function handleRestored() {
await refresh()
toast.add({
title: $t('versions.restored'),
description: $t('versions.restoredDescription'),
color: 'success'
})
if (!applicationForm.value?.id) {
return
}
router.push(`/application-forms/${applicationForm.value.id}/0`)
}
</script>

View File

@@ -1,33 +0,0 @@
<template>
<div class="min-h-screen w-full flex items-center justify-center">
<UCard variant="subtle" class="w-full max-w-sm text-center">
<div class="flex flex-col items-center gap-4 py-4">
<UIcon name="i-lucide-loader-circle" class="size-10 text-primary animate-spin" />
<p class="text-xl font-medium text-muted">
{{ $t('auth.redirecting') }}
</p>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
definePageMeta({ auth: false, layout: 'auth' })
const { t: $t } = useI18n()
onMounted(async () => {
const logger = useLogger().withTag('auth callback')
try {
// Check if we're in a popup window opened by nuxt-auth-utils
if (window.opener) {
window.close()
return
}
// Regular redirect flow (not a popup)
await navigateTo('/')
} catch (e) {
logger.error('Error during login', e)
}
})
</script>

View File

@@ -1,110 +0,0 @@
<template>
<UDashboardPanel id="contact">
<template #header>
<UDashboardNavbar :title="$t('contact.title')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-6 w-full lg:max-w-4xl mx-auto p-6">
<!-- Introduction Card -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('contact.header') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('contact.description') }}</p>
</div>
</template>
</UCard>
<!-- Contact Form -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('contact.form.title') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('contact.form.description') }}</p>
</div>
</template>
<form class="space-y-4" @submit.prevent="handleSubmit">
<UFormField :label="$t('contact.form.subject')" required>
<UInput
v-model="subject"
:placeholder="$t('contact.form.subjectPlaceholder')"
:disabled="isSubmitting"
class="w-full"
aria-required="true"
/>
</UFormField>
<UFormField :label="$t('contact.form.message')" required>
<ContactEditor
v-model="message"
:disabled="isSubmitting"
:placeholder="$t('contact.form.messagePlaceholder')"
/>
</UFormField>
<UButton
type="submit"
:label="$t('contact.form.submit')"
:loading="isSubmitting"
:disabled="isSubmitting || !isFormValid"
color="primary"
/>
</form>
</UCard>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { useContact } from '~/composables'
definePageMeta({
layout: 'default'
})
const { t: $t } = useI18n()
const toast = useToast()
const { sendContactMessage } = useContact()
const subject = ref('')
const message = ref('')
const isSubmitting = ref(false)
const isFormValid = computed(() => {
return subject.value.trim().length > 0 && message.value.trim().length > 0
})
async function handleSubmit() {
if (!isFormValid.value) return
isSubmitting.value = true
try {
await sendContactMessage(subject.value, message.value)
toast.add({
title: $t('contact.success.title'),
description: $t('contact.success.description'),
color: 'success'
})
// Reset form
subject.value = ''
message.value = ''
} catch {
toast.add({
title: $t('common.error'),
description: $t('contact.error.description'),
color: 'error'
})
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -1,253 +0,0 @@
<template>
<UDashboardPanel id="home">
<template #header>
<UDashboardNavbar :title="$t('common.home')" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<div class="flex-1" />
<USeparator orientation="vertical" class="h-8 mx-2" />
<FormValidationIndicator :status="validationStatus" />
</UDashboardToolbar>
</template>
<template #body>
<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">{{ $t('applicationForms.noPermission') }}</h2>
<p class="text-gray-500 mb-4">{{ $t('applicationForms.noPermissionDescription') }}</p>
<UButton to="/" class="mt-4"> {{ $t('applicationForms.backToOverview') }} </UButton>
</div>
<div v-else-if="applicationFormTemplate">
<FormStepperWithNavigation
:form-element-sections="applicationFormTemplate.formElementSections"
@save="onSave"
@submit="onSubmit"
@add-input-form="handleAddInputForm"
@update:form-element-sections="handleFormElementSectionsUpdate"
>
<UFormField :label="$t('common.name')" class="mb-4">
<UInput v-model="applicationFormTemplate.name" class="w-full" />
</UFormField>
</FormStepperWithNavigation>
</div>
</template>
</UDashboardPanel>
<!-- Recovery Modal - outside UDashboardPanel to avoid SSR Teleport hydration conflicts -->
<UModal
:open="showRecoveryModal"
:title="$t('applicationForms.recovery.title')"
@update:open="showRecoveryModal = $event"
>
<template #body>
<p class="text-sm text-(--ui-text-muted)">
{{
$t('applicationForms.recovery.message', {
timestamp: backupTimestamp?.toLocaleString()
})
}}
</p>
</template>
<template #footer>
<UButton color="neutral" variant="outline" @click="handleDiscardBackup">
{{ $t('applicationForms.recovery.discard') }}
</UButton>
<UButton color="primary" @click="handleRestoreBackup">
{{ $t('applicationForms.recovery.restore') }}
</UButton>
</template>
</UModal>
</template>
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import {
ComplianceStatus,
type FormElementDto,
type FormElementSectionDto,
type PagedApplicationFormDto
} from '~~/.api-client'
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
import { useLocalStorageBackup } from '~/composables/useLocalStorageBackup'
import type { FormElementId } from '~~/types/formElement'
import { useUserStore } from '~~/stores/useUserStore'
const { getAllApplicationFormTemplates } = useApplicationFormTemplate()
const { createApplicationForm, submitApplicationForm } = useApplicationForm()
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
const { evaluateFormElementVisibility } = useFormElementVisibility()
const { canWriteApplicationForms } = usePermissions()
const userStore = useUserStore()
const { selectedOrganization } = storeToRefs(userStore)
const toast = useToast()
const { t: $t } = useI18n()
const logger = useLogger().withTag('create')
const { data, error } = await useAsyncData<PagedApplicationFormDto>(
'create-application-form',
async () => {
return await getAllApplicationFormTemplates()
},
{ deep: true }
)
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
)
// LocalStorage backup for auto-save (using 'create' as key for new forms)
const { hasBackup, backupTimestamp, saveBackup, loadBackup, clearBackup } = useLocalStorageBackup('create')
const showRecoveryModal = ref(false)
const validationMap = ref<Map<FormElementId, ComplianceStatus> | undefined>()
const validationStatus = ref<ComplianceStatus>(ComplianceStatus.NonCritical)
// Unsaved changes tracking
const originalFormJson = ref<string>('')
onMounted(() => {
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
window.addEventListener('beforeunload', handleBeforeUnload)
// Show recovery modal if backup exists
if (hasBackup.value) {
showRecoveryModal.value = true
}
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
const hasUnsavedChanges = computed(() => {
if (!originalFormJson.value) return false
return JSON.stringify(applicationFormTemplate.value) !== originalFormJson.value
})
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (hasUnsavedChanges.value) {
e.preventDefault()
}
}
onBeforeRouteLeave((_to, _from, next) => {
if (hasUnsavedChanges.value) {
const answer = window.confirm($t('applicationForms.unsavedWarning'))
next(answer)
} else {
next()
}
})
const allFormElements = computed(() => {
return (
applicationFormTemplate.value?.formElementSections?.flatMap((section: FormElementSectionDto) =>
section.formElementSubSections.flatMap((subsection) => subsection.formElements)
) ?? []
)
})
const visibilityMap = computed(() => {
return evaluateFormElementVisibility(allFormElements.value)
})
watch(
() => allFormElements.value,
(updatedFormElements: FormElementDto[]) => {
validationMap.value = validateFormElements(updatedFormElements, visibilityMap.value)
validationStatus.value = getHighestComplianceStatus()
},
{ deep: true }
)
// Auto-save to localStorage with 5 second debounce
watchDebounced(
() => applicationFormTemplate.value,
(form) => {
if (form && hasUnsavedChanges.value) {
saveBackup(form, 0)
}
},
{ debounce: 5000, deep: true }
)
function handleRestoreBackup() {
const backupData = loadBackup()
if (backupData && data.value?.content[0]) {
// Restore the backed up form data to the template
Object.assign(data.value.content[0], backupData)
originalFormJson.value = JSON.stringify(backupData)
showRecoveryModal.value = false
clearBackup()
toast.add({
title: $t('common.success'),
description: $t('applicationForms.recovery.restore'),
color: 'success'
})
}
}
function handleDiscardBackup() {
clearBackup()
showRecoveryModal.value = false
}
async function onSave() {
const applicationForm = await prepareAndCreateApplicationForm()
if (applicationForm?.id) {
// Reset to prevent unsaved changes warning when navigating
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
clearBackup()
toast.add({ title: $t('common.success'), description: $t('applicationForms.saved'), color: 'success' })
await navigateTo(`/application-forms/${applicationForm.id}/0`)
}
}
async function onSubmit() {
const applicationForm = await prepareAndCreateApplicationForm()
if (applicationForm?.id) {
await submitApplicationForm(applicationForm.id)
// Reset to prevent unsaved changes warning when navigating
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
clearBackup()
await navigateTo('/')
toast.add({ title: $t('common.success'), description: $t('applicationForms.submitted'), color: 'success' })
}
}
function handleAddInputForm() {
// In create mode (no applicationFormId), addInputFormToApplicationForm returns undefined
// The form element is added locally to the template sections, which are reactive
// No action needed here
}
function handleFormElementSectionsUpdate(sections: FormElementSectionDto[]) {
if (applicationFormTemplate.value) {
applicationFormTemplate.value.formElementSections = sections
}
}
async function prepareAndCreateApplicationForm() {
if (!applicationFormTemplate.value) {
logger.error('Application form data is undefined')
return null
}
logger.debug('selectedOrganization', selectedOrganization.value)
applicationFormTemplate.value.organizationId = selectedOrganization.value?.id ?? ''
return await createApplicationForm(applicationFormTemplate.value)
}
</script>

View File

@@ -1,75 +0,0 @@
<template>
<UDashboardPanel id="help">
<template #header>
<UDashboardNavbar :title="$t('help.pageTitle')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-6 w-full lg:max-w-4xl mx-auto p-6">
<!-- Introduction Card -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('help.pageTitle') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('help.description') }}</p>
</div>
</template>
</UCard>
<!-- FAQ Section -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('help.faq.title') }}</h3>
</div>
</template>
<UAccordion :items="faqItems" />
</UCard>
<!-- Support Section -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('help.support.title') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('help.support.description') }}</p>
</div>
</template>
<UButton :label="$t('help.support.contactLink')" to="/contact" icon="i-lucide-mail" />
</UCard>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default'
})
const { t: $t } = useI18n()
const faqItems = computed(() => [
{
label: $t('help.faq.q1.question'),
content: $t('help.faq.q1.answer')
},
{
label: $t('help.faq.q2.question'),
content: $t('help.faq.q2.answer')
},
{
label: $t('help.faq.q3.question'),
content: $t('help.faq.q3.answer')
},
{
label: $t('help.faq.q4.question'),
content: $t('help.faq.q4.answer')
}
])
</script>

View File

@@ -1,242 +0,0 @@
<template>
<UDashboardPanel id="home">
<template #header>
<UDashboardNavbar :title="$t('common.home')" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
<div class="header-logo-accent w-8 h-8 rounded-lg flex items-center justify-center ml-2">
<UIcon name="i-lucide-file-text" class="size-5 text-white" />
</div>
</template>
<template #right>
{{ $t('organization.current') }}
<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="$t('notifications.tooltip')" :shortcuts="['N']">
<UButton
color="neutral"
variant="ghost"
square
class="hover:bg-teal-50 dark:hover:bg-teal-950/50 transition-colors"
@click="isNotificationsSlideoverOpen = true"
>
<UChip :show="hasUnread" color="error" inset>
<UIcon name="i-lucide-bell" class="size-5 shrink-0" />
</UChip>
</UButton>
</UTooltip>
<UButton
icon="i-lucide-circle-plus"
:label="$t('applicationForms.createNew')"
to="/create"
:disabled="!canWriteApplicationForms"
size="xl"
class="bg-gradient-to-br from-teal-500 to-cyan-500 text-white font-semibold rounded-xl shadow-lg shadow-teal-500/25 hover:from-cyan-500 hover:to-violet-500 hover:shadow-xl hover:shadow-violet-500/30 transition-all duration-200"
/>
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 w-full lg:max-w-4xl mx-auto p-4">
<UCard
v-for="(applicationFormElem, index) in applicationForms"
:key="applicationFormElem.id ?? index"
class="cursor-pointer hover:ring-2 hover:ring-primary transition-all duration-200"
@click="openApplicationForm(applicationFormElem.id)"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-lg text-highlighted truncate">
{{ applicationFormElem.name }}
</h3>
<p class="text-xs text-muted mt-1">#{{ index }}</p>
</div>
<div class="flex items-center gap-2">
<UTooltip
v-if="(applicationFormElem.commentCount ?? 0) > 0"
:text="$t('comments.count', { count: applicationFormElem.commentCount ?? 0 })"
>
<UBadge
:label="applicationFormElem.commentCount ?? 0"
color="neutral"
variant="subtle"
icon="i-lucide-message-square"
size="sm"
/>
</UTooltip>
<UBadge
v-if="applicationFormElem.status"
:label="applicationFormElem.status"
:color="getStatusColor(applicationFormElem.status)"
variant="subtle"
size="sm"
/>
<UDropdownMenu :items="[getLinksForApplicationForm(applicationFormElem)]" :content="{ align: 'end' }">
<UButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
size="sm"
square
@click.stop
/>
</UDropdownMenu>
</div>
</div>
</template>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm">
<UIcon name="i-lucide-pencil" class="size-4 text-muted shrink-0" />
<span class="text-muted">
{{ $t('applicationForms.lastEditedBy') }}
<span class="font-medium text-highlighted">
{{ applicationFormElem.lastModifiedBy?.name ?? '-' }}
</span>
{{ $t('common.on') }}
{{ applicationFormElem.modifiedAt ? formatDate(applicationFormElem.modifiedAt) : '-' }}
</span>
</div>
<div class="flex items-center gap-2 text-sm">
<UIcon name="i-lucide-user-plus" class="size-4 text-muted shrink-0" />
<span class="text-muted">
{{ $t('applicationForms.createdBy') }}
<span class="font-medium text-highlighted">
{{ applicationFormElem.createdBy?.name ?? '-' }}
</span>
{{ $t('common.on') }}
{{ applicationFormElem.createdAt ? formatDate(applicationFormElem.createdAt) : '-' }}
</span>
</div>
</div>
</UCard>
</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)
const { t: $t } = useI18n()
// Inject notification state from layout
const { isNotificationsSlideoverOpen, hasUnread } = inject('notificationState', {
isNotificationsSlideoverOpen: ref(false),
hasUnread: ref(false)
})
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
const foundOrg = organizations.value?.find((i: Organization) => i.id === item) ?? null
if (foundOrg !== undefined) {
selectedOrganization.value = foundOrg
}
}
})
const { canWriteApplicationForms } = usePermissions()
const applicationForms = computed({
get: () => data?.value?.content ?? [],
set: (val) => {
if (val && data.value) {
data.value.content = val
}
}
})
function getLinksForApplicationForm(applicationForm: ApplicationFormDto) {
if (!applicationForm.id) {
return []
}
return [
{
label: $t('common.delete'),
icon: 'i-lucide-trash',
to: `?delete&id=${applicationForm.id}`,
disabled: !canWriteApplicationForms.value
}
]
}
function getStatusColor(status: string) {
const statusMap: Record<string, 'success' | 'warning' | 'error' | 'info' | 'neutral'> = {
COMPLETED: 'success',
IN_PROGRESS: 'warning',
DRAFT: 'info',
REJECTED: 'error',
PENDING: 'warning'
}
return statusMap[status] || 'neutral'
}
async function deleteApplicationForm(applicationFormId: string) {
await deleteApplicationFormById(applicationFormId)
data.value?.content.splice(
data.value?.content.findIndex((appForm) => appForm.id === applicationFormId),
1
)
isDeleteModalOpen.value = false
}
function openApplicationForm(applicationFormId: string | null | undefined) {
if (!applicationFormId) {
return
}
navigateTo(`application-forms/${applicationFormId}/0`)
}
</script>

View File

@@ -1,46 +0,0 @@
<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 dark:text-gray-100 mb-2">
{{ $t('auth.welcome') }}
</h1>
<p class="text-gray-600 dark:text-gray-400">
{{ $t('auth.redirectMessage') }}
</p>
</div>
</template>
<div class="text-center">
<UButton color="primary" size="xl" icon="i-lucide-log-in" @click="handleSignIn">
{{ $t('auth.signIn') }}
</UButton>
</div>
<template #footer>
<div class="text-center text-xs text-gray-500">
{{ $t('auth.termsAgreement') }}
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
definePageMeta({ auth: false, layout: 'auth' })
const { t: $t } = useI18n()
const { loggedIn, openInPopup } = useUserSession()
useSeoMeta({ title: $t('auth.login') })
watch(loggedIn, (isLoggedIn) => {
if (isLoggedIn) {
navigateTo('/')
}
})
function handleSignIn() {
openInPopup('/auth/keycloak')
}
</script>

View File

@@ -1,74 +0,0 @@
<template>
<UDashboardPanel id="organization">
<template #header>
<UDashboardNavbar :title="$t('organization.pageTitle')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-6 w-full lg:max-w-4xl mx-auto p-6">
<template v-if="selectedOrganization">
<!-- Organization Details Card -->
<UCard>
<template #header>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-12 h-12 rounded-lg bg-primary/10">
<UIcon name="i-lucide-building-2" class="w-6 h-6 text-primary" />
</div>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ selectedOrganization.name }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('organization.current') }}</p>
</div>
</div>
</template>
<div class="space-y-4">
<div>
<label class="text-sm font-medium text-muted">{{ $t('organization.name') }}</label>
<p class="text-base text-highlighted mt-1">{{ selectedOrganization.name }}</p>
</div>
<div>
<label class="text-sm font-medium text-muted">{{ $t('organization.id') }}</label>
<p class="text-base text-highlighted mt-1 font-mono text-sm">{{ selectedOrganization.id }}</p>
</div>
</div>
</UCard>
</template>
<template v-else>
<!-- No Organization Card -->
<UCard>
<template #header>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-12 h-12 rounded-lg bg-warning/10">
<UIcon name="i-lucide-alert-triangle" class="w-6 h-6 text-warning" />
</div>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('organization.noOrganization') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('organization.noOrganizationDescription') }}</p>
</div>
</div>
</template>
</UCard>
</template>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { useUserStore } from '~~/stores/useUserStore'
definePageMeta({
layout: 'default'
})
const { t: $t } = useI18n()
const userStore = useUserStore()
const { selectedOrganization } = storeToRefs(userStore)
</script>

View File

@@ -1,220 +0,0 @@
<template>
<UDashboardPanel id="settings">
<template #header>
<UDashboardNavbar :title="$t('settings.title')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-6 w-full lg:max-w-4xl mx-auto p-6">
<!-- Language Section -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('settings.language.title') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('settings.language.description') }}</p>
</div>
</template>
<ULocaleSelect
:model-value="locale"
:locales="[de, en]"
class="w-full max-w-xs"
size="md"
@update:model-value="handleLocaleChange"
/>
</UCard>
<!-- Appearance Section -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('settings.appearance.title') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('settings.appearance.description') }}</p>
</div>
</template>
<URadioGroup v-model="selectedColorMode" :items="colorModeOptions" class="gap-4" />
</UCard>
<!-- Theme Colors Section -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('settings.theme.title') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('settings.theme.description') }}</p>
</div>
</template>
<div class="space-y-6">
<!-- Primary Color -->
<div>
<h4 class="text-sm font-medium text-highlighted mb-3">{{ $t('settings.theme.primary') }}</h4>
<div class="grid grid-cols-10 gap-2">
<button
v-for="color in colors"
:key="color"
type="button"
:class="[
'w-10 h-10 rounded-md transition-all',
appConfig.ui.colors.primary === color
? 'ring-2 ring-offset-2 ring-offset-background'
: 'hover:scale-110'
]"
:style="{
backgroundColor: `var(--color-${color}-500)`,
'--tw-ring-color': `var(--color-${color}-500)`
}"
@click="appConfig.ui.colors.primary = color"
/>
</div>
</div>
</div>
</UCard>
<!-- Email Notifications Section -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('settings.email.title') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('settings.email.description') }}</p>
</div>
</template>
<div class="space-y-4">
<UInput
v-model="emailAddress"
:label="$t('settings.email.emailAddress')"
type="email"
placeholder="user@example.com"
class="w-full max-w-md"
/>
<div class="space-y-3">
<UCheckbox v-model="emailOnFormCreated" :label="$t('settings.email.onFormCreated')" />
<UCheckbox v-model="emailOnFormSubmitted" :label="$t('settings.email.onFormSubmitted')" />
<UCheckbox v-model="emailOnFormUpdated" :label="$t('settings.email.onFormUpdated')" />
<UCheckbox v-model="emailOnCommentAdded" :label="$t('settings.email.onCommentAdded')" />
</div>
<UButton :label="$t('common.save')" color="primary" :loading="isSaving" @click="saveEmailPreferences" />
</div>
</UCard>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { de, en } from '@nuxt/ui/locale'
import { useUserStore } from '~~/stores/useUserStore'
import { useUser } from '~/composables'
definePageMeta({
layout: 'default'
})
const { t: $t, locale, setLocale } = useI18n()
const colorMode = useColorMode()
const appConfig = useAppConfig()
const toast = useToast()
const userStore = useUserStore()
const { getUserById, updateEmailPreferences } = useUser()
const logger = useLogger().withTag('settings')
const colors = [
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink'
]
const emailAddress = ref('')
const emailOnFormCreated = ref(true)
const emailOnFormSubmitted = ref(true)
const emailOnFormUpdated = ref(true)
const emailOnCommentAdded = ref(true)
const isSaving = ref(false)
onMounted(async () => {
if (userStore.user) {
try {
const userData = await getUserById(userStore.user.keycloakId)
emailAddress.value = userData.email || ''
emailOnFormCreated.value = userData.emailOnFormCreated ?? true
emailOnFormSubmitted.value = userData.emailOnFormSubmitted ?? true
emailOnFormUpdated.value = userData.emailOnFormUpdated ?? true
emailOnCommentAdded.value = userData.emailOnCommentAdded ?? true
} catch (error) {
logger.error('Failed to load user email preferences:', error)
}
}
})
async function saveEmailPreferences() {
if (!userStore.user) return
isSaving.value = true
try {
await updateEmailPreferences(
userStore.user.keycloakId,
emailAddress.value || null,
emailOnFormCreated.value,
emailOnFormSubmitted.value,
emailOnFormUpdated.value,
emailOnCommentAdded.value
)
toast.add({
title: $t('settings.email.saved'),
color: 'success'
})
} catch {
toast.add({
title: $t('common.error'),
color: 'error'
})
} finally {
isSaving.value = false
}
}
function handleLocaleChange(newLocale: string | undefined) {
if (newLocale) {
setLocale(newLocale as 'de' | 'en')
}
}
const colorModeOptions = computed(() => [
{
value: 'light',
label: $t('settings.appearance.light'),
icon: 'i-lucide-sun'
},
{
value: 'dark',
label: $t('settings.appearance.dark'),
icon: 'i-lucide-moon'
}
])
const selectedColorMode = computed({
get: () => colorMode.value,
set: (value) => {
colorMode.preference = value as 'light' | 'dark'
}
})
</script>

View File

@@ -1,18 +0,0 @@
import { createLogger } from '~~/shared/utils/logger'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const logger = createLogger({
level: config.public.logLevel,
tag: 'app error-handler',
fancy: import.meta.env.MODE !== 'production'
})
nuxtApp.hook('vue:error', (error, instance, info) => {
logger.error('Vue error:', error, 'Instance:', instance, 'Info:', info)
})
nuxtApp.hook('app:error', (error) => {
logger.error('App error:', error)
})
})

View File

@@ -1,16 +0,0 @@
import { createLogger } from '~~/shared/utils/logger'
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const logger = createLogger({
level: config.public.logLevel,
tag: 'legalconsenthub',
fancy: import.meta.env.MODE !== 'production'
})
return {
provide: {
logger
}
}
})

View File

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

View File

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

View File

@@ -1,439 +0,0 @@
import type {
ApplicationFormDto,
ApplicationFormSnapshotDto,
FormElementDto,
FormElementSnapshotDto,
FormOptionDto,
FormElementType
} from '~~/.api-client'
import type { FormValueDiff, ValueChange, SectionChanges, TableDiff, TableRowDiff } from '~~/types/formSnapshotComparison'
// Element types that use true/false selection model
const SELECTION_TYPES: FormElementType[] = ['SELECT', 'RADIOBUTTON', 'CHECKBOX', 'SWITCH']
// Element types that store text in the first option's value
const TEXT_INPUT_TYPES: FormElementType[] = ['TEXTFIELD', 'TEXTAREA', 'RICH_TEXT', 'DATE']
/**
* Compare two application forms and return a value-based diff for improved UX.
* This focuses on what values changed rather than technical option-level changes.
*/
export function compareApplicationFormValues(
current: ApplicationFormDto,
versionSnapshot: ApplicationFormSnapshotDto
): FormValueDiff {
const diff: FormValueDiff = {
newAnswers: [],
changedAnswers: [],
clearedAnswers: []
}
const currentElements = flattenFormElements(current)
const versionElements = flattenSnapshotElements(versionSnapshot)
// Create maps by reference for matching elements
const currentByRef = new Map(
currentElements.filter((el) => el.element.reference).map((el) => [el.element.reference!, el])
)
const versionByRef = new Map(
versionElements.filter((el) => el.element.reference).map((el) => [el.element.reference!, el])
)
// Compare elements that exist in current form
for (const [ref, currentData] of currentByRef) {
const elementType = currentData.element.type
const versionData = versionByRef.get(ref)
// Extract the actual user-selected/entered value based on element type
const currentValue = extractUserValue(currentData.element.options, elementType)
const versionValue = versionData ? extractUserValue(versionData.element.options, versionData.element.type) : null
// Get human-readable labels
const currentLabel = formatUserValueLabel(currentValue, currentData.element.options, elementType)
const versionLabel = versionData
? formatUserValueLabel(versionValue, versionData.element.options, versionData.element.type)
: null
// Skip if values are the same (actual values haven't changed)
// For tables, compare the serialized data directly since labels only show row count
if (elementType === 'TABLE') {
const currentSerialized = typeof currentValue === 'string' ? currentValue : null
const versionSerialized = typeof versionValue === 'string' ? versionValue : null
if (currentSerialized === versionSerialized) {
continue
}
} else if (currentLabel === versionLabel) {
continue
}
// For tables, compute structured diff
let tableDiff: TableDiff | undefined
if (elementType === 'TABLE') {
tableDiff = computeTableDiff(versionData?.element.options || [], currentData.element.options)
}
const change: ValueChange = {
sectionTitle: currentData.sectionTitle,
elementTitle: currentData.element.title || '',
elementType: elementType,
previousValue: versionValue,
currentValue: currentValue,
previousLabel: versionLabel,
currentLabel: currentLabel,
tableDiff
}
if (isEmptyLabel(versionLabel) && !isEmptyLabel(currentLabel)) {
// New answer (first time answered)
diff.newAnswers.push(change)
} else if (!isEmptyLabel(versionLabel) && isEmptyLabel(currentLabel)) {
// Cleared answer
diff.clearedAnswers.push(change)
} else {
// Changed answer
diff.changedAnswers.push(change)
}
}
// Check for elements that existed in version but not in current (cleared)
for (const [ref, versionData] of versionByRef) {
if (!currentByRef.has(ref)) {
const elementType = versionData.element.type
const versionValue = extractUserValue(versionData.element.options, elementType)
const versionLabel = formatUserValueLabel(versionValue, versionData.element.options, elementType)
if (!isEmptyLabel(versionLabel)) {
let tableDiff: TableDiff | undefined
if (elementType === 'TABLE') {
tableDiff = computeTableDiff(versionData.element.options, [])
}
diff.clearedAnswers.push({
sectionTitle: versionData.sectionTitle,
elementTitle: versionData.element.title || '',
elementType: elementType,
previousValue: versionValue,
currentValue: null,
previousLabel: versionLabel,
currentLabel: null,
tableDiff
})
}
}
}
return diff
}
/**
* Compute a structured diff between two table states.
*/
function computeTableDiff(previousOptions: FormOptionDto[], currentOptions: FormOptionDto[]): TableDiff {
const previousRows = parseTableToRows(previousOptions)
const currentRows = parseTableToRows(currentOptions)
// Get all column labels from both tables
const columnSet = new Set<string>()
previousOptions.forEach((opt) => columnSet.add(opt.label))
currentOptions.forEach((opt) => columnSet.add(opt.label))
const columns = Array.from(columnSet)
const maxRows = Math.max(previousRows.length, currentRows.length)
const rows: TableRowDiff[] = []
let addedCount = 0
let removedCount = 0
let modifiedCount = 0
for (let i = 0; i < maxRows; i++) {
const prevRow = previousRows[i]
const currRow = currentRows[i]
if (!prevRow && currRow) {
// Row was added
rows.push({
rowIndex: i,
changeType: 'added',
previousValues: {},
currentValues: currRow
})
addedCount++
} else if (prevRow && !currRow) {
// Row was removed
rows.push({
rowIndex: i,
changeType: 'removed',
previousValues: prevRow,
currentValues: {}
})
removedCount++
} else if (prevRow && currRow) {
// Check if row was modified
const isModified = columns.some((col) => {
const prevVal = prevRow[col] || ''
const currVal = currRow[col] || ''
return prevVal !== currVal
})
rows.push({
rowIndex: i,
changeType: isModified ? 'modified' : 'unchanged',
previousValues: prevRow,
currentValues: currRow
})
if (isModified) {
modifiedCount++
}
}
}
return {
columns,
rows,
addedCount,
removedCount,
modifiedCount
}
}
/**
* Group changes by section for accordion display
*/
export function groupChangesBySection(diff: FormValueDiff): SectionChanges[] {
const allChanges = [...diff.newAnswers, ...diff.changedAnswers, ...diff.clearedAnswers]
const sectionMap = new Map<string, ValueChange[]>()
for (const change of allChanges) {
const existing = sectionMap.get(change.sectionTitle) || []
existing.push(change)
sectionMap.set(change.sectionTitle, existing)
}
return Array.from(sectionMap.entries()).map(([sectionTitle, changes]) => ({
sectionTitle,
changes
}))
}
/**
* Extract the actual user-selected or user-entered value from form options.
* Different element types store values differently.
*/
function extractUserValue(options: FormOptionDto[], elementType: FormElementType): string | string[] | null {
if (!options || options.length === 0) {
return null
}
// For selection-based elements (SELECT, RADIOBUTTON, CHECKBOX, SWITCH)
// The selected option(s) have value === "true"
if (SELECTION_TYPES.includes(elementType)) {
const selectedOptions = options.filter((opt) => opt.value === 'true')
if (selectedOptions.length === 0) {
return null
}
// Return the labels of selected options (what the user actually sees)
const selectedLabels = selectedOptions.map((opt) => opt.label).filter((label): label is string => !!label)
if (selectedLabels.length === 0) {
return null
}
return selectedLabels.length === 1 ? (selectedLabels[0] ?? null) : selectedLabels
}
// For text input elements (TEXTFIELD, TEXTAREA, RICH_TEXT, DATE)
// The value is stored in the first option's value field
if (TEXT_INPUT_TYPES.includes(elementType)) {
const value = options[0]?.value
if (!value || value.trim() === '') {
return null
}
return value
}
// For TABLE elements - return serialized table data for comparison
if (elementType === 'TABLE') {
return serializeTableData(options)
}
return null
}
/**
* Serialize table data into a comparable string format.
* This captures all rows and all columns for accurate comparison.
*/
function serializeTableData(options: FormOptionDto[]): string | null {
if (!options || options.length === 0) {
return null
}
const tableRows = parseTableToRows(options)
if (tableRows.length === 0) {
return null
}
// Serialize as JSON for accurate comparison
return JSON.stringify(tableRows)
}
/**
* Parse table options into row-based data structure.
* Returns array of rows, where each row is an object with column labels as keys.
*/
function parseTableToRows(options: FormOptionDto[]): Record<string, string>[] {
if (!options || options.length === 0) {
return []
}
// Parse all columns
const columnData: { label: string; values: (string | boolean | string[])[] }[] = options.map((option) => {
let values: (string | boolean | string[])[] = []
try {
const parsed = JSON.parse(option.value || '[]')
values = Array.isArray(parsed) ? parsed : []
} catch {
values = []
}
return { label: option.label, values }
})
// Find max row count
const rowCount = Math.max(...columnData.map((col) => col.values.length), 0)
if (rowCount === 0) {
return []
}
// Build rows
const rows: Record<string, string>[] = []
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row: Record<string, string> = {}
columnData.forEach((col) => {
const cellValue = col.values[rowIndex]
const formattedValue = formatTableCellValue(cellValue)
row[col.label] = formattedValue
})
rows.push(row)
}
return rows
}
/**
* Format a single table cell value to a readable string.
*/
function formatTableCellValue(value: string | boolean | string[] | undefined | null): string {
if (value === undefined || value === null) {
return ''
}
if (typeof value === 'boolean') {
return value ? 'Ja' : 'Nein'
}
if (Array.isArray(value)) {
const filtered = value.filter((v) => v && String(v).trim() !== '')
return filtered.length > 0 ? filtered.join(', ') : ''
}
if (typeof value === 'string') {
return value.trim()
}
return String(value)
}
/**
* Format a user value to a human-readable label.
* For selection types, the value IS already the label.
* For text types, we return the text as-is.
* For tables, we format as a summary.
*/
function formatUserValueLabel(
value: string | string[] | null,
options: FormOptionDto[],
elementType: FormElementType
): string | null {
if (value === null || value === undefined) {
return null
}
// For tables, create a summary (row count)
if (elementType === 'TABLE') {
return formatTableSummary(options)
}
if (Array.isArray(value)) {
if (value.length === 0) {
return null
}
return value.join(', ')
}
if (typeof value === 'string' && value.trim() === '') {
return null
}
return value
}
/**
* Format table data into a brief summary label.
*/
function formatTableSummary(options: FormOptionDto[]): string | null {
const rows = parseTableToRows(options)
if (rows.length === 0) {
return null
}
const rowLabel = rows.length === 1 ? 'Zeile' : 'Zeilen'
return `${rows.length} ${rowLabel}`
}
/**
* Check if a label is considered "empty" (no answer given)
*/
function isEmptyLabel(label: string | null | undefined): boolean {
if (label === null || label === undefined) return true
if (label.trim() === '') return true
return false
}
/**
* Flatten form elements from ApplicationFormDto into a flat list.
*/
function flattenFormElements(form: ApplicationFormDto): Array<{ element: FormElementDto; sectionTitle: string }> {
const elements: Array<{ element: FormElementDto; sectionTitle: string }> = []
for (const section of form.formElementSections) {
for (const subsection of section.formElementSubSections) {
for (const element of subsection.formElements) {
elements.push({ element, sectionTitle: section.title })
}
}
}
return elements
}
/**
* Flatten form elements from snapshot into a flat list.
*/
function flattenSnapshotElements(
snapshot: ApplicationFormSnapshotDto
): Array<{ element: FormElementSnapshotDto; sectionTitle: string }> {
const elements: Array<{ element: FormElementSnapshotDto; sectionTitle: string }> = []
for (const section of snapshot.sections) {
for (const subsection of section.subsections) {
for (const element of subsection.elements) {
elements.push({ element, sectionTitle: section.title })
}
}
}
return elements
}

View File

@@ -1,62 +0,0 @@
import type { HTTPMethod } from 'h3'
import { useLogger } from '../composables/useLogger'
// 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) {
const logger = useLogger().withTag('wrappedFetch')
logger.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'
}
})
}
}
}

View File

@@ -1,15 +0,0 @@
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
rules: {
'vue/no-multiple-template-root': 'off',
'vue/html-self-closing': [
'error',
{
html: {
void: 'any'
}
}
]
}
})

View File

@@ -1,336 +0,0 @@
{
"applicationForms": {
"title": "Mitbestimmungsanträge",
"createNew": "Neuer Mitbestimmungsantrag",
"noFormsAvailable": "Keine Anträge vorhanden",
"noPermission": "Keine Berechtigung",
"noPermissionDescription": "Sie haben keine Berechtigung zum Erstellen von Anträgen.",
"backToOverview": "Zurück zur Übersicht",
"noVisibleElements": "Alle Formularelemente in diesem Abschnitt sind ausgeblendet",
"deleteConfirm": "Möchten Sie wirklich den Mitbestimmungsantrag \"{name}\" löschen?",
"deleteTitle": "Mitbestimmungsantrag löschen",
"lastEditedBy": "Zuletzt bearbeitet von",
"createdBy": "Erstellt von",
"saved": "Antrag erfolgreich gespeichert",
"submitted": "Antrag erfolgreich eingereicht",
"deleted": "Antrag erfolgreich gelöscht",
"formElements": {
"comments": "Kommentare",
"addInputBelow": "Eingabefeld hinzufügen",
"addAnother": "Weiteres hinzufügen",
"selectPlaceholder": "Status auswählen",
"selectDate": "Datum auswählen",
"title": "Titel",
"text": "Text",
"unimplemented": "Element nicht implementiert:",
"richTextPlaceholder": "Schreiben Sie hier Ihre Ergänzungen...",
"table": {
"addRow": "Zeile hinzufügen",
"removeRow": "Zeile entfernen",
"emptyValue": "Keine Eingabe",
"noData": "Keine Daten vorhanden",
"selectValue": "Wert auswählen",
"enlargeTable": "Tabelle vergrößern",
"editTable": "Tabelle bearbeiten"
},
"fileUpload": {
"label": "Dateien hochladen",
"dropFiles": "Dateien hier ablegen",
"orClickToUpload": "oder klicken Sie, um Dateien auszuwählen",
"selectFiles": "Dateien auswählen",
"allowedTypes": "Erlaubt: PDF, DOCX, DOC, ODT, JPG, PNG, ZIP (max. 10MB pro Datei)",
"uploadedFiles": "Hochgeladene Dateien",
"uploading": "Wird hochgeladen...",
"uploadError": "Upload-Fehler",
"uploadFailed": "Datei-Upload fehlgeschlagen. Bitte versuchen Sie es erneut.",
"fileTooLarge": "Die Datei \"{filename}\" ist zu groß. Maximale Größe: {maxSize}",
"unsupportedType": "Dieser Dateityp wird nicht unterstützt.",
"deleteFailed": "Datei konnte nicht gelöscht werden.",
"downloadFailed": "Datei konnte nicht heruntergeladen werden.",
"viewFailed": "Datei konnte nicht zur Ansicht geöffnet werden.",
"view": "Ansehen"
}
},
"status": {
"draft": "Entwurf",
"submitted": "Eingereicht",
"approved": "Genehmigt",
"rejected": "Abgelehnt",
"signed": "Signiert"
},
"navigation": {
"previous": "Zurück",
"next": "Weiter",
"save": "Speichern",
"submit": "Einreichen"
},
"tabs": {
"form": "Formular",
"versions": "Versionen",
"preview": "Vorschau"
},
"unsavedWarning": "Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?",
"recovery": {
"title": "Nicht gespeicherte Änderungen gefunden",
"message": "Es wurde eine lokale Sicherung von {timestamp} gefunden. Möchten Sie diese wiederherstellen?",
"sectionNote": "Sie haben Abschnitt {section} bearbeitet.",
"restore": "Wiederherstellen",
"discard": "Verwerfen"
}
},
"templates": {
"title": "Vorlagen",
"editorTitle": "Administration - JSON Template Editor",
"newTemplate": "Neue Vorlage",
"reset": "Zurücksetzen",
"unsavedChanges": "Ungespeicherte Änderungen",
"noTemplateFound": "Keine Vorlage gefunden. Erstellen Sie eine neue Vorlage.",
"invalidJson": "Ungültiges JSON",
"invalidJsonDescription": "Das JSON-Format ist ungültig. Bitte korrigieren Sie die Syntax.",
"lastModified": "Zuletzt bearbeitet am",
"created": "Vorlage erfolgreich erstellt",
"updated": "Vorlage erfolgreich aktualisiert",
"saveError": "Fehler beim Speichern der Vorlage",
"unsavedWarning": "Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?"
},
"versions": {
"title": "Versionen",
"pageTitle": "Versionen: {name}",
"empty": "Keine Versionen verfügbar",
"loading": "Versionen werden geladen...",
"loadError": "Fehler beim Laden der Versionen",
"loadErrorDescription": "Versionen konnten nicht geladen werden",
"unknownError": "Unbekannter Fehler",
"compare": "Vergleichen",
"restore": "Wiederherstellen",
"openPdf": "PDF öffnen",
"restored": "Erfolg",
"restoredDescription": "Das Formular wurde auf die ausgewählte Version zurückgesetzt.",
"restoreError": "Version konnte nicht wiederhergestellt werden",
"restoreTitle": "Version wiederherstellen",
"restoreConfirm": "Möchten Sie Version v{number} wirklich wiederherstellen?",
"restoreDescription": "Dies erstellt eine neue Version mit dem Inhalt der ausgewählten Version. Die aktuelle Version und alle Änderungen bleiben in der Historie erhalten.",
"comparisonTitle": "Vergleich: Aktuelles Formular mit Version v{number}",
"comparisonError": "Fehler beim Laden der Version",
"elementsAdded": "Hinzugefügte Elemente ({count})",
"elementsRemoved": "Entfernte Elemente ({count})",
"elementsModified": "Geänderte Elemente ({count})",
"elementWithoutTitle": "Ohne Titel",
"elementIn": "Element in",
"optionsAdded": "Optionen hinzugefügt ({count})",
"optionsRemoved": "Optionen entfernt ({count})",
"optionsModified": "Optionen geändert ({count})",
"noChanges": "Keine Unterschiede gefunden",
"changesSummary": "{count} Änderungen seit dieser Version",
"changesCount": "{count} Änderung | {count} Änderungen",
"before": "Vorher",
"after": "Jetzt",
"noValue": "Keine Angabe",
"newAnswer": "Neu beantwortet",
"changedAnswer": "Geändert",
"clearedAnswer": "Gelöscht",
"tableRowsAdded": "hinzugefügt",
"tableRowsRemoved": "entfernt",
"tableRowsModified": "geändert",
"tableStatus": "Status",
"rowAdded": "Neu",
"rowRemoved": "Entfernt",
"rowModified": "Geändert"
},
"comments": {
"title": "Kommentare",
"empty": "Keine Kommentare vorhanden",
"count": "{count} Kommentare",
"placeholder": "Kommentar hinzufügen...",
"loadMore": "Mehr laden",
"submit": "Absenden",
"edit": "Kommentar bearbeiten",
"editAction": "Bearbeiten",
"saveChanges": "Änderungen speichern",
"created": "Kommentar erfolgreich erstellt",
"createError": "Fehler beim Erstellen des Kommentars",
"updated": "Kommentar erfolgreich aktualisiert",
"updateError": "Fehler beim Aktualisieren des Kommentars"
},
"compliance": {
"title": "Compliance-Status",
"critical": "Kritisch",
"warning": "Warnung",
"nonCritical": "Unkritisch"
},
"notifications": {
"title": "Benachrichtigungen",
"empty": "Keine Benachrichtigungen",
"unreadCount": "{count} ungelesen",
"tooltip": "Benachrichtigungen",
"markAllRead": "Alle als gelesen markieren",
"deleteAll": "Alle löschen",
"delete": "Benachrichtigung löschen"
},
"administration": {
"title": "Administration",
"accessDenied": "Zugriff verweigert"
},
"user": {
"administration": "Administration",
"settings": "Einstellungen",
"logout": "Abmelden"
},
"settings": {
"title": "Einstellungen",
"language": {
"title": "Sprache",
"description": "Wählen Sie Ihre bevorzugte Sprache"
},
"appearance": {
"title": "Erscheinungsbild",
"description": "Wählen Sie zwischen hellem und dunklem Modus",
"light": "Hell",
"dark": "Dunkel"
},
"theme": {
"title": "Farbschema",
"description": "Passen Sie die Farben der Anwendung an",
"primary": "Primärfarbe",
"neutral": "Neutralfarbe"
}
},
"organization": {
"title": "Organisation",
"pageTitle": "Organisationsprofil",
"current": "Aktuelle Organisation",
"name": "Organisationsname",
"id": "Organisations-ID",
"noOrganization": "Keine Organisation zugewiesen",
"noOrganizationDescription": "Sie sind derzeit keiner Organisation zugewiesen. Bitte kontaktieren Sie Ihren Administrator."
},
"help": {
"title": "Hilfe",
"pageTitle": "Hilfe & FAQ",
"description": "Hier finden Sie Antworten auf häufig gestellte Fragen und Anleitungen zur Nutzung der Plattform.",
"faq": {
"title": "Häufig gestellte Fragen",
"q1": {
"question": "Was ist Mitbestimmung?",
"answer": "Mitbestimmung bezeichnet das Recht der Arbeitnehmer, an Entscheidungen im Unternehmen mitzuwirken. In Deutschland ist dieses Recht gesetzlich verankert, insbesondere durch das Betriebsverfassungsgesetz."
},
"q2": {
"question": "Wer kann Anträge einreichen?",
"answer": "Anträge können von berechtigten Nutzern eingereicht werden, die eine entsprechende Rolle in der Organisation haben. Die genauen Berechtigungen werden von Ihrem Administrator festgelegt."
},
"q3": {
"question": "Wie lange dauert die Bearbeitung eines Antrags?",
"answer": "Die Bearbeitungszeit variiert je nach Art des Antrags und der Verfügbarkeit der zuständigen Personen. Sie werden über Statusänderungen per E-Mail benachrichtigt."
},
"q4": {
"question": "Kann ich meine Einstellungen ändern?",
"answer": "Ja, unter 'Einstellungen' können Sie Ihre Sprach- und Anzeigeeinstellungen sowie E-Mail-Benachrichtigungen anpassen."
}
},
"support": {
"title": "Weitere Unterstützung",
"description": "Haben Sie weitere Fragen oder benötigen Sie individuelle Unterstützung? Nutzen Sie unser Kontaktformular.",
"contactLink": "Zum Kontaktformular"
}
},
"auth": {
"welcome": "Willkommen",
"redirectMessage": "Sie werden zur Authentifizierung zu Keycloak weitergeleitet",
"signIn": "Anmelden",
"termsAgreement": "Mit der Anmeldung stimmen Sie unseren Nutzungsbedingungen zu",
"login": "Anmelden",
"redirecting": "Sie werden weitergeleitet"
},
"error": {
"pageNotFound": "Seite nicht gefunden",
"pageNotFoundDescription": "Es tut uns leid, aber diese Seite konnte nicht gefunden werden."
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"confirmDelete": "Möchten Sie dies wirklich löschen?",
"edit": "Bearbeiten",
"close": "Schließen",
"confirm": "Bestätigen",
"back": "Zurück",
"loading": "Laden...",
"error": "Fehler",
"success": "Erfolg",
"name": "Name",
"home": "Home",
"type": "Typ",
"general": "Allgemein",
"on": "am"
},
"serverConnection": {
"title": "Verbindung zum Server unterbrochen",
"message": "Die Verbindung zum Server ist momentan nicht verfügbar. Wir versuchen automatisch, die Verbindung wiederherzustellen.",
"checking": "Verbindung wird geprüft...",
"lastCheck": "Letzte Überprüfung",
"retryInfo": "Automatischer Wiederholungsversuch alle 60 Sekunden",
"retryNow": "Jetzt erneut versuchen"
},
"roles": {
"admin": "Administrator",
"employee": "Arbeitnehmer",
"employer": "Arbeitgeber",
"worksCouncilMember": "Betriebsratsmitglied",
"worksCouncilChair": "Betriebsratsvorsitzender"
},
"settings": {
"title": "Einstellungen",
"language": {
"title": "Sprache",
"description": "Wählen Sie Ihre bevorzugte Sprache"
},
"appearance": {
"title": "Erscheinungsbild",
"description": "Wählen Sie Ihr bevorzugtes Farbschema",
"light": "Hell",
"dark": "Dunkel"
},
"theme": {
"title": "Designfarben",
"description": "Passen Sie die Primärfarbe an",
"primary": "Primärfarbe"
},
"email": {
"title": "E-Mail-Benachrichtigungen",
"description": "Verwalten Sie Ihre E-Mail-Benachrichtigungseinstellungen",
"emailAddress": "E-Mail-Adresse",
"onFormCreated": "Bei Erstellung eines Antrags",
"onFormSubmitted": "Bei Einreichung eines Antrags",
"onFormUpdated": "Wenn jemand meinen Antrag bearbeitet",
"onCommentAdded": "Wenn jemand meinen Antrag kommentiert",
"saved": "Einstellungen gespeichert"
}
},
"contact": {
"title": "Kontakt",
"header": "Kontaktieren Sie unsere Experten",
"description": "Haben Sie Fragen zur Mitbestimmung, zum Datenschutz oder zu IT-Systemen? Unsere Experten helfen Ihnen gerne weiter.",
"experts": {
"laborLaw": "Fachanwalt für Arbeitsrecht",
"itConsultant": "IT-Consultant",
"dataProtection": "Datenschutzbeauftragter",
"itSecurity": "IT-Sicherheitsbeauftragter"
},
"form": {
"title": "Nachricht senden",
"description": "Beschreiben Sie Ihr Anliegen und wir melden uns schnellstmöglich bei Ihnen.",
"subject": "Betreff",
"subjectPlaceholder": "Worum geht es in Ihrer Anfrage?",
"message": "Nachricht",
"messagePlaceholder": "Beschreiben Sie Ihr Anliegen",
"submit": "Nachricht senden"
},
"success": {
"title": "Nachricht gesendet",
"description": "Ihre Nachricht wurde erfolgreich gesendet. Wir werden uns so schnell wie möglich bei Ihnen melden."
},
"error": {
"description": "Die Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut."
}
}
}

View File

@@ -1,336 +0,0 @@
{
"applicationForms": {
"title": "Co-determination Applications",
"createNew": "New Co-determination Application",
"noFormsAvailable": "No applications available",
"noPermission": "No Permission",
"noPermissionDescription": "You do not have permission to create applications.",
"backToOverview": "Back to Overview",
"noVisibleElements": "All form elements in this section are hidden",
"deleteConfirm": "Do you really want to delete the co-determination application \"{name}\"?",
"deleteTitle": "Delete Co-determination Application",
"lastEditedBy": "Last edited by",
"createdBy": "Created by",
"saved": "Application form saved",
"submitted": "Application form submitted",
"deleted": "Application form deleted",
"formElements": {
"comments": "Comments",
"addInputBelow": "Add input field below",
"addAnother": "Add another",
"selectPlaceholder": "Select status",
"selectDate": "Select a date",
"title": "Title",
"text": "Text",
"unimplemented": "Element unimplemented:",
"richTextPlaceholder": "Write your additions here...",
"table": {
"addRow": "Add row",
"removeRow": "Remove row",
"emptyValue": "No input",
"noData": "No data available",
"selectValue": "Select value",
"enlargeTable": "Enlarge table",
"editTable": "Edit table"
},
"fileUpload": {
"label": "Upload files",
"dropFiles": "Drop files here",
"orClickToUpload": "or click to select files",
"selectFiles": "Select files",
"allowedTypes": "Allowed: PDF, DOCX, DOC, ODT, JPG, PNG, ZIP (max. 10MB per file)",
"uploadedFiles": "Uploaded files",
"uploading": "Uploading...",
"uploadError": "Upload error",
"uploadFailed": "File upload failed. Please try again.",
"fileTooLarge": "The file \"{filename}\" is too large. Maximum size: {maxSize}",
"unsupportedType": "This file type is not supported.",
"deleteFailed": "Could not delete file.",
"downloadFailed": "Could not download file.",
"viewFailed": "Could not open file for viewing.",
"view": "View"
}
},
"status": {
"draft": "Draft",
"submitted": "Submitted",
"approved": "Approved",
"rejected": "Rejected",
"signed": "Signed"
},
"navigation": {
"previous": "Previous",
"next": "Next",
"save": "Save",
"submit": "Submit"
},
"tabs": {
"form": "Form",
"versions": "Versions",
"preview": "Preview"
},
"unsavedWarning": "You have unsaved changes. Do you really want to leave this page?",
"recovery": {
"title": "Unsaved changes found",
"message": "A local backup from {timestamp} was found. Would you like to restore it?",
"sectionNote": "You were editing section {section}.",
"restore": "Restore",
"discard": "Discard"
}
},
"templates": {
"title": "Templates",
"editorTitle": "Administration - JSON Template Editor",
"newTemplate": "New Template",
"reset": "Reset",
"unsavedChanges": "Unsaved Changes",
"noTemplateFound": "No template found. Create a new template.",
"invalidJson": "Invalid JSON",
"invalidJsonDescription": "The JSON format is invalid. Please correct the syntax.",
"lastModified": "Last edited on",
"created": "Template successfully created",
"updated": "Template successfully updated",
"saveError": "Error saving template",
"unsavedWarning": "You have unsaved changes. Do you really want to leave this page?"
},
"versions": {
"title": "Versions",
"pageTitle": "Versions: {name}",
"empty": "No versions available",
"loading": "Loading versions...",
"loadError": "Error loading versions",
"loadErrorDescription": "Versions could not be loaded",
"unknownError": "Unknown error",
"compare": "Compare",
"restore": "Restore",
"openPdf": "Open PDF",
"restored": "Success",
"restoredDescription": "The form has been restored to the selected version.",
"restoreError": "Version could not be restored",
"restoreTitle": "Restore Version",
"restoreConfirm": "Do you really want to restore version v{number}?",
"restoreDescription": "This will create a new version with the content of the selected version. The current version and all changes will remain in the history.",
"comparisonTitle": "Comparison: Current Form with Version v{number}",
"comparisonError": "Error loading version",
"elementsAdded": "Added Elements ({count})",
"elementsRemoved": "Removed Elements ({count})",
"elementsModified": "Modified Elements ({count})",
"elementWithoutTitle": "Without Title",
"elementIn": "Element in",
"optionsAdded": "Options added ({count})",
"optionsRemoved": "Options removed ({count})",
"optionsModified": "Options modified ({count})",
"noChanges": "No differences found",
"changesSummary": "{count} changes since this version",
"changesCount": "{count} change | {count} changes",
"before": "Before",
"after": "Now",
"noValue": "No value",
"newAnswer": "Newly answered",
"changedAnswer": "Changed",
"clearedAnswer": "Cleared",
"tableRowsAdded": "added",
"tableRowsRemoved": "removed",
"tableRowsModified": "modified",
"tableStatus": "Status",
"rowAdded": "New",
"rowRemoved": "Removed",
"rowModified": "Modified"
},
"comments": {
"title": "Comments",
"empty": "No comments available",
"count": "{count} comments",
"placeholder": "Add a comment...",
"loadMore": "Load more",
"submit": "Submit",
"edit": "Edit Comment",
"editAction": "Edit",
"saveChanges": "Save changes",
"created": "Comment created successfully",
"createError": "Error creating comment",
"updated": "Comment updated successfully",
"updateError": "Error updating comment"
},
"compliance": {
"title": "Compliance Status",
"critical": "Critical",
"warning": "Warning",
"nonCritical": "Non-critical"
},
"notifications": {
"title": "Notifications",
"empty": "No notifications",
"unreadCount": "{count} unread",
"tooltip": "Notifications",
"markAllRead": "Mark all as read",
"deleteAll": "Delete all",
"delete": "Delete notification"
},
"administration": {
"title": "Administration",
"accessDenied": "Access denied"
},
"user": {
"administration": "Administration",
"settings": "Settings",
"logout": "Log out"
},
"settings": {
"title": "Settings",
"language": {
"title": "Language",
"description": "Choose your preferred language"
},
"appearance": {
"title": "Appearance",
"description": "Choose between light and dark mode",
"light": "Light",
"dark": "Dark"
},
"theme": {
"title": "Color Theme",
"description": "Customize the application colors",
"primary": "Primary Color",
"neutral": "Neutral Color"
}
},
"organization": {
"title": "Organization",
"pageTitle": "Organization Profile",
"current": "Current Organization",
"name": "Organization Name",
"id": "Organization ID",
"noOrganization": "No Organization Assigned",
"noOrganizationDescription": "You are currently not assigned to any organization. Please contact your administrator."
},
"help": {
"title": "Help",
"pageTitle": "Help & FAQ",
"description": "Find answers to frequently asked questions and guides on how to use the platform.",
"faq": {
"title": "Frequently Asked Questions",
"q1": {
"question": "What is co-determination?",
"answer": "Co-determination refers to the right of employees to participate in decisions within the company. In Germany, this right is legally established, particularly through the Works Constitution Act."
},
"q2": {
"question": "Who can submit applications?",
"answer": "Applications can be submitted by authorized users who have an appropriate role in the organization. The exact permissions are set by your administrator."
},
"q3": {
"question": "How long does it take to process an application?",
"answer": "Processing time varies depending on the type of application and the availability of the responsible parties. You will be notified of status changes by email."
},
"q4": {
"question": "Can I change my settings?",
"answer": "Yes, under 'Settings' you can adjust your language and display preferences as well as email notifications."
}
},
"support": {
"title": "Further Support",
"description": "Do you have more questions or need individual support? Use our contact form.",
"contactLink": "Go to Contact Form"
}
},
"auth": {
"welcome": "Welcome",
"redirectMessage": "You will be redirected to Keycloak to authenticate",
"signIn": "Sign in",
"termsAgreement": "By signing in, you agree to our terms of service",
"login": "Login",
"redirecting": "You are being redirected"
},
"error": {
"pageNotFound": "Page not found",
"pageNotFoundDescription": "We are sorry but this page could not be found."
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"confirmDelete": "Do you really want to delete this?",
"edit": "Edit",
"close": "Close",
"confirm": "Confirm",
"back": "Back",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"name": "Name",
"home": "Home",
"type": "Type",
"general": "General",
"on": "on"
},
"serverConnection": {
"title": "Server Connection Lost",
"message": "The connection to the server is currently unavailable. We are automatically trying to restore the connection.",
"checking": "Checking connection...",
"lastCheck": "Last check",
"retryInfo": "Automatic retry every 60 seconds",
"retryNow": "Try again now"
},
"roles": {
"admin": "Administrator",
"employee": "Employee",
"employer": "Employer",
"worksCouncilMember": "Works Council Member",
"worksCouncilChair": "Works Council Chair"
},
"settings": {
"title": "Settings",
"language": {
"title": "Language",
"description": "Select your preferred language"
},
"appearance": {
"title": "Appearance",
"description": "Choose your preferred color scheme",
"light": "Light",
"dark": "Dark"
},
"theme": {
"title": "Theme Colors",
"description": "Customize the primary color",
"primary": "Primary Color"
},
"email": {
"title": "Email Notifications",
"description": "Manage your email notification preferences",
"emailAddress": "Email Address",
"onFormCreated": "When an application form is created",
"onFormSubmitted": "When an application form is submitted",
"onFormUpdated": "When someone edits my application form",
"onCommentAdded": "When someone comments on my application form",
"saved": "Settings saved"
}
},
"contact": {
"title": "Contact",
"header": "Contact Our Experts",
"description": "Do you have questions about co-determination, data protection, or IT systems? Our experts are happy to help.",
"experts": {
"laborLaw": "Labor Law Attorney",
"itConsultant": "IT Consultant",
"dataProtection": "Data Protection Officer",
"itSecurity": "IT Security Officer"
},
"form": {
"title": "Send a Message",
"description": "Describe your inquiry and we will get back to you as soon as possible.",
"subject": "Subject",
"subjectPlaceholder": "What is your inquiry about?",
"message": "Message",
"messagePlaceholder": "Describe your inquiry",
"submit": "Send Message"
},
"success": {
"title": "Message Sent",
"description": "Your message has been sent successfully. We will get back to you as soon as possible."
},
"error": {
"description": "The message could not be sent. Please try again later."
}
}
}

View File

@@ -1,24 +0,0 @@
declare module 'nuxt/schema' {
interface PublicRuntimeConfig {
clientProxyBasePath: string
serverApiBaseUrl: string
serverApiBasePath: string
keycloakTokenUrl: string
logLevel: string
}
}
declare module '#app' {
interface NuxtApp {
$logger: import('consola').ConsolaInstance
}
}
declare module 'vue' {
interface ComponentCustomProperties {
$logger: import('consola').ConsolaInstance
}
}
// It is always important to ensure you import/export something when augmenting a type
export {}

View File

@@ -1,44 +0,0 @@
export default defineNuxtConfig({
sourcemap: true,
modules: ['@nuxt/ui', '@nuxt/eslint', '@pinia/nuxt', '@nuxtjs/i18n', 'nuxt-auth-utils', '@nuxt/test-utils/module'],
css: ['~/assets/css/main.css'],
runtimeConfig: {
public: {
clientProxyBasePath: 'NOT_SET',
serverApiBaseUrl: 'NOT_SET',
serverApiBasePath: 'NOT_SET',
keycloakTokenUrl: 'NOT_SET',
logLevel: 'NOT_SET'
},
oauth: {
keycloak: {
clientId: 'NOT_SET',
clientSecret: 'NOT_SET',
realm: 'NOT_SET',
serverUrl: 'NOT_SET',
serverUrlInternal: 'NOT_SET',
redirectURL: 'NOT_SET',
scope: ['openid', 'organization']
}
}
},
components: [
{
path: '~/components',
pathPrefix: false
}
],
i18n: {
defaultLocale: 'de',
strategy: 'no_prefix',
locales: [
{ code: 'en', name: 'English', file: 'en.json' },
{ code: 'de', name: 'Deutsch', file: 'de.json' }
]
},
// typescript: {
// typeCheck: true
// },
devtools: { enabled: true },
compatibilityDate: '2024-11-01'
})

View File

@@ -1,7 +0,0 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.11.0"
}
}

View File

@@ -1,56 +0,0 @@
{
"name": "legalconsenthub",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev --port 3001",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare && pnpm run api:generate",
"format": "prettier . --write",
"type-check": "nuxi typecheck",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"check": "pnpm run lint && pnpm run type-check && pnpm run format && pnpm run test",
"api:generate": "openapi-generator-cli generate -i ../api/legalconsenthub.yml -g typescript-fetch -o .api-client"
},
"dependencies": {
"@guolao/vue-monaco-editor": "1.6.0",
"@nuxt/ui": "4.3.0",
"@nuxtjs/i18n": "10.0.3",
"@pinia/nuxt": "0.11.2",
"@vueuse/core": "^13.6.0",
"consola": "3.4.2",
"h3": "1.15.4",
"jwt-decode": "4.0.0",
"nuxt": "4.2.0",
"nuxt-auth-utils": "0.5.25",
"pinia": "3.0.3",
"resend": "4.3.0",
"vue": "latest",
"vue-router": "latest"
},
"devDependencies": {
"@nuxt/eslint": "1.1.0",
"@nuxt/test-utils": "^3.21.0",
"@openapitools/openapi-generator-cli": "2.16.3",
"@pinia/testing": "^0.1.7",
"@vitest/coverage-v8": "4.0.16",
"@vue/test-utils": "^2.4.6",
"eslint": "9.20.1",
"happy-dom": "^20.0.11",
"prettier": "3.5.1",
"typescript": "5.7.3",
"vitest": "^4.0.16",
"vue-tsc": "2.2.2"
},
"volta": {
"node": "22.16.0",
"pnpm": "10.11.0"
},
"packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#14b8a6"/>
<stop offset="100%" stop-color="#06b6d4"/>
</linearGradient>
</defs>
<!-- Rounded rectangle background -->
<rect x="0" y="0" width="32" height="32" rx="6" ry="6" fill="url(#bg)"/>
<!-- Scale icon (simplified from Lucide scale) -->
<g fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Base/stand -->
<line x1="16" y1="7" x2="16" y2="25"/>
<line x1="11" y1="25" x2="21" y2="25"/>
<!-- Balance beam -->
<line x1="7" y1="11" x2="25" y2="11"/>
<!-- Left pan -->
<path d="M7 11 L5 17 Q6 19 9 19 Q12 19 13 17 L11 11"/>
<!-- Right pan -->
<path d="M21 11 L19 17 Q20 19 23 19 Q26 19 27 17 L25 11"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 889 B

View File

@@ -1 +0,0 @@

Some files were not shown because too many files have changed in this diff Show More