Procházet zdrojové kódy

adding option for adding a table

Medowar před 1 měsícem
rodič
revize
778cb0122d
6 změnil soubory, kde provedl 440 přidání a 6 odebrání
  1. 34 0
      assets/css/base.css
  2. 209 0
      assets/js/form.js
  3. 16 1
      config/form_schema.php
  4. 22 1
      docs/FORM_SCHEMA.md
  5. 73 1
      index.php
  6. 86 3
      src/Form/Validator.php

+ 34 - 0
assets/css/base.css

@@ -365,6 +365,35 @@ small {
   background: var(--brand-surface-alt);
 }
 
+.table-input-wrapper {
+  border: 1px solid var(--brand-border);
+  border-radius: 8px;
+  background: var(--brand-surface-alt);
+  padding: 0.6rem;
+}
+
+.form-table-input {
+  margin: 0;
+  box-shadow: none;
+}
+
+.form-table-input th {
+  background: var(--brand-surface);
+  color: var(--brand-text);
+}
+
+.form-table-input tr:hover td {
+  background: inherit;
+}
+
+.form-table-input td {
+  background: var(--brand-surface-alt);
+}
+
+.table-cell-input {
+  min-width: 150px;
+}
+
 .upload-action-btn {
   display: block;
   width: 100%;
@@ -522,6 +551,11 @@ small {
   color: var(--brand-text);
 }
 
+.summary-item-value-multiline {
+  white-space: pre-wrap;
+  font-family: "Courier New", Courier, monospace;
+}
+
 .summary-item-value-empty {
   color: var(--brand-muted);
 }

+ 209 - 0
assets/js/form.js

@@ -51,6 +51,7 @@
   const startEmailRequiredMark = document.querySelector('#startEmailField .required-mark-field-start');
   const fieldContainersByKey = new Map();
   const fieldsByKey = new Map();
+  const tableFieldsByKey = new Map();
 
   document.querySelectorAll('.field[data-field]').forEach((container) => {
     const key = String(container.getAttribute('data-field') || '').trim();
@@ -72,6 +73,17 @@
     });
   });
 
