diff --git a/legalconsenthub/app/components/VersionComparisonModal.vue b/legalconsenthub/app/components/VersionComparisonModal.vue index 677c3ad..9e14454 100644 --- a/legalconsenthub/app/components/VersionComparisonModal.vue +++ b/legalconsenthub/app/components/VersionComparisonModal.vue @@ -11,116 +11,165 @@
{{ $t('versions.comparisonError') }}: {{ error }}
-
-
-

- - {{ $t('versions.elementsAdded', { count: diff.elementsAdded.length }) }} -

- -
- + -
-
{{ element.title || 'Ohne Titel' }}
-
Typ: {{ element.type }} · Sektion: {{ element.sectionTitle }}
-
-
-
-
+
+ + -
-

- - {{ $t('versions.elementsRemoved', { count: diff.elementsRemoved.length }) }} -

- -
- - -
-
{{ element.title || $t('versions.elementWithoutTitle') }}
-
- {{ $t('common.type') }}: {{ element.type }} · {{ $t('versions.elementIn') }} - {{ element.sectionTitle }} + + + +
-
{{ $t('versions.noChanges') }}
+
+ +

{{ $t('versions.noChanges') }}

+
diff --git a/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue b/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue index b1d37bd..7b109bc 100644 --- a/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue +++ b/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue @@ -133,6 +133,11 @@ async function onSave() { 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' }) diff --git a/legalconsenthub/app/utils/formDiff.ts b/legalconsenthub/app/utils/formDiff.ts index 6ae3de8..deec55a 100644 --- a/legalconsenthub/app/utils/formDiff.ts +++ b/legalconsenthub/app/utils/formDiff.ts @@ -3,72 +3,405 @@ import type { ApplicationFormSnapshotDto, FormElementDto, FormElementSnapshotDto, - FormOptionDto + FormOptionDto, + FormElementType } from '~~/.api-client' -import type { FormDiff, ElementModification, OptionChange, OptionModification } from '~~/types/formDiff' +import type { FormValueDiff, ValueChange, SectionChanges, TableDiff, TableRowDiff } from '~~/types/formDiff' -export function compareApplicationForms( +// 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 -): FormDiff { - const diff: FormDiff = { - elementsAdded: [], - elementsRemoved: [], - elementsModified: [] +): FormValueDiff { + const diff: FormValueDiff = { + newAnswers: [], + changedAnswers: [], + clearedAnswers: [] } const currentElements = flattenFormElements(current) const versionElements = flattenSnapshotElements(versionSnapshot) - const currentElementsMap = new Map( - currentElements.map((el, idx) => [ - createElementKey(el.element, idx), - { element: el.element, index: idx, sectionTitle: el.sectionTitle } - ]) + // Create maps by reference for matching elements + const currentByRef = new Map( + currentElements.filter((el) => el.element.reference).map((el) => [el.element.reference!, el]) ) - const versionElementsMap = new Map( - versionElements.map((el, idx) => [ - createSnapshotElementKey(el.element, idx), - { element: el.element, index: idx, sectionTitle: el.sectionTitle } - ]) + const versionByRef = new Map( + versionElements.filter((el) => el.element.reference).map((el) => [el.element.reference!, el]) ) - for (const [key, currentData] of currentElementsMap) { - if (!versionElementsMap.has(key)) { - diff.elementsAdded.push({ - sectionTitle: currentData.sectionTitle, - title: currentData.element.title, - type: currentData.element.type, - position: currentData.index - }) + // 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 labels are the same (actual displayed values haven't changed) + 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 { - const versionData = versionElementsMap.get(key)! - const modifications = compareElements( - currentData.element, - versionData.element, - currentData.sectionTitle, - currentData.index - ) - if (modifications) { - diff.elementsModified.push(modifications) - } + // Changed answer + diff.changedAnswers.push(change) } } - for (const [key, versionData] of versionElementsMap) { - if (!currentElementsMap.has(key)) { - diff.elementsRemoved.push({ - sectionTitle: versionData.sectionTitle, - title: versionData.element.title, - type: versionData.element.type, - position: versionData.index - }) + // 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() + 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() + + 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[] { + 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[] = [] + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const row: Record = {} + + 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) { @@ -81,6 +414,9 @@ function flattenFormElements(form: ApplicationFormDto): Array<{ element: FormEle return elements } +/** + * Flatten form elements from snapshot into a flat list. + */ function flattenSnapshotElements( snapshot: ApplicationFormSnapshotDto ): Array<{ element: FormElementSnapshotDto; sectionTitle: string }> { @@ -94,74 +430,3 @@ function flattenSnapshotElements( } return elements } - -function createElementKey(element: FormElementDto, index: number): string { - return `${index}-${element.type}-${element.title || 'untitled'}` -} - -function createSnapshotElementKey(element: FormElementSnapshotDto, index: number): string { - return `${index}-${element.type}-${element.title || 'untitled'}` -} - -function compareElements( - current: FormElementDto, - version: FormElementSnapshotDto, - sectionTitle: string, - position: number -): ElementModification | null { - const optionsDiff = compareOptions(current.options, version.options) - - if (optionsDiff.added.length > 0 || optionsDiff.removed.length > 0 || optionsDiff.modified.length > 0) { - return { - sectionTitle, - position, - optionsAdded: optionsDiff.added, - optionsRemoved: optionsDiff.removed, - optionsModified: optionsDiff.modified - } - } - - return null -} - -function compareOptions( - currentOptions: FormOptionDto[], - versionOptions: FormOptionDto[] -): { added: OptionChange[]; removed: OptionChange[]; modified: OptionModification[] } { - const result = { - added: [] as OptionChange[], - removed: [] as OptionChange[], - modified: [] as OptionModification[] - } - - const currentMap = new Map(currentOptions.map((opt) => [opt.value, opt])) - const versionMap = new Map(versionOptions.map((opt) => [opt.value, opt])) - - for (const [value, currentOpt] of currentMap) { - if (!versionMap.has(value)) { - result.added.push({ - value: currentOpt.value, - label: currentOpt.label - }) - } else { - const versionOpt = versionMap.get(value)! - if (currentOpt.label !== versionOpt.label) { - result.modified.push({ - value, - labelChanged: { from: versionOpt.label, to: currentOpt.label } - }) - } - } - } - - for (const [value, versionOpt] of versionMap) { - if (!currentMap.has(value)) { - result.removed.push({ - value: versionOpt.value, - label: versionOpt.label - }) - } - } - - return result -} diff --git a/legalconsenthub/i18n/locales/de.json b/legalconsenthub/i18n/locales/de.json index c16fd72..bbbf569 100644 --- a/legalconsenthub/i18n/locales/de.json +++ b/legalconsenthub/i18n/locales/de.json @@ -95,7 +95,22 @@ "optionsAdded": "Optionen hinzugefügt ({count})", "optionsRemoved": "Optionen entfernt ({count})", "optionsModified": "Optionen geändert ({count})", - "noChanges": "Keine Unterschiede gefunden" + "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", diff --git a/legalconsenthub/i18n/locales/en.json b/legalconsenthub/i18n/locales/en.json index a772629..c5b8872 100644 --- a/legalconsenthub/i18n/locales/en.json +++ b/legalconsenthub/i18n/locales/en.json @@ -95,7 +95,22 @@ "optionsAdded": "Options added ({count})", "optionsRemoved": "Options removed ({count})", "optionsModified": "Options modified ({count})", - "noChanges": "No differences found" + "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", diff --git a/legalconsenthub/types/formDiff.ts b/legalconsenthub/types/formDiff.ts index 5fbdc6d..b2b875e 100644 --- a/legalconsenthub/types/formDiff.ts +++ b/legalconsenthub/types/formDiff.ts @@ -1,30 +1,72 @@ -export interface FormDiff { - elementsAdded: ElementChange[] - elementsRemoved: ElementChange[] - elementsModified: ElementModification[] +import type { FormElementType } from '~~/.api-client' + +/** + * Represents a single row in a table diff. + */ +export interface TableRowDiff { + /** Row index (0-based) */ + rowIndex: number + /** Type of change: 'added', 'removed', 'modified', 'unchanged' */ + changeType: 'added' | 'removed' | 'modified' | 'unchanged' + /** Previous row values (keyed by column label) */ + previousValues: Record + /** Current row values (keyed by column label) */ + currentValues: Record } -export interface ElementChange { +/** + * Structured table diff for detailed comparison display. + */ +export interface TableDiff { + /** Column labels/headers */ + columns: string[] + /** Row-by-row diff */ + rows: TableRowDiff[] + /** Summary counts */ + addedCount: number + removedCount: number + modifiedCount: number +} + +/** + * Represents a single value change in a form element. + */ +export interface ValueChange { + /** The section this element belongs to */ sectionTitle: string - title: string | undefined - type: string - position: number + /** The title/label of the form element */ + elementTitle: string + /** The type of form element (SELECT, TEXTFIELD, TABLE, etc.) */ + elementType: FormElementType + /** The raw previous value (null = no previous value) */ + previousValue: string | string[] | null + /** The raw current value (null = value was cleared) */ + currentValue: string | string[] | null + /** Human-readable label for the previous value */ + previousLabel: string | null + /** Human-readable label for the current value */ + currentLabel: string | null + /** Structured table diff (only for TABLE elements) */ + tableDiff?: TableDiff } -export interface ElementModification { +/** + * The result of comparing two form versions. + * Changes are categorized by type for better UX presentation. + */ +export interface FormValueDiff { + /** Elements that were answered for the first time */ + newAnswers: ValueChange[] + /** Elements where the value was changed */ + changedAnswers: ValueChange[] + /** Elements where the value was cleared/removed */ + clearedAnswers: ValueChange[] +} + +/** + * Changes grouped by section for accordion display. + */ +export interface SectionChanges { sectionTitle: string - position: number - optionsAdded: OptionChange[] - optionsRemoved: OptionChange[] - optionsModified: OptionModification[] -} - -export interface OptionChange { - value: string - label: string -} - -export interface OptionModification { - value: string - labelChanged: { from: string; to: string } + changes: ValueChange[] }