diff --git a/api/legalconsenthub.yml b/api/legalconsenthub.yml index d058709..d78bbb8 100644 --- a/api/legalconsenthub.yml +++ b/api/legalconsenthub.yml @@ -1681,6 +1681,30 @@ components: type: boolean default: false description: If true, renders a checkbox instead of text input + readOnlyConditions: + $ref: "#/components/schemas/VisibilityConditionGroup" + description: If set, the column is read-only when conditions evaluate to true. + readOnlyDefaultValue: + type: string + description: Value to write into each cell when the column transitions to read-only via readOnlyConditions. + rowVisibilityCondition: + $ref: "#/components/schemas/TableRowVisibilityConditionDto" + description: If set, individual cells are hidden/shown based on the row's value in the specified column + + TableRowVisibilityConditionDto: + type: object + description: Per-row cell visibility condition referencing another column in the same table + properties: + sourceColumnIndex: + type: integer + description: Index of the column in the same table to evaluate (0-based) + expectedValues: + type: array + items: + type: string + description: Cell is visible if the source column's value matches any of these with the given operator + operator: + $ref: "#/components/schemas/VisibilityConditionOperator" TableRowConstraintDto: type: object diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormOption.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormOption.kt index 04eb66d..640463a 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormOption.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormOption.kt @@ -35,6 +35,18 @@ class FormOption( AttributeOverride(name = "filterCondition.operator", column = Column(name = "col_config_filter_operator")), AttributeOverride(name = "isReadOnly", column = Column(name = "col_config_is_read_only")), AttributeOverride(name = "isCheckbox", column = Column(name = "col_config_is_checkbox")), + AttributeOverride( + name = "readOnlyDefaultValue", + column = Column(name = "col_config_read_only_default_value"), + ), + AttributeOverride( + name = "readOnlyConditions", + column = Column(name = "col_config_read_only_conditions", columnDefinition = "jsonb"), + ), + AttributeOverride( + name = "rowVisibilityCondition", + column = Column(name = "col_config_row_visibility_condition", columnDefinition = "jsonb"), + ), ) var columnConfig: TableColumnConfig? = null, @JdbcTypeCode(SqlTypes.JSON) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/TableColumnConfig.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/TableColumnConfig.kt index 9196b2c..13a984a 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/TableColumnConfig.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/TableColumnConfig.kt @@ -5,6 +5,8 @@ import jakarta.persistence.AttributeOverrides import jakarta.persistence.Column import jakarta.persistence.Embeddable import jakarta.persistence.Embedded +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes @Embeddable data class TableColumnConfig( @@ -29,4 +31,11 @@ data class TableColumnConfig( val isReadOnly: Boolean = false, val isMultipleAllowed: Boolean = false, val isCheckbox: Boolean = false, + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + val readOnlyConditions: GroupCondition? = null, + val readOnlyDefaultValue: String? = null, + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + val rowVisibilityCondition: RowVisibilityCondition? = null, ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/TableColumnConfigMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/TableColumnConfigMapper.kt index 258fa26..9c531e4 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/TableColumnConfigMapper.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/TableColumnConfigMapper.kt @@ -1,12 +1,15 @@ package com.betriebsratkanzlei.legalconsenthub.form_element import com.betriebsratkanzlei.legalconsenthub_api.model.TableColumnConfigDto +import com.betriebsratkanzlei.legalconsenthub_api.model.TableRowVisibilityConditionDto import org.springframework.stereotype.Component +import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto @Component class TableColumnConfigMapper( private val filterMapper: TableColumnFilterMapper, private val rowConstraintMapper: TableRowConstraintMapper, + private val visibilityConditionMapper: FormElementVisibilityConditionMapper, ) { fun toTableColumnConfigDto(config: TableColumnConfig): TableColumnConfigDto = TableColumnConfigDto( @@ -17,6 +20,9 @@ class TableColumnConfigMapper( isReadOnly = config.isReadOnly, isMultipleAllowed = config.isMultipleAllowed, isCheckbox = config.isCheckbox, + readOnlyConditions = config.readOnlyConditions?.let { visibilityConditionMapper.toGroupConditionDto(it) }, + readOnlyDefaultValue = config.readOnlyDefaultValue, + rowVisibilityCondition = config.rowVisibilityCondition?.let { toRowVisibilityConditionDto(it) }, ) fun toTableColumnConfig(dto: TableColumnConfigDto): TableColumnConfig = @@ -28,5 +34,42 @@ class TableColumnConfigMapper( isReadOnly = dto.isReadOnly ?: false, isMultipleAllowed = dto.isMultipleAllowed ?: false, isCheckbox = dto.isCheckbox ?: false, + readOnlyConditions = dto.readOnlyConditions?.let { visibilityConditionMapper.toGroupCondition(it) }, + readOnlyDefaultValue = dto.readOnlyDefaultValue, + rowVisibilityCondition = dto.rowVisibilityCondition?.let { toRowVisibilityCondition(it) }, ) + + private fun toRowVisibilityConditionDto(entity: RowVisibilityCondition): TableRowVisibilityConditionDto = + TableRowVisibilityConditionDto( + sourceColumnIndex = entity.sourceColumnIndex, + expectedValues = entity.expectedValues, + operator = entity.operator.toDto(), + ) + + private fun toRowVisibilityCondition(dto: TableRowVisibilityConditionDto): RowVisibilityCondition = + RowVisibilityCondition( + sourceColumnIndex = dto.sourceColumnIndex ?: 0, + expectedValues = dto.expectedValues ?: emptyList(), + operator = dto.operator?.toEntity() ?: VisibilityConditionOperator.CONTAINS, + ) + + private fun VisibilityConditionOperator.toDto(): VisibilityConditionOperatorDto = + when (this) { + VisibilityConditionOperator.EQUALS -> VisibilityConditionOperatorDto.EQUALS + VisibilityConditionOperator.NOT_EQUALS -> VisibilityConditionOperatorDto.NOT_EQUALS + VisibilityConditionOperator.IS_EMPTY -> VisibilityConditionOperatorDto.IS_EMPTY + VisibilityConditionOperator.IS_NOT_EMPTY -> VisibilityConditionOperatorDto.IS_NOT_EMPTY + VisibilityConditionOperator.CONTAINS -> VisibilityConditionOperatorDto.CONTAINS + VisibilityConditionOperator.NOT_CONTAINS -> VisibilityConditionOperatorDto.NOT_CONTAINS + } + + private fun VisibilityConditionOperatorDto.toEntity(): VisibilityConditionOperator = + when (this) { + VisibilityConditionOperatorDto.EQUALS -> VisibilityConditionOperator.EQUALS + VisibilityConditionOperatorDto.NOT_EQUALS -> VisibilityConditionOperator.NOT_EQUALS + VisibilityConditionOperatorDto.IS_EMPTY -> VisibilityConditionOperator.IS_EMPTY + VisibilityConditionOperatorDto.IS_NOT_EMPTY -> VisibilityConditionOperator.IS_NOT_EMPTY + VisibilityConditionOperatorDto.CONTAINS -> VisibilityConditionOperator.CONTAINS + VisibilityConditionOperatorDto.NOT_CONTAINS -> VisibilityConditionOperator.NOT_CONTAINS + } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionNode.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionNode.kt index 7153e20..08c8d28 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionNode.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionNode.kt @@ -25,6 +25,12 @@ data class LeafCondition( val formElementOperator: VisibilityConditionOperator = VisibilityConditionOperator.EQUALS, ) : VisibilityConditionNode +data class RowVisibilityCondition( + val sourceColumnIndex: Int, + val expectedValues: List, + val operator: VisibilityConditionOperator = VisibilityConditionOperator.CONTAINS, +) + enum class GroupOperator { AND, OR, diff --git a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql index 4fd6333..8fd662b 100644 --- a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql +++ b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql @@ -70,10 +70,13 @@ create table form_element_options col_config_filter_operator varchar(255) check (col_config_filter_operator in ('EQUALS', 'NOT_EQUALS', 'IS_EMPTY', 'IS_NOT_EMPTY', 'CONTAINS', 'NOT_CONTAINS')), + col_config_read_only_default_value varchar(255), col_config_source_table_ref varchar(255), label varchar(255) not null, option_value TEXT not null, row_constraint_table_reference varchar(255), + col_config_read_only_conditions jsonb, + col_config_row_visibility_condition jsonb, visibility_conditions jsonb ); diff --git a/legalconsenthub-backend/src/main/resources/seed/demo/section_03_verarbeitung_von_mitarbeiterdaten.yaml b/legalconsenthub-backend/src/main/resources/seed/demo/section_03_verarbeitung_von_mitarbeiterdaten.yaml index 21b39c6..3dd5bde 100644 --- a/legalconsenthub-backend/src/main/resources/seed/demo/section_03_verarbeitung_von_mitarbeiterdaten.yaml +++ b/legalconsenthub-backend/src/main/resources/seed/demo/section_03_verarbeitung_von_mitarbeiterdaten.yaml @@ -251,6 +251,12 @@ formElementSubSections: employeeDataCategory: SENSITIVE columnConfig: isCheckbox: true + rowVisibilityCondition: + sourceColumnIndex: 5 + expectedValues: + - "Aggregiert (Team)" + - "Aggregiert (Abteilung)" + operator: CONTAINS - value: '["N/A", "Min. 5 Personen im Vergleich", "Min. 10 Personen pro Auswertung"]' label: Mindestgruppe/Schwelle processingPurpose: DATA_ANALYSIS diff --git a/legalconsenthub-backend/src/main/resources/seed/template/section_08_verarbeitung_von_mitarbeiterdaten.yaml b/legalconsenthub-backend/src/main/resources/seed/template/section_08_verarbeitung_von_mitarbeiterdaten.yaml index 38244be..b842c1e 100644 --- a/legalconsenthub-backend/src/main/resources/seed/template/section_08_verarbeitung_von_mitarbeiterdaten.yaml +++ b/legalconsenthub-backend/src/main/resources/seed/template/section_08_verarbeitung_von_mitarbeiterdaten.yaml @@ -76,6 +76,29 @@ formElementSubSections: label: Leistungs-/Verhaltenskontrolle processingPurpose: DATA_ANALYSIS employeeDataCategory: SENSITIVE + columnConfig: + readOnlyDefaultValue: "Nein" + readOnlyConditions: + operator: OR + conditions: + - nodeType: LEAF + formElementConditionType: SHOW + sourceFormElementReference: sens_sichtbarkeit + formElementExpectedValue: Für Administratoren + formElementOperator: EQUALS + - nodeType: GROUP + groupOperator: AND + conditions: + - nodeType: LEAF + formElementConditionType: SHOW + sourceFormElementReference: sens_sichtbarkeit + formElementExpectedValue: Für mehrere Rollen + formElementOperator: EQUALS + - nodeType: LEAF + formElementConditionType: SHOW + sourceFormElementReference: sens_auswertung + formElementExpectedValue: Keine + formElementOperator: EQUALS - title: Rollen-Sichtbarkeit (Einfache Darstellung) formElements: - reference: rollen_sichtbarkeit_einfach_tabelle @@ -405,6 +428,12 @@ formElementSubSections: employeeDataCategory: SENSITIVE columnConfig: isCheckbox: true + rowVisibilityCondition: + sourceColumnIndex: 5 + expectedValues: + - "Aggregiert (Team)" + - "Aggregiert (Abteilung)" + operator: CONTAINS - value: '[]' label: Mindestgruppe/Schwelle processingPurpose: DATA_ANALYSIS diff --git a/legalconsenthub/app/components/formelements/TheTable.vue b/legalconsenthub/app/components/formelements/TheTable.vue index 8272fc6..d2a6ed5 100644 --- a/legalconsenthub/app/components/formelements/TheTable.vue +++ b/legalconsenthub/app/components/formelements/TheTable.vue @@ -22,6 +22,8 @@ :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" @@ -60,6 +62,8 @@ :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" @@ -151,6 +155,44 @@ const visibleColumns = computed(() => { }) }) +const readOnlyColumnIndices = computed>(() => { + 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(() => visibleColumns.value.map(({ originalIndex }) => ({ key: `col_${originalIndex}`, @@ -179,23 +221,16 @@ const tableData = computed(() => { if (props.formOptions.length === 0) return [] const columnData: CellValue[][] = props.formOptions.map((option, colIndex) => { - try { - const parsed = JSON.parse(option.value || '[]') - if (!Array.isArray(parsed)) return [] + const parsed = parseColumnValues(option.value) - // For multi-select columns, each cell value is already an array - // For checkbox columns, each cell value is a boolean - // For single-select columns, each cell value is a string - if (isColumnMultipleAllowed(colIndex)) { - return parsed.map((val: CellValue) => (Array.isArray(val) ? val : [])) - } - if (isColumnCheckbox(colIndex)) { - return parsed.map((val: CellValue) => val === true) - } - return parsed - } catch { - return [] + // 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) @@ -266,14 +301,7 @@ function updateCell(rowIndex: number, columnKey: string, value: string) { const updatedOptions = props.formOptions.map((option, index) => { if (index !== colIndex) return option - let columnValues: CellValue[] - try { - columnValues = JSON.parse(option.value || '[]') - if (!Array.isArray(columnValues)) columnValues = [] - } catch { - columnValues = [] - } - + const columnValues = parseColumnValues(option.value) while (columnValues.length <= rowIndex) { columnValues.push('') } @@ -289,14 +317,7 @@ function updateCellValue(rowIndex: number, _columnKey: string, colIndex: number, const updatedOptions = props.formOptions.map((option, index) => { if (index !== colIndex) return option - let columnValues: CellValue[] - try { - columnValues = JSON.parse(option.value || '[]') - if (!Array.isArray(columnValues)) columnValues = [] - } catch { - columnValues = [] - } - + const columnValues = parseColumnValues(option.value) const isMultiple = isColumnMultipleAllowed(colIndex) while (columnValues.length <= rowIndex) { columnValues.push(isMultiple ? [] : '') @@ -313,14 +334,7 @@ function updateCheckboxCell(rowIndex: number, colIndex: number, value: boolean) const updatedOptions = props.formOptions.map((option, index) => { if (index !== colIndex) return option - let columnValues: CellValue[] - try { - columnValues = JSON.parse(option.value || '[]') - if (!Array.isArray(columnValues)) columnValues = [] - } catch { - columnValues = [] - } - + const columnValues = parseColumnValues(option.value) while (columnValues.length <= rowIndex) { columnValues.push(false) } @@ -334,24 +348,18 @@ function updateCheckboxCell(rowIndex: number, colIndex: number, value: boolean) function addRow() { const updatedOptions = props.formOptions.map((option, colIndex) => { - let columnValues: CellValue[] - try { - columnValues = JSON.parse(option.value || '[]') - if (!Array.isArray(columnValues)) columnValues = [] - } catch { - columnValues = [] - } + const columnValues = parseColumnValues(option.value) - // For multi-select columns, initialize with empty array - // For checkbox columns, initialize with false - // Otherwise empty string - let emptyValue: CellValue = '' - if (isColumnMultipleAllowed(colIndex)) { - emptyValue = [] + // 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)) { - emptyValue = false + initialValue = false } - columnValues.push(emptyValue) + columnValues.push(initialValue) return { ...option, value: JSON.stringify(columnValues) } }) @@ -361,19 +369,44 @@ function addRow() { function removeRow(rowIndex: number) { const updatedOptions = props.formOptions.map((option) => { - let columnValues: CellValue[] - try { - columnValues = JSON.parse(option.value || '[]') - if (!Array.isArray(columnValues)) columnValues = [] - } catch { - columnValues = [] - } - + 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 [] + } +} diff --git a/legalconsenthub/app/components/formelements/TheTableContent.vue b/legalconsenthub/app/components/formelements/TheTableContent.vue index 60194a4..e900cba 100644 --- a/legalconsenthub/app/components/formelements/TheTableContent.vue +++ b/legalconsenthub/app/components/formelements/TheTableContent.vue @@ -2,54 +2,64 @@