+  document.querySelectorAll('[data-table-field="1"][data-table-key]').forEach((tableEl) => {
+    const key = String(tableEl.getAttribute('data-table-key') || '').trim();
+    if (key === '') {
+      return;
+    }
+
+    const rows = Math.max(1, Number(tableEl.getAttribute('data-table-rows') || 0));
+    const headers = Array.from(tableEl.querySelectorAll('thead th')).map((th) => String(th.textContent || '').trim());
+    tableFieldsByKey.set(key, { key, tableEl, rows, headers });
+  });
+
   function appUrl(path) {
     const normalizedPath = String(path || '').replace(/^\/+/, '');
     return (baseUrl ? baseUrl + '/' : '/') + normalizedPath;
@@ -272,6 +284,8 @@
 
   function clearWizardData() {
     applicationForm.reset();
+    populateAllTablesFromHiddenValues();
+    syncAllTableHiddenValues();
     clearErrors();
     renderUploadInfo({});
     state.currentStep = 1;
@@ -358,6 +372,183 @@
     return data;
   }
 
+  function csvEscapeValue(value) {
+    const normalized = String(value || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
+    if (normalized.includes('"') || normalized.includes(',') || normalized.includes('\n')) {
+      return '"' + normalized.replace(/"/g, '""') + '"';
+    }
+    return normalized;
+  }
+
+  function parseCsvLine(line) {
+    const cells = [];
+    let current = '';
+    let inQuotes = false;
+
+    for (let i = 0; i < line.length; i += 1) {
+      const char = line[i];
+
+      if (char === '"') {
+        if (inQuotes && line[i + 1] === '"') {
+          current += '"';
+          i += 1;
+          continue;
+        }
+        inQuotes = !inQuotes;
+        continue;
+      }
+
+      if (char === ',' && !inQuotes) {
+        cells.push(current);
+        current = '';
+        continue;
+      }
+
+      current += char;
+    }
+
+    cells.push(current);
+    return cells;
+  }
+
+  function csvHeaderMatches(headers, row) {
+    if (!Array.isArray(headers) || headers.length === 0 || !Array.isArray(row)) {
+      return false;
+    }
+
+    if (row.length !== headers.length) {
+      return false;
+    }
+
+    return headers.every((header, index) => String(header || '').trim().toLowerCase() === String(row[index] || '').trim().toLowerCase());
+  }
+
+  function isTableCsvEmpty(value, field) {
+    const raw = String(value || '').trim();
+    if (raw === '') {
+      return true;
+    }
+
+    const lines = raw
+      .split(/\r?\n/)
+      .map((line) => line.trim())
+      .filter((line) => line !== '');
+    if (lines.length === 0) {
+      return true;
+    }
+
+    const parsedRows = lines.map((line) => parseCsvLine(line));
+    const headers = Array.isArray(field && field.columns)
+      ? field.columns
+          .filter((column) => column && typeof column === 'object')
+          .map((column) => String(column.label || '').trim())
+          .filter((label) => label !== '')
+      : [];
+
+    const dataRows = csvHeaderMatches(headers, parsedRows[0]) ? parsedRows.slice(1) : parsedRows;
+    if (dataRows.length === 0) {
+      return true;
+    }
+
+    return !dataRows.some((row) => row.some((cell) => String(cell || '').trim() !== ''));
+  }
+
+  function syncTableHiddenValue(tableKey) {
+    const tableMeta = tableFieldsByKey.get(tableKey);
+    if (!tableMeta) {
+      return;
+    }
+
+    const hiddenField = applicationForm.querySelector('[name="form_data[' + tableKey + ']"]');
+    if (!hiddenField) {
+      return;
+    }
+
+    const dataRows = [];
+    for (let rowIndex = 0; rowIndex < tableMeta.rows; rowIndex += 1) {
+      const rowValues = [];
+      for (let colIndex = 0; colIndex < tableMeta.headers.length; colIndex += 1) {
+        const input = tableMeta.tableEl.querySelector('[data-table-cell="1"][data-row-index="' + rowIndex + '"][data-col-index="' + colIndex + '"]');
+        rowValues.push(String((input && input.value) || '').trim());
+      }
+      dataRows.push(rowValues);
+    }
+
+    const hasAnyValue = dataRows.some((row) => row.some((cell) => cell !== ''));
+    if (!hasAnyValue) {
+      hiddenField.value = '';
+      return;
+    }
+
+    const lines = [];
+    lines.push(tableMeta.headers.map((header) => csvEscapeValue(header)).join(','));
+    dataRows.forEach((row) => {
+      lines.push(row.map((cell) => csvEscapeValue(cell)).join(','));
+    });
+    hiddenField.value = lines.join('\n');
+  }
+
+  function syncAllTableHiddenValues() {
+    tableFieldsByKey.forEach((tableMeta) => {
+      syncTableHiddenValue(tableMeta.key);
+    });
+  }
+
+  function populateTableFromHiddenValue(tableKey) {
+    const tableMeta = tableFieldsByKey.get(tableKey);
+    if (!tableMeta) {
+      return;
+    }
+
+    const hiddenField = applicationForm.querySelector('[name="form_data[' + tableKey + ']"]');
+    if (!hiddenField) {
+      return;
+    }
+
+    const raw = String(hiddenField.value || '').trim();
+    const lines = raw === '' ? [] : raw.split(/\r?\n/);
+    const parsedRows = lines.map((line) => parseCsvLine(line));
+    const dataRows = parsedRows.length > 0 && csvHeaderMatches(tableMeta.headers, parsedRows[0]) ? parsedRows.slice(1) : parsedRows;
+
+    for (let rowIndex = 0; rowIndex < tableMeta.rows; rowIndex += 1) {
+      const sourceRow = Array.isArray(dataRows[rowIndex]) ? dataRows[rowIndex] : [];
+      for (let colIndex = 0; colIndex < tableMeta.headers.length; colIndex += 1) {
+        const input = tableMeta.tableEl.querySelector('[data-table-cell="1"][data-row-index="' + rowIndex + '"][data-col-index="' + colIndex + '"]');
+        if (!input) {
+          continue;
+        }
+        input.value = String(sourceRow[colIndex] || '');
+      }
+    }
+  }
+
+  function populateAllTablesFromHiddenValues() {
+    tableFieldsByKey.forEach((tableMeta) => {
+      populateTableFromHiddenValue(tableMeta.key);
+    });
+  }
+
+  function initTableFields() {
+    tableFieldsByKey.forEach((tableMeta) => {
+      const cells = Array.from(tableMeta.tableEl.querySelectorAll('[data-table-cell="1"]'));
+      cells.forEach((cell) => {
+        const syncAndRefresh = () => {
+          syncTableHiddenValue(tableMeta.key);
+          applyFieldVisibility();
+          refreshRequiredMarkers();
+          if (state.currentStep === state.summaryStep) {
+            renderSummary();
+          }
+        };
+        cell.addEventListener('input', syncAndRefresh);
+        cell.addEventListener('change', syncAndRefresh);
+      });
+    });
+
+    populateAllTablesFromHiddenValues();
+    syncAllTableHiddenValues();
+  }
+
   function collectCurrentFormData() {
     const data = {};
 
@@ -507,6 +698,10 @@
       return isCheckboxTrue(formData[key]);
     }
 
+    if (type === 'table') {
+      return !isTableCsvEmpty(formData[key], field);
+    }
+
     return String(formData[key] || '').trim() !== '';
   }
 
@@ -619,6 +814,10 @@
       return !isCheckboxTrue(formData[key]);
     }
 
+    if (type === 'table') {
+      return isTableCsvEmpty(formData[key], field);
+    }
+
     return String(formData[key] || '').trim() === '';
   }
 
