diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index f6db87f..4f7ce6c 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -977,6 +977,14 @@ body { gap: 14px; } +.builder-card-head-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + .builder-entity-card-head strong { color: #142033; font-size: 15px; @@ -1003,6 +1011,7 @@ body { .builder-switch-stack { display: grid; gap: 8px; + justify-items: end; } .builder-switch input[type='checkbox'], @@ -1031,61 +1040,62 @@ body { 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; + padding: 14px 16px; border: 1px solid #d6e0ec; - border-radius: 14px; - background: linear-gradient(180deg, #f9fbff, #ffffff); - cursor: move; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff, #ffffff); 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; + box-shadow: 0 14px 24px rgba(15, 23, 42, 0.07); + border-color: #b2c6df; } .section-rule-card.is-locked { - background: linear-gradient(180deg, #f4f7fb, #fafcff); + background: linear-gradient(180deg, #f5f8fc, #fbfdff); } -.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; +.section-rule-actions { display: inline-flex; align-items: center; + gap: 6px; padding-right: 2px; - cursor: grab; } -body.builder-dragging, -body.builder-dragging * { - cursor: grabbing !important; - user-select: none !important; +.section-move-btn { + width: 34px; + height: 34px; + border: 1px solid #cdd9e8; + border-radius: 11px; + background: linear-gradient(180deg, #ffffff, #f5f9ff); + color: #274264; + font-size: 15px; + font-weight: 700; + line-height: 1; + cursor: pointer; + box-shadow: 0 6px 12px rgba(15, 23, 42, 0.04); + transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease; +} + +.section-move-btn:hover { + transform: translateY(-1px); + border-color: #9db4d2; + background: linear-gradient(180deg, #ffffff, #eef5ff); + box-shadow: 0 10px 16px rgba(15, 23, 42, 0.07); +} + +.section-move-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; } .section-rule-copy { @@ -1096,7 +1106,8 @@ body.builder-dragging * { .section-rule-copy strong { color: #0f172a; - font-size: 14px; + font-size: 15px; + font-weight: 700; overflow-wrap: anywhere; } diff --git a/backend/workflows/static/workflows/css/onboarding_form.css b/backend/workflows/static/workflows/css/onboarding_form.css index a787442..9030da4 100644 --- a/backend/workflows/static/workflows/css/onboarding_form.css +++ b/backend/workflows/static/workflows/css/onboarding_form.css @@ -192,6 +192,10 @@ h1 { } .section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; margin-bottom: 12px; border-bottom: 1px dashed #dde4f1; padding-bottom: 8px; @@ -210,6 +214,9 @@ h1 { } .section-itsetup .section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; margin: -14px -14px 16px; padding: 12px 14px; border-bottom: 1px solid #d5e2f9; @@ -348,6 +355,11 @@ h1 { transform: translateY(1px); } +.section-toggle-btn { + flex-shrink: 0; + margin-left: auto; +} + .itsetup-checklist-body { padding: 0; background: #ffffff; diff --git a/backend/workflows/static/workflows/js/form_builder.js b/backend/workflows/static/workflows/js/form_builder.js index 1fceee8..393cf0e 100644 --- a/backend/workflows/static/workflows/js/form_builder.js +++ b/backend/workflows/static/workflows/js/form_builder.js @@ -148,92 +148,36 @@ 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 updateSectionMoveButtons() { + const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card')); + cards.forEach((card, index) => { + const upBtn = card.querySelector('[data-move-section="up"]'); + const downBtn = card.querySelector('[data-move-section="down"]'); + if (upBtn) upBtn.disabled = index === 0; + if (downBtn) downBtn.disabled = index === cards.length - 1; }); } - 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('click', (event) => { + const button = event.target.closest('[data-move-section]'); + if (!button) return; + const card = button.closest('.section-rule-card'); + if (!card) return; + const direction = button.dataset.moveSection; + if (direction === 'up') { + const previousCard = card.previousElementSibling; + if (previousCard) { + sectionRuleGrid.insertBefore(card, previousCard); + } + } else if (direction === 'down') { + const nextCard = card.nextElementSibling; + if (nextCard) { + sectionRuleGrid.insertBefore(nextCard, card); + } } + updateSectionMoveButtons(); }); - 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'); - }); + updateSectionMoveButtons(); } })(); diff --git a/backend/workflows/static/workflows/js/onboarding_form.js b/backend/workflows/static/workflows/js/onboarding_form.js index 534355a..e6de8a3 100644 --- a/backend/workflows/static/workflows/js/onboarding_form.js +++ b/backend/workflows/static/workflows/js/onboarding_form.js @@ -222,6 +222,45 @@ }); } + function setupSectionCheckboxToggles() { + document.querySelectorAll('[data-section-checkbox-toggle]').forEach(function (button) { + const sectionCard = button.closest('.section-card'); + if (!sectionCard) return; + + const getCheckboxes = function () { + return Array.from(sectionCard.querySelectorAll('.custom-section-checkbox input[type="checkbox"]')); + }; + + const refreshButtonLabel = function () { + const checkboxes = getCheckboxes(); + if (!checkboxes.length) { + button.classList.add('hidden'); + return; + } + button.classList.remove('hidden'); + const allChecked = checkboxes.every(function (box) { return box.checked; }); + button.textContent = allChecked ? (button.dataset.labelClear || 'Auswahl aufheben') : (button.dataset.labelSelect || 'Alle auswählen'); + }; + + button.addEventListener('click', function () { + const checkboxes = getCheckboxes(); + if (!checkboxes.length) return; + const shouldCheck = checkboxes.some(function (box) { return !box.checked; }); + checkboxes.forEach(function (box) { + box.checked = shouldCheck; + box.dispatchEvent(new Event('change', { bubbles: true })); + }); + refreshButtonLabel(); + }); + + getCheckboxes().forEach(function (box) { + box.addEventListener('change', refreshButtonLabel); + }); + + refreshButtonLabel(); + }); + } + function setupChecklistColumns() { document.querySelectorAll('.itsetup-checklist-body > [id^="id_"]').forEach(function (container) { const itemCount = container.querySelectorAll(':scope > div').length; @@ -271,6 +310,7 @@ setupWorkEmailAutofill(); setupBusinessCardAutofill(); setupChecklistToggles(); + setupSectionCheckboxToggles(); setupChecklistColumns(); jumpToFirstErrorPage(); updateStep(); diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index 80fc5ec..1380495 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -98,11 +98,17 @@ {% for section in section_rule_items %}
- +
+ + +
{{ section.title }} {% blocktrans trimmed with count=section.field_count %}{{ count }} Feld/Felder in diesem Abschnitt.{% endblocktrans %} @@ -491,10 +497,13 @@ {{ item.title }}
{{ item.section_key }}
- +
+ + +
-
+
-
+ +
-
- -
{% empty %}
{% trans "Keine eigenen Felder vorhanden." %}
diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html index 295f06a..78dfda4 100644 --- a/backend/workflows/templates/workflows/onboarding_form.html +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -50,8 +50,19 @@
-

{{ section.title }}

-

{{ section.subtitle }}

+
+

{{ section.title }}

+

{{ section.subtitle }}

+
+ {% if section.has_custom_checkbox_fields %} + + {% endif %}
{% for block in section.blocks %} @@ -78,7 +89,7 @@
{% 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 }} @@ -121,7 +132,7 @@
{% 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 }} diff --git a/backend/workflows/tests/test_form_builder_admin.py b/backend/workflows/tests/test_form_builder_admin.py index 85bd536..a1c6d22 100644 --- a/backend/workflows/tests/test_form_builder_admin.py +++ b/backend/workflows/tests/test_form_builder_admin.py @@ -331,6 +331,60 @@ class FormBuilderAdminTests(TestCase): self.assertEqual(section.title, 'Benefits') self.assertEqual(section.sort_order, 5) + def test_staff_can_delete_custom_field(self): + self.client.force_login(self.staff) + field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='laptop_tag', + section_key='itsetup', + sort_order=0, + field_type='text', + label='Laptop-Tag', + ) + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={'delete_custom_field_id': str(field.id)}, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + self.assertFalse(FormCustomFieldConfig.objects.filter(id=field.id).exists()) + + def test_staff_can_delete_custom_section_and_its_fields(self): + self.client.force_login(self.staff) + section = FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=0, + title='Benefits', + ) + field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='checkbox', + label='Essenszuschuss', + ) + FormConditionalRuleConfig.objects.create( + form_type='onboarding', + target_key='custom__meal_allowance', + clauses=[{'field': 'employment_type', 'operator': 'equals', 'value': 'unbefristet'}], + is_active=True, + ) + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={'delete_custom_section_id': str(section.id)}, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + self.assertFalse(FormCustomSectionConfig.objects.filter(id=section.id).exists()) + self.assertFalse(FormCustomFieldConfig.objects.filter(id=field.id).exists()) + self.assertFalse(FormConditionalRuleConfig.objects.filter(target_key='custom__meal_allowance').exists()) + def test_save_order_accepts_custom_section_column(self): self.client.force_login(self.staff) FormCustomSectionConfig.objects.create( diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py index 5da61f5..84d0efc 100644 --- a/backend/workflows/tests/test_onboarding_flow.py +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -226,6 +226,41 @@ class OnboardingFlowTests(TestCase): self.assertIn('Benefits', html) self.assertIn('Essenszuschuss', html) + def test_onboarding_custom_section_with_checkbox_fields_shows_section_select_all(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='checkbox', + is_active=True, + label='Essenszuschuss', + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='parking_spot', + section_key='benefits', + sort_order=1, + field_type='checkbox', + is_active=True, + label='Parkplatz', + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('data-section-checkbox-toggle', html) + self.assertIn('Essenszuschuss', html) + self.assertIn('Parkplatz', 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/views.py b/backend/workflows/views.py index f061f4f..9f3faa0 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -610,16 +610,33 @@ def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str], section_key = 'abschluss' grouped[section_key].append(block) visible_keys = visible_section_keys or set(section_order) - return [ - { - 'key': key, - '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 section_order - if key in visible_keys - ] + sections = [] + custom_section_keys = {item['key'] for item in section_defs if item.get('is_custom')} + for key in section_order: + if key not in visible_keys: + continue + blocks_for_section = grouped[key] + has_custom_checkbox_fields = False + for block in blocks_for_section: + candidate_fields = [block['field']] if block['kind'] == 'field' else (block.get('fields') or []) + for bound_field in candidate_fields: + widget_type = getattr(getattr(bound_field.field, 'widget', None), 'input_type', '') + if bound_field.name.startswith('custom__') and widget_type == 'checkbox': + has_custom_checkbox_fields = True + break + if has_custom_checkbox_fields: + break + sections.append( + { + 'key': key, + 'title': section_titles.get(key, ONBOARDING_SECTION_META.get(key, {}).get('title', key)), + 'subtitle': ONBOARDING_SECTION_META.get(key, {}).get('subtitle', ''), + 'blocks': blocks_for_section, + 'is_custom': key in custom_section_keys, + 'has_custom_checkbox_fields': has_custom_checkbox_fields, + } + ) + return sections OFFBOARDING_SECTION_META = { @@ -2182,6 +2199,7 @@ def form_builder_page(request): if request.method == 'POST': delete_option_id = request.POST.get('delete_option_id', '').strip() delete_custom_field_id = request.POST.get('delete_custom_field_id', '').strip() + delete_custom_section_id = request.POST.get('delete_custom_section_id', '').strip() if delete_option_id: option = FormOption.objects.filter(id=delete_option_id).first() if not option: @@ -2205,6 +2223,34 @@ def form_builder_page(request): _audit(request, 'form_custom_field_deleted', target_type='form_custom_field', target_id=deleted_id, target_label=deleted_label) messages.success(request, 'Benutzerdefiniertes Feld wurde gelöscht.') return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-fields#builder-content") + if delete_custom_section_id: + custom_section = FormCustomSectionConfig.objects.filter(id=delete_custom_section_id, form_type=form_type).first() + if not custom_section: + messages.error(request, 'Benutzerdefinierter Abschnitt nicht gefunden.') + else: + deleted_label = custom_section.title + deleted_id = custom_section.id + section_key = custom_section.section_key + custom_fields = list(FormCustomFieldConfig.objects.filter(form_type=form_type, section_key=section_key)) + deleted_field_count = len(custom_fields) + if custom_fields: + field_keys = [item.field_key for item in custom_fields] + FormConditionalRuleConfig.objects.filter( + form_type=form_type, + target_key__in=[f'custom__{field_key}' for field_key in field_keys], + ).delete() + FormCustomFieldConfig.objects.filter(id__in=[item.id for item in custom_fields]).delete() + custom_section.delete() + _audit( + request, + 'form_custom_section_deleted', + target_type='form_custom_section', + target_id=deleted_id, + target_label=deleted_label, + details={'section_key': section_key, 'deleted_field_count': deleted_field_count}, + ) + messages.success(request, 'Benutzerdefinierter Abschnitt wurde gelöscht.') + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-sections#builder-content") action = request.POST.get('builder_action', '') if action == 'add_option':