From 30877ed8ee753556612b76812fa2b7bbc61b1a06 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 16:54:11 +0100 Subject: [PATCH] snapshot: preserve dynamic builder and section ordering work --- backend/workflows/form_builder.py | 72 ++- ...mcustomfieldconfig_section_key_and_more.py | 46 ++ .../0057_remove_phone_box_conditional_rule.py | 20 + ...lter_formsectionconfig_options_and_more.py | 34 ++ backend/workflows/models.py | 55 +- .../static/workflows/css/form_builder.css | 455 ++++++++++++-- .../static/workflows/js/form_builder.js | 91 +++ .../templates/workflows/form_builder.html | 573 +++++++++++------- .../templates/workflows/onboarding_form.html | 32 +- .../tests/test_form_builder_admin.py | 131 +++- .../workflows/tests/test_onboarding_flow.py | 37 +- backend/workflows/tests/test_pdf_sections.py | 37 +- backend/workflows/views.py | 173 ++++-- 13 files changed, 1391 insertions(+), 365 deletions(-) create mode 100644 backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py create mode 100644 backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py create mode 100644 backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py diff --git a/backend/workflows/form_builder.py b/backend/workflows/form_builder.py index 43bb7a0..8d6e06b 100644 --- a/backend/workflows/form_builder.py +++ b/backend/workflows/form_builder.py @@ -3,7 +3,7 @@ from django import forms from django.utils.text import slugify from django.utils.translation import get_language -from .models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormSectionConfig +from .models import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig DEFAULT_FIELD_ORDER = { @@ -72,6 +72,10 @@ OFFBOARDING_PAGE_LABELS = { 'austritt': '2. Austritt', 'abschluss': '3. Abschluss', } +CORE_SECTION_LABELS = { + 'onboarding': ONBOARDING_PAGE_LABELS, + 'offboarding': OFFBOARDING_PAGE_LABELS, +} LOCKED_FIELD_RULES = { 'onboarding': {'full_name', 'work_email', 'contract_start', 'agreement_confirm'}, @@ -157,12 +161,6 @@ DEFAULT_CONDITIONAL_RULES = { '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'}, - ], - }, }, } @@ -170,19 +168,11 @@ CUSTOM_FIELD_PREFIX = 'custom__' def get_section_order(form_type: str) -> list[str]: - if form_type == 'onboarding': - return ONBOARDING_PAGE_ORDER - if form_type == 'offboarding': - return OFFBOARDING_PAGE_ORDER - return [] + return [item['key'] for item in get_section_definitions(form_type)] def get_section_labels(form_type: str) -> dict[str, str]: - if form_type == 'onboarding': - return ONBOARDING_PAGE_LABELS - if form_type == 'offboarding': - return OFFBOARDING_PAGE_LABELS - return {} + return {item['key']: item['title'] for item in get_section_definitions(form_type)} def get_default_page_map(form_type: str) -> dict[str, str]: @@ -193,6 +183,42 @@ def get_default_page_map(form_type: str) -> dict[str, str]: return {} +def get_custom_section_configs(form_type: str, include_inactive: bool = False) -> list[FormCustomSectionConfig]: + qs = FormCustomSectionConfig.objects.filter(form_type=form_type) + if not include_inactive: + qs = qs.filter(is_active=True) + return list(qs.order_by('sort_order', 'section_key')) + + +def get_section_definitions(form_type: str, include_inactive_custom: bool = False) -> list[dict[str, object]]: + definitions: list[dict[str, object]] = [] + section_configs = ensure_form_section_configs(form_type) + for cfg in sorted(section_configs.values(), key=lambda item: (item.sort_order, item.section_key)): + label_map = CORE_SECTION_LABELS.get(form_type, {}) + definitions.append( + { + 'key': cfg.section_key, + 'title': label_map.get(cfg.section_key, cfg.section_key), + 'locked': cfg.section_key in LOCKED_SECTION_RULES.get(form_type, set()), + 'is_custom': False, + 'sort_order': cfg.sort_order, + } + ) + for cfg in get_custom_section_configs(form_type, include_inactive=include_inactive_custom): + definitions.append( + { + 'key': cfg.section_key, + 'title': cfg.translated_title(get_language()), + 'locked': False, + 'is_custom': True, + 'is_active': cfg.is_active, + 'sort_order': cfg.sort_order, + } + ) + definitions.sort(key=lambda item: (item.get('sort_order', 9999), item['key'])) + return definitions + + def get_default_conditional_rules(form_type: str) -> dict[str, dict]: return DEFAULT_CONDITIONAL_RULES.get(form_type, {}) @@ -323,7 +349,7 @@ def ensure_form_field_configs(form_type: str, field_names: list[str]) -> dict[st def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: - section_order = get_section_order(form_type) + section_order = list(CORE_SECTION_LABELS.get(form_type, {}).keys()) if not section_order: return {} existing = { @@ -333,7 +359,15 @@ def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: missing = [key for key in section_order if key not in existing] if missing: FormSectionConfig.objects.bulk_create( - [FormSectionConfig(form_type=form_type, section_key=key, is_visible=True) for key in missing], + [ + FormSectionConfig( + form_type=form_type, + section_key=key, + sort_order=section_order.index(key), + is_visible=True, + ) + for key in missing + ], ignore_conflicts=True, ) existing = { diff --git a/backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py b/backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py new file mode 100644 index 0000000..b981014 --- /dev/null +++ b/backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.5 on 2026-03-27 12:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0055_offboardingrequest_custom_field_values_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='formcustomfieldconfig', + name='section_key', + field=models.CharField(max_length=80), + ), + migrations.AlterField( + model_name='formfieldconfig', + name='page_key', + field=models.CharField(blank=True, default='', max_length=80), + ), + migrations.AlterField( + model_name='formsectionconfig', + name='section_key', + field=models.CharField(max_length=80), + ), + migrations.CreateModel( + name='FormCustomSectionConfig', + 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)), + ('section_key', models.SlugField(max_length=80)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('title', models.CharField(max_length=255)), + ('title_en', models.CharField(blank=True, max_length=255)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Benutzerdefinierter Formularabschnitt', + 'verbose_name_plural': 'Benutzerdefinierte Formularabschnitte', + 'ordering': ['form_type', 'sort_order', 'section_key'], + 'unique_together': {('form_type', 'section_key')}, + }, + ), + ] diff --git a/backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py b/backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py new file mode 100644 index 0000000..d221eb2 --- /dev/null +++ b/backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +def remove_phone_box_rule(apps, schema_editor): + FormConditionalRuleConfig = apps.get_model('workflows', 'FormConditionalRuleConfig') + FormConditionalRuleConfig.objects.filter( + form_type='onboarding', + target_key='phone-box', + ).update(is_active=False, clauses=[]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0056_alter_formcustomfieldconfig_section_key_and_more'), + ] + + operations = [ + migrations.RunPython(remove_phone_box_rule, migrations.RunPython.noop), + ] diff --git a/backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py b/backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py new file mode 100644 index 0000000..d98d85f --- /dev/null +++ b/backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.5 on 2026-03-27 15:45 + +from django.db import migrations, models + + +def seed_section_sort_order(apps, schema_editor): + FormSectionConfig = apps.get_model('workflows', 'FormSectionConfig') + defaults = { + 'onboarding': ['stammdaten', 'vertrag', 'itsetup', 'abschluss'], + 'offboarding': ['mitarbeitende', 'austritt', 'abschluss'], + } + for form_type, section_keys in defaults.items(): + for index, section_key in enumerate(section_keys): + FormSectionConfig.objects.filter(form_type=form_type, section_key=section_key).update(sort_order=index) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0057_remove_phone_box_conditional_rule'), + ] + + operations = [ + migrations.AlterModelOptions( + name='formsectionconfig', + options={'ordering': ['form_type', 'sort_order', 'section_key'], 'verbose_name': 'Formularabschnitt-Konfiguration', 'verbose_name_plural': 'Formularabschnitt-Konfigurationen'}, + ), + migrations.AddField( + model_name='formsectionconfig', + name='sort_order', + field=models.PositiveIntegerField(default=0), + ), + migrations.RunPython(seed_section_sort_order, migrations.RunPython.noop), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index a406bab..8caf2a6 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -491,7 +491,7 @@ class FormFieldConfig(models.Model): sort_order = models.PositiveIntegerField(default=0) is_visible = models.BooleanField(default=True) is_required = models.BooleanField(null=True, blank=True, default=None) - page_key = models.CharField(max_length=20, blank=True, default='', choices=PAGE_CHOICES) + page_key = models.CharField(max_length=80, blank=True, default='') label_override = models.CharField(max_length=255, blank=True) label_override_en = models.CharField(max_length=255, blank=True) help_text_override = models.TextField(blank=True) @@ -524,21 +524,13 @@ class FormSectionConfig(models.Model): ('onboarding', _('Onboarding')), ('offboarding', _('Offboarding')), ] - SECTION_CHOICES = [ - ('stammdaten', _('Stammdaten')), - ('vertrag', _('Vertrag')), - ('itsetup', _('IT-Setup')), - ('abschluss', _('Abschluss')), - ('mitarbeitende', _('Mitarbeitende')), - ('austritt', _('Austritt')), - ] - form_type = models.CharField(max_length=20, choices=FORM_CHOICES) - section_key = models.CharField(max_length=20, choices=SECTION_CHOICES) + section_key = models.CharField(max_length=80) + sort_order = models.PositiveIntegerField(default=0) is_visible = models.BooleanField(default=True) class Meta: - ordering = ['form_type', 'section_key'] + ordering = ['form_type', 'sort_order', 'section_key'] unique_together = ('form_type', 'section_key') verbose_name = 'Formularabschnitt-Konfiguration' verbose_name_plural = 'Formularabschnitt-Konfigurationen' @@ -567,6 +559,34 @@ class FormConditionalRuleConfig(models.Model): return f'{self.form_type}: {self.target_key}' +class FormCustomSectionConfig(models.Model): + FORM_CHOICES = [ + ('onboarding', _('Onboarding')), + ] + + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + section_key = models.SlugField(max_length=80) + sort_order = models.PositiveIntegerField(default=0) + title = models.CharField(max_length=255) + title_en = models.CharField(max_length=255, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['form_type', 'sort_order', 'section_key'] + unique_together = ('form_type', 'section_key') + verbose_name = 'Benutzerdefinierter Formularabschnitt' + verbose_name_plural = 'Benutzerdefinierte Formularabschnitte' + + def __str__(self) -> str: + return f'{self.form_type}: {self.title}' + + def translated_title(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.title_en.strip(): + return self.title_en.strip() + return self.title.strip() + + class FormCustomFieldConfig(models.Model): FIELD_TYPE_TEXT = 'text' FIELD_TYPE_TEXTAREA = 'textarea' @@ -582,18 +602,9 @@ class FormCustomFieldConfig(models.Model): ('onboarding', _('Onboarding')), ('offboarding', _('Offboarding')), ] - SECTION_CHOICES = [ - ('stammdaten', _('Stammdaten')), - ('vertrag', _('Vertrag')), - ('itsetup', _('IT-Setup')), - ('abschluss', _('Abschluss')), - ('mitarbeitende', _('Mitarbeitende')), - ('austritt', _('Austritt')), - ] - form_type = models.CharField(max_length=20, choices=FORM_CHOICES) field_key = models.SlugField(max_length=80) - section_key = models.CharField(max_length=20, choices=SECTION_CHOICES) + section_key = models.CharField(max_length=80) sort_order = models.PositiveIntegerField(default=0) field_type = models.CharField(max_length=20, choices=FIELD_TYPE_CHOICES, default=FIELD_TYPE_TEXT) is_active = models.BooleanField(default=True) diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index fce3a2e..f6db87f 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -109,14 +109,31 @@ body { color: #166534; } -.builder-overview { +.builder-summary-strip { margin-top: 12px; - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.builder-summary-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 12px; + border: 1px solid #d5dfec; + border-radius: 999px; + background: #f8fbff; + color: #304159; + font-size: 13px; + font-weight: 700; +} + +.builder-summary-pill strong { + color: #101c30; } -.builder-stat-card, .builder-panel, .options-panel { border: 1px solid rgba(201, 212, 226, 0.95); @@ -125,34 +142,6 @@ body { box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); } -.builder-stat-card { - padding: 14px 16px; - transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; -} - -.builder-stat-card:hover { - transform: translateY(-2px); - border-color: rgba(146, 170, 199, 0.95); - box-shadow: 0 16px 28px rgba(15, 23, 42, 0.08); -} - -.builder-stat-label { - display: block; - color: #65758f; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; -} - -.builder-stat-card strong { - display: block; - margin-top: 6px; - font-size: 28px; - line-height: 1; - color: #101c30; -} - .builder-panel-head h2, .options-head h2 { margin: 0; @@ -371,6 +360,18 @@ body { gap: 12px; } +.preview-shell-compact .preview-section { + border-radius: 12px; +} + +.preview-shell-compact .preview-section-head { + padding: 10px 12px; +} + +.preview-shell-compact .preview-chip-list { + padding: 12px; +} + .preview-section { border: 1px solid #d7e0ec; border-radius: 14px; @@ -500,6 +501,169 @@ body { justify-content: flex-end; } +.conditional-rule-grid { + display: grid; + gap: 12px; +} + +.conditional-rule-card { + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + padding: 14px; + display: grid; + gap: 12px; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.conditional-rule-card:hover { + transform: translateY(-1px); + border-color: #bfd0e4; + box-shadow: 0 14px 26px rgba(15, 23, 42, 0.06); +} + +.conditional-rule-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px solid #e6edf6; +} + +.conditional-rule-head-main { + min-width: 0; +} + +.conditional-rule-head h3 { + margin: 2px 0 4px; + font-size: 16px; + color: #142033; +} + +.conditional-rule-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + background: #eef4ff; + color: #214d99; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.conditional-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + color: #5f7089; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.conditional-toggle input[type='checkbox'] { + width: 16px; + height: 16px; +} + +.conditional-targets { + display: grid; + gap: 8px; +} + +.conditional-target-label { + color: #5f7089; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.conditional-target-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.conditional-clause-list { + display: grid; + gap: 10px; +} + +.conditional-clause-row { + display: grid; + grid-template-columns: 56px minmax(220px, 1.35fr) minmax(180px, 0.8fr) minmax(180px, 0.85fr); + gap: 10px; + align-items: end; + padding: 12px; + border: 1px solid #e5ebf3; + border-radius: 14px; + background: #f8fbff; +} + +.conditional-clause-index { + display: inline-flex; + align-items: center; + min-height: 38px; + color: #33506f; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.conditional-clause-control { + display: grid; + gap: 6px; +} + +.conditional-clause-control span { + color: #5f7089; + font-size: 12px; + font-weight: 800; +} + +.conditional-clause-control select, +.conditional-clause-control input[type='text'] { + width: 100%; + min-height: 40px; +} + +.conditional-extra-clause { + border: 1px dashed #d7e0ec; + border-radius: 14px; + background: #fbfdff; +} + +.conditional-extra-clause summary { + list-style: none; + cursor: pointer; + padding: 10px 12px; + color: #35506f; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.conditional-extra-clause summary::-webkit-details-marker { + display: none; +} + +.conditional-extra-clause[open] summary { + border-bottom: 1px solid #e6edf6; +} + +.conditional-extra-clause .conditional-clause-row { + border: 0; + border-radius: 0 0 14px 14px; + background: transparent; +} + .columns { display: grid; grid-template-columns: repeat(4, minmax(220px, 1fr)); @@ -728,35 +892,212 @@ body { justify-content: flex-end; } -.section-rule-grid { +.builder-entity-form { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 10px; + gap: 12px; + margin-bottom: 14px; + padding: 14px; + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); } -.section-rule-card { +.builder-entity-head h3, +.builder-group-head h3 { + margin: 0; + font-size: 16px; + color: #142033; +} + +.builder-entity-head .mini { + margin-top: 4px; +} + +.builder-entity-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.builder-entity-control { + display: grid; + gap: 6px; +} + +.builder-entity-control span { + color: #5f7089; + font-size: 12px; + font-weight: 800; +} + +.builder-entity-control-narrow { + max-width: 180px; +} + +.builder-entity-control-full { + grid-column: 1 / -1; +} + +.builder-card-list, +.builder-group-stack { + display: grid; + gap: 12px; +} + +.builder-group-card { + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + overflow: hidden; +} + +.builder-group-head { display: flex; align-items: center; justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid #dfe7f1; + background: #f2f7ff; +} + +.builder-entity-card { + padding: 14px; + border: 1px solid #e5ebf3; + border-radius: 16px; + background: #ffffff; + display: grid; + gap: 12px; +} + +.builder-entity-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.builder-entity-card-head strong { + color: #142033; + font-size: 15px; +} + +.entity-meta { + margin-top: 4px; + color: #64748b; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + overflow-wrap: anywhere; +} + +.builder-switch, +.builder-switch-inline { + display: inline-flex; + align-items: center; + gap: 8px; + color: #5f7089; + font-size: 12px; + font-weight: 800; +} + +.builder-switch-stack { + display: grid; + gap: 8px; +} + +.builder-switch input[type='checkbox'], +.builder-switch-inline input[type='checkbox'] { + width: 16px; + height: 16px; +} + +.builder-entity-card-actions { + display: flex; + justify-content: flex-end; +} + +.builder-empty-state { + padding: 14px; + border: 1px dashed #d7e0ec; + border-radius: 14px; + background: #fbfdff; + color: #64748b; + font-size: 13px; +} + +.section-rule-grid { + display: flex; + flex-direction: column; + gap: 10px; +} + +.section-rule-grid.drag-over { + outline: 1px dashed #9db4d2; + outline-offset: 6px; +} + +.section-rule-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; gap: 12px; padding: 13px 14px; border: 1px solid #d6e0ec; border-radius: 14px; background: linear-gradient(180deg, #f9fbff, #ffffff); + cursor: move; + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; + width: 100%; +} + +.section-rule-card:hover { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.06); + border-color: #b8cae0; } .section-rule-card.is-locked { background: linear-gradient(180deg, #f4f7fb, #fafcff); } +.section-rule-card.dragging { + opacity: 0.58; +} + +.section-rule-card.manual-dragging { + opacity: 0.72; + border-color: #8fb1d8; + box-shadow: 0 16px 30px rgba(15, 23, 42, 0.10); +} + +.section-rule-drag { + color: #8aa0be; + font-size: 18px; + letter-spacing: -2px; + user-select: none; + align-self: stretch; + display: inline-flex; + align-items: center; + padding-right: 2px; + cursor: grab; +} + +body.builder-dragging, +body.builder-dragging * { + cursor: grabbing !important; + user-select: none !important; +} + .section-rule-copy { display: grid; gap: 4px; + min-width: 0; } .section-rule-copy strong { color: #0f172a; font-size: 14px; + overflow-wrap: anywhere; } .section-rule-copy span, @@ -772,6 +1113,11 @@ body { gap: 8px; } +.section-rule-checkbox { + display: inline-flex; + align-items: center; +} + @keyframes builderFadeIn { from { opacity: 0; @@ -821,10 +1167,6 @@ body { } @media (max-width: 1220px) { - .builder-overview { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .builder-rule-layout, .columns { grid-template-columns: repeat(2, minmax(220px, 1fr)); @@ -834,7 +1176,8 @@ body { @media (max-width: 900px) { .builder-hero, .builder-panel-head, - .options-head { + .options-head, + .conditional-rule-head { flex-direction: column; align-items: flex-start; } @@ -846,15 +1189,20 @@ body { .builder-rule-layout { grid-template-columns: 1fr; } + + .builder-entity-card-head { + flex-direction: column; + align-items: flex-start; + } } @media (max-width: 760px) { - .builder-overview, .columns { grid-template-columns: 1fr; } - .field-rule-row { + .field-rule-row, + .conditional-clause-row { grid-template-columns: 1fr; } @@ -862,7 +1210,26 @@ body { justify-content: flex-start; } + .conditional-clause-index { + min-height: auto; + } + .add-option-form { grid-template-columns: 1fr; } + + .builder-entity-grid { + grid-template-columns: 1fr; + } + + .builder-entity-control-narrow, + .builder-entity-control-full { + max-width: none; + grid-column: auto; + } + + .builder-group-head { + flex-direction: column; + align-items: flex-start; + } } diff --git a/backend/workflows/static/workflows/js/form_builder.js b/backend/workflows/static/workflows/js/form_builder.js index bb3b7c4..1fceee8 100644 --- a/backend/workflows/static/workflows/js/form_builder.js +++ b/backend/workflows/static/workflows/js/form_builder.js @@ -145,4 +145,95 @@ } }); } + + const sectionRuleGrid = document.getElementById('section-rule-grid'); + if (sectionRuleGrid) { + let draggingSectionCard = null; + let manualDraggingSectionCard = null; + + function getSectionInsertBeforeNode(mouseY) { + const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card:not(.dragging)')); + return cards.find((card) => { + const box = card.getBoundingClientRect(); + return mouseY < box.top + box.height / 2; + }); + } + + function getManualSectionInsertBeforeNode(mouseY) { + const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card:not(.manual-dragging)')); + return cards.find((card) => { + const box = card.getBoundingClientRect(); + return mouseY < box.top + box.height / 2; + }); + } + + function onManualSectionMove(event) { + if (!manualDraggingSectionCard) return; + event.preventDefault(); + sectionRuleGrid.classList.add('drag-over'); + const beforeNode = getManualSectionInsertBeforeNode(event.clientY); + if (beforeNode) { + sectionRuleGrid.insertBefore(manualDraggingSectionCard, beforeNode); + } else { + sectionRuleGrid.appendChild(manualDraggingSectionCard); + } + } + + function endManualSectionDrag() { + if (!manualDraggingSectionCard) return; + manualDraggingSectionCard.classList.remove('manual-dragging'); + manualDraggingSectionCard = null; + sectionRuleGrid.classList.remove('drag-over'); + document.body.classList.remove('builder-dragging'); + document.removeEventListener('mousemove', onManualSectionMove); + document.removeEventListener('mouseup', endManualSectionDrag); + } + + sectionRuleGrid.querySelectorAll('.section-rule-card').forEach((card) => { + card.addEventListener('dragstart', (event) => { + draggingSectionCard = card; + card.classList.add('dragging'); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', card.dataset.sectionKey || ''); + }); + card.addEventListener('dragend', () => { + card.classList.remove('dragging'); + sectionRuleGrid.classList.remove('drag-over'); + draggingSectionCard = null; + }); + const handle = card.querySelector('.section-rule-drag'); + if (handle) { + handle.addEventListener('mousedown', (event) => { + event.preventDefault(); + manualDraggingSectionCard = card; + card.classList.add('manual-dragging'); + sectionRuleGrid.classList.add('drag-over'); + document.body.classList.add('builder-dragging'); + document.addEventListener('mousemove', onManualSectionMove); + document.addEventListener('mouseup', endManualSectionDrag); + }); + } + }); + + sectionRuleGrid.addEventListener('dragover', (event) => { + event.preventDefault(); + sectionRuleGrid.classList.add('drag-over'); + if (!draggingSectionCard) return; + const beforeNode = getSectionInsertBeforeNode(event.clientY); + if (beforeNode) { + sectionRuleGrid.insertBefore(draggingSectionCard, beforeNode); + } else { + sectionRuleGrid.appendChild(draggingSectionCard); + } + }); + + sectionRuleGrid.addEventListener('dragleave', () => { + sectionRuleGrid.classList.remove('drag-over'); + }); + + sectionRuleGrid.addEventListener('drop', (event) => { + event.preventDefault(); + sectionRuleGrid.classList.remove('drag-over'); + }); + } })(); diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index b032696..80fc5ec 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -32,81 +32,6 @@
- - -
-
- {% trans "Fixe Kernfelder" %} - {{ builder_summary.locked_field_count }} -
-
- {% trans "Konfigurierbar" %} - {{ builder_summary.configurable_field_count }} -
-
- {% trans "Aktuell ausgeblendet" %} - {{ builder_summary.hidden_field_count }} -
-
- {% trans "Eigene Felder" %} - {{ builder_summary.custom_field_count }} -
- {% if form_type == 'onboarding' %} -
- {% trans "Versteckte Abschnitte" %} - {{ builder_summary.hidden_section_count }} -
- {% endif %} -
- -
-
- {% csrf_token %} - - - - -
-
- -
- -
-
-