@@ -675,6 +874,10 @@
       return resolveSelectLabel(field, rawValue);
     }
 
+    if (type === 'table') {
+      return rawValue;
+    }
+
     return rawValue;
   }
 
@@ -759,6 +962,9 @@
         const value = fieldDisplayValue(field, formData);
         if (value !== '') {
           valueEl.textContent = value;
+          if (String(field.type || '') === 'table') {
+            valueEl.classList.add('summary-item-value-multiline');
+          }
         } else {
           valueEl.textContent = String(field.type || '') === 'file' ? 'Keine Datei hochgeladen' : 'Nicht ausgefüllt';
           valueEl.classList.add('summary-item-value-empty');
@@ -834,6 +1040,8 @@
         field.value = data[key] || '';
       }
     });
+    populateAllTablesFromHiddenValues();
+    syncAllTableHiddenValues();
     applyFieldVisibility();
     refreshRequiredMarkers();
   }
@@ -1311,6 +1519,7 @@
   disclaimerSection.classList.remove('hidden');
   startSection.classList.add('hidden');
 
+  initTableFields();
   initUploadControls();
   initRequiredMarkerTracking();
   applyFieldVisibility();

+ 16 - 1
config/form_schema.php

@@ -241,7 +241,22 @@ return [
                 [
                     'key' => 'bisherige_dienstzeiten',
                     'label' => 'Bisherige Dienstzeiten in Hilfsorganisationen',
-                    'type' => 'text',
+                    'type' => 'table',
+                    'rows' => 4,
+                    'columns' => [
+                        [
+                            'label' => 'Feuerwehr/Hilfsorganisation',
+                            'type' => 'text',
+                        ],
+                        [
+                            'label' => 'von',
+                            'type' => 'date',
+                        ],
+                        [
+                            'label' => 'bis',
+                            'type' => 'date',
+                        ],
+                    ],
                     'required_if' => [
                         'field' => 'qualifikation_vorhanden',
                         'equals' => 'ja',

+ 22 - 1
docs/FORM_SCHEMA.md

@@ -16,10 +16,15 @@
         [
           'key' => 'feldname',
           'label' => 'Label',
-          'type' => 'text|email|date|select|textarea|checkbox|file',
+          'type' => 'text|email|date|select|textarea|checkbox|file|table',
           'required' => true|false,
           'required_if' => ['field' => 'anderes_feld', 'equals' => 'Wert'],
           'options' => [['value' => 'x', 'label' => 'X']],
+          'rows' => 4, // nur für type=table
+          'columns' => [ // nur für type=table
+            ['label' => 'Spalte 1', 'type' => 'text'],
+            ['label' => 'Spalte 2', 'type' => 'date'],
+          ],
           'accept' => '.pdf,.jpg',
           'max_length' => 100,
           'max_size' => 10485760,
@@ -37,6 +42,22 @@
 - `required: true` macht Feld immer verpflichtend.
 - `required_if` macht Feld verpflichtend, wenn Quellfeld exakt `equals` entspricht.
 - Upload-Pflicht wird gegen vorhandene Upload-Metadaten geprüft.
+- Für `type: table` gilt ein Feld als leer, wenn in allen Datenzeilen alle Zellen leer sind.
+
+## Table-Felder (CSV-Textblock)
+
+- `type: table` rendert eine feste Tabelle mit vordefinierten Spalten (`columns`) und fixer Zeilenanzahl (`rows`).
+- Es gibt bewusst keine UI, um zusätzliche Zeilen hinzuzufügen.
+- Der Inhalt wird als einzelner `form_data`-Wert gespeichert (CSV-Textblock mit Headerzeile + festen Datenzeilen).
+
+Beispiel-Format im gespeicherten Feldwert:
+
+```text
+Feuerwehr/Hilfsorganisation,von,bis
+Organisation A,2026-01-01,2026-02-01
+Organisation B,2025-01-01,2025-02-01
+...
+```
 
 ## Upload-Verhalten
 

+ 73 - 1
index.php

@@ -64,7 +64,8 @@ function renderField(array $field, string $addressDisclaimerText): void
     if ($type === 'checkbox') {
         echo '<label class="checkbox-label"><input type="checkbox" name="form_data[' . $key . ']" value="1" ' . $required . '> ' . $label . $requiredLabel . '</label>';
     } else {
-        echo '<label for="' . $key . '">' . $label . $requiredLabel . '</label>';
+        $labelFor = $type === 'table' ? htmlspecialchars($keyRaw . '__r0__c0') : $key;
+        echo '<label for="' . $labelFor . '">' . $label . $requiredLabel . '</label>';
 
         if ($type === 'textarea') {
             echo '<textarea id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '></textarea>';
@@ -80,6 +81,77 @@ function renderField(array $field, string $addressDisclaimerText): void
                 echo '<option value="' . $value . '">' . $optLabel . '</option>';
             }
             echo '</select>';
+        } elseif ($type === 'table') {
+            $rows = (int) ($field['rows'] ?? 4);
+            if ($rows < 1) {
+                $rows = 1;
+            } elseif ($rows > 50) {
+                $rows = 50;
+            }
+
+            $columns = [];
+            if (isset($field['columns']) && is_array($field['columns'])) {
+                foreach ($field['columns'] as $index => $column) {
+                    if (!is_array($column)) {
+                        continue;
+                    }
+
+                    $columnLabelRaw = trim((string) ($column['label'] ?? ''));
+                    if ($columnLabelRaw === '') {
+                        $columnLabelRaw = 'Spalte ' . ($index + 1);
+                    }
+
+                    $columnTypeRaw = strtolower(trim((string) ($column['type'] ?? 'text')));
+                    $columnType = in_array($columnTypeRaw, ['text', 'date', 'number', 'email', 'tel'], true) ? $columnTypeRaw : 'text';
+
+                    $columns[] = [
+                        'label' => $columnLabelRaw,
+                        'type' => $columnType,
+                        'placeholder' => (string) ($column['placeholder'] ?? ''),
+                    ];
+                }
+            }
+
+            if (empty($columns)) {
+                $columns = [
+                    ['label' => 'Spalte 1', 'type' => 'text', 'placeholder' => ''],
+                    ['label' => 'Spalte 2', 'type' => 'text', 'placeholder' => ''],
+                    ['label' => 'Spalte 3', 'type' => 'text', 'placeholder' => ''],
+                ];
+            }
+
+            echo '<input id="' . $key . '" type="hidden" name="form_data[' . $key . ']">';
+            echo '<div class="table-input-wrapper">';
+            echo '<div class="table-responsive">';
+            echo '<table class="form-table-input" data-table-field="1" data-table-key="' . $key . '" data-table-rows="' . (string) $rows . '">';
+            echo '<thead><tr>';
+            foreach ($columns as $column) {
+                echo '<th>' . htmlspecialchars($column['label']) . '</th>';
+            }
+            echo '</tr></thead>';
+            echo '<tbody>';
+            for ($row = 0; $row < $rows; $row++) {
+                echo '<tr>';
+                foreach ($columns as $columnIndex => $column) {
+                    $cellId = $keyRaw . '__r' . $row . '__c' . $columnIndex;
+                    $cellIdEscaped = htmlspecialchars($cellId);
+                    $placeholder = trim((string) ($column['placeholder'] ?? ''));
+                    $placeholderEscaped = htmlspecialchars($placeholder);
+                    $ariaLabel = htmlspecialchars($column['label'] . ' Zeile ' . ($row + 1));
+                    echo '<td>';
+                    echo '<input id="' . $cellIdEscaped . '" class="table-cell-input" type="' . htmlspecialchars((string) $column['type']) . '" data-table-cell="1" data-table-key="' . $key . '" data-row-index="' . (string) $row . '" data-col-index="' . (string) $columnIndex . '" aria-label="' . $ariaLabel . '" autocomplete="off"';
+                    if ($placeholder !== '') {
+                        echo ' placeholder="' . $placeholderEscaped . '"';
+                    }
+                    echo '>';
+                    echo '</td>';
+                }
+                echo '</tr>';
+            }
+            echo '</tbody>';
+            echo '</table>';
+            echo '</div>';
+            echo '</div>';
         } elseif ($type === 'file') {
             $accept = htmlspecialchars((string) ($field['accept'] ?? ''));
             $description = trim((string) ($field['description'] ?? ''));

+ 86 - 3
src/Form/Validator.php

@@ -41,12 +41,12 @@ final class Validator
 
             $value = $data[$key] ?? null;
 
-            if ($required && $this->isEmptyValue($value, $type)) {
+            if ($required && $this->isEmptyValue($value, $type, $field)) {
                 $errors[$key] = 'Dieses Feld ist erforderlich.';
                 continue;
             }
 
-            if ($this->isEmptyValue($value, $type)) {
+            if ($this->isEmptyValue($value, $type, $field)) {
                 continue;
             }
 
@@ -191,12 +191,95 @@ final class Validator
         return true;
     }
 
-    private function isEmptyValue(mixed $value, string $type): bool
+    /** @param array<string, mixed> $field */
+    private function isEmptyValue(mixed $value, string $type, array $field): bool
     {
         if ($type === 'checkbox') {
             return !in_array((string) $value, ['1', 'on', 'true'], true);
         }
 
+        if ($type === 'table') {
+            return $this->isTableValueEmpty($value, $field);
+        }
+
         return $value === null || trim((string) $value) === '';
     }
+
+    /** @param array<string, mixed> $field */
+    private function isTableValueEmpty(mixed $value, array $field): bool
+    {
+        $raw = trim((string) $value);
+        if ($raw === '') {
+            return true;
+        }
+
+        $lines = preg_split('/\R/', $raw);
+        if (!is_array($lines)) {
+            return true;
+        }
+
+        $rows = [];
+        foreach ($lines as $line) {
+            $line = trim((string) $line);
+            if ($line === '') {
+                continue;
+            }
+            $rows[] = str_getcsv($line);
+        }
+
+        if (empty($rows)) {
+            return true;
+        }
+
+        $columnHeaders = [];
+        if (isset($field['columns']) && is_array($field['columns'])) {
+            foreach ($field['columns'] as $column) {
+                if (!is_array($column)) {
+                    continue;
+                }
+                $label = trim((string) ($column['label'] ?? ''));
+                if ($label !== '') {
+                    $columnHeaders[] = $label;
+                }
+            }
+        }
+
+        $dataRows = $rows;
+        if (!empty($columnHeaders) && $this->tableRowMatchesHeader($rows[0], $columnHeaders)) {
+            $dataRows = array_slice($rows, 1);
+        }
+
+        if (empty($dataRows)) {
+            return true;
+        }
+
+        foreach ($dataRows as $dataRow) {
+            foreach ((array) $dataRow as $cell) {
+                if (trim((string) $cell) !== '') {
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * @param array<int, string> $row
+     * @param array<int, string> $header
+     */
+    private function tableRowMatchesHeader(array $row, array $header): bool
+    {
+        if (count($row) !== count($header)) {
+            return false;
+        }
+
+        foreach ($header as $index => $headerValue) {
+            if (strtolower(trim((string) ($row[$index] ?? ''))) !== strtolower(trim($headerValue))) {
+                return false;
+            }
+        }
+
+        return true;
+    }
 }