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