{% trans "Live-Vorschau" %}

-
- {% trans "Öffnen" %} -
-
-
-
- {% for section in preview_sections %} -
-
-

{{ section.title }}

- {% blocktrans trimmed with count=section.items|length %}{{ count }} Feld/Felder{% endblocktrans %} -
-
- {% for item in section.items %} - {{ item.label }} - {% empty %} - {% trans "Keine sichtbaren Felder." %} - {% endfor %} -
-
- {% endfor %} -
-
-
-
@@ -158,46 +83,63 @@
-
-
-
-

{% trans "Abschnitte steuern" %}

-
+
+
+ +
+

{% trans "Abschnitte steuern" %}

+ {% trans "Öffnen" %} +
+
+
{% csrf_token %} -
+
{% for section in section_rule_items %} -
-
- -
-
-

{% trans "Feldregeln verwalten" %}

+
+ +
+ +
+

{% trans "Feldregeln verwalten" %}

+ {% trans "Öffnen" %} +
+
+
{% csrf_token %}
@@ -250,61 +192,104 @@
- +
+
{% if form_type == 'onboarding' %} -
-
-

{% trans "Bedingte Logik" %}

-
+
+ +
+

{% trans "Bedingte Logik" %}

+ {% trans "Öffnen" %} +
+
+
{% csrf_token %} -
+
{% for item in conditional_rule_items %} -
-
-

