snapshot: preserve dynamic builder and section ordering work

This commit is contained in:
Md Bayazid Bostame
2026-03-27 16:54:11 +01:00
parent fdc27f2123
commit 30877ed8ee
13 changed files with 1391 additions and 365 deletions

View File

@@ -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 = {

View File

@@ -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')},
},
),
]

View File

@@ -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),
]

View File

@@ -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),
]

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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');
});
}
})();

View File

@@ -32,81 +32,6 @@
<div id="status-message" class="status" aria-live="polite"></div>
<nav class="builder-quicknav" aria-label="{% trans 'Bereiche' %}">
<a href="#builder-structure">{% trans "Reihenfolge" %}</a>
<a href="#builder-rules">{% trans "Regeln" %}</a>
<a href="#builder-content">{% trans "Optionen & Texte" %}</a>
</nav>
<section class="builder-overview">
<article class="builder-stat-card">
<span class="builder-stat-label">{% trans "Fixe Kernfelder" %}</span>
<strong>{{ builder_summary.locked_field_count }}</strong>
</article>
<article class="builder-stat-card">
<span class="builder-stat-label">{% trans "Konfigurierbar" %}</span>
<strong>{{ builder_summary.configurable_field_count }}</strong>
</article>
<article class="builder-stat-card">
<span class="builder-stat-label">{% trans "Aktuell ausgeblendet" %}</span>
<strong>{{ builder_summary.hidden_field_count }}</strong>
</article>
<article class="builder-stat-card">
<span class="builder-stat-label">{% trans "Eigene Felder" %}</span>
<strong>{{ builder_summary.custom_field_count }}</strong>
</article>
{% if form_type == 'onboarding' %}
<article class="builder-stat-card">
<span class="builder-stat-label">{% trans "Versteckte Abschnitte" %}</span>
<strong>{{ builder_summary.hidden_section_count }}</strong>
</article>
{% endif %}
</section>
<section class="builder-preset-bar">
<form class="builder-preset-form" method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<input type="hidden" name="builder_action" value="apply_preset" />
<label class="builder-preset-label" for="preset_key">{% trans "Vorlage anwenden" %}</label>
<select id="preset_key" name="preset_key">
{% for preset_key, preset in available_presets.items %}
<option value="{{ preset_key }}">{{ preset.label }}</option>
{% endfor %}
</select>
<button class="btn btn-secondary" type="submit">{% trans "Anwenden" %}</button>
</form>
</section>
<details class="builder-panel builder-accordion js-single-accordion" data-accordion-group="builder-panels" id="builder-preview" {% if active_panel == 'builder-preview' %}open{% endif %}>
<summary class="builder-panel-summary">
<div class="builder-panel-head">
<div>
<h2>{% trans "Live-Vorschau" %}</h2>
</div>
<span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
</div>
</summary>
<div class="builder-panel-body">
<div class="preview-shell">
{% for section in preview_sections %}
<section class="preview-section">
<div class="preview-section-head">
<h3>{{ section.title }}</h3>
<span class="column-count">{% blocktrans trimmed with count=section.items|length %}{{ count }} Feld/Felder{% endblocktrans %}</span>
</div>
<div class="preview-chip-list">
{% for item in section.items %}
<span class="preview-chip{% if item.locked %} is-locked{% endif %}">{{ item.label }}</span>
{% empty %}
<span class="mini">{% trans "Keine sichtbaren Felder." %}</span>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
</div>
</details>
<details class="builder-panel builder-accordion js-single-accordion" data-accordion-group="builder-panels" id="builder-structure" {% if active_panel == 'builder-structure' %}open{% endif %}>
<summary class="builder-panel-summary">
<div class="builder-panel-head">
@@ -158,46 +83,63 @@
</summary>
<div class="builder-panel-body">
<div class="builder-rule-layout">
<section class="options-panel">
<div class="options-head">
<h2>{% trans "Abschnitte steuern" %}</h2>
</div>
<div class="builder-stack-layout">
<details class="options-panel nested-accordion js-single-accordion" data-accordion-group="builder-rules-subpanels" {% if active_rules_panel == 'section-rules' %}open{% endif %}>
<summary class="nested-accordion-summary">
<div class="options-head">
<h2>{% trans "Abschnitte steuern" %}</h2>
<span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
</div>
</summary>
<div class="nested-accordion-body">
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<div class="section-rule-grid">
<div class="section-rule-grid" id="section-rule-grid">
{% for section in section_rule_items %}
<label class="section-rule-card{% if section.locked %} is-locked{% endif %}">
<article
class="section-rule-card{% if section.locked %} is-locked{% endif %}"
draggable="true"
data-section-key="{{ section.key }}"
>
<input type="hidden" name="section_order" value="{{ section.key }}" />
<div class="section-rule-drag" aria-hidden="true" title="{% trans 'Zum Verschieben ziehen' %}">⋮⋮</div>
<div class="section-rule-copy">
<strong>{{ section.title }}</strong>
<span>{% blocktrans trimmed with count=section.field_count %}{{ count }} Feld/Felder in diesem Abschnitt.{% endblocktrans %}</span>
</div>
<div class="section-rule-toggle">
<input
type="checkbox"
name="section_visible_{{ section.key }}"
{% if section.is_visible %}checked{% endif %}
{% if section.locked %}disabled{% endif %}
/>
<label class="section-rule-checkbox">
<input
type="checkbox"
name="section_visible_{{ section.key }}"
{% if section.is_visible %}checked{% endif %}
{% if section.locked %}disabled{% endif %}
/>
</label>
<span class="badge {% if section.is_visible %}required{% else %}hidden{% endif %}">
{% if section.locked %}{% trans "Fix" %}
{% elif section.is_visible %}{% trans "Sichtbar" %}
{% else %}{% trans "Ausgeblendet" %}{% endif %}
</span>
</div>
</label>
</article>
{% endfor %}
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_section_rules">{% trans "Abschnittsregeln speichern" %}</button>
</div>
</form>
</section>
<section class="options-panel">
<div class="options-head">
<h2>{% trans "Feldregeln verwalten" %}</h2>
</div>
</details>
<details class="options-panel nested-accordion js-single-accordion" data-accordion-group="builder-rules-subpanels" {% if active_rules_panel == 'field-rules' %}open{% endif %}>
<summary class="nested-accordion-summary">
<div class="options-head">
<h2>{% trans "Feldregeln verwalten" %}</h2>
<span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
</div>
</summary>
<div class="nested-accordion-body">
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<div class="field-rule-groups">
@@ -250,61 +192,104 @@
<button class="btn btn-primary" type="submit" name="builder_action" value="save_field_rules">{% trans "Feldregeln speichern" %}</button>
</div>
</form>
</section>
</div>
</details>
{% if form_type == 'onboarding' %}
<section class="options-panel">
<div class="options-head">
<h2>{% trans "Bedingte Logik" %}</h2>
</div>
<details class="options-panel nested-accordion js-single-accordion" data-accordion-group="builder-rules-subpanels" {% if active_rules_panel == 'conditional-rules' %}open{% endif %}>
<summary class="nested-accordion-summary">
<div class="options-head">
<h2>{% trans "Bedingte Logik" %}</h2>
<span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
</div>
</summary>
<div class="nested-accordion-body">
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<div class="field-rule-groups">
<div class="conditional-rule-grid">
{% for item in conditional_rule_items %}
<section class="field-rule-group">
<div class="field-rule-group-head">
<h3>{{ item.title }}</h3>
<span class="column-count">{{ item.target_fields|join:", " }}</span>
</div>
<div class="field-rule-list">
<div class="field-rule-row">
<div class="field-rule-main">
<strong>{{ item.title }}</strong>
<div class="mini">{{ item.description }}</div>
</div>
<label class="field-rule-control">
<span>{% trans "Aktiv" %}</span>
<input type="checkbox" name="conditional_active_{{ item.target_key }}" {% if item.is_active %}checked{% endif %} />
</label>
<section class="conditional-rule-card">
<div class="conditional-rule-head">
<div class="conditional-rule-head-main">
<span class="conditional-rule-eyebrow">{% trans "Sichtbarkeit" %}</span>
<h3>{{ item.title }}</h3>
{% if item.description %}
<p class="mini">{{ item.description }}</p>
{% endif %}
</div>
{% for clause in item.clauses %}
<div class="field-rule-row">
<div class="field-rule-main">
<strong>{% blocktrans trimmed with number=forloop.counter %}Bedingung {{ number }}{% endblocktrans %}</strong>
<label class="conditional-toggle">
<span>{% trans "Aktiv" %}</span>
<input type="checkbox" name="conditional_active_{{ item.target_key }}" {% if item.is_active %}checked{% endif %} />
</label>
</div>
<div class="conditional-targets">
<span class="conditional-target-label">{% trans "Steuert" %}</span>
<div class="conditional-target-chips">
{% for field_name in item.target_fields %}
<span class="preview-chip">{{ field_name }}</span>
{% empty %}
<span class="mini">{% trans "Keine Ziel-Felder." %}</span>
{% endfor %}
</div>
</div>
<div class="conditional-clause-list">
{% with first_clause=item.clauses.0 second_clause=item.clauses.1 %}
<div class="conditional-clause-row">
<div class="conditional-clause-index">
{% trans "Wenn" %}
</div>
<label class="field-rule-control">
<label class="conditional-clause-control conditional-clause-field">
<span>{% trans "Feld" %}</span>
<select name="conditional_field_{{ item.target_key }}_{{ forloop.counter0 }}">
<select name="conditional_field_{{ item.target_key }}_0">
<option value="">{% trans "Keine" %}</option>
{% for value, label in item.field_choices %}
<option value="{{ value }}" {% if clause.field == value %}selected{% endif %}>{{ label }}</option>
<option value="{{ value }}" {% if first_clause.field == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="field-rule-control">
<label class="conditional-clause-control conditional-clause-operator">
<span>{% trans "Operator" %}</span>
<select name="conditional_operator_{{ item.target_key }}_{{ forloop.counter0 }}">
<select name="conditional_operator_{{ item.target_key }}_0">
{% for value, label in item.operator_choices %}
<option value="{{ value }}" {% if clause.operator == value %}selected{% endif %}>{{ label }}</option>
<option value="{{ value }}" {% if first_clause.operator == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="field-rule-control">
<label class="conditional-clause-control conditional-clause-value">
<span>{% trans "Wert" %}</span>
<input type="text" name="conditional_value_{{ item.target_key }}_{{ forloop.counter0 }}" value="{{ clause.value }}" {% if clause.operator == 'checked' %}placeholder="{% trans 'wird ignoriert' %}"{% endif %} />
<input type="text" name="conditional_value_{{ item.target_key }}_0" value="{{ first_clause.value }}" {% if first_clause.operator == 'checked' %}placeholder="{% trans 'wird ignoriert' %}"{% endif %} />
</label>
</div>
{% endfor %}
<details class="conditional-extra-clause" {% if second_clause.field %}open{% endif %}>
<summary>{% trans "Zusätzliche Bedingung" %}</summary>
<div class="conditional-clause-row conditional-clause-row-secondary">
<div class="conditional-clause-index">
{% trans "Und" %}
</div>
<label class="conditional-clause-control conditional-clause-field">
<span>{% trans "Feld" %}</span>
<select name="conditional_field_{{ item.target_key }}_1">
<option value="">{% trans "Keine" %}</option>
{% for value, label in item.field_choices %}
<option value="{{ value }}" {% if second_clause.field == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="conditional-clause-control conditional-clause-operator">
<span>{% trans "Operator" %}</span>
<select name="conditional_operator_{{ item.target_key }}_1">
{% for value, label in item.operator_choices %}
<option value="{{ value }}" {% if second_clause.operator == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="conditional-clause-control conditional-clause-value">
<span>{% trans "Wert" %}</span>
<input type="text" name="conditional_value_{{ item.target_key }}_1" value="{{ second_clause.value }}" {% if second_clause.operator == 'checked' %}placeholder="{% trans 'wird ignoriert' %}"{% endif %} />
</label>
</div>
</details>
{% endwith %}
</div>
</section>
{% endfor %}
@@ -313,7 +298,8 @@
<button class="btn btn-primary" type="submit" name="builder_action" value="save_conditional_rules">{% trans "Bedingte Logik speichern" %}</button>
</div>
</form>
</section>
</div>
</details>
{% endif %}
</div>
</div>
@@ -457,6 +443,86 @@
</div>
</details>
{% if form_type == 'onboarding' %}
<details class="options-panel nested-accordion js-single-accordion" data-accordion-group="builder-content-subpanels" {% if active_subpanel == 'custom-sections' %}open{% endif %}>
<summary class="nested-accordion-summary">
<div class="options-head">
<h2>{% trans "Eigene Abschnitte" %}</h2>
<span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
</div>
</summary>
<div class="nested-accordion-body">
<form class="builder-entity-form" method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<input type="hidden" name="builder_action" value="add_custom_section" />
<div class="builder-entity-head">
<div>
<h3>{% trans "Abschnitt hinzufügen" %}</h3>
<p class="mini">{% trans "Erstellen Sie zusätzliche Bereiche für deployment-spezifische Informationen." %}</p>
</div>
</div>
<div class="builder-entity-grid">
<label class="builder-entity-control">
<span>{% trans "Titel (DE)" %}</span>
<input type="text" name="custom_section_title" required />
</label>
<label class="builder-entity-control">
<span>{% trans "Titel (EN)" %}</span>
<input type="text" name="custom_section_title_en" />
</label>
<label class="builder-entity-control builder-entity-control-narrow">
<span>{% trans "Sortierung" %}</span>
<input type="number" name="custom_section_sort_order" min="0" value="0" />
</label>
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit">{% trans "Abschnitt hinzufügen" %}</button>
</div>
</form>
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<div class="builder-card-list">
{% for item in custom_section_items %}
<article class="builder-entity-card">
<input type="hidden" name="custom_section_ids" value="{{ item.id }}" />
<div class="builder-entity-card-head">
<div>
<strong>{{ item.title }}</strong>
<div class="entity-meta">{{ item.section_key }}</div>
</div>
<label class="builder-switch">
<input type="checkbox" name="custom_section_is_active_{{ item.id }}" {% if item.is_active %}checked{% endif %} />
<span>{% trans "Aktiv" %}</span>
</label>
</div>
<div class="builder-entity-grid">
<label class="builder-entity-control">
<span>{% trans "Titel (DE)" %}</span>
<input type="text" name="custom_section_title_{{ item.id }}" value="{{ item.title }}" required />
</label>
<label class="builder-entity-control">
<span>{% trans "Titel (EN)" %}</span>
<input type="text" name="custom_section_title_en_{{ item.id }}" value="{{ item.title_en }}" />
</label>
<label class="builder-entity-control builder-entity-control-narrow">
<span>{% trans "Sortierung" %}</span>
<input type="number" min="0" name="custom_section_sort_order_{{ item.id }}" value="{{ item.sort_order }}" />
</label>
</div>
</article>
{% empty %}
<div class="builder-empty-state">{% trans "Keine eigenen Abschnitte vorhanden." %}</div>
{% endfor %}
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_custom_sections">{% trans "Abschnitte speichern" %}</button>
</div>
</form>
</div>
</details>
{% endif %}
<details class="options-panel nested-accordion js-single-accordion" data-accordion-group="builder-content-subpanels" {% if active_subpanel == 'custom-fields' %}open{% endif %}>
<summary class="nested-accordion-summary">
<div class="options-head">
@@ -465,98 +531,155 @@
</div>
</summary>
<div class="nested-accordion-body">
<form class="add-option-form" method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
<form class="builder-entity-form" method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<input type="hidden" name="builder_action" value="add_custom_field" />
<input type="text" name="custom_label" placeholder="{% trans 'Label (DE)' %}" required />
<input type="text" name="custom_label_en" placeholder="{% trans 'Label (EN, optional)' %}" />
<select name="custom_section_key">
{% for group in custom_field_groups %}
<option value="{{ group.key }}">{{ group.title }}</option>
{% endfor %}
</select>
<select name="custom_field_type">
{% for value, label in custom_field_type_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<input type="number" name="custom_sort_order" min="0" value="0" placeholder="{% trans 'Sortierung' %}" />
<label class="field-rule-control compact-inline">
<span>{% trans "Pflicht" %}</span>
<input type="checkbox" name="custom_is_required" />
</label>
<input type="text" name="custom_help_text" placeholder="{% trans 'Hilfetext (DE, optional)' %}" />
<input type="text" name="custom_help_text_en" placeholder="{% trans 'Hilfetext (EN, optional)' %}" />
<textarea name="custom_select_options" rows="3" placeholder="{% trans 'Optionen (eine pro Zeile, optional: wert|Label)' %}"></textarea>
<textarea name="custom_select_options_en" rows="3" placeholder="{% trans 'Optionen EN (eine pro Zeile, optional: value|Label)' %}"></textarea>
<button class="btn btn-primary" type="submit">{% trans "Eigenes Feld hinzufügen" %}</button>
<div class="builder-entity-head">
<div>
<h3>{% trans "Feld hinzufügen" %}</h3>
<p class="mini">{% trans "Erstellen Sie zusätzliche Eingaben innerhalb eines bestehenden oder eigenen Abschnitts." %}</p>
</div>
</div>
<div class="builder-entity-grid">
<label class="builder-entity-control">
<span>{% trans "Label (DE)" %}</span>
<input type="text" name="custom_label" required />
</label>
<label class="builder-entity-control">
<span>{% trans "Label (EN)" %}</span>
<input type="text" name="custom_label_en" />
</label>
<label class="builder-entity-control">
<span>{% trans "Abschnitt" %}</span>
<select name="custom_section_key">
{% for group in custom_field_groups %}
<option value="{{ group.key }}">{{ group.title }}</option>
{% endfor %}
</select>
</label>
<label class="builder-entity-control">
<span>{% trans "Typ" %}</span>
<select name="custom_field_type">
{% for value, label in custom_field_type_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="builder-entity-control builder-entity-control-narrow">
<span>{% trans "Sortierung" %}</span>
<input type="number" name="custom_sort_order" min="0" value="0" />
</label>
<label class="builder-switch builder-switch-inline">
<input type="checkbox" name="custom_is_required" />
<span>{% trans "Pflichtfeld" %}</span>
</label>
<label class="builder-entity-control">
<span>{% trans "Hilfetext (DE)" %}</span>
<input type="text" name="custom_help_text" />
</label>
<label class="builder-entity-control">
<span>{% trans "Hilfetext (EN)" %}</span>
<input type="text" name="custom_help_text_en" />
</label>
<label class="builder-entity-control builder-entity-control-full">
<span>{% trans "Optionen (DE)" %}</span>
<textarea name="custom_select_options" rows="3" placeholder="{% trans 'Eine Option pro Zeile, optional: wert|Label' %}"></textarea>
</label>
<label class="builder-entity-control builder-entity-control-full">
<span>{% trans "Optionen (EN)" %}</span>
<textarea name="custom_select_options_en" rows="3" placeholder="{% trans 'Eine Option pro Zeile, optional: value|Label' %}"></textarea>
</label>
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit">{% trans "Eigenes Feld hinzufügen" %}</button>
</div>
</form>
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<div class="option-table-wrap">
<table class="option-table">
<thead>
<tr>
<th>{% trans "Schlüssel" %}</th>
<th>{% trans "Abschnitt" %}</th>
<th>{% trans "Typ" %}</th>
<th>{% trans "Sortierung" %}</th>
<th>{% trans "Label (DE)" %}</th>
<th>{% trans "Label (EN)" %}</th>
<th>{% trans "Pflicht" %}</th>
<th>{% trans "Aktiv" %}</th>
<th>{% trans "Löschen" %}</th>
</tr>
</thead>
<tbody>
{% for group in custom_field_groups %}
<tr class="option-table-group-row">
<th colspan="9">{{ group.title }}</th>
</tr>
<div class="builder-group-stack">
{% for group in custom_field_groups %}
<section class="builder-group-card">
<div class="builder-group-head">
<h3>{{ group.title }}</h3>
<span class="column-count">{% blocktrans trimmed with count=group.items|length %}{{ count }} Feld/Felder{% endblocktrans %}</span>
</div>
<div class="builder-card-list">
{% for item in group.items %}
<tr>
<td>
<input type="hidden" name="custom_field_ids" value="{{ item.id }}" />
<strong>{{ item.field_key }}</strong>
</td>
<td>
<select name="custom_section_key_{{ item.id }}">
{% for group_choice in custom_field_groups %}
<option value="{{ group_choice.key }}" {% if group_choice.key == item.section_key %}selected{% endif %}>{{ group_choice.title }}</option>
{% endfor %}
</select>
</td>
<td>
<select name="custom_field_type_{{ item.id }}">
{% for value, label in custom_field_type_choices %}
<option value="{{ value }}" {% if value == item.field_type %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</td>
<td><input type="number" min="0" name="custom_sort_order_{{ item.id }}" value="{{ item.sort_order }}" /></td>
<td>
<input type="text" name="custom_label_{{ item.id }}" value="{{ item.label }}" required />
<input type="text" name="custom_help_text_{{ item.id }}" value="{{ item.help_text }}" placeholder="{% trans 'Hilfetext (DE)' %}" />
<textarea name="custom_select_options_{{ item.id }}" rows="2" placeholder="{% trans 'Optionen (DE)' %}">{{ item.select_options }}</textarea>
</td>
<td>
<input type="text" name="custom_label_en_{{ item.id }}" value="{{ item.label_en }}" />
<input type="text" name="custom_help_text_en_{{ item.id }}" value="{{ item.help_text_en }}" placeholder="{% trans 'Hilfetext (EN)' %}" />
<textarea name="custom_select_options_en_{{ item.id }}" rows="2" placeholder="{% trans 'Optionen (EN)' %}">{{ item.select_options_en }}</textarea>
</td>
<td><input type="checkbox" name="custom_is_required_{{ item.id }}" {% if item.is_required %}checked{% endif %} /></td>
<td><input type="checkbox" name="custom_is_active_{{ item.id }}" {% if item.is_active %}checked{% endif %} /></td>
<td>
<article class="builder-entity-card">
<input type="hidden" name="custom_field_ids" value="{{ item.id }}" />
<div class="builder-entity-card-head">
<div>
<strong>{{ item.label }}</strong>
<div class="entity-meta">{{ item.field_key }}</div>
</div>
<div class="builder-switch-stack">
<label class="builder-switch">
<input type="checkbox" name="custom_is_required_{{ item.id }}" {% if item.is_required %}checked{% endif %} />
<span>{% trans "Pflicht" %}</span>
</label>
<label class="builder-switch">
<input type="checkbox" name="custom_is_active_{{ item.id }}" {% if item.is_active %}checked{% endif %} />
<span>{% trans "Aktiv" %}</span>
</label>
</div>
</div>
<div class="builder-entity-grid">
<label class="builder-entity-control">
<span>{% trans "Abschnitt" %}</span>
<select name="custom_section_key_{{ item.id }}">
{% for group_choice in custom_field_groups %}
<option value="{{ group_choice.key }}" {% if group_choice.key == item.section_key %}selected{% endif %}>{{ group_choice.title }}</option>
{% endfor %}
</select>
</label>
<label class="builder-entity-control">
<span>{% trans "Typ" %}</span>
<select name="custom_field_type_{{ item.id }}">
{% for value, label in custom_field_type_choices %}
<option value="{{ value }}" {% if value == item.field_type %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="builder-entity-control builder-entity-control-narrow">
<span>{% trans "Sortierung" %}</span>
<input type="number" min="0" name="custom_sort_order_{{ item.id }}" value="{{ item.sort_order }}" />
</label>
<label class="builder-entity-control">
<span>{% trans "Label (DE)" %}</span>
<input type="text" name="custom_label_{{ item.id }}" value="{{ item.label }}" required />
</label>
<label class="builder-entity-control">
<span>{% trans "Label (EN)" %}</span>
<input type="text" name="custom_label_en_{{ item.id }}" value="{{ item.label_en }}" />
</label>
<label class="builder-entity-control">
<span>{% trans "Hilfetext (DE)" %}</span>
<input type="text" name="custom_help_text_{{ item.id }}" value="{{ item.help_text }}" />
</label>
<label class="builder-entity-control">
<span>{% trans "Hilfetext (EN)" %}</span>
<input type="text" name="custom_help_text_en_{{ item.id }}" value="{{ item.help_text_en }}" />
</label>
<label class="builder-entity-control builder-entity-control-full">
<span>{% trans "Optionen (DE)" %}</span>
<textarea name="custom_select_options_{{ item.id }}" rows="2">{{ item.select_options }}</textarea>
</label>
<label class="builder-entity-control builder-entity-control-full">
<span>{% trans "Optionen (EN)" %}</span>
<textarea name="custom_select_options_en_{{ item.id }}" rows="2">{{ item.select_options_en }}</textarea>
</label>
</div>
<div class="builder-entity-card-actions">
<button class="btn btn-secondary" type="submit" name="delete_custom_field_id" value="{{ item.id }}" data-confirm="{% trans 'Eigenes Feld wirklich löschen?' %}">{% trans "Löschen" %}</button>
</td>
</tr>
</div>
</article>
{% empty %}
<tr><td colspan="9">{% trans "Keine eigenen Felder vorhanden." %}</td></tr>
<div class="builder-empty-state">{% trans "Keine eigenen Felder vorhanden." %}</div>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endfor %}
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_custom_fields">{% trans "Eigene Felder speichern" %}</button>
@@ -564,6 +687,34 @@
</form>
</div>
</details>
<details class="options-panel nested-accordion js-single-accordion" data-accordion-group="builder-content-subpanels" {% if active_subpanel == 'preview' %}open{% endif %}>
<summary class="nested-accordion-summary">
<div class="options-head">
<h2>{% trans "Live-Vorschau" %}</h2>
<span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
</div>
</summary>
<div class="nested-accordion-body">
<div class="preview-shell preview-shell-compact">
{% for section in preview_sections %}
<section class="preview-section">
<div class="preview-section-head">
<h3>{{ section.title }}</h3>
<span class="column-count">{% blocktrans trimmed with count=section.items|length %}{{ count }} Feld/Felder{% endblocktrans %}</span>
</div>
<div class="preview-chip-list">
{% for item in section.items %}
<span class="preview-chip{% if item.locked %} is-locked{% endif %}">{{ item.label }}</span>
{% empty %}
<span class="mini">{% trans "Keine sichtbaren Felder." %}</span>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
</div>
</details>
</div>
</div>
</details>

View File

@@ -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' %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% elif section.key == 'itsetup' and field.name in onboarding_checkbox_lists %}
{% elif field.name in onboarding_checkbox_lists %}
<div class="itsetup-checklist-panel field-full">
<div class="itsetup-checklist-head">
<h3>{{ field.label }}</h3>
@@ -83,8 +77,14 @@
{{ field.errors }}
</div>
</div>
{% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% else %}
<div class="field {% if section.key == 'abschluss' %}finish-field{% endif %} {% if field.name in onboarding_checkbox_lists or field.name == 'gender' or field.name == 'additional_hardware_needed_choice' or field.name == 'additional_software_needed_choice' or field.name == 'additional_access_needed_choice' or field.name == 'successor_required_choice' %}field-full{% endif %} {% if field.name in onboarding_checkbox_lists %}checkbox-list{% endif %} {% if section.key == 'itsetup' and field.name in onboarding_checkbox_lists %}itsetup-checklist-block{% endif %}">
<div class="field {% if section.key == 'abschluss' %}finish-field{% endif %} {% if field.name in onboarding_checkbox_lists or field.name == 'gender' or field.name == 'additional_hardware_needed_choice' or field.name == 'additional_software_needed_choice' or field.name == 'additional_access_needed_choice' or field.name == 'successor_required_choice' %}field-full{% endif %} {% if field.name in onboarding_checkbox_lists %}checkbox-list itsetup-checklist-block{% endif %}">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% 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' %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% elif section.key == 'itsetup' and field.name in onboarding_checkbox_lists %}
{% elif field.name in onboarding_checkbox_lists %}
<div class="itsetup-checklist-panel field-full">
<div class="itsetup-checklist-head">
<h3>{{ field.label }}</h3>
@@ -126,8 +120,14 @@
{{ field.errors }}
</div>
</div>
{% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
</div>
{% else %}
<div class="field {% if section.key == 'abschluss' %}finish-field{% endif %} {% if field.name in onboarding_checkbox_lists or field.name == 'gender' or field.name == 'additional_hardware_needed_choice' or field.name == 'additional_software_needed_choice' or field.name == 'additional_access_needed_choice' or field.name == 'successor_required_choice' %}field-full{% endif %} {% if field.name in onboarding_checkbox_lists %}checkbox-list{% endif %} {% if section.key == 'itsetup' and field.name in onboarding_checkbox_lists %}itsetup-checklist-block{% endif %}">
<div class="field {% if section.key == 'abschluss' %}finish-field{% endif %} {% if field.name in onboarding_checkbox_lists or field.name == 'gender' or field.name == 'additional_hardware_needed_choice' or field.name == 'additional_software_needed_choice' or field.name == 'additional_access_needed_choice' or field.name == 'successor_required_choice' %}field-full{% endif %} {% if field.name in onboarding_checkbox_lists %}checkbox-list itsetup-checklist-block{% endif %}">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}

View File

@@ -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')

View File

@@ -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(

View File

@@ -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']])

View File

@@ -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: