Files
gremiumhub/legalconsenthub/app/pages/create.vue

254 lines
8.1 KiB
Vue

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