{{ item.title }}

- {{ item.target_fields|join:", " }} -
-
-
-
- {{ item.title }} -
{{ item.description }}
-
- +
+
+
+ {% trans "Sichtbarkeit" %} +

{{ item.title }}

+ {% if item.description %} +

{{ item.description }}

+ {% endif %}
- {% for clause in item.clauses %} -
-
- {% blocktrans trimmed with number=forloop.counter %}Bedingung {{ number }}{% endblocktrans %} + +
+
+ {% trans "Steuert" %} +
+ {% for field_name in item.target_fields %} + {{ field_name }} + {% empty %} + {% trans "Keine Ziel-Felder." %} + {% endfor %} +
+
+
+ {% with first_clause=item.clauses.0 second_clause=item.clauses.1 %} +
+
+ {% trans "Wenn" %}
-
- {% endfor %} +
+ {% trans "Zusätzliche Bedingung" %} +
+
+ {% trans "Und" %} +
+ + + +
+
+ {% endwith %}
{% endfor %} @@ -313,7 +298,8 @@
-
+
+
{% endif %} @@ -457,6 +443,86 @@ + {% if form_type == 'onboarding' %} +
+ +
+

{% trans "Eigene Abschnitte" %}

+ {% trans "Öffnen" %} +
+
+
+
+ {% csrf_token %} + +
+
+

