import type { ApplicationFormDto, ApplicationFormSnapshotDto, FormElementDto, FormElementSnapshotDto, FormOptionDto, FormElementType } from '~~/.api-client' import type { FormValueDiff, ValueChange, SectionChanges, TableDiff, TableRowDiff } from '~~/types/formDiff' // 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() 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) { 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 }