diff --git a/backend/workflows/form_builder.py b/backend/workflows/form_builder.py index a366a38..fa2094a 100644 --- a/backend/workflows/form_builder.py +++ b/backend/workflows/form_builder.py @@ -1,7 +1,7 @@ from collections import OrderedDict from django.utils.translation import get_language -from .models import FormFieldConfig, FormSectionConfig +from .models import FormConditionalRuleConfig, FormFieldConfig, FormSectionConfig DEFAULT_FIELD_ORDER = { @@ -132,6 +132,38 @@ OFFBOARDING_DEFAULT_PAGE = { 'notes': 'abschluss', } +DEFAULT_CONDITIONAL_RULES = { + 'onboarding': { + 'business-card-box': { + 'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}], + }, + 'employment-end-box': { + 'clauses': [{'field': 'employment_type', 'operator': 'equals', 'value': 'befristet'}], + }, + 'group-mailboxes-box': { + 'clauses': [{'field': 'group_mailboxes_required_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'extra-hardware-box': { + 'clauses': [{'field': 'additional_hardware_needed_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'extra-software-box': { + 'clauses': [{'field': 'additional_software_needed_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'extra-access-box': { + 'clauses': [{'field': 'additional_access_needed_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'successor-box': { + 'clauses': [{'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'phone-box': { + 'clauses': [ + {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, + {'field': 'inherit_phone_number_choice', 'operator': 'not_equals', 'value': 'ja'}, + ], + }, + }, +} + def get_section_order(form_type: str) -> list[str]: if form_type == 'onboarding': @@ -157,6 +189,10 @@ def get_default_page_map(form_type: str) -> dict[str, str]: return {} +def get_default_conditional_rules(form_type: str) -> dict[str, dict]: + return DEFAULT_CONDITIONAL_RULES.get(form_type, {}) + + def _default_sort(form_type: str, field_name: str) -> int: ordered = DEFAULT_FIELD_ORDER.get(form_type, []) if field_name in ordered: @@ -215,6 +251,35 @@ def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: return existing +def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormConditionalRuleConfig]: + defaults = get_default_conditional_rules(form_type) + if not defaults: + return {} + existing = { + cfg.target_key: cfg + for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) + } + missing = [key for key in defaults.keys() if key not in existing] + if missing: + FormConditionalRuleConfig.objects.bulk_create( + [ + FormConditionalRuleConfig( + form_type=form_type, + target_key=key, + clauses=defaults[key].get('clauses', []), + is_active=True, + ) + for key in missing + ], + ignore_conflicts=True, + ) + existing = { + cfg.target_key: cfg + for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) + } + return existing + + def apply_form_field_config(form_type: str, form) -> None: field_names = list(form.fields.keys()) configs = _ensure_configs(form_type, field_names) diff --git a/backend/workflows/migrations/0054_formconditionalruleconfig.py b/backend/workflows/migrations/0054_formconditionalruleconfig.py new file mode 100644 index 0000000..f1e705a --- /dev/null +++ b/backend/workflows/migrations/0054_formconditionalruleconfig.py @@ -0,0 +1,67 @@ +from django.db import migrations, models + + +DEFAULT_RULES = { + 'business-card-box': [ + {'field': 'order_business_cards', 'operator': 'checked', 'value': True}, + ], + 'employment-end-box': [ + {'field': 'employment_type', 'operator': 'equals', 'value': 'befristet'}, + ], + 'group-mailboxes-box': [ + {'field': 'group_mailboxes_required_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'extra-hardware-box': [ + {'field': 'additional_hardware_needed_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'extra-software-box': [ + {'field': 'additional_software_needed_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'extra-access-box': [ + {'field': 'additional_access_needed_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'successor-box': [ + {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'phone-box': [ + {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, + {'field': 'inherit_phone_number_choice', 'operator': 'not_equals', 'value': 'ja'}, + ], +} + + +def seed_conditional_rules(apps, schema_editor): + FormConditionalRuleConfig = apps.get_model('workflows', 'FormConditionalRuleConfig') + for target_key, clauses in DEFAULT_RULES.items(): + FormConditionalRuleConfig.objects.get_or_create( + form_type='onboarding', + target_key=target_key, + defaults={'clauses': clauses, 'is_active': True}, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0053_formsectionconfig'), + ] + + operations = [ + migrations.CreateModel( + name='FormConditionalRuleConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('form_type', models.CharField(choices=[('onboarding', 'Onboarding')], max_length=20)), + ('target_key', models.CharField(max_length=80)), + ('clauses', models.JSONField(blank=True, default=list)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Formular-Bedingungsregel', + 'verbose_name_plural': 'Formular-Bedingungsregeln', + 'ordering': ['form_type', 'target_key'], + 'unique_together': {('form_type', 'target_key')}, + }, + ), + migrations.RunPython(seed_conditional_rules, migrations.RunPython.noop), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index f524efa..57ceb76 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -546,6 +546,26 @@ class FormSectionConfig(models.Model): return f'{self.form_type}: {self.section_key}' +class FormConditionalRuleConfig(models.Model): + FORM_CHOICES = [ + ('onboarding', _('Onboarding')), + ] + + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + target_key = models.CharField(max_length=80) + clauses = models.JSONField(default=list, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['form_type', 'target_key'] + unique_together = ('form_type', 'target_key') + verbose_name = 'Formular-Bedingungsregel' + verbose_name_plural = 'Formular-Bedingungsregeln' + + def __str__(self) -> str: + return f'{self.form_type}: {self.target_key}' + + class NotificationTemplate(models.Model): TEMPLATE_CHOICES = [ ('onboarding_it', _('Onboarding: IT')), diff --git a/backend/workflows/static/workflows/js/onboarding_form.js b/backend/workflows/static/workflows/js/onboarding_form.js index b33ac38..534355a 100644 --- a/backend/workflows/static/workflows/js/onboarding_form.js +++ b/backend/workflows/static/workflows/js/onboarding_form.js @@ -6,6 +6,8 @@ const btnSubmit = document.getElementById('btn-submit'); const form = document.getElementById('onboarding-form'); const emailDomain = ((form && form.dataset.emailDomain) || 'workdock.de').replace(/^@+/, '').trim(); + const conditionalRulesNode = document.getElementById('onboarding-conditional-rules'); + const conditionalRules = conditionalRulesNode ? JSON.parse(conditionalRulesNode.textContent || '{}') : {}; let current = 0; form.setAttribute('novalidate', 'novalidate'); @@ -14,34 +16,39 @@ const el = document.getElementById(id); if (!el) return; el.classList.toggle('hidden', !state); + el.setAttribute('aria-hidden', state ? 'false' : 'true'); + } + + function fieldState(name) { + const field = byName(name); + if (!field) return { exists: false, value: '', checked: false }; + return { + exists: true, + value: (field.value || '').trim(), + checked: !!field.checked, + }; + } + + function evaluateClause(clause) { + const state = fieldState(clause.field); + if (!state.exists) return false; + if (clause.operator === 'checked') return state.checked === !!clause.value; + if (clause.operator === 'equals') return state.value === String(clause.value); + if (clause.operator === 'not_equals') return state.value !== String(clause.value); + return false; + } + + function evaluateRule(rule) { + const all = Array.isArray(rule.all) ? rule.all : []; + return all.every(evaluateClause); } function syncConditionals() { - const orderCards = byName('order_business_cards'); - toggle('business-card-box', orderCards && orderCards.checked); - - const employmentType = byName('employment_type'); - toggle('employment-end-box', employmentType && employmentType.value === 'befristet'); - - const groupMailbox = byName('group_mailboxes_required_choice'); - toggle('group-mailboxes-box', groupMailbox && groupMailbox.value === 'ja'); - - const extraHardware = byName('additional_hardware_needed_choice'); - toggle('extra-hardware-box', extraHardware && extraHardware.value === 'ja'); - - const extraSoftware = byName('additional_software_needed_choice'); - toggle('extra-software-box', extraSoftware && extraSoftware.value === 'ja'); - - const extraAccess = byName('additional_access_needed_choice'); - toggle('extra-access-box', extraAccess && extraAccess.value === 'ja'); - - const successor = byName('successor_required_choice'); - const showSuccessor = successor && successor.value === 'ja'; - toggle('successor-box', showSuccessor); - - const inheritPhone = byName('inherit_phone_number_choice'); - const hidePhone = showSuccessor && inheritPhone && inheritPhone.value === 'ja'; - toggle('phone-box', !hidePhone); + Object.entries(conditionalRules).forEach(function (entry) { + const targetId = entry[0]; + const rule = entry[1] || {}; + toggle(targetId, evaluateRule(rule)); + }); // Hidden conditional groups must not block submit with invisible required fields. document.querySelectorAll('.field-group').forEach(function (group) { @@ -60,6 +67,22 @@ }); } + function setupConditionalBindings() { + const watched = new Set(); + Object.values(conditionalRules).forEach(function (rule) { + const all = Array.isArray(rule.all) ? rule.all : []; + all.forEach(function (clause) { + if (clause.field) watched.add(clause.field); + }); + }); + watched.forEach(function (name) { + const field = byName(name); + if (!field) return; + field.addEventListener('change', syncConditionals); + field.addEventListener('input', syncConditionals); + }); + } + function slugifyForEmail(value) { const map = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' }; const lower = (value || '').toLowerCase(); @@ -224,7 +247,6 @@ current = Math.max(0, step); } - document.addEventListener('change', syncConditionals); navItems.forEach((n, idx) => { n.addEventListener('click', function () { current = idx; updateStep(); }); n.addEventListener('keydown', function (e) { @@ -245,6 +267,7 @@ }); syncConditionals(); + setupConditionalBindings(); setupWorkEmailAutofill(); setupBusinessCardAutofill(); setupChecklistToggles(); diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index 2e1cef1..f14536a 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -246,6 +246,70 @@ + + {% if form_type == 'onboarding' %} + + {% endif %} diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html index 9a2953a..121cc05 100644 --- a/backend/workflows/templates/workflows/onboarding_form.html +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -44,6 +44,7 @@