{% trans "Abschnitt hinzufügen" %}

+

{% trans "Erstellen Sie zusätzliche Bereiche für deployment-spezifische Informationen." %}

+
+
+
+ + + +
+
+ +
+
+ +
+ {% csrf_token %} +
+ {% for item in custom_section_items %} +
+ +
+
+ {{ item.title }} +
{{ item.section_key }}
+
+ +
+
+ + + +
+
+ {% empty %} +
{% trans "Keine eigenen Abschnitte vorhanden." %}
+ {% endfor %} +
+
+ +
+
+
+
+ {% endif %} +
@@ -465,98 +531,155 @@
-
+ {% csrf_token %} - - - - - - - - - - - +
+
+

{% trans "Feld hinzufügen" %}

+

{% trans "Erstellen Sie zusätzliche Eingaben innerhalb eines bestehenden oder eigenen Abschnitts." %}

+
+
+
+ + + + + + + + + + +
+
+ +
{% csrf_token %} -
- - - - - - - - - - - - - - - - {% for group in custom_field_groups %} - - - +
+ {% for group in custom_field_groups %} +
+
+

{{ group.title }}

+ {% blocktrans trimmed with count=group.items|length %}{{ count }} Feld/Felder{% endblocktrans %} +
+
{% for item in group.items %} -
- - - - - - - - - - + + {% empty %} - +
{% trans "Keine eigenen Felder vorhanden." %}
{% endfor %} - {% endfor %} - -
{% trans "Schlüssel" %}{% trans "Abschnitt" %}{% trans "Typ" %}{% trans "Sortierung" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}{% trans "Pflicht" %}{% trans "Aktiv" %}{% trans "Löschen" %}
{{ group.title }}
- - {{ item.field_key }} - - - - - - - - - - - - - +
+ +
+
+ {{ item.label }} +
{{ item.field_key }}
+
+
+ + +
+
+
+ + + + + + + + + +
+
-
{% trans "Keine eigenen Felder vorhanden." %}
+
+
+ {% endfor %}
@@ -564,6 +687,34 @@
+ +
+ +
+

