snapshot: preserve dynamic form builder parity and presets

This commit is contained in:
Md Bayazid Bostame
2026-03-27 12:30:10 +01:00
parent aa54f41731
commit e929e7509b
12 changed files with 2097 additions and 505 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
from collections import OrderedDict from collections import OrderedDict
from django.utils.translation import get_language from django.utils.translation import get_language
from .models import FormFieldConfig from .models import FormFieldConfig, FormSectionConfig
DEFAULT_FIELD_ORDER = { DEFAULT_FIELD_ORDER = {
@@ -58,18 +58,29 @@ DEFAULT_FIELD_ORDER = {
} }
ONBOARDING_PAGE_ORDER = ['stammdaten', 'vertrag', 'itsetup', 'abschluss'] ONBOARDING_PAGE_ORDER = ['stammdaten', 'vertrag', 'itsetup', 'abschluss']
OFFBOARDING_PAGE_ORDER = ['mitarbeitende', 'austritt', 'abschluss']
ONBOARDING_PAGE_LABELS = { ONBOARDING_PAGE_LABELS = {
'stammdaten': '1. Stammdaten', 'stammdaten': '1. Stammdaten',
'vertrag': '2. Vertrag', 'vertrag': '2. Vertrag',
'itsetup': '3. IT-Setup', 'itsetup': '3. IT-Setup',
'abschluss': '4. Abschluss', 'abschluss': '4. Abschluss',
} }
OFFBOARDING_PAGE_LABELS = {
'mitarbeitende': '1. Mitarbeitende',
'austritt': '2. Austritt',
'abschluss': '3. Abschluss',
}
LOCKED_FIELD_RULES = { LOCKED_FIELD_RULES = {
'onboarding': {'full_name', 'work_email', 'contract_start', 'agreement_confirm'}, 'onboarding': {'full_name', 'work_email', 'contract_start', 'agreement_confirm'},
'offboarding': {'full_name', 'work_email', 'last_working_day'}, 'offboarding': {'full_name', 'work_email', 'last_working_day'},
} }
LOCKED_SECTION_RULES = {
'onboarding': {'stammdaten', 'vertrag', 'abschluss'},
'offboarding': {'mitarbeitende', 'austritt'},
}
ONBOARDING_DEFAULT_PAGE = { ONBOARDING_DEFAULT_PAGE = {
'first_name': 'stammdaten', 'first_name': 'stammdaten',
'last_name': 'stammdaten', 'last_name': 'stammdaten',
@@ -112,6 +123,38 @@ ONBOARDING_DEFAULT_PAGE = {
'onboarded_by_email': 'abschluss', 'onboarded_by_email': 'abschluss',
'agreement_confirm': 'abschluss', 'agreement_confirm': 'abschluss',
} }
OFFBOARDING_DEFAULT_PAGE = {
'full_name': 'mitarbeitende',
'work_email': 'mitarbeitende',
'department': 'mitarbeitende',
'job_title': 'mitarbeitende',
'last_working_day': 'austritt',
'notes': 'abschluss',
}
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 []
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 {}
def get_default_page_map(form_type: str) -> dict[str, str]:
if form_type == 'onboarding':
return ONBOARDING_DEFAULT_PAGE
if form_type == 'offboarding':
return OFFBOARDING_DEFAULT_PAGE
return {}
def _default_sort(form_type: str, field_name: str) -> int: def _default_sort(form_type: str, field_name: str) -> int:
@@ -134,7 +177,7 @@ def _ensure_configs(form_type: str, field_names: list[str]) -> dict[str, FormFie
form_type=form_type, form_type=form_type,
field_name=name, field_name=name,
sort_order=_default_sort(form_type, name), sort_order=_default_sort(form_type, name),
page_key=ONBOARDING_DEFAULT_PAGE.get(name, '') if form_type == 'onboarding' else '', page_key=get_default_page_map(form_type).get(name, ''),
) )
for name in missing_names for name in missing_names
], ],
@@ -151,10 +194,34 @@ def ensure_form_field_configs(form_type: str, field_names: list[str]) -> dict[st
return _ensure_configs(form_type, field_names) return _ensure_configs(form_type, field_names)
def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]:
section_order = get_section_order(form_type)
if not section_order:
return {}
existing = {
cfg.section_key: cfg
for cfg in FormSectionConfig.objects.filter(form_type=form_type)
}
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],
ignore_conflicts=True,
)
existing = {
cfg.section_key: cfg
for cfg in FormSectionConfig.objects.filter(form_type=form_type)
}
return existing
def apply_form_field_config(form_type: str, form) -> None: def apply_form_field_config(form_type: str, form) -> None:
field_names = list(form.fields.keys()) field_names = list(form.fields.keys())
configs = _ensure_configs(form_type, field_names) configs = _ensure_configs(form_type, field_names)
section_configs = ensure_form_section_configs(form_type)
locked = LOCKED_FIELD_RULES.get(form_type, set()) locked = LOCKED_FIELD_RULES.get(form_type, set())
locked_sections = LOCKED_SECTION_RULES.get(form_type, set())
default_page_map = get_default_page_map(form_type)
language_code = get_language() language_code = get_language()
for field_name, field in list(form.fields.items()): for field_name, field in list(form.fields.items()):
@@ -173,7 +240,14 @@ def apply_form_field_config(form_type: str, form) -> None:
if field_name not in locked and cfg.is_required is not None: if field_name not in locked and cfg.is_required is not None:
field.required = cfg.is_required field.required = cfg.is_required
if field_name not in locked and not cfg.is_visible: section_key = cfg.page_key or default_page_map.get(field_name, '')
section_hidden = (
form_type in {'onboarding', 'offboarding'}
and section_key not in locked_sections
and section_key in section_configs
and not section_configs[section_key].is_visible
)
if field_name not in locked and (not cfg.is_visible or section_hidden):
form.fields.pop(field_name, None) form.fields.pop(field_name, None)
ordered_items = sorted( ordered_items = sorted(
@@ -184,9 +258,138 @@ def apply_form_field_config(form_type: str, form) -> None:
), ),
) )
form.fields = OrderedDict(ordered_items) form.fields = OrderedDict(ordered_items)
if form_type == 'onboarding': if form_type in {'onboarding', 'offboarding'}:
form._field_page_keys = { form._field_page_keys = {
name: (configs[name].page_key or ONBOARDING_DEFAULT_PAGE.get(name, 'abschluss')) name: (configs[name].page_key or default_page_map.get(name, ''))
for name in form.fields.keys() for name in form.fields.keys()
if name in configs if name in configs
} }
FORM_PRESETS = {
'onboarding': {
'standard': {
'label': 'Standard',
'sections': {
'stammdaten': True,
'vertrag': True,
'itsetup': True,
'abschluss': True,
},
'fields': {},
},
'lean': {
'label': 'Lean',
'sections': {
'stammdaten': True,
'vertrag': True,
'itsetup': False,
'abschluss': True,
},
'fields': {
'gender': {'is_visible': False},
'order_business_cards': {'is_visible': False},
'business_card_name': {'is_visible': False},
'business_card_title': {'is_visible': False},
'business_card_email': {'is_visible': False},
'business_card_phone': {'is_visible': False},
'employment_end_date': {'is_visible': False},
'group_mailboxes_required_choice': {'is_visible': False},
'group_mailboxes': {'is_visible': False},
'additional_notes': {'is_required': False},
},
},
'it_heavy': {
'label': 'IT-heavy',
'sections': {
'stammdaten': True,
'vertrag': True,
'itsetup': True,
'abschluss': True,
},
'fields': {
'needed_devices_multi': {'is_required': True},
'needed_software_multi': {'is_required': True},
'needed_accesses_multi': {'is_required': True},
'needed_workspace_groups_multi': {'is_required': True},
'needed_resources_multi': {'is_required': True},
'additional_hardware_needed_choice': {'is_visible': True},
'additional_software_needed_choice': {'is_visible': True},
'additional_access_needed_choice': {'is_visible': True},
'successor_required_choice': {'is_visible': True},
},
},
},
'offboarding': {
'standard': {
'label': 'Standard',
'sections': {
'mitarbeitende': True,
'austritt': True,
'abschluss': True,
},
'fields': {},
},
'lean': {
'label': 'Lean',
'sections': {
'mitarbeitende': True,
'austritt': True,
'abschluss': False,
},
'fields': {
'department': {'is_visible': False},
'job_title': {'is_visible': False},
'notes': {'is_visible': False},
},
},
'hr_heavy': {
'label': 'HR-heavy',
'sections': {
'mitarbeitende': True,
'austritt': True,
'abschluss': True,
},
'fields': {
'department': {'is_visible': True, 'is_required': True},
'job_title': {'is_visible': True, 'is_required': True},
'notes': {'is_visible': True, 'is_required': True},
},
},
},
}
def apply_form_preset(form_type: str, preset_key: str) -> bool:
preset = FORM_PRESETS.get(form_type, {}).get(preset_key)
if not preset:
return False
locked_fields = LOCKED_FIELD_RULES.get(form_type, set())
locked_sections = LOCKED_SECTION_RULES.get(form_type, set())
default_names = list(DEFAULT_FIELD_ORDER.get(form_type, []))
ensure_form_field_configs(form_type, default_names)
section_configs = ensure_form_section_configs(form_type)
for section_key, is_visible in preset.get('sections', {}).items():
cfg = section_configs.get(section_key)
if not cfg or section_key in locked_sections:
continue
cfg.is_visible = bool(is_visible)
cfg.save(update_fields=['is_visible'])
for cfg in FormFieldConfig.objects.filter(form_type=form_type):
if cfg.field_name in locked_fields:
cfg.is_visible = True
cfg.is_required = None
else:
cfg.is_visible = True
cfg.is_required = None
override = preset.get('fields', {}).get(cfg.field_name, {})
if 'is_visible' in override:
cfg.is_visible = bool(override['is_visible'])
if 'is_required' in override:
cfg.is_required = override['is_required']
cfg.save(update_fields=['is_visible', 'is_required'])
return True

