major: Rename legalconsenthub to gremiumhub
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.api-client
|
||||
.api-client-middleware
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
dist
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
shamefully-hoist=true
|
||||
@@ -1 +0,0 @@
|
||||
22
|
||||
@@ -1,7 +0,0 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
.nuxt
|
||||
.output
|
||||
.api-client
|
||||
pnpm-lock.yaml
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -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}`
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Legal Consent Hub
|
||||
|
||||
## Pipeline Triggering
|
||||
|
||||
Trigger count: 12
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<div>{{ $t('applicationForms.formElements.unimplemented') }}</div>
|
||||
</template>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { ConsolaInstance } from 'consola'
|
||||
|
||||
export function useLogger(): ConsolaInstance {
|
||||
return useNuxtApp().$logger
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { canWriteApplicationForms } = usePermissions()
|
||||
|
||||
if (to.path === '/create' && !canWriteApplicationForms.value) {
|
||||
return navigateTo('/', { replace: true })
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
24
legalconsenthub/index.d.ts
vendored
24
legalconsenthub/index.d.ts
vendored
@@ -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 {}
|
||||
@@ -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'
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
|
||||
"spaces": 2,
|
||||
"generator-cli": {
|
||||
"version": "7.11.0"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
13091
legalconsenthub/pnpm-lock.yaml
generated
13091
legalconsenthub/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -1 +0,0 @@
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user