{% trans "Live-Vorschau" %}

+ {% trans "Öffnen" %} +
+
+
+
+ {% for section in preview_sections %} +
+
+

{{ section.title }}

+ {% blocktrans trimmed with count=section.items|length %}{{ count }} Feld/Felder{% endblocktrans %} +
+
+ {% for item in section.items %} + {{ item.label }} + {% empty %} + {% trans "Keine sichtbaren Felder." %} + {% endfor %} +
+
+ {% endfor %} +
+
+
diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html index 0fdfb48..295f06a 100644 --- a/backend/workflows/templates/workflows/onboarding_form.html +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -59,13 +59,7 @@ {% with field=block.field %} {% if field.is_hidden %} {{ field }} - {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} -
- {{ field }} {{ field.label_tag }} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} - {{ field.errors }} -
- {% elif section.key == 'itsetup' and field.name in onboarding_checkbox_lists %} + {% elif field.name in onboarding_checkbox_lists %}

{{ field.label }}

@@ -83,8 +77,14 @@ {{ field.errors }}
+ {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} +
+ {{ field }} {{ field.label_tag }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
{% else %} -
+
{{ field.label_tag }} {{ field }} {% if field.help_text %}
{{ field.help_text }}
{% endif %} @@ -102,13 +102,7 @@ {% for field in block.fields %} {% if field.is_hidden %} {{ field }} - {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} -
- {{ field }} {{ field.label_tag }} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} - {{ field.errors }} -
- {% elif section.key == 'itsetup' and field.name in onboarding_checkbox_lists %} + {% elif field.name in onboarding_checkbox_lists %}

{{ field.label }}