View File

@@ -0,0 +1,37 @@
from django.db import migrations, models
def seed_onboarding_sections(apps, schema_editor):
FormSectionConfig = apps.get_model('workflows', 'FormSectionConfig')
for section_key in ['stammdaten', 'vertrag', 'itsetup', 'abschluss']:
FormSectionConfig.objects.get_or_create(
form_type='onboarding',
section_key=section_key,
defaults={'is_visible': True},
)
class Migration(migrations.Migration):
dependencies = [
('workflows', '0052_userprofile_notification_preferences'),
]
operations = [
migrations.CreateModel(
name='FormSectionConfig',
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.CharField(choices=[('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss')], max_length=20)),
('is_visible', models.BooleanField(default=True)),
],
options={
'verbose_name': 'Formularabschnitt-Konfiguration',
'verbose_name_plural': 'Formularabschnitt-Konfigurationen',
'ordering': ['form_type', 'section_key'],
'unique_together': {('form_type', 'section_key')},
},
),
migrations.RunPython(seed_onboarding_sections, migrations.RunPython.noop),
]

View File

@@ -516,6 +516,31 @@ class FormFieldConfig(models.Model):
return self.help_text_override.strip() return self.help_text_override.strip()
class FormSectionConfig(models.Model):
FORM_CHOICES = [
('onboarding', _('Onboarding')),
]
SECTION_CHOICES = [
('stammdaten', _('Stammdaten')),
('vertrag', _('Vertrag')),
('itsetup', _('IT-Setup')),
('abschluss', _('Abschluss')),
]
form_type = models.CharField(max_length=20, choices=FORM_CHOICES)
section_key = models.CharField(max_length=20, choices=SECTION_CHOICES)
is_visible = models.BooleanField(default=True)
class Meta:
ordering = ['form_type', 'section_key']
unique_together = ('form_type', 'section_key')
verbose_name = 'Formularabschnitt-Konfiguration'
verbose_name_plural = 'Formularabschnitt-Konfigurationen'
def __str__(self) -> str:
return f'{self.form_type}: {self.section_key}'
class NotificationTemplate(models.Model): class NotificationTemplate(models.Model):
TEMPLATE_CHOICES = [ TEMPLATE_CHOICES = [
('onboarding_it', _('Onboarding: IT')), ('onboarding_it', _('Onboarding: IT')),

View File

@@ -1,72 +1,98 @@
body { body {
margin: 0; margin: 0;
font-family: Arial, sans-serif; font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: #f4f7fb; background:
color: #1f2937; radial-gradient(circle at top right, rgba(0, 120, 255, 0.08), transparent 28%),
linear-gradient(180deg, #eef4fb 0%, #f7f9fc 100%);
color: #142033;
} }
.shell { .shell {
width: min(1280px, 94%); width: min(1320px, 94%);
margin: 20px auto 28px; margin: 20px auto 32px;
background: #ffffff; background: rgba(255, 255, 255, 0.92);
border: 1px solid #d8e2f0; border: 1px solid rgba(191, 204, 222, 0.8);
border-radius: 14px; border-radius: 20px;
padding: 18px; padding: 20px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); box-shadow: 0 22px 60px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(12px);
} }
.topbar { .builder-hero,
.builder-panel,
.builder-stat-card,
.section-rule-card,
.field-card,
.options-panel {
animation: builderFadeIn 0.32s ease;
}
.builder-hero {
display: flex; display: flex;
align-items: center; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 20px;
margin-bottom: 10px; padding: 8px 0 6px;
} }
.brand-logo { .builder-hero-copy {
width: 190px; max-width: 760px;
max-width: 100%;
height: auto;
display: block;
} }
.header h1 { .builder-eyebrow {
margin: 0; display: inline-flex;
font-size: 28px;
}
.header p {
margin: 6px 0 0;
color: #64748b;
}
.toolbar {
margin-top: 14px;
display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 10px;
padding: 6px 11px;
border-radius: 999px;
background: #e9f2ff;
color: #174ea6;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.builder-hero h1 {
margin: 0;
font-size: clamp(30px, 4vw, 40px);
line-height: 1.02;
}
.builder-hero-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
} }
.tab { .tab {
border: 1px solid #cbd5e1; border: 1px solid #c6d1e1;
border-radius: 999px; border-radius: 999px;
padding: 8px 14px; padding: 9px 15px;
text-decoration: none; text-decoration: none;
color: #1f2937; color: #1c2a41;
background: #f8fafc; background: #f8fbff;
font-weight: 600; font-weight: 700;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.tab:hover {
transform: translateY(-1px);
border-color: #9db4d2;
} }
.tab.active { .tab.active {
background: #000078; background: linear-gradient(135deg, #0f3b7a 0%, #1759b8 100%);
color: #ffffff; color: #ffffff;
border-color: #000078; border-color: #1759b8;
} }
.status { .status {
min-height: 22px; min-height: 22px;
margin: 10px 0 8px; margin: 14px 0 10px;
color: #334155; color: #334155;
font-size: 14px; font-size: 14px;
} }
@@ -83,11 +109,401 @@ body {
color: #166534; color: #166534;
} }
.builder-overview {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.builder-stat-card,
.builder-panel,
.options-panel {
border: 1px solid rgba(201, 212, 226, 0.95);
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 255, 0.98));
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;
color: #142033;
}
.mini {
color: #61718a;
font-size: 13px;
line-height: 1.55;
}
.builder-quicknav {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.builder-quicknav a {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border: 1px solid #d1dbea;
border-radius: 999px;
background: #f7fbff;
color: #304159;
font-size: 13px;
font-weight: 700;
text-decoration: none;
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease, box-shadow 0.18s ease;
}
.builder-quicknav a:hover {
transform: translateY(-1px);
border-color: #adc2dd;
background: #ffffff;
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.06);
}
.builder-preset-bar {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.builder-preset-form {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.builder-preset-label {
color: #61718a;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.builder-preset-form select {
min-height: 36px;
padding: 0 12px;
border: 1px solid #cfdbeb;
border-radius: 999px;
background: linear-gradient(180deg, #ffffff, #f6faff);
color: #24405f;
font-size: 13px;
font-weight: 700;
}
.builder-panel {
margin-top: 14px;
padding: 16px;
}
.builder-panel-head,
.options-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
}
.options-head-inline {
margin-bottom: 14px;
}
.builder-accordion {
padding: 0;
overflow: hidden;
}
.nested-accordion {
padding: 0;
overflow: hidden;
}
.nested-accordion-summary {
list-style: none;
cursor: pointer;
padding: 14px 16px;
transition: background-color 0.18s ease;
}
.nested-accordion-summary::-webkit-details-marker {
display: none;
}
.nested-accordion-summary .options-head {
margin-bottom: 0;
}
.nested-accordion-summary:hover {
background: rgba(242, 247, 255, 0.72);
}
.nested-accordion[open] .nested-accordion-summary {
border-bottom: 1px solid rgba(201, 212, 226, 0.8);
}
.nested-accordion:not([open]) .builder-panel-toggle::after {
transform: rotate(-90deg);
}
.nested-accordion[open] .builder-panel-toggle {
color: #194ea7;
}
.nested-accordion-body {
padding: 14px 16px 16px;
}
.nested-accordion[open] .nested-accordion-body {
animation: builderReveal 0.24s ease;
}
.builder-panel-summary {
list-style: none;
cursor: pointer;
padding: 14px 16px;
transition: background-color 0.18s ease;
}
.builder-panel-summary::-webkit-details-marker {
display: none;
}
.builder-panel-summary .builder-panel-head {
margin-bottom: 0;
}
.builder-panel-summary:hover {
background: rgba(242, 247, 255, 0.72);
}
.builder-panel-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
color: #5f7089;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.builder-panel-toggle::after {
content: "▾";
font-size: 13px;
line-height: 1;
transition: transform 0.18s ease;
}
.builder-accordion:not([open]) .builder-panel-toggle::after {
transform: rotate(-90deg);
}
.builder-accordion[open] .builder-panel-toggle {
color: #194ea7;
}
.builder-accordion[open] .builder-panel-toggle::before {
content: "";
}
.builder-accordion[open] .builder-panel-summary {
border-bottom: 1px solid rgba(201, 212, 226, 0.8);
}
.builder-panel-body {
padding: 14px 16px 16px;
}
.builder-accordion[open] .builder-panel-body {
animation: builderReveal 0.24s ease;
}
.builder-rule-layout {
display: grid;
grid-template-columns: minmax(300px, 0.85fr) minmax(0, 1.15fr);
gap: 14px;
}
.builder-stack-layout {
display: grid;
gap: 14px;
}
.preview-shell {
display: grid;
gap: 12px;
}
.preview-section {
border: 1px solid #d7e0ec;
border-radius: 14px;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
overflow: hidden;
}
.preview-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid #dfe7f1;
background: #f2f7ff;
}
.preview-section-head h3 {
margin: 0;
font-size: 15px;
color: #142033;
}
.preview-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 14px;
}
.preview-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 10px;
border: 1px solid #d8e1ec;
border-radius: 999px;
background: #ffffff;
color: #304159;
font-size: 13px;
font-weight: 700;
}
.preview-chip.is-locked {
background: #eef2ff;
border-color: #c7d2fe;
color: #3730a3;
}
.field-rule-groups {
display: grid;
gap: 12px;
}
.field-rule-group {
border: 1px solid #d7e0ec;
border-radius: 14px;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.field-rule-group:hover {
transform: translateY(-1px);
border-color: #bfd0e4;
box-shadow: 0 14px 26px rgba(15, 23, 42, 0.06);
}
.field-rule-group-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid #dfe7f1;
background: #f2f7ff;
}
.field-rule-group-head h3 {
margin: 0;
font-size: 15px;
color: #142033;
}
.field-rule-list {
display: grid;
}
.field-rule-row {
display: grid;
grid-template-columns: minmax(220px, 1.4fr) 120px 160px 120px;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-top: 1px solid #edf2f7;
transition: background-color 0.18s ease;
}
.field-rule-row:first-child {
border-top: 0;
}
.field-rule-row:hover {
background: rgba(246, 250, 255, 0.92);
}
.field-rule-main strong {
display: block;
color: #162133;
}
.field-rule-control {
display: grid;
gap: 6px;
color: #5f7089;
font-size: 12px;
font-weight: 700;
}
.field-rule-control input[type='checkbox'] {
width: 16px;
height: 16px;
}
.field-rule-status {
display: flex;
justify-content: flex-end;
}
.columns { .columns {
margin-top: 8px;
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(220px, 1fr)); grid-template-columns: repeat(4, minmax(220px, 1fr));
gap: 10px; gap: 12px;
} }
.columns.single { .columns.single {
@@ -95,28 +511,47 @@ body {
} }
.column { .column {
border: 1px solid #d4dce7; border: 1px solid #d7e0ec;
border-radius: 12px; border-radius: 16px;
background: #f9fbff; background: linear-gradient(180deg, #f7faff 0%, #fdfefe 100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 460px; min-height: 460px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
} }
.column h2 { .column:hover {
transform: translateY(-2px);
border-color: #bfd0e4;
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.07);
}
.column-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 13px 14px;
border-bottom: 1px solid #deE7f1;
background: linear-gradient(180deg, #eef5ff, #f7fbff);
}
.column-head h3 {
margin: 0; margin: 0;
padding: 10px 12px;
border-bottom: 1px solid #dbe3ee;
font-size: 16px; font-size: 16px;
color: #0f172a; }
background: #edf3fb;
border-radius: 12px 12px 0 0; .column-count {
color: #5c6d87;
font-size: 12px;
font-weight: 700;
} }
.dropzone { .dropzone {
padding: 10px; padding: 12px;
display: grid; display: grid;
gap: 8px; gap: 10px;
align-content: start; align-content: start;
min-height: 140px; min-height: 140px;
flex: 1; flex: 1;
@@ -127,14 +562,22 @@ body {
} }
.field-card { .field-card {
background: #ffffff; background: rgba(255, 255, 255, 0.96);
border: 1px solid #d3dbe8; border: 1px solid #d7dfeb;
border-radius: 10px; border-radius: 14px;
padding: 9px 10px; padding: 11px 12px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px; align-items: flex-start;
gap: 10px;
cursor: move; cursor: move;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.field-card:hover {
transform: translateY(-1px);
border-color: #b9cadf;
box-shadow: 0 12px 20px rgba(15, 23, 42, 0.06);
} }
.field-card.dragging { .field-card.dragging {
@@ -145,13 +588,20 @@ body {
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 14px;
color: #0f172a; color: #0f172a;
overflow-wrap: anywhere;
} }
.field-name { .field-name {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px; font-size: 12px;
color: #64748b; color: #64748b;
margin-top: 2px; margin-top: 3px;
overflow-wrap: anywhere;
}
.field-main {
min-width: 0;
flex: 1 1 auto;
} }
.badges { .badges {
@@ -159,13 +609,15 @@ body {
gap: 6px; gap: 6px;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
flex: 0 0 auto;
} }
.badge { .badge {
font-size: 11px; font-size: 11px;
border-radius: 999px; border-radius: 999px;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
padding: 2px 7px; padding: 3px 8px;
background: #f8fafc; background: #f8fafc;
color: #334155; color: #334155;
} }
@@ -189,24 +641,7 @@ body {
} }
.options-panel { .options-panel {
margin-top: 16px; padding: 14px;
border: 1px solid #d4dce7;
border-radius: 12px;
background: #ffffff;
padding: 12px;
}
.options-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.options-head h2 {
margin: 0;
font-size: 18px;
} }
.category-switch { .category-switch {
@@ -215,23 +650,22 @@ body {
gap: 8px; gap: 8px;
} }
.category-switch select { .category-switch select,
.option-table select,
.add-option-form input,
.option-table input[type='text'] {
border: 1px solid #cbd5e1; border: 1px solid #cbd5e1;
border-radius: 8px; border-radius: 10px;
padding: 6px 8px; padding: 8px 10px;
box-sizing: border-box;
background: #fff;
} }
.add-option-form { .add-option-form {
display: grid; display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) minmax(180px, 1fr) auto; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) minmax(180px, 1fr) auto;
gap: 8px; gap: 8px;
margin-bottom: 10px; margin-bottom: 12px;
}
.add-option-form input {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 8px 9px;
} }
.option-table-wrap { .option-table-wrap {
@@ -246,21 +680,22 @@ body {
.option-table th, .option-table th,
.option-table td { .option-table td {
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
padding: 7px 8px; padding: 8px 9px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
.option-table th { .option-table th {
background: #f8fafc; background: #f8fbff;
color: #3d4c63;
} }
.option-table input[type='text'] { .option-table-group-row th {
width: 100%; background: #eef5ff;
border: 1px solid #cbd5e1; color: #17335e;
border-radius: 7px; font-size: 12px;
padding: 6px 8px; text-transform: uppercase;
box-sizing: border-box; letter-spacing: 0.04em;
} }
.option-row { .option-row {
@@ -279,7 +714,7 @@ body {
width: 28px; width: 28px;
height: 28px; height: 28px;
border: 1px solid #cbd5e1; border: 1px solid #cbd5e1;
border-radius: 6px; border-radius: 8px;
background: #f8fafc; background: #f8fafc;
color: #475569; color: #475569;
font-size: 14px; font-size: 14px;
@@ -288,28 +723,146 @@ body {
} }
.options-actions { .options-actions {
margin-top: 10px; margin-top: 12px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
@media (max-width: 1120px) { .section-rule-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.section-rule-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid #d6e0ec;
border-radius: 14px;
background: linear-gradient(180deg, #f9fbff, #ffffff);
}
.section-rule-card.is-locked {
background: linear-gradient(180deg, #f4f7fb, #fafcff);
}
.section-rule-copy {
display: grid;
gap: 4px;
}
.section-rule-copy strong {
color: #0f172a;
font-size: 14px;
}
.section-rule-copy span,
.mini {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.section-rule-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
}
@keyframes builderFadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes builderReveal {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.builder-hero,
.builder-panel,
.builder-stat-card,
.section-rule-card,
.field-card,
.options-panel {
animation: none;
}
.field-card,
.tab,
.builder-stat-card,
.builder-quicknav a,
.builder-panel-summary,
.field-rule-group,
.field-rule-row,
.column {
transition: none;
}
.builder-accordion[open] .builder-panel-body {
animation: none;
}
}
@media (max-width: 1220px) {
.builder-overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.builder-rule-layout,
.columns { .columns {
grid-template-columns: repeat(2, minmax(220px, 1fr)); grid-template-columns: repeat(2, minmax(220px, 1fr));
} }
} }
@media (max-width: 900px) {
.builder-hero,
.builder-panel-head,
.options-head {
flex-direction: column;
align-items: flex-start;
}
.builder-hero-actions {
justify-content: flex-start;
}
.builder-rule-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.builder-overview,
.columns { .columns {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.field-rule-row {
grid-template-columns: 1fr;
}
.field-rule-status {
justify-content: flex-start;
}
.add-option-form { .add-option-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.options-head {
flex-direction: column;
align-items: flex-start;
}
} }

View File

@@ -14,6 +14,7 @@ body {
.card { background: linear-gradient(180deg, #ffffff, #fbfcff); border: 1px solid #d9dcf3; border-radius: 14px; padding: 18px; margin-bottom: 14px; box-shadow: 0 10px 24px rgba(0, 0, 120, 0.08); } .card { background: linear-gradient(180deg, #ffffff, #fbfcff); border: 1px solid #d9dcf3; border-radius: 14px; padding: 18px; margin-bottom: 14px; box-shadow: 0 10px 24px rgba(0, 0, 120, 0.08); }
.wrap-body .card:last-child { margin-bottom: 0; } .wrap-body .card:last-child { margin-bottom: 0; }
h1 { margin-top: 0; color: #000078; } h1 { margin-top: 0; color: #000078; }
h2 { margin: 0; color: #17335e; font-size: 18px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field { margin-bottom: 12px; } .field { margin-bottom: 12px; }
.field-full { grid-column: 1 / -1; } .field-full { grid-column: 1 / -1; }
@@ -21,6 +22,11 @@ label { display: block; font-weight: 600; margin-bottom: 6px; }
input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; box-sizing: border-box; border: 1px solid #d4dbf7; border-radius: 10px; background: #fff; } input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; box-sizing: border-box; border: 1px solid #d4dbf7; border-radius: 10px; background: #fff; }
textarea { min-height: 120px; resize: vertical; } textarea { min-height: 120px; resize: vertical; }
.hint { color: #64748b; font-size: 12px; margin-top: 4px; } .hint { color: #64748b; font-size: 12px; margin-top: 4px; }
.offboarding-sections { display: grid; gap: 14px; margin-bottom: 14px; }
.offboarding-section-card { border: 1px solid #dbe5f1; border-radius: 14px; background: linear-gradient(180deg, #f9fbff, #ffffff); overflow: hidden; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05); }
.offboarding-section-head { padding: 14px 16px; border-bottom: 1px solid #e2e8f4; background: #f2f7ff; }
.offboarding-section-head p { margin: 4px 0 0; color: #5f7089; font-size: 13px; }
.offboarding-section-card .grid { padding: 16px; }
.results a { display: inline-block; margin: 4px 8px 4px 0; padding: 6px 8px; border: 1px solid #d4dbf7; border-radius: 6px; text-decoration: none; color: #000078; background: #f7f8ff; } .results a { display: inline-block; margin: 4px 8px 4px 0; padding: 6px 8px; border: 1px solid #d4dbf7; border-radius: 6px; text-decoration: none; color: #000078; background: #f7f8ff; }
.errorlist { color: #b91c1c; margin: 4px 0; } .errorlist { color: #b91c1c; margin: 4px 0; }
.popup-backdrop { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.38); display: none; align-items: center; justify-content: center; z-index: 1000; } .popup-backdrop { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.38); display: none; align-items: center; justify-content: center; z-index: 1000; }

View File

@@ -10,153 +10,399 @@
{% block shell_body %} {% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} {% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %}
<header class="header"> <section class="builder-hero">
<h1>{% trans "Form Builder" %}</h1> <div class="builder-hero-copy">
<p>{% trans "Felder per Drag-and-Drop sortieren und pro Schritt gruppieren." %}</p> <span class="builder-eyebrow">{% trans "Deployment Configuration" %}</span>
</header> <h1>{% trans "Form Builder" %}</h1>
</div>
<div class="builder-hero-actions">
{% for key, label in form_types %}
<a
class="tab {% if form_type == key %}active{% endif %}"
href="/admin-tools/form-builder/?form_type={{ key }}"
>
{{ label }}
</a>
{% endfor %}
<button id="save-order" class="btn btn-primary" type="button">{% trans "Reihenfolge speichern" %}</button>
</div>
</section>
{% include 'workflows/includes/messages.html' %} {% include 'workflows/includes/messages.html' %}
<div class="toolbar">
{% for key, label in form_types %}
<a
class="tab {% if form_type == key %}active{% endif %}"
href="/admin-tools/form-builder/?form_type={{ key }}"
>
{{ label }}
</a>
{% endfor %}
<button id="save-order" class="btn btn-primary" type="button">{% trans "Reihenfolge speichern" %}</button>
</div>
<div id="status-message" class="status" aria-live="polite"></div> <div id="status-message" class="status" aria-live="polite"></div>
<div class="columns {% if form_type == 'offboarding' %}single{% endif %}" id="builder-columns" data-form-type="{{ form_type }}"> <nav class="builder-quicknav" aria-label="{% trans 'Bereiche' %}">
{% for column in columns %} <a href="#builder-structure">{% trans "Reihenfolge" %}</a>
<section class="column" data-column-key="{{ column.key }}"> <a href="#builder-rules">{% trans "Regeln" %}</a>
<h2>{{ column.title }}</h2> <a href="#builder-content">{% trans "Optionen & Texte" %}</a>
<div class="dropzone" data-column-key="{{ column.key }}"> </nav>
{% for item in column.items %}
<article class="field-card" draggable="true" data-field-name="{{ item.field_name }}"> <section class="builder-overview">
<div class="field-main"> <article class="builder-stat-card">
<div class="field-label">{{ item.label }}</div> <span class="builder-stat-label">{% trans "Fixe Kernfelder" %}</span>
<div class="field-name">{{ item.field_name }}</div> <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>
{% 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>
<div class="badges"> <div class="preview-chip-list">
{% if item.locked %}<span class="badge locked">{% trans "Fix" %}</span>{% endif %} {% for item in section.items %}
{% if not item.is_visible %}<span class="badge hidden">{% trans "Ausgeblendet" %}</span>{% endif %} <span class="preview-chip{% if item.locked %} is-locked{% endif %}">{{ item.label }}</span>
{% if item.is_required %}<span class="badge required">{% trans "Pflicht" %}</span>{% endif %} {% empty %}
<span class="mini">{% trans "Keine sichtbaren Felder." %}</span>
{% endfor %}
</div> </div>
</article> </section>
{% endfor %} {% endfor %}
</div> </div>
</section> </div>
{% endfor %} </details>
</div>
<section class="options-panel"> <details class="builder-panel builder-accordion js-single-accordion" data-accordion-group="builder-panels" id="builder-structure" {% if active_panel == 'builder-structure' %}open{% endif %}>
<div class="options-head"> <summary class="builder-panel-summary">
<h2>{% trans "Optionen verwalten" %}</h2> <div class="builder-panel-head">
<form class="category-switch" method="get" action="/admin-tools/form-builder/"> <div>
<input type="hidden" name="form_type" value="{{ form_type }}" /> <h2>{% trans "Struktur & Reihenfolge" %}</h2>
<label for="option_category">{% trans "Kategorie" %}</label> </div>
<select id="option_category" name="option_category" onchange="this.form.submit()"> <span class="builder-panel-toggle">{% trans "Geöffnet" %}</span>
{% for value, label in option_categories %} </div>
<option value="{{ value }}" {% if value == selected_option_category %}selected{% endif %}>{{ label }}</option> </summary>
<div class="builder-panel-body">
<div class="columns {% if columns|length == 1 %}single{% endif %}" id="builder-columns" data-form-type="{{ form_type }}">
{% for column in columns %}
<section class="column" data-column-key="{{ column.key }}">
<div class="column-head">
<h3>{{ column.title }}</h3>
<span class="column-count">{% blocktrans trimmed with count=column.items|length %}{{ count }} Feld/Felder{% endblocktrans %}</span>
</div>
<div class="dropzone" data-column-key="{{ column.key }}">
{% for item in column.items %}
<article class="field-card" draggable="true" data-field-name="{{ item.field_name }}">
<div class="field-main">
<div class="field-label">{{ item.label }}</div>
<div class="field-name">{{ item.field_name }}</div>
</div>
<div class="badges">
{% if item.locked %}<span class="badge locked">{% trans "Fix" %}</span>{% endif %}
{% if not item.is_visible %}<span class="badge hidden">{% trans "Ausgeblendet" %}</span>{% endif %}
{% if item.is_required %}<span class="badge required">{% trans "Pflicht" %}</span>{% endif %}
</div>
</article>
{% endfor %} {% endfor %}
</select> </div>
</form> </section>
{% endfor %}
</div> </div>
<form class="add-option-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_option" />
<input type="hidden" name="category" value="{{ selected_option_category }}" />
<input type="text" name="label" placeholder="{% trans 'Label (DE)' %}" required />
<input type="text" name="label_en" placeholder="{% trans 'Label (EN, optional)' %}" />
<input type="text" name="value" placeholder="{% trans 'Technischer Wert (optional)' %}" />
<button class="btn btn-primary" type="submit">{% trans "Option hinzufügen" %}</button>
</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 "Sortierung" %}</th>
<th>{% trans "Label (DE)" %}</th>
<th>{% trans "Label (EN)" %}</th>
<th>Value</th>
<th>{% trans "Aktiv" %}</th>
<th>{% trans "Löschen" %}</th>
</tr>
</thead>
<tbody id="option-table-body">
{% for item in option_items %}
<tr class="option-row" draggable="true" data-option-row="1">
<td>
<input type="hidden" name="option_ids" value="{{ item.id }}" />
<span class="drag-handle" title="{% trans 'Ziehen zum Sortieren' %}">⋮⋮</span>
</td>
<td><input type="text" name="label_{{ item.id }}" value="{{ item.label }}" required /></td>
<td><input type="text" name="label_en_{{ item.id }}" value="{{ item.label_en }}" /></td>
<td><input type="text" name="value_{{ item.id }}" value="{{ item.value }}" /></td>
<td><input type="checkbox" name="active_{{ item.id }}" {% if item.is_active %}checked{% endif %} /></td>
<td>
<button class="btn btn-secondary" type="submit" name="delete_option_id" value="{{ item.id }}" data-confirm="{% trans 'Option wirklich löschen?' %}">{% trans "Löschen" %}</button>
</td>
</tr>
{% empty %}
<tr><td colspan="6">{% trans "Keine Optionen in dieser Kategorie." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_options">{% trans "Optionen speichern" %}</button>
</div>
</form>
</section>
<section class="options-panel">
<div class="options-head">
<h2>{% trans "Feldtexte verwalten" %}</h2>
</div> </div>
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}"> </details>
{% csrf_token %}
<div class="option-table-wrap"> <details class="builder-panel builder-accordion js-single-accordion" data-accordion-group="builder-panels" id="builder-rules" {% if active_panel == 'builder-rules' %}open{% endif %}>
<table class="option-table"> <summary class="builder-panel-summary">
<thead> <div class="builder-panel-head">
<tr> <div>
<th>{% trans "Feld" %}</th> <h2>{% trans "Sichtbarkeit & Regeln" %}</h2>
<th>{% trans "Label (DE)" %}</th> </div>
<th>{% trans "Label (EN)" %}</th> <span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
<th>{% trans "Hilfetext (DE)" %}</th> </div>
<th>{% trans "Hilfetext (EN)" %}</th> </summary>
</tr> <div class="builder-panel-body">
</thead>
<tbody> <div class="builder-rule-layout">
{% for item in field_text_items %} <section class="options-panel">
<tr> <div class="options-head">
<td> <h2>{% trans "Abschnitte steuern" %}</h2>
<input type="hidden" name="field_ids" value="{{ item.id }}" /> </div>
<strong>{{ item.field_name }}</strong> <form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
</td> {% csrf_token %}
<td><input type="text" name="label_override_{{ item.id }}" value="{{ item.label_override }}" placeholder="{% trans 'Fallback: Standardlabel' %}" /></td> <div class="section-rule-grid">
<td><input type="text" name="label_override_en_{{ item.id }}" value="{{ item.label_override_en }}" placeholder="{% trans 'English label' %}" /></td> {% for section in section_rule_items %}
<td><input type="text" name="help_text_override_{{ item.id }}" value="{{ item.help_text_override }}" placeholder="{% trans 'Optionaler Hilfetext' %}" /></td> <label class="section-rule-card{% if section.locked %} is-locked{% endif %}">
<td><input type="text" name="help_text_override_en_{{ item.id }}" value="{{ item.help_text_override_en }}" placeholder="{% trans 'Optional English help text' %}" /></td> <div class="section-rule-copy">
</tr> <strong>{{ section.title }}</strong>
{% empty %} <span>{% blocktrans trimmed with count=section.field_count %}{{ count }} Feld/Felder in diesem Abschnitt.{% endblocktrans %}</span>
<tr><td colspan="5">{% trans "Keine Feldkonfigurationen verfügbar." %}</td></tr> </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 %}
/>
<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>
{% endfor %} {% endfor %}
</tbody> </div>
</table> <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>
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
{% csrf_token %}
<div class="field-rule-groups">
{% for group in field_rule_groups %}
<section class="field-rule-group">
<div class="field-rule-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="field-rule-list">
{% for item in group.items %}
<div class="field-rule-row">
<div class="field-rule-main">
<input type="hidden" name="field_rule_ids" value="{{ item.id }}" />
<strong>{{ item.label }}</strong>
<div class="mini">{{ item.field_name }}</div>
</div>
<label class="field-rule-control">
<span>{% trans "Sichtbar" %}</span>
<input type="checkbox" name="is_visible_{{ item.id }}" {% if item.is_visible %}checked{% endif %} {% if item.locked %}disabled{% endif %}/>
</label>
<label class="field-rule-control">
<span>{% trans "Pflicht" %}</span>
<select name="is_required_{{ item.id }}" {% if item.locked %}disabled{% endif %}>
<option value="" {% if item.is_required is None %}selected{% endif %}>{% trans "Standard" %}</option>
<option value="required" {% if item.is_required is True %}selected{% endif %}>{% trans "Pflicht" %}</option>
<option value="optional" {% if item.is_required is False %}selected{% endif %}>{% trans "Optional" %}</option>
</select>
</label>
<div class="field-rule-status">
{% if item.locked %}
<span class="badge locked">{% trans "Fix" %}</span>
{% elif not item.is_visible %}
<span class="badge hidden">{% trans "Ausgeblendet" %}</span>
{% elif item.is_required %}
<span class="badge required">{% trans "Pflicht" %}</span>
{% else %}
<span class="badge">{% trans "Flexibel" %}</span>
{% endif %}
</div>
</div>
{% empty %}
<div class="mini">{% trans "Keine Feldregeln verfügbar." %}</div>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_field_rules">{% trans "Feldregeln speichern" %}</button>
</div>
</form>
</section>
</div>
</div>
</details>
<details class="builder-panel builder-accordion js-single-accordion" data-accordion-group="builder-panels" id="builder-content" {% if active_panel == 'builder-content' %}open{% endif %}>
<summary class="builder-panel-summary">
<div class="builder-panel-head">
<div>
<h2>{% trans "Optionen & Texte" %}</h2>
</div>
<span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
</div> </div>
<div class="options-actions"> </summary>
<button class="btn btn-primary" type="submit" name="builder_action" value="save_field_texts">{% trans "Feldtexte speichern" %}</button> <div class="builder-panel-body">
</div>
</form> <div class="builder-stack-layout">
</section> <details class="options-panel nested-accordion js-single-accordion" data-accordion-group="builder-content-subpanels" {% if active_subpanel == 'options' %}open{% endif %}>
<summary class="nested-accordion-summary">
<div class="options-head">
<h2>{% trans "Optionen verwalten" %}</h2>
<span class="builder-panel-toggle">{% trans "Öffnen" %}</span>
</div>
</summary>
<div class="nested-accordion-body">
<div class="options-head options-head-inline">
<form class="category-switch" method="get" action="/admin-tools/form-builder/">
<input type="hidden" name="form_type" value="{{ form_type }}" />
<input type="hidden" name="anchor" value="builder-content" />
<input type="hidden" name="panel" value="builder-content" />
<input type="hidden" name="subpanel" value="options" />
<label for="option_category">{% trans "Kategorie" %}</label>
<select id="option_category" name="option_category" onchange="this.form.submit()">
{% for value, label in option_categories %}
<option value="{{ value }}" {% if value == selected_option_category %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</form>
</div>
<form class="add-option-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_option" />
<input type="hidden" name="category" value="{{ selected_option_category }}" />
<input type="text" name="label" placeholder="{% trans 'Label (DE)' %}" required />
<input type="text" name="label_en" placeholder="{% trans 'Label (EN, optional)' %}" />
<input type="text" name="value" placeholder="{% trans 'Technischer Wert (optional)' %}" />
<button class="btn btn-primary" type="submit">{% trans "Option hinzufügen" %}</button>
</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 "Sortierung" %}</th>
<th>{% trans "Label (DE)" %}</th>
<th>{% trans "Label (EN)" %}</th>
<th>Value</th>
<th>{% trans "Aktiv" %}</th>
<th>{% trans "Löschen" %}</th>
</tr>
</thead>
<tbody id="option-table-body">
{% for item in option_items %}
<tr class="option-row" draggable="true" data-option-row="1">
<td>
<input type="hidden" name="option_ids" value="{{ item.id }}" />
<span class="drag-handle" title="{% trans 'Ziehen zum Sortieren' %}">⋮⋮</span>
</td>
<td><input type="text" name="label_{{ item.id }}" value="{{ item.label }}" required /></td>
<td><input type="text" name="label_en_{{ item.id }}" value="{{ item.label_en }}" /></td>
<td><input type="text" name="value_{{ item.id }}" value="{{ item.value }}" /></td>
<td><input type="checkbox" name="active_{{ item.id }}" {% if item.is_active %}checked{% endif %} /></td>
<td>
<button class="btn btn-secondary" type="submit" name="delete_option_id" value="{{ item.id }}" data-confirm="{% trans 'Option wirklich löschen?' %}">{% trans "Löschen" %}</button>
</td>
</tr>
{% empty %}
<tr><td colspan="6">{% trans "Keine Optionen in dieser Kategorie." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_options">{% trans "Optionen speichern" %}</button>
</div>
</form>
</div>
</details>
<details class="options-panel nested-accordion js-single-accordion" data-accordion-group="builder-content-subpanels" {% if active_subpanel == 'field-texts' %}open{% endif %}>
<summary class="nested-accordion-summary">
<div class="options-head">
<h2>{% trans "Feldtexte 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="option-table-wrap">
<table class="option-table">
<thead>
<tr>
<th>{% trans "Feld" %}</th>
<th>{% trans "Label (DE)" %}</th>
<th>{% trans "Label (EN)" %}</th>
<th>{% trans "Hilfetext (DE)" %}</th>
<th>{% trans "Hilfetext (EN)" %}</th>
</tr>
</thead>
<tbody>
{% for group in field_text_groups %}
<tr class="option-table-group-row">
<th colspan="5">{{ group.title }}</th>
</tr>
{% for item in group.items %}
<tr>
<td>
<input type="hidden" name="field_ids" value="{{ item.id }}" />
<strong>{{ item.field_name }}</strong>
</td>
<td><input type="text" name="label_override_{{ item.id }}" value="{{ item.label_override }}" placeholder="{% trans 'Fallback: Standardlabel' %}" /></td>
<td><input type="text" name="label_override_en_{{ item.id }}" value="{{ item.label_override_en }}" placeholder="{% trans 'English label' %}" /></td>
<td><input type="text" name="help_text_override_{{ item.id }}" value="{{ item.help_text_override }}" placeholder="{% trans 'Optionaler Hilfetext' %}" /></td>
<td><input type="text" name="help_text_override_en_{{ item.id }}" value="{{ item.help_text_override_en }}" placeholder="{% trans 'Optional English help text' %}" /></td>
</tr>
{% empty %}
<tr><td colspan="5">{% trans "Keine Feldkonfigurationen verfügbar." %}</td></tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
<div class="options-actions">
<button class="btn btn-primary" type="submit" name="builder_action" value="save_field_texts">{% trans "Feldtexte speichern" %}</button>
</div>
</form>
</div>
</details>
</div>
</div>
</details>
<script>
(() => {
const accordions = document.querySelectorAll('.js-single-accordion[data-accordion-group]');
accordions.forEach((accordion) => {
accordion.addEventListener('toggle', () => {
if (!accordion.open) return;
const group = accordion.dataset.accordionGroup;
document.querySelectorAll(`.js-single-accordion[data-accordion-group="${group}"]`).forEach((peer) => {
if (peer !== accordion) peer.open = false;
});
});
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -49,19 +49,29 @@
<div class="card"> <div class="card">
<form method="post" data-email-domain="{{ portal_email_domain }}"> <form method="post" data-email-domain="{{ portal_email_domain }}">
{% csrf_token %} {% csrf_token %}
<div class="grid"> <div class="offboarding-sections">
{% for field in form.visible_fields %} {% for section in offboarding_sections %}
{% if field.name != 'search_query' %} <section class="offboarding-section-card">
<div class="offboarding-section-head">
<div>
<h2>{{ section.title }}</h2>
<p>{{ section.subtitle }}</p>
</div>
</div>
<div class="grid">
{% for field in section.fields %}
<div class="field {% if field.name == 'notes' %}field-full{% endif %}"> <div class="field {% if field.name == 'notes' %}field-full{% endif %}">
{{ field.label_tag }} {{ field.label_tag }}
{{ field }} {{ field }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %} {% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }} {{ field.errors }}
</div> </div>
{% endif %} {% endfor %}
</div>
</section>
{% endfor %} {% endfor %}
</div> </div>
<button class="btn btn-primary" type="submit">{% trans "Offboarding-Anfrage speichern" %}</button> <button class="btn btn-primary" type="submit">{% trans "Offboarding-Anfrage speichern" %}</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@ import json
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from workflows.models import FormFieldConfig, FormOption from workflows.models import FormFieldConfig, FormOption, FormSectionConfig
class FormBuilderAdminTests(TestCase): class FormBuilderAdminTests(TestCase):
@@ -94,3 +94,81 @@ class FormBuilderAdminTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertTrue(FormOption.objects.filter(category='device', label='Tablet').exists()) self.assertTrue(FormOption.objects.filter(category='device', label='Tablet').exists())
def test_staff_can_save_field_rules(self):
self.client.force_login(self.staff)
self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost')
department = FormFieldConfig.objects.get(form_type='onboarding', field_name='department')
contract_start = FormFieldConfig.objects.get(form_type='onboarding', field_name='contract_start')
response = self.client.post(
'/admin-tools/form-builder/?form_type=onboarding&option_category=device',
data={
'builder_action': 'save_field_rules',
'field_rule_ids': [str(department.id), str(contract_start.id)],
f'is_required_{department.id}': 'required',
f'is_visible_{contract_start.id}': 'on',
},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
department.refresh_from_db()
contract_start.refresh_from_db()
self.assertEqual(department.is_required, True)
self.assertEqual(contract_start.is_required, None)
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')
response = self.client.post(
'/admin-tools/form-builder/?form_type=onboarding&option_category=device',
data={
'builder_action': 'save_section_rules',
},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
itsetup = FormSectionConfig.objects.get(form_type='onboarding', section_key='itsetup')
stammdaten = FormSectionConfig.objects.get(form_type='onboarding', section_key='stammdaten')
self.assertEqual(itsetup.is_visible, False)
self.assertEqual(stammdaten.is_visible, True)
def test_apply_onboarding_lean_preset_updates_section_and_field_rules(self):
self.client.force_login(self.staff)
response = self.client.post(
'/admin-tools/form-builder/?form_type=onboarding&option_category=device',
data={
'builder_action': 'apply_preset',
'preset_key': 'lean',
},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
itsetup = FormSectionConfig.objects.get(form_type='onboarding', section_key='itsetup')
gender = FormFieldConfig.objects.get(form_type='onboarding', field_name='gender')
contract_start = FormFieldConfig.objects.get(form_type='onboarding', field_name='contract_start')
self.assertEqual(itsetup.is_visible, False)
self.assertEqual(gender.is_visible, False)
self.assertEqual(contract_start.is_required, None)
def test_apply_offboarding_hr_heavy_preset_updates_fields(self):
self.client.force_login(self.staff)
response = self.client.post(
'/admin-tools/form-builder/?form_type=offboarding&option_category=device',
data={
'builder_action': 'apply_preset',
'preset_key': 'hr_heavy',
},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
notes = FormFieldConfig.objects.get(form_type='offboarding', field_name='notes')
department = FormFieldConfig.objects.get(form_type='offboarding', field_name='department')
self.assertEqual(notes.is_visible, True)
self.assertEqual(notes.is_required, True)
self.assertEqual(department.is_required, True)

View File

@@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from workflows.branding import get_company_email_domain from workflows.branding import get_company_email_domain
from workflows.models import FormFieldConfig, OnboardingRequest from workflows.models import FormFieldConfig, FormSectionConfig, OnboardingRequest
class OnboardingFlowTests(TestCase): class OnboardingFlowTests(TestCase):
@@ -112,3 +112,34 @@ class OnboardingFlowTests(TestCase):
self.assertContains(response, 'Dieses Feld ist zwingend erforderlich.') self.assertContains(response, 'Dieses Feld ist zwingend erforderlich.')
self.assertFalse(OnboardingRequest.objects.filter(work_email=f'lina.leer@{self.company_domain}').exists()) self.assertFalse(OnboardingRequest.objects.filter(work_email=f'lina.leer@{self.company_domain}').exists())
mock_delay.assert_not_called() mock_delay.assert_not_called()
@patch('workflows.views.process_onboarding_request.delay')
def test_hidden_itsetup_section_is_removed_from_form_and_submission(self, mock_delay):
FormSectionConfig.objects.update_or_create(
form_type='onboarding',
section_key='itsetup',
defaults={'is_visible': False},
)
response = self.client.get('/onboarding/new/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, '3. IT-Setup')
payload = {
'first_name': 'Nora',
'last_name': 'Neutral',
'gender': 'frau',
'job_title': 'Consultant',
'department': 'IT-Service',
'work_email': f'nora.section@{self.company_domain}',
'contract_start': '2026-11-01',
'employment_type': 'unbefristet',
'group_mailboxes_required_choice': 'nein',
'agreement_confirm': 'on',
}
submit_response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost')
self.assertEqual(submit_response.status_code, 302)
self.assertTrue(OnboardingRequest.objects.filter(work_email=f'nora.section@{self.company_domain}').exists())
mock_delay.assert_called_once()

View File

@@ -37,13 +37,22 @@ from .branding import get_branding_email_copy, get_company_email_domain, get_def
from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .form_builder import ( from .form_builder import (
DEFAULT_FIELD_ORDER, DEFAULT_FIELD_ORDER,
FORM_PRESETS,
LOCKED_FIELD_RULES, LOCKED_FIELD_RULES,
LOCKED_SECTION_RULES,
OFFBOARDING_PAGE_LABELS,
OFFBOARDING_PAGE_ORDER,
ONBOARDING_DEFAULT_PAGE, ONBOARDING_DEFAULT_PAGE,
ONBOARDING_PAGE_LABELS, ONBOARDING_PAGE_LABELS,
ONBOARDING_PAGE_ORDER, ONBOARDING_PAGE_ORDER,
ensure_form_field_configs, ensure_form_field_configs,
ensure_form_section_configs,
get_default_page_map,
get_section_labels,
get_section_order,
apply_form_preset,
) )
from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, 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 .emailing import send_system_email
from .notifications import notify_user 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 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
@@ -531,13 +540,14 @@ def _section_for_block(block: dict, field_pages: dict[str, str]) -> str:
return field_pages.get(fields[0].name, 'abschluss') return field_pages.get(fields[0].name, 'abschluss')
def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str]) -> list[dict]: 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} grouped = {key: [] for key in ONBOARDING_SECTION_ORDER}
for block in blocks: for block in blocks:
section_key = _section_for_block(block, field_pages) section_key = _section_for_block(block, field_pages)
if section_key not in grouped: if section_key not in grouped:
section_key = 'abschluss' section_key = 'abschluss'
grouped[section_key].append(block) grouped[section_key].append(block)
visible_keys = visible_section_keys or set(ONBOARDING_SECTION_ORDER)
return [ return [
{ {
'key': key, 'key': key,
@@ -546,6 +556,35 @@ def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str])
'blocks': grouped[key], 'blocks': grouped[key],
} }
for key in ONBOARDING_SECTION_ORDER for key in ONBOARDING_SECTION_ORDER
if key in visible_keys
]
OFFBOARDING_SECTION_META = {
'mitarbeitende': {'title': gettext_lazy('Mitarbeitende'), 'subtitle': gettext_lazy('Person, Rolle und Bereich')},
'austritt': {'title': gettext_lazy('Austritt'), 'subtitle': gettext_lazy('Letzter Arbeitstag')},
'abschluss': {'title': gettext_lazy('Abschluss'), 'subtitle': gettext_lazy('Hinweise und Abschlussnotizen')},
}
def _build_offboarding_sections(form, visible_section_keys: set[str] | None = None) -> list[dict]:
field_pages = getattr(form, '_field_page_keys', {})
grouped = {key: [] for key in OFFBOARDING_PAGE_ORDER}
for field_name in form.fields.keys():
section_key = field_pages.get(field_name, 'abschluss')
if section_key not in grouped:
section_key = 'abschluss'
grouped[section_key].append(form[field_name])
visible_keys = visible_section_keys or set(OFFBOARDING_PAGE_ORDER)
return [
{
'key': key,
'title': OFFBOARDING_SECTION_META[key]['title'],
'subtitle': OFFBOARDING_SECTION_META[key]['subtitle'],
'fields': grouped[key],
}
for key in OFFBOARDING_PAGE_ORDER
if key in visible_keys and grouped[key]
] ]
@@ -1780,7 +1819,12 @@ def onboarding_create(request):
onboarding_blocks = _build_onboarding_layout(form) onboarding_blocks = _build_onboarding_layout(form)
field_pages = getattr(form, '_field_page_keys', {}) field_pages = getattr(form, '_field_page_keys', {})
onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages) 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
}
onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages, visible_section_keys=visible_section_keys)
return render( return render(
request, request,
@@ -1969,6 +2013,14 @@ def offboarding_create(request):
else: else:
form = OffboardingRequestForm(prefill_profile=selected_profile, initial={'search_query': search_query}) form = OffboardingRequestForm(prefill_profile=selected_profile, initial={'search_query': search_query})
field_pages = getattr(form, '_field_page_keys', {})
section_configs = ensure_form_section_configs('offboarding')
visible_section_keys = {
key for key in OFFBOARDING_PAGE_ORDER
if key in LOCKED_SECTION_RULES.get('offboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible
}
offboarding_sections = _build_offboarding_sections(form, visible_section_keys=visible_section_keys)
return render( return render(
request, request,
'workflows/offboarding_form.html', 'workflows/offboarding_form.html',
@@ -1980,6 +2032,7 @@ def offboarding_create(request):
'saved': request.GET.get('saved') == '1', 'saved': request.GET.get('saved') == '1',
'saved_request_id': request.GET.get('id', ''), 'saved_request_id': request.GET.get('id', ''),
'portal_email_domain': get_company_email_domain(), 'portal_email_domain': get_company_email_domain(),
'offboarding_sections': offboarding_sections,
}, },
) )
@@ -1997,6 +2050,9 @@ def offboarding_success(request, request_id: int):
def form_builder_page(request): def form_builder_page(request):
language_code = get_language() language_code = get_language()
form_type = request.GET.get('form_type', 'onboarding') form_type = request.GET.get('form_type', 'onboarding')
anchor = (request.GET.get('anchor') or '').strip()
active_panel = (request.GET.get('panel') or '').strip()
active_subpanel = (request.GET.get('subpanel') or '').strip()
if form_type not in DEFAULT_FIELD_ORDER: if form_type not in DEFAULT_FIELD_ORDER:
form_type = 'onboarding' form_type = 'onboarding'
option_category = request.GET.get('option_category', 'department') option_category = request.GET.get('option_category', 'department')
@@ -2017,7 +2073,7 @@ def form_builder_page(request):
option.delete() option.delete()
_audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label) _audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label)
messages.success(request, 'Option wurde gelöscht.') messages.success(request, 'Option wurde gelöscht.')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=options#builder-content")
action = request.POST.get('builder_action', '') action = request.POST.get('builder_action', '')
if action == 'add_option': if action == 'add_option':
@@ -2068,7 +2124,7 @@ def form_builder_page(request):
option.save(update_fields=['label', 'label_en', 'value', 'is_active', 'sort_order']) option.save(update_fields=['label', 'label_en', 'value', 'is_active', 'sort_order'])
except IntegrityError: except IntegrityError:
messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}') messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}") return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}&panel=builder-content&subpanel=options#builder-content")
option_category = option.category option_category = option.category
_audit(request, 'form_options_saved', target_type='form_option', target_label=option_category, details={'count': len(option_ids)}) _audit(request, 'form_options_saved', target_type='form_option', target_label=option_category, details={'count': len(option_ids)})
messages.success(request, 'Optionen wurden gespeichert.') messages.success(request, 'Optionen wurden gespeichert.')
@@ -2087,7 +2143,67 @@ def form_builder_page(request):
_audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)}) _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.') messages.success(request, 'Feldtexte wurden gespeichert.')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") elif action == 'save_field_rules':
field_ids = request.POST.getlist('field_rule_ids')
locked_fields = LOCKED_FIELD_RULES.get(form_type, set())
updated = 0
for raw_id in field_ids:
cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first()
if not cfg:
continue
if cfg.field_name in locked_fields:
cfg.is_visible = True
cfg.is_required = None
else:
cfg.is_visible = request.POST.get(f'is_visible_{cfg.id}') == 'on'
required_mode = (request.POST.get(f'is_required_{cfg.id}') or '').strip()
cfg.is_required = True if required_mode == 'required' else False if required_mode == 'optional' else None
cfg.save(update_fields=['is_visible', 'is_required'])
updated += 1
_audit(request, 'form_field_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated})
messages.success(request, 'Feldregeln wurden gespeichert.')
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())
updated = 0
for section_key, cfg in section_configs.items():
if section_key in locked_sections:
if not cfg.is_visible:
cfg.is_visible = True
cfg.save(update_fields=['is_visible'])
continue
cfg.is_visible = request.POST.get(f'section_visible_{section_key}') == 'on'
cfg.save(update_fields=['is_visible'])
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.')
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'
_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'}:
active_panel = 'builder-content'
if action in {'add_option', 'save_options'}:
active_subpanel = 'options'
elif action == 'save_field_texts':
active_subpanel = 'field-texts'
elif action in {'save_field_rules', 'save_section_rules'}:
active_panel = 'builder-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 anchor == 'builder-content' or active_panel == 'builder-content':
redirect_target += "#builder-content"
return redirect(redirect_target)
default_names = list(DEFAULT_FIELD_ORDER.get(form_type, [])) default_names = list(DEFAULT_FIELD_ORDER.get(form_type, []))
existing_names = list( existing_names = list(
@@ -2100,12 +2216,17 @@ def form_builder_page(request):
default_names.append(name) default_names.append(name)
ensure_form_field_configs(form_type, default_names) ensure_form_field_configs(form_type, default_names)
section_configs = ensure_form_section_configs(form_type)
section_order = get_section_order(form_type)
section_labels = get_section_labels(form_type)
default_page_map = get_default_page_map(form_type)
configs = list( configs = list(
FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name') FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name')
) )
labels = _form_field_labels(form_type) labels = _form_field_labels(form_type)
locked = LOCKED_FIELD_RULES.get(form_type, set()) locked = LOCKED_FIELD_RULES.get(form_type, set())
locked_sections = LOCKED_SECTION_RULES.get(form_type, set())
if form_type == 'onboarding': if form_type == 'onboarding':
columns = [ columns = [
@@ -2131,27 +2252,127 @@ def form_builder_page(request):
'is_visible': cfg.is_visible, 'is_visible': cfg.is_visible,
'is_required': cfg.is_required, 'is_required': cfg.is_required,
'locked': cfg.field_name in locked, 'locked': cfg.field_name in locked,
'page_key': page_key,
} }
) )
else: else:
columns = [ columns = [
{ {
'key': 'all', 'key': key,
'title': 'Offboarding Felder', 'title': section_labels.get(key, key),
'items': [ 'items': [],
{
'field_name': cfg.field_name,
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name),
'label_en': cfg.label_override_en,
'is_visible': cfg.is_visible,
'is_required': cfg.is_required,
'locked': cfg.field_name in locked,
}
for cfg in configs
],
} }
for key in section_order
] ]
column_by_key = {c['key']: c for c in columns}
fallback = section_order[-1] if section_order else 'all'
for cfg in configs:
page_key = cfg.page_key or default_page_map.get(cfg.field_name, fallback)
if page_key not in column_by_key:
page_key = fallback
column_by_key[page_key]['items'].append(
{
'field_name': cfg.field_name,
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name),
'label_en': cfg.label_override_en,
'is_visible': cfg.is_visible,
'is_required': cfg.is_required,
'locked': cfg.field_name in locked,
'page_key': page_key,
}
)
section_rule_items = []
if section_order:
fallback_section = section_order[-1] if section_order else ''
for key in section_order:
cfg = section_configs.get(key)
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]),
}
)
field_rule_items = []
for cfg in configs:
page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '')
field_rule_items.append(
{
'id': cfg.id,
'field_name': cfg.field_name,
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
'page_key': page_key,
'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,
}
)
field_rule_groups = []
if section_order:
grouped_rules = {key: [] for key in section_order}
for item in field_rule_items:
grouped_rules.setdefault(item['page_key'], []).append(item)
for key in section_order:
field_rule_groups.append(
{
'key': key,
'title': section_labels.get(key, key),
'items': grouped_rules.get(key, []),
}
)
field_text_groups = []
if section_order:
grouped_texts = {key: [] for key in section_order}
for cfg in configs:
page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '')
grouped_texts.setdefault(page_key, []).append(cfg)
for key in section_order:
field_text_groups.append(
{
'key': key,
'title': section_labels.get(key, key),
'items': grouped_texts.get(key, []),
}
)
preview_sections = []
if section_order:
field_rule_group_map = {group['key']: group['items'] for group in field_rule_groups}
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
visible_items = [
item for item in field_rule_group_map.get(key, [])
if item['locked'] or item['is_visible']
]
if section_visible:
preview_sections.append(
{
'key': key,
'title': section_labels.get(key, key),
'items': visible_items,
}
)
locked_field_count = len([item for item in field_rule_items if item['locked']])
hidden_field_count = len([item for item in field_rule_items if not item['is_visible']])
configurable_field_count = len(field_rule_items) - locked_field_count
hidden_section_count = len([item for item in section_rule_items if not item['is_visible']]) if section_rule_items else 0
builder_summary = {
'locked_field_count': locked_field_count,
'configurable_field_count': configurable_field_count,
'hidden_field_count': hidden_field_count,
'hidden_section_count': hidden_section_count,
}
return render( return render(
request, request,
@@ -2164,6 +2385,15 @@ def form_builder_page(request):
'selected_option_category': option_category, 'selected_option_category': option_category,
'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'), 'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'),
'field_text_items': configs, 'field_text_items': configs,
'field_rule_items': field_rule_items,
'field_rule_groups': field_rule_groups,
'field_text_groups': field_text_groups,
'preview_sections': preview_sections,
'section_rule_items': section_rule_items,
'builder_summary': builder_summary,
'active_panel': active_panel,
'active_subpanel': active_subpanel,
'available_presets': FORM_PRESETS.get(form_type, {}),
}, },
) )