@@ -126,8 +120,14 @@ {{ field.errors }}
+ {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} +
+ {{ field }} {{ field.label_tag }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
{% else %} -
+
{{ field.label_tag }} {{ field }} {% if field.help_text %}
{{ field.help_text }}
{% endif %} diff --git a/backend/workflows/tests/test_form_builder_admin.py b/backend/workflows/tests/test_form_builder_admin.py index 3620174..85bd536 100644 --- a/backend/workflows/tests/test_form_builder_admin.py +++ b/backend/workflows/tests/test_form_builder_admin.py @@ -3,7 +3,8 @@ import json from django.contrib.auth import get_user_model from django.test import TestCase -from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormOption, FormSectionConfig +from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormOption, FormSectionConfig +from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role class FormBuilderAdminTests(TestCase): @@ -20,6 +21,14 @@ class FormBuilderAdminTests(TestCase): password='secret123', email='builder_user@tub.co', ) + self.platform_owner = user_model.objects.create_user( + username='builder_owner', + password='secret123', + email='builder_owner@tub.co', + is_staff=True, + is_superuser=True, + ) + assign_user_role(self.platform_owner, ROLE_PLATFORM_OWNER) def test_staff_can_open_form_builder(self): self.client.force_login(self.staff) @@ -118,6 +127,25 @@ class FormBuilderAdminTests(TestCase): self.assertEqual(department.is_required, True) self.assertEqual(contract_start.is_required, None) + def test_platform_owner_can_modify_locked_field_rules(self): + self.client.force_login(self.platform_owner) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + full_name = FormFieldConfig.objects.get(form_type='onboarding', field_name='full_name') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_field_rules', + 'field_rule_ids': [str(full_name.id)], + f'is_required_{full_name.id}': 'optional', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + full_name.refresh_from_db() + self.assertEqual(full_name.is_required, False) + def test_staff_can_save_section_rules_with_locked_sections_preserved(self): self.client.force_login(self.staff) self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') @@ -126,6 +154,7 @@ class FormBuilderAdminTests(TestCase): '/admin-tools/form-builder/?form_type=onboarding&option_category=device', data={ 'builder_action': 'save_section_rules', + 'section_order': ['itsetup', 'stammdaten', 'vertrag', 'abschluss'], }, HTTP_HOST='localhost', ) @@ -135,6 +164,31 @@ class FormBuilderAdminTests(TestCase): stammdaten = FormSectionConfig.objects.get(form_type='onboarding', section_key='stammdaten') self.assertEqual(itsetup.is_visible, False) self.assertEqual(stammdaten.is_visible, True) + self.assertEqual(itsetup.sort_order, 0) + self.assertEqual(stammdaten.sort_order, 1) + + def test_platform_owner_can_modify_locked_section_rules(self): + self.client.force_login(self.platform_owner) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_section_rules', + 'section_order': ['vertrag', 'stammdaten', 'itsetup', 'abschluss'], + 'section_visible_vertrag': 'on', + 'section_visible_itsetup': 'on', + 'section_visible_abschluss': 'on', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + stammdaten = FormSectionConfig.objects.get(form_type='onboarding', section_key='stammdaten') + vertrag = FormSectionConfig.objects.get(form_type='onboarding', section_key='vertrag') + self.assertEqual(vertrag.sort_order, 0) + self.assertEqual(stammdaten.sort_order, 1) + self.assertEqual(stammdaten.is_visible, False) def test_apply_onboarding_lean_preset_updates_section_and_field_rules(self): self.client.force_login(self.staff) @@ -185,19 +239,19 @@ class FormBuilderAdminTests(TestCase): 'conditional_field_employment-end-box_0': 'employment_type', 'conditional_operator_employment-end-box_0': 'equals', 'conditional_value_employment-end-box_0': 'befristet', - 'conditional_active_phone-box': 'on', - 'conditional_field_phone-box_0': 'successor_required_choice', - 'conditional_operator_phone-box_0': 'equals', - 'conditional_value_phone-box_0': 'ja', - 'conditional_field_phone-box_1': 'inherit_phone_number_choice', - 'conditional_operator_phone-box_1': 'not_equals', - 'conditional_value_phone-box_1': 'ja', + 'conditional_active_successor-box': 'on', + 'conditional_field_successor-box_0': 'successor_required_choice', + 'conditional_operator_successor-box_0': 'equals', + 'conditional_value_successor-box_0': 'ja', + 'conditional_field_successor-box_1': 'inherit_phone_number_choice', + 'conditional_operator_successor-box_1': 'not_equals', + 'conditional_value_successor-box_1': 'ja', }, HTTP_HOST='localhost', ) self.assertEqual(response.status_code, 302) - rule = FormConditionalRuleConfig.objects.get(form_type='onboarding', target_key='phone-box') + rule = FormConditionalRuleConfig.objects.get(form_type='onboarding', target_key='successor-box') self.assertEqual(rule.is_active, True) self.assertEqual(len(rule.clauses), 2) self.assertEqual(rule.clauses[0]['field'], 'successor_required_choice') @@ -258,3 +312,62 @@ class FormBuilderAdminTests(TestCase): custom_field.refresh_from_db() self.assertEqual(custom_field.section_key, 'itsetup') self.assertEqual(custom_field.sort_order, 2) + + def test_staff_can_add_custom_section(self): + self.client.force_login(self.staff) + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'add_custom_section', + 'custom_section_title': 'Benefits', + 'custom_section_title_en': 'Benefits', + 'custom_section_sort_order': '5', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + section = FormCustomSectionConfig.objects.get(form_type='onboarding', section_key='benefits') + self.assertEqual(section.title, 'Benefits') + self.assertEqual(section.sort_order, 5) + + def test_save_order_accepts_custom_section_column(self): + self.client.force_login(self.staff) + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + custom_field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='stammdaten', + sort_order=99, + field_type='text', + label='Essenszuschuss', + ) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + payload = { + 'form_type': 'onboarding', + 'columns': { + 'stammdaten': ['department'], + 'vertrag': ['contract_start'], + 'itsetup': [], + 'abschluss': [], + 'benefits': ['custom__meal_allowance'], + }, + } + + response = self.client.post( + '/admin-tools/form-builder/save-order/', + data=json.dumps(payload), + content_type='application/json', + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 200) + custom_field.refresh_from_db() + self.assertEqual(custom_field.section_key, 'benefits') diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py index 8cad82c..5da61f5 100644 --- a/backend/workflows/tests/test_onboarding_flow.py +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from workflows.branding import get_company_email_domain -from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest +from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest class OnboardingFlowTests(TestCase): @@ -153,7 +153,7 @@ class OnboardingFlowTests(TestCase): self.assertIn('business-card-box', html) self.assertIn('employment-end-box', html) self.assertIn('data-conditional-target="business-card-box"', html) - self.assertIn('data-conditional-target="phone-box"', html) + self.assertNotIn('data-conditional-target="phone-box"', html) def test_onboarding_page_uses_stored_conditional_rule_config(self): FormConditionalRuleConfig.objects.update_or_create( @@ -193,6 +193,39 @@ class OnboardingFlowTests(TestCase): self.assertLess(html.index('Bürostandort'), html.index('Anrede')) + def test_phone_direct_dial_field_is_visible_without_successor(self): + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('Telefon-Direktwahl', html) + self.assertNotIn('data-conditional-target="phone-box"', html) + + def test_onboarding_custom_section_is_rendered_in_navigation(self): + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='text', + is_active=True, + label='Essenszuschuss', + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('Benefits', html) + self.assertIn('Essenszuschuss', html) + @patch('workflows.views.process_onboarding_request.delay') def test_onboarding_custom_field_is_rendered_and_saved(self, mock_delay): FormCustomFieldConfig.objects.create( diff --git a/backend/workflows/tests/test_pdf_sections.py b/backend/workflows/tests/test_pdf_sections.py index 80ee418..8ae826b 100644 --- a/backend/workflows/tests/test_pdf_sections.py +++ b/backend/workflows/tests/test_pdf_sections.py @@ -1,6 +1,6 @@ from django.test import TestCase -from workflows.models import FormCustomFieldConfig, FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest +from workflows.models import FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest from workflows.pdf_sections import build_pdf_sections @@ -123,3 +123,38 @@ class PDFSectionBuilderTests(TestCase): self.assertEqual(custom_field['label'], 'Bürostandort') self.assertEqual(custom_field['display_value'], 'Berlin Mitte') + + def test_custom_section_title_is_used_in_pdf_sections(self): + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='text', + is_active=True, + label='Essenszuschuss', + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + custom_field_values={'meal_allowance': 'Ja'}, + ) + + sections = build_pdf_sections('onboarding', request_obj, 'de') + + custom_section = next(section for section in sections if section['key'] == 'benefits') + self.assertEqual(custom_section['title'], 'Benefits') + self.assertIn('custom__meal_allowance', [field['name'] for field in custom_section['fields']]) diff --git a/backend/workflows/views.py b/backend/workflows/views.py index ad2422d..f061f4f 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -45,20 +45,20 @@ from .form_builder import ( OFFBOARDING_PAGE_LABELS, OFFBOARDING_PAGE_ORDER, ONBOARDING_DEFAULT_PAGE, - ONBOARDING_PAGE_LABELS, - ONBOARDING_PAGE_ORDER, build_custom_field_key, custom_field_target_key, ensure_form_field_configs, ensure_form_conditional_rule_configs, ensure_form_section_configs, get_custom_field_configs, + get_custom_section_configs, get_default_page_map, + get_section_definitions, get_section_labels, get_section_order, apply_form_preset, ) -from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig +from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig from .emailing import send_system_email from .notifications import notify_user from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability @@ -106,7 +106,6 @@ ONBOARDING_GROUPS = { 'extra-software-box': ['additional_software_multi', 'additional_software'], 'extra-access-box': ['additional_access_text'], 'successor-box': ['successor_name', 'inherit_phone_number_choice'], - 'phone-box': ['phone_number_choice'], } ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'} @@ -119,7 +118,6 @@ ONBOARDING_CHECKBOX_LISTS = { 'needed_workspace_groups_multi', 'needed_resources_multi', } -ONBOARDING_SECTION_ORDER = ['stammdaten', 'vertrag', 'itsetup', 'abschluss'] ONBOARDING_SECTION_META = { 'stammdaten': {'title': gettext_lazy('Stammdaten'), 'subtitle': gettext_lazy('Person, Rolle, Abteilung')}, 'vertrag': {'title': gettext_lazy('Vertrag'), 'subtitle': gettext_lazy('Beschäftigung und Termine')}, @@ -602,21 +600,24 @@ def _section_for_block(block: dict, field_pages: dict[str, str]) -> str: def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str], visible_section_keys: set[str] | None = None) -> list[dict]: - grouped = {key: [] for key in ONBOARDING_SECTION_ORDER} + section_defs = get_section_definitions('onboarding') + section_order = [item['key'] for item in section_defs] + section_titles = {item['key']: item['title'] for item in section_defs} + grouped = {key: [] for key in section_order} for block in blocks: section_key = _section_for_block(block, field_pages) if section_key not in grouped: section_key = 'abschluss' grouped[section_key].append(block) - visible_keys = visible_section_keys or set(ONBOARDING_SECTION_ORDER) + visible_keys = visible_section_keys or set(section_order) return [ { 'key': key, - 'title': ONBOARDING_SECTION_META[key]['title'], - 'subtitle': ONBOARDING_SECTION_META[key]['subtitle'], + 'title': section_titles.get(key, ONBOARDING_SECTION_META.get(key, {}).get('title', key)), + 'subtitle': ONBOARDING_SECTION_META.get(key, {}).get('subtitle', ''), 'blocks': grouped[key], } - for key in ONBOARDING_SECTION_ORDER + for key in section_order if key in visible_keys ] @@ -1930,10 +1931,14 @@ def onboarding_create(request): onboarding_blocks = _build_onboarding_layout(form) field_pages = getattr(form, '_field_page_keys', {}) section_configs = ensure_form_section_configs('onboarding') - visible_section_keys = { - key for key in ONBOARDING_SECTION_ORDER - if key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible - } + visible_section_keys = set() + for section in get_section_definitions('onboarding'): + key = section['key'] + if section.get('is_custom'): + if section.get('is_active', True): + visible_section_keys.add(key) + elif key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible: + visible_section_keys.add(key) onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages, visible_section_keys=visible_section_keys) onboarding_conditional_rules = _normalized_conditional_rule_payload('onboarding') @@ -2162,9 +2167,11 @@ def offboarding_success(request, request_id: int): def form_builder_page(request): language_code = get_language() form_type = request.GET.get('form_type', 'onboarding') + can_override_locked_builder_rules = get_user_role_key(request.user) == ROLE_PLATFORM_OWNER anchor = (request.GET.get('anchor') or '').strip() active_panel = (request.GET.get('panel') or '').strip() active_subpanel = (request.GET.get('subpanel') or '').strip() + active_rules_panel = (request.GET.get('rules_panel') or '').strip() if form_type not in DEFAULT_FIELD_ORDER: form_type = 'onboarding' option_category = request.GET.get('option_category', 'department') @@ -2267,6 +2274,54 @@ def form_builder_page(request): _audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)}) messages.success(request, 'Feldtexte wurden gespeichert.') + elif action == 'add_custom_section' and form_type == 'onboarding': + title = (request.POST.get('custom_section_title') or '').strip() + title_en = (request.POST.get('custom_section_title_en') or '').strip() + sort_order_raw = (request.POST.get('custom_section_sort_order') or '').strip() + if not title: + messages.error(request, 'Bitte einen Titel für den benutzerdefinierten Abschnitt angeben.') + else: + section_key_base = build_custom_field_key(title) + section_key = section_key_base + suffix = 2 + while FormCustomSectionConfig.objects.filter(form_type=form_type, section_key=section_key).exists(): + section_key = f'{section_key_base}_{suffix}' + suffix += 1 + try: + sort_order = int(sort_order_raw or 0) + except ValueError: + sort_order = 0 + FormCustomSectionConfig.objects.create( + form_type=form_type, + section_key=section_key, + sort_order=max(0, sort_order), + title=title, + title_en=title_en, + is_active=True, + ) + _audit(request, 'form_custom_section_added', target_type='form_custom_section', target_label=title, details={'form_type': form_type, 'section_key': section_key}) + messages.success(request, 'Benutzerdefinierter Abschnitt wurde hinzugefügt.') + + elif action == 'save_custom_sections' and form_type == 'onboarding': + section_ids = request.POST.getlist('custom_section_ids') + updated = 0 + for raw_id in section_ids: + cfg = FormCustomSectionConfig.objects.filter(id=raw_id, form_type=form_type).first() + if not cfg: + continue + try: + sort_order = int((request.POST.get(f'custom_section_sort_order_{cfg.id}') or '').strip() or cfg.sort_order) + except ValueError: + sort_order = cfg.sort_order + cfg.title = (request.POST.get(f'custom_section_title_{cfg.id}') or '').strip() or cfg.title + cfg.title_en = (request.POST.get(f'custom_section_title_en_{cfg.id}') or '').strip() + cfg.is_active = request.POST.get(f'custom_section_is_active_{cfg.id}') == 'on' + cfg.sort_order = max(0, sort_order) + cfg.save(update_fields=['title', 'title_en', 'is_active', 'sort_order']) + updated += 1 + _audit(request, 'form_custom_sections_saved', target_type='form_custom_section', target_label=form_type, details={'count': updated}) + messages.success(request, 'Benutzerdefinierte Abschnitte wurden gespeichert.') + elif action == 'add_custom_field': label = (request.POST.get('custom_label') or '').strip() label_en = (request.POST.get('custom_label_en') or '').strip() @@ -2360,7 +2415,7 @@ def form_builder_page(request): cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first() if not cfg: continue - if cfg.field_name in locked_fields: + if cfg.field_name in locked_fields and not can_override_locked_builder_rules: cfg.is_visible = True cfg.is_required = None else: @@ -2375,9 +2430,27 @@ def form_builder_page(request): elif action == 'save_section_rules' and form_type in {'onboarding', 'offboarding'}: section_configs = ensure_form_section_configs(form_type) locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + posted_order = request.POST.getlist('section_order') + next_sort_order = 0 updated = 0 + for section_key in posted_order: + cfg = section_configs.get(section_key) + if cfg is not None: + if cfg.sort_order != next_sort_order: + cfg.sort_order = next_sort_order + cfg.save(update_fields=['sort_order']) + updated += 1 + next_sort_order += 1 + continue + if form_type == 'onboarding': + custom_cfg = FormCustomSectionConfig.objects.filter(form_type=form_type, section_key=section_key).first() + if custom_cfg and custom_cfg.sort_order != next_sort_order: + custom_cfg.sort_order = next_sort_order + custom_cfg.save(update_fields=['sort_order']) + updated += 1 + next_sort_order += 1 for section_key, cfg in section_configs.items(): - if section_key in locked_sections: + if section_key in locked_sections and not can_override_locked_builder_rules: if not cfg.is_visible: cfg.is_visible = True cfg.save(update_fields=['is_visible']) @@ -2385,6 +2458,11 @@ def form_builder_page(request): cfg.is_visible = request.POST.get(f'section_visible_{section_key}') == 'on' cfg.save(update_fields=['is_visible']) updated += 1 + if form_type == 'onboarding': + for cfg in FormCustomSectionConfig.objects.filter(form_type=form_type): + cfg.is_active = request.POST.get(f'section_visible_{cfg.section_key}') == 'on' + cfg.save(update_fields=['is_active']) + updated += 1 _audit(request, 'form_section_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) messages.success(request, 'Abschnittsregeln wurden gespeichert.') @@ -2412,13 +2490,14 @@ def form_builder_page(request): elif action == 'apply_preset': preset_key = (request.POST.get('preset_key') or '').strip() if apply_form_preset(form_type, preset_key): - active_panel = 'builder-preview' + active_panel = 'builder-content' + active_subpanel = 'preview' _audit(request, 'form_preset_applied', target_type='form_config', target_label=form_type, details={'preset': preset_key}) messages.success(request, 'Preset wurde angewendet.') else: messages.error(request, 'Preset konnte nicht angewendet werden.') - if action in {'add_option', 'save_options', 'save_field_texts', 'add_custom_field', 'save_custom_fields'}: + if action in {'add_option', 'save_options', 'save_field_texts', 'add_custom_field', 'save_custom_fields', 'add_custom_section', 'save_custom_sections'}: active_panel = 'builder-content' if action in {'add_option', 'save_options'}: active_subpanel = 'options' @@ -2426,13 +2505,23 @@ def form_builder_page(request): active_subpanel = 'field-texts' elif action in {'add_custom_field', 'save_custom_fields'}: active_subpanel = 'custom-fields' + elif action in {'add_custom_section', 'save_custom_sections'}: + active_subpanel = 'custom-sections' elif action in {'save_field_rules', 'save_section_rules', 'save_conditional_rules'}: active_panel = 'builder-rules' + if action == 'save_section_rules': + active_rules_panel = 'section-rules' + elif action == 'save_field_rules': + active_rules_panel = 'field-rules' + elif action == 'save_conditional_rules': + active_rules_panel = 'conditional-rules' redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}" if active_panel: redirect_target += f"&panel={active_panel}" if active_subpanel: redirect_target += f"&subpanel={active_subpanel}" + if active_rules_panel: + redirect_target += f"&rules_panel={active_rules_panel}" if anchor == 'builder-content' or active_panel == 'builder-content': redirect_target += "#builder-content" return redirect(redirect_target) @@ -2450,8 +2539,9 @@ def form_builder_page(request): ensure_form_field_configs(form_type, default_names) section_configs = ensure_form_section_configs(form_type) conditional_rule_configs = ensure_form_conditional_rule_configs(form_type) if form_type == 'onboarding' else {} - section_order = get_section_order(form_type) - section_labels = get_section_labels(form_type) + section_definitions = get_section_definitions(form_type, include_inactive_custom=True) + section_order = [item['key'] for item in section_definitions] + section_labels = {item['key']: item['title'] for item in section_definitions} default_page_map = get_default_page_map(form_type) configs = list( @@ -2461,15 +2551,16 @@ def form_builder_page(request): locked = LOCKED_FIELD_RULES.get(form_type, set()) locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) custom_field_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('section_key', 'sort_order', 'field_key')) + custom_section_configs = get_custom_section_configs(form_type, include_inactive=True) if form_type == 'onboarding': columns = [ { 'key': key, - 'title': ONBOARDING_PAGE_LABELS.get(key, key), + 'title': section_labels.get(key, key), 'items': [], } - for key in ONBOARDING_PAGE_ORDER + for key in section_order ] column_by_key = {c['key']: c for c in columns} fallback = 'abschluss' @@ -2564,15 +2655,20 @@ def form_builder_page(request): section_rule_items = [] if section_order: fallback_section = section_order[-1] if section_order else '' + custom_section_map = {cfg.section_key: cfg for cfg in custom_section_configs} for key in section_order: cfg = section_configs.get(key) + custom_cfg = custom_section_map.get(key) + is_custom = custom_cfg is not None section_rule_items.append( { 'key': key, 'title': section_labels.get(key, key), - 'is_visible': True if not cfg else cfg.is_visible, - 'locked': key in locked_sections, - 'field_count': len([c for c in configs if (c.page_key or default_page_map.get(c.field_name, fallback_section)) == key]), + 'is_visible': bool(custom_cfg.is_active) if is_custom else (True if not cfg else cfg.is_visible), + 'locked': False if is_custom else (key in locked_sections and not can_override_locked_builder_rules), + 'is_custom': is_custom, + 'sort_order': custom_cfg.sort_order if is_custom else (cfg.sort_order if cfg else 0), + 'field_count': len([c for c in configs if (c.page_key or default_page_map.get(c.field_name, fallback_section)) == key]) + len([c for c in custom_field_configs if c.section_key == key]), } ) @@ -2588,7 +2684,7 @@ def form_builder_page(request): 'page_label': section_labels.get(page_key, page_key) if section_order else '', 'is_visible': cfg.is_visible, 'is_required': cfg.is_required, - 'locked': cfg.field_name in locked, + 'locked': cfg.field_name in locked and not can_override_locked_builder_rules, } ) @@ -2659,7 +2755,6 @@ def form_builder_page(request): 'extra-software-box': _('Zusätzliche Software'), 'extra-access-box': _('Zusätzliche Zugänge'), 'successor-box': _('Nachfolge'), - 'phone-box': _('Direktwahl'), } conditional_target_descriptions = { 'business-card-box': _('Steuert die Detailfelder für Visitenkarten.'), @@ -2669,7 +2764,6 @@ def form_builder_page(request): 'extra-software-box': _('Steuert zusätzliche Software-Felder.'), 'extra-access-box': _('Steuert zusätzliche Zugangsangaben.'), 'successor-box': _('Steuert Nachfolge- und Übernahmefelder.'), - 'phone-box': _('Steuert die manuelle Direktwahl.'), } for target_key, cfg in conditional_rule_configs.items(): clauses = list(cfg.clauses or []) @@ -2704,7 +2798,8 @@ def form_builder_page(request): for key in section_order: section_cfg = section_configs.get(key) section_locked = key in locked_sections - section_visible = True if section_locked or not section_cfg else section_cfg.is_visible + custom_section = next((cfg for cfg in custom_section_configs if cfg.section_key == key), None) + section_visible = bool(custom_section.is_active) if custom_section else (True if section_locked or not section_cfg else section_cfg.is_visible) visible_items = [ item for item in field_rule_group_map.get(key, []) if item['locked'] or item['is_visible'] @@ -2738,6 +2833,7 @@ def form_builder_page(request): 'hidden_field_count': hidden_field_count, 'hidden_section_count': hidden_section_count, 'custom_field_count': len([cfg for cfg in custom_field_configs if cfg.is_active]), + 'custom_section_count': len([cfg for cfg in custom_section_configs if cfg.is_active]), } return render( @@ -2760,9 +2856,12 @@ def form_builder_page(request): 'conditional_rule_items': conditional_rule_items, 'custom_field_groups': custom_field_groups, 'custom_field_type_choices': _translate_choice_list(FormCustomFieldConfig.FIELD_TYPE_CHOICES), + 'custom_section_items': custom_section_configs, 'active_panel': active_panel, 'active_subpanel': active_subpanel, + 'active_rules_panel': active_rules_panel, 'available_presets': FORM_PRESETS.get(form_type, {}), + 'can_override_locked_builder_rules': can_override_locked_builder_rules, }, ) @@ -3213,10 +3312,8 @@ def form_builder_save_order(request): allowed_names = {cfg.field_name for cfg in configs} | {f'custom__{cfg.field_key}' for cfg in custom_configs} seen = set() - if form_type == 'onboarding': - allowed_columns = ONBOARDING_PAGE_ORDER - else: - allowed_columns = OFFBOARDING_PAGE_ORDER + allowed_columns = get_section_order(form_type) + fallback_section = allowed_columns[-1] if allowed_columns else '' name_to_cfg = {cfg.field_name: cfg for cfg in configs} custom_name_to_cfg = {f'custom__{cfg.field_key}': cfg for cfg in custom_configs} @@ -3248,20 +3345,14 @@ def form_builder_save_order(request): cfg = name_to_cfg[name] cfg.sort_order = sort_order sort_order += 1 - if form_type == 'onboarding': - cfg.page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(name, 'abschluss') - else: - cfg.page_key = cfg.page_key or default_page_map.get(name, OFFBOARDING_PAGE_ORDER[-1]) + cfg.page_key = cfg.page_key or default_page_map.get(name, fallback_section) missing_custom = [name for name in custom_name_to_cfg.keys() if name not in seen] for name in missing_custom: cfg = custom_name_to_cfg[name] cfg.sort_order = sort_order sort_order += 1 - if form_type == 'onboarding': - cfg.section_key = cfg.section_key or 'abschluss' - else: - cfg.section_key = cfg.section_key or OFFBOARDING_PAGE_ORDER[-1] + cfg.section_key = cfg.section_key or fallback_section FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key']) if custom_configs: