Commit 216ae487 authored by Administrator's avatar Administrator

Update 2 files via Son of Anton

parent 76b3b1b0
...@@ -20,6 +20,7 @@ final class FormRenderer ...@@ -20,6 +20,7 @@ final class FormRenderer
$sectionLabel = $section['label_ar'] ?? ''; $sectionLabel = $section['label_ar'] ?? '';
$visibleWhen = $section['visible_when'] ?? null; $visibleWhen = $section['visible_when'] ?? null;
$repeatable = $section['repeatable'] ?? false; $repeatable = $section['repeatable'] ?? false;
$maxRepeats = $section['max_repeats'] ?? 10;
$visAttr = ''; $visAttr = '';
if ($visibleWhen) { if ($visibleWhen) {
...@@ -27,9 +28,19 @@ final class FormRenderer ...@@ -27,9 +28,19 @@ final class FormRenderer
$visAttr .= ' style="display:none;"'; $visAttr .= ' style="display:none;"';
} }
$html .= '<div class="card" style="margin-bottom:20px;" id="section-' . e($sectionKey) . '"' . $visAttr . '>'; $repeatAttr = '';
$html .= '<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">' . e($sectionLabel) . '</h3></div>'; if ($repeatable && !$readOnly) {
$html .= '<div style="padding:20px;"><div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">'; $repeatAttr = ' data-repeatable="true" data-section-key="' . e($sectionKey) . '" data-max-repeats="' . (int) $maxRepeats . '"';
}
$html .= '<div class="card" style="margin-bottom:20px;" id="section-' . e($sectionKey) . '"' . $visAttr . $repeatAttr . '>';
$html .= '<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">';
$html .= '<h3 style="margin:0;color:#0D7377;">' . e($sectionLabel) . '</h3>';
if ($repeatable && !$readOnly) {
$html .= '<span style="font-size:12px;color:#9CA3AF;">الحد الأقصى: ' . (int) $maxRepeats . ' سجلات</span>';
}
$html .= '</div>';
$html .= '<div style="padding:20px;"><div class="repeatable-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">';
$fields = $section['fields'] ?? []; $fields = $section['fields'] ?? [];
usort($fields, fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999)); usort($fields, fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999));
...@@ -38,12 +49,47 @@ final class FormRenderer ...@@ -38,12 +49,47 @@ final class FormRenderer
$html .= self::renderField($field, $data, $errors, $readOnly); $html .= self::renderField($field, $data, $errors, $readOnly);
} }
// If repeatable and we have submitted data with indexed fields, render additional rows
if ($repeatable && !empty($data)) {
$additionalRows = self::detectRepeatableRows($fields, $data);
foreach ($additionalRows as $rowIndex) {
$html .= '<div style="grid-column:1/-1;border-top:2px dashed #E5E7EB;margin:10px 0;padding-top:10px;" data-row-index="' . $rowIndex . '">';
$html .= '<span style="color:#0D7377;font-weight:600;">سجل #' . $rowIndex . '</span>';
$html .= '</div>';
foreach ($fields as $field) {
$indexedField = $field;
$origKey = $field['key'] ?? '';
$indexedField['key'] = $origKey . '_' . $rowIndex;
$html .= self::renderField($indexedField, $data, $errors, $readOnly);
}
}
}
$html .= '</div></div></div>'; $html .= '</div></div></div>';
} }
return $html; return $html;
} }
private static function detectRepeatableRows(array $fields, array $data): array
{
if (empty($fields)) return [];
$firstFieldKey = $fields[0]['key'] ?? '';
if ($firstFieldKey === '') return [];
$indices = [];
foreach ($data as $key => $value) {
if (preg_match('/^' . preg_quote($firstFieldKey, '/') . '_(\d+)$/', $key, $m)) {
$idx = (int) $m[1];
if ($idx > 1) {
$indices[] = $idx;
}
}
}
sort($indices);
return array_unique($indices);
}
public static function renderField(array $field, array $data, array $errors, bool $readOnly): string public static function renderField(array $field, array $data, array $errors, bool $readOnly): string
{ {
$key = $field['key'] ?? ''; $key = $field['key'] ?? '';
...@@ -58,22 +104,17 @@ final class FormRenderer ...@@ -58,22 +104,17 @@ final class FormRenderer
$visibleWhen = $field['visible_when'] ?? null; $visibleWhen = $field['visible_when'] ?? null;
$autoParse = $field['auto_parse'] ?? false; $autoParse = $field['auto_parse'] ?? false;
$populates = $field['populates'] ?? []; $populates = $field['populates'] ?? [];
$computedFrom = $field['computed_from'] ?? '';
$width = $field['width'] ?? 'half'; $width = $field['width'] ?? 'half';
$colStyle = match ($width) { $colStyle = '';
'full' => 'grid-column:1/-1;',
'quarter' => '',
'third' => '',
default => '',
};
if ($width === 'full') { if ($width === 'full') {
$colStyle = 'grid-column:1/-1;'; $colStyle = 'grid-column:1/-1;';
} }
$disabled = ($readOnly || !$editable) ? ' disabled' : ''; $disabled = ($readOnly || !$editable) ? ' disabled' : '';
$requiredAttr = ($required && !$readOnly && $editable) ? ' required' : '';
$requiredMark = $required ? ' <span style="color:#DC2626;">*</span>' : ''; $requiredMark = $required ? ' <span style="color:#DC2626;">*</span>' : '';
$errorClass = !empty($fieldErrors) ? ' border-color:#DC2626;' : ''; $errorClass = !empty($fieldErrors) ? 'border-color:#DC2626;' : '';
$visAttr = ''; $visAttr = '';
if ($visibleWhen) { if ($visibleWhen) {
...@@ -103,30 +144,33 @@ final class FormRenderer ...@@ -103,30 +144,33 @@ final class FormRenderer
case 'phone_eg': case 'phone_eg':
case 'email': case 'email':
$inputType = ($type === 'email') ? 'email' : (($type === 'phone' || $type === 'phone_eg') ? 'tel' : 'text'); $inputType = ($type === 'email') ? 'email' : (($type === 'phone' || $type === 'phone_eg') ? 'tel' : 'text');
$html .= '<input type="' . $inputType . '" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="' . $errorClass . '"' . $disabled . $dataAttrs . '>'; $html .= '<input type="' . $inputType . '" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="' . $errorClass . '"' . $disabled . $requiredAttr . $dataAttrs . '>';
break; break;
case 'national_id': case 'national_id':
$html .= '<input type="text" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input nid-input" maxlength="14" pattern="\\d{14}" style="direction:ltr;text-align:left;' . $errorClass . '"' . $disabled . $dataAttrs . ' data-nid-parser="true">'; $html .= '<input type="text" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input nid-input" maxlength="14" pattern="\\d{14}" style="direction:ltr;text-align:left;' . $errorClass . '"' . $disabled . $requiredAttr . $dataAttrs . ' data-nid-parser="true">';
break; break;
case 'number': case 'number':
case 'decimal': case 'decimal':
$step = ($type === 'decimal') ? '0.01' : '1'; $step = ($type === 'decimal') ? '0.01' : '1';
$html .= '<input type="number" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" step="' . $step . '" class="form-input" style="' . $errorClass . '"' . $disabled . '>'; $html .= '<input type="number" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" step="' . $step . '" class="form-input" style="' . $errorClass . '"' . $disabled . $requiredAttr . '>';
break; break;
case 'currency': case 'currency':
$html .= '<div style="position:relative;"><input type="text" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="direction:ltr;text-align:left;padding-left:40px;' . $errorClass . '" disabled>'; $displayVal = $value !== '' && $value !== null ? number_format((float) $value, 2) : '';
$html .= '<span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#6B7280;font-size:12px;">ج.م</span></div>'; $html .= '<div style="position:relative;">';
$html .= '<input type="text" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e($displayVal) . '" class="form-input" style="direction:ltr;text-align:left;padding-left:40px;background:#F3F4F6;' . $errorClass . '" readonly>';
$html .= '<span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#6B7280;font-size:12px;">ج.م</span>';
$html .= '</div>';
break; break;
case 'date': case 'date':
$html .= '<input type="date" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="' . $errorClass . '"' . $disabled . '>'; $html .= '<input type="date" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="' . $errorClass . '"' . $disabled . $requiredAttr . '>';
break; break;
case 'select': case 'select':
$html .= '<select id="field-' . e($key) . '" name="' . e($key) . '" class="form-select" style="' . $errorClass . '"' . $disabled . '>'; $html .= '<select id="field-' . e($key) . '" name="' . e($key) . '" class="form-select" style="' . $errorClass . '"' . $disabled . $requiredAttr . '>';
$html .= '<option value="">-- اختر --</option>'; $html .= '<option value="">-- اختر --</option>';
foreach ($options as $opt) { foreach ($options as $opt) {
$optVal = $opt['value'] ?? ''; $optVal = $opt['value'] ?? '';
...@@ -139,7 +183,7 @@ final class FormRenderer ...@@ -139,7 +183,7 @@ final class FormRenderer
case 'select_dynamic': case 'select_dynamic':
$dataSource = $field['data_source'] ?? ''; $dataSource = $field['data_source'] ?? '';
$html .= '<select id="field-' . e($key) . '" name="' . e($key) . '" class="form-select" style="' . $errorClass . '"' . $disabled . ' data-source="' . e($dataSource) . '">'; $html .= '<select id="field-' . e($key) . '" name="' . e($key) . '" class="form-select" style="' . $errorClass . '"' . $disabled . $requiredAttr . ' data-source="' . e($dataSource) . '">';
$html .= '<option value="">-- اختر --</option>'; $html .= '<option value="">-- اختر --</option>';
$dynamicOptions = self::loadDynamicOptions($dataSource); $dynamicOptions = self::loadDynamicOptions($dataSource);
foreach ($dynamicOptions as $opt) { foreach ($dynamicOptions as $opt) {
...@@ -150,12 +194,16 @@ final class FormRenderer ...@@ -150,12 +194,16 @@ final class FormRenderer
break; break;
case 'textarea': case 'textarea':
$html .= '<textarea id="field-' . e($key) . '" name="' . e($key) . '" class="form-textarea" rows="3" style="' . $errorClass . '"' . $disabled . '>' . e((string) $value) . '</textarea>'; $html .= '<textarea id="field-' . e($key) . '" name="' . e($key) . '" class="form-textarea" rows="3" style="' . $errorClass . '"' . $disabled . $requiredAttr . '>' . e((string) $value) . '</textarea>';
break; break;
case 'checkbox': case 'checkbox':
$checked = $value ? ' checked' : ''; $checked = $value ? ' checked' : '';
$html .= '<label style="display:flex;align-items:center;gap:8px;cursor:pointer;"><input type="checkbox" id="field-' . e($key) . '" name="' . e($key) . '" value="1"' . $checked . $disabled . '> <span>' . e($labelAr) . '</span></label>'; $reqStr = ($required && !$readOnly) ? ' required' : '';
$html .= '<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">';
$html .= '<input type="checkbox" id="field-' . e($key) . '" name="' . e($key) . '" value="1"' . $checked . $disabled . $reqStr . '>';
$html .= '<span>' . e($labelAr) . ($required ? ' <span style="color:#DC2626;">*</span>' : '') . '</span>';
$html .= '</label>';
break; break;
case 'radio': case 'radio':
...@@ -168,15 +216,19 @@ final class FormRenderer ...@@ -168,15 +216,19 @@ final class FormRenderer
break; break;
case 'file': case 'file':
$html .= '<input type="file" id="field-' . e($key) . '" name="' . e($key) . '" class="form-input"' . $disabled . '>'; if (!$readOnly) {
$html .= '<input type="file" id="field-' . e($key) . '" name="' . e($key) . '" class="form-input"' . $disabled . $requiredAttr . '>';
} else {
$html .= '<span style="color:#6B7280;font-size:13px;">' . ($value ? e((string) $value) : 'لا يوجد ملف') . '</span>';
}
break; break;
case 'computed': case 'computed':
$html .= '<input type="text" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="background:#F3F4F6;' . $errorClass . '" readonly>'; $html .= '<input type="text" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="background:#F3F4F6;font-weight:600;' . $errorClass . '" readonly>';
break; break;
case 'static_text': case 'static_text':
$html .= '<div id="field-' . e($key) . '" style="padding:8px 12px;background:#F9FAFB;border:1px solid #E5E7EB;border-radius:6px;font-size:14px;min-height:38px;">' . e((string) $value) . '</div>'; $html .= '<div id="field-' . e($key) . '" style="padding:8px 12px;background:#F9FAFB;border:1px solid #E5E7EB;border-radius:6px;font-size:14px;min-height:38px;display:flex;align-items:center;">' . e((string) $value) . '</div>';
break; break;
case 'hidden': case 'hidden':
...@@ -184,7 +236,21 @@ final class FormRenderer ...@@ -184,7 +236,21 @@ final class FormRenderer
break; break;
case 'auto_increment': case 'auto_increment':
$html .= '<input type="text" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="background:#F3F4F6;font-weight:700;color:#0D7377;" readonly>';
break;
case 'signature': case 'signature':
if ($readOnly) {
$html .= '<span style="color:' . ($value ? '#059669' : '#DC2626') . ';font-weight:600;">' . ($value ? '✓ تم الإقرار' : '✗ لم يتم الإقرار') . '</span>';
} else {
$checked = $value ? ' checked' : '';
$html .= '<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:10px;background:#FFF7ED;border:1px solid #FED7AA;border-radius:6px;">';
$html .= '<input type="checkbox" id="field-' . e($key) . '" name="' . e($key) . '" value="1"' . $checked . $requiredAttr . '>';
$html .= '<span style="font-weight:600;">' . e($labelAr) . '</span>';
$html .= '</label>';
}
break;
default: default:
$html .= '<input type="text" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="background:#F3F4F6;" readonly>'; $html .= '<input type="text" id="field-' . e($key) . '" name="' . e($key) . '" value="' . e((string) $value) . '" class="form-input" style="background:#F3F4F6;" readonly>';
break; break;
...@@ -197,7 +263,7 @@ final class FormRenderer ...@@ -197,7 +263,7 @@ final class FormRenderer
} }
if ($helpText) { if ($helpText) {
$html .= '<small style="color:#9CA3AF;font-size:12px;">' . e($helpText) . '</small>'; $html .= '<small style="color:#9CA3AF;font-size:12px;display:block;margin-top:3px;">' . e($helpText) . '</small>';
} }
$html .= '</div>'; $html .= '</div>';
...@@ -207,7 +273,6 @@ final class FormRenderer ...@@ -207,7 +273,6 @@ final class FormRenderer
private static function loadDynamicOptions(string $source): array private static function loadDynamicOptions(string $source): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$options = [];
switch ($source) { switch ($source) {
case 'governorates': case 'governorates':
...@@ -226,6 +291,30 @@ final class FormRenderer ...@@ -226,6 +291,30 @@ final class FormRenderer
$rows = $db->select("SELECT id as value, name_ar as label FROM branches WHERE is_active = 1 ORDER BY name_ar"); $rows = $db->select("SELECT id as value, name_ar as label FROM branches WHERE is_active = 1 ORDER BY name_ar");
return array_map(fn($r) => ['value' => (string) $r['value'], 'label' => $r['label']], $rows); return array_map(fn($r) => ['value' => (string) $r['value'], 'label' => $r['label']], $rows);
case 'religions':
return [
['value' => 'muslim', 'label' => 'مسلم'],
['value' => 'christian', 'label' => 'مسيحي'],
['value' => 'other', 'label' => 'أخرى'],
];
case 'marital_statuses':
return [
['value' => 'single', 'label' => 'أعزب'],
['value' => 'married', 'label' => 'متزوج'],
['value' => 'divorced', 'label' => 'مطلق'],
['value' => 'widowed', 'label' => 'أرمل'],
];
case 'employment_types':
return [
['value' => 'employed', 'label' => 'موظف'],
['value' => 'self_employed', 'label' => 'أعمال حرة'],
['value' => 'professions', 'label' => 'مهن حرة'],
['value' => 'retired', 'label' => 'متقاعد'],
['value' => 'other', 'label' => 'أخرى'],
];
default: default:
return []; return [];
} }
......
/** /**
* Forms Engine — Client-side dynamic form companion * Forms Engine — Client-side dynamic form companion
* Handles: conditional visibility, NID auto-parse, dependent fields, fee displays * Handles: conditional visibility, NID auto-parse, dependent fields,
* repeatable sections, fee calculation triggers, client-side validation
*/ */
var FormsEngine = (function() { var FormsEngine = (function() {
'use strict'; 'use strict';
var repeatCounters = {};
function init() { function init() {
initConditionalVisibility(); initConditionalVisibility();
initNidParsers(); initNidParsers();
initDynamicSources(); initRepeatableSections();
initDependentDropdowns();
initFeeCalculationTriggers();
initClientValidation();
} }
// ─────────────────────────────────────────────
// CONDITIONAL VISIBILITY
// ─────────────────────────────────────────────
function initConditionalVisibility() { function initConditionalVisibility() {
var elements = document.querySelectorAll('[data-visible-when]'); var elements = document.querySelectorAll('[data-visible-when]');
elements.forEach(function(el) { elements.forEach(function(el) {
try { try {
var condition = JSON.parse(el.getAttribute('data-visible-when')); var condition = JSON.parse(el.getAttribute('data-visible-when'));
if (!condition || !condition.field) return; if (!condition || !condition.field) return;
bindVisibilityWatcher(el, condition);
} catch (e) {
// Bad JSON — skip
}
});
}
var watchField = document.getElementById('field-' + condition.field); function bindVisibilityWatcher(el, condition) {
if (!watchField) { var watchField = findFieldByKey(condition.field);
watchField = document.querySelector('[name="' + condition.field + '"]'); if (!watchField) return;
}
if (!watchField) return;
function evaluate() {
var actual = watchField.value;
var expected = condition.value;
var op = condition.operator || 'eq';
var visible = false;
switch (op) {
case 'eq': visible = actual === expected; break;
case 'neq': visible = actual !== expected; break;
case 'not_empty': visible = actual !== '' && actual !== null; break;
case 'empty': visible = actual === '' || actual === null; break;
default: visible = actual === expected;
}
el.style.display = visible ? '' : 'none'; function evaluate() {
var inputs = el.querySelectorAll('input, select, textarea'); var visible = evaluateCondition(condition, getFieldValue(watchField));
inputs.forEach(function(inp) { el.style.display = visible ? '' : 'none';
if (!visible) {
inp.removeAttribute('required');
}
});
}
watchField.addEventListener('change', evaluate); // When hidden, strip required so form can submit
watchField.addEventListener('input', evaluate); if (!visible) {
evaluate(); el.querySelectorAll('[required]').forEach(function(inp) {
} catch (e) { inp.setAttribute('data-was-required', 'true');
// Silently ignore bad JSON inp.removeAttribute('required');
});
} else {
el.querySelectorAll('[data-was-required="true"]').forEach(function(inp) {
inp.setAttribute('required', 'required');
inp.removeAttribute('data-was-required');
});
} }
}); }
watchField.addEventListener('change', evaluate);
watchField.addEventListener('input', evaluate);
evaluate();
}
function evaluateCondition(condition, actualValue) {
var expected = condition.value;
var op = condition.operator || 'eq';
switch (op) {
case 'eq': return String(actualValue) === String(expected);
case 'neq': return String(actualValue) !== String(expected);
case 'in': return Array.isArray(expected) && expected.indexOf(String(actualValue)) !== -1;
case 'gt': return parseFloat(actualValue) > parseFloat(expected);
case 'lt': return parseFloat(actualValue) < parseFloat(expected);
case 'gte': return parseFloat(actualValue) >= parseFloat(expected);
case 'lte': return parseFloat(actualValue) <= parseFloat(expected);
case 'not_empty': return actualValue !== '' && actualValue !== null && actualValue !== undefined;
case 'empty': return actualValue === '' || actualValue === null || actualValue === undefined;
default: return String(actualValue) === String(expected);
}
} }
// ─────────────────────────────────────────────
// NATIONAL ID PARSER
// ─────────────────────────────────────────────
function initNidParsers() { function initNidParsers() {
var nidInputs = document.querySelectorAll('[data-nid-parser="true"]'); var nidInputs = document.querySelectorAll('[data-nid-parser="true"]');
nidInputs.forEach(function(input) { nidInputs.forEach(function(input) {
input.addEventListener('input', function() { input.addEventListener('input', function() {
var val = input.value.replace(/\D/g, ''); var val = input.value.replace(/\D/g, '');
input.value = val; input.value = val;
if (val.length === 14) { if (val.length === 14) {
parseNid(val, input); parseNid(val, input);
} else {
input.style.borderColor = '';
} }
}); });
...@@ -72,6 +98,9 @@ var FormsEngine = (function() { ...@@ -72,6 +98,9 @@ var FormsEngine = (function() {
var val = input.value.replace(/\D/g, ''); var val = input.value.replace(/\D/g, '');
if (val.length === 14) { if (val.length === 14) {
parseNid(val, input); parseNid(val, input);
} else if (val.length > 0 && val.length < 14) {
input.style.borderColor = '#DC2626';
showFieldError(input, 'الرقم القومي يجب أن يكون 14 رقم');
} }
}); });
}); });
...@@ -80,82 +109,453 @@ var FormsEngine = (function() { ...@@ -80,82 +109,453 @@ var FormsEngine = (function() {
function parseNid(nid, inputEl) { function parseNid(nid, inputEl) {
if (nid.length !== 14 || !/^\d{14}$/.test(nid)) return; if (nid.length !== 14 || !/^\d{14}$/.test(nid)) return;
// Client-side parsing
var century = parseInt(nid[0]); var century = parseInt(nid[0]);
if (century !== 2 && century !== 3) return; if (century !== 2 && century !== 3) {
showFieldError(inputEl, 'رمز القرن غير صالح');
inputEl.style.borderColor = '#DC2626';
return;
}
var yearPrefix = century === 2 ? '19' : '20'; var yearPrefix = century === 2 ? '19' : '20';
var year = yearPrefix + nid.substring(1, 3); var year = parseInt(yearPrefix + nid.substring(1, 3));
var month = nid.substring(3, 5); var month = parseInt(nid.substring(3, 5));
var day = nid.substring(5, 7); var day = parseInt(nid.substring(5, 7));
var monthInt = parseInt(month); if (month < 1 || month > 12 || day < 1 || day > 31) {
var dayInt = parseInt(day); showFieldError(inputEl, 'تاريخ الميلاد في الرقم القومي غير صالح');
if (monthInt < 1 || monthInt > 12 || dayInt < 1 || dayInt > 31) return; inputEl.style.borderColor = '#DC2626';
return;
}
// Validate actual date
var testDate = new Date(year, month - 1, day);
if (testDate.getFullYear() !== year || testDate.getMonth() !== month - 1 || testDate.getDate() !== day) {
showFieldError(inputEl, 'تاريخ الميلاد في الرقم القومي غير صالح');
inputEl.style.borderColor = '#DC2626';
return;
}
var dob = year + '-' + month + '-' + day; var dob = year + '-' + pad(month) + '-' + pad(day);
// Calculate age // Age calculation
var birthDate = new Date(parseInt(year), monthInt - 1, dayInt); var birthDate = new Date(year, month - 1, day);
var today = new Date(); var today = new Date();
var ageYears = today.getFullYear() - birthDate.getFullYear(); var ageYears = today.getFullYear() - birthDate.getFullYear();
var ageMonths = today.getMonth() - birthDate.getMonth(); var ageMonths = today.getMonth() - birthDate.getMonth();
if (ageMonths < 0 || (ageMonths === 0 && today.getDate() < birthDate.getDate())) { if (today.getMonth() < birthDate.getMonth() ||
(today.getMonth() === birthDate.getMonth() && today.getDate() < birthDate.getDate())) {
ageYears--; ageYears--;
ageMonths += 12;
} }
ageMonths = today.getMonth() - birthDate.getMonth();
if (ageMonths < 0) ageMonths += 12;
if (today.getDate() < birthDate.getDate()) { if (today.getDate() < birthDate.getDate()) {
ageMonths--; ageMonths--;
if (ageMonths < 0) ageMonths += 12; if (ageMonths < 0) ageMonths += 12;
} }
// Gender from positions 12-13 // Gender: position 13 (index 12), odd = male
var seqNum = parseInt(nid.substring(12, 13)); var genderDigit = parseInt(nid[12]);
var gender = (seqNum % 2 !== 0) ? 'male' : 'female'; var gender = (genderDigit % 2 !== 0) ? 'male' : 'female';
// Governorate code // Governorate code: positions 8-9 (index 7-8)
var govCode = nid.substring(7, 9); var govCode = nid.substring(7, 9);
// Try to populate fields // Get populates list from the input
var populatesAttr = inputEl.getAttribute('data-populates'); var populates = getPopulatesList(inputEl);
var populates = [];
if (populatesAttr) {
try { populates = JSON.parse(populatesAttr); } catch(e) {}
}
// Auto-fill DOB
setFieldValue('date_of_birth', dob);
setFieldValue('child_dob', dob);
setFieldValue('spouse_dob', dob);
setFieldValue('seasonal_dob', dob);
// Auto-fill age // Determine field prefix from input name (handles repeatable: child_national_id -> child_)
setFieldValue('age_years', ageYears.toString()); var inputName = inputEl.getAttribute('name') || inputEl.id.replace('field-', '');
setFieldValue('age_months', ageMonths.toString()); var prefix = '';
setFieldValue('child_age', ageYears.toString()); if (inputName.indexOf('national_id') > 0) {
setFieldValue('spouse_age', ageYears.toString()); prefix = inputName.replace('national_id', '');
setFieldValue('seasonal_age', ageYears.toString()); }
// Auto-fill gender // Populate target fields
setFieldValue('gender', gender); var dobTargets = ['date_of_birth', prefix + 'dob', prefix + 'date_of_birth'];
setFieldValue('child_gender', gender); var ageYearTargets = ['age_years', prefix + 'age', prefix + 'age_years'];
setFieldValue('spouse_gender', gender); var ageMonthTargets = ['age_months', prefix + 'age_months'];
var genderTargets = ['gender', prefix + 'gender'];
var govTargets = ['governorate_code'];
// Auto-fill governorate dobTargets.forEach(function(k) { setFieldValue(k, dob); });
setFieldValue('governorate_code', govCode); ageYearTargets.forEach(function(k) { setFieldValue(k, String(ageYears)); });
ageMonthTargets.forEach(function(k) { setFieldValue(k, String(ageMonths)); });
genderTargets.forEach(function(k) { setFieldValue(k, gender); });
govTargets.forEach(function(k) { setFieldValue(k, govCode); });
// Spouse classification // Classification for spouses
if (ageYears >= 21) { if (ageYears >= 21) {
setFieldValue('spouse_classification', 'عضو عامل'); setFieldValue('spouse_classification', 'عضو عامل');
} else { } else {
setFieldValue('spouse_classification', 'عضو تابع'); setFieldValue('spouse_classification', 'عضو تابع');
} }
// Visual feedback // Clear errors and show success
clearFieldError(inputEl);
inputEl.style.borderColor = '#059669'; inputEl.style.borderColor = '#059669';
setTimeout(function() { setTimeout(function() { inputEl.style.borderColor = ''; }, 3000);
inputEl.style.borderColor = ''; }
}, 2000);
// ─────────────────────────────────────────────
// REPEATABLE SECTIONS
// ─────────────────────────────────────────────
function initRepeatableSections() {
var repeatableSections = document.querySelectorAll('[data-repeatable="true"]');
repeatableSections.forEach(function(section) {
var sectionKey = section.getAttribute('data-section-key') || '';
var maxRepeats = parseInt(section.getAttribute('data-max-repeats') || '10');
repeatCounters[sectionKey] = 1;
var btnContainer = document.createElement('div');
btnContainer.style.cssText = 'padding:0 20px 15px;display:flex;gap:10px;';
var addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'btn btn-sm btn-outline';
addBtn.textContent = '+ إضافة سجل جديد';
addBtn.style.color = '#059669';
addBtn.addEventListener('click', function() {
if (repeatCounters[sectionKey] >= maxRepeats) {
if (typeof toast === 'function') {
toast('تم الوصول للحد الأقصى (' + maxRepeats + ')', 'warning');
} else {
alert('تم الوصول للحد الأقصى (' + maxRepeats + ')');
}
return;
}
repeatCounters[sectionKey]++;
addRepeatableRow(section, sectionKey, repeatCounters[sectionKey]);
});
btnContainer.appendChild(addBtn);
section.appendChild(btnContainer);
});
}
function addRepeatableRow(section, sectionKey, index) {
var gridContainer = section.querySelector('.repeatable-grid');
if (!gridContainer) {
// Find the grid div inside the section
var grids = section.querySelectorAll('div[style*="display:grid"]');
if (grids.length > 0) {
gridContainer = grids[grids.length - 1];
} else {
return;
}
}
// Clone all field wrappers from the first row
var firstRowFields = gridContainer.querySelectorAll('.form-group');
if (firstRowFields.length === 0) return;
// Add separator
var separator = document.createElement('div');
separator.style.cssText = 'grid-column:1/-1;border-top:2px dashed #E5E7EB;margin:10px 0;padding-top:10px;display:flex;justify-content:space-between;align-items:center;';
separator.innerHTML = '<span style="color:#0D7377;font-weight:600;">سجل #' + index + '</span>';
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-sm';
removeBtn.style.cssText = 'color:#DC2626;border:1px solid #DC2626;background:transparent;cursor:pointer;';
removeBtn.textContent = '✕ حذف';
removeBtn.setAttribute('data-remove-index', String(index));
removeBtn.addEventListener('click', function() {
// Remove all elements for this index
var toRemove = gridContainer.querySelectorAll('[data-row-index="' + index + '"]');
toRemove.forEach(function(el) { el.remove(); });
separator.remove();
repeatCounters[sectionKey]--;
});
separator.appendChild(removeBtn);
gridContainer.appendChild(separator);
separator.setAttribute('data-row-index', String(index));
firstRowFields.forEach(function(fieldWrap) {
if (fieldWrap.getAttribute('data-row-index') && fieldWrap.getAttribute('data-row-index') !== '1') return;
var clone = fieldWrap.cloneNode(true);
clone.setAttribute('data-row-index', String(index));
// Update field names and IDs to include index
var inputs = clone.querySelectorAll('input, select, textarea');
inputs.forEach(function(inp) {
var origName = inp.getAttribute('name') || '';
var origId = inp.getAttribute('id') || '';
if (origName) {
inp.setAttribute('name', origName + '_' + index);
}
if (origId) {
inp.setAttribute('id', origId + '_' + index);
}
// Clear values
if (inp.type === 'checkbox' || inp.type === 'radio') {
inp.checked = false;
} else {
inp.value = '';
}
});
// Update label for attributes
var labels = clone.querySelectorAll('label');
labels.forEach(function(lbl) {
var forAttr = lbl.getAttribute('for');
if (forAttr) {
lbl.setAttribute('for', forAttr + '_' + index);
}
});
// Clear error messages
var errDivs = clone.querySelectorAll('div[style*="color:#DC2626"]');
errDivs.forEach(function(d) { d.remove(); });
gridContainer.appendChild(clone);
// Re-init NID parsers on new fields
var newNids = clone.querySelectorAll('[data-nid-parser="true"]');
newNids.forEach(function(nidInput) {
nidInput.addEventListener('input', function() {
var val = nidInput.value.replace(/\D/g, '');
nidInput.value = val;
if (val.length === 14) parseNid(val, nidInput);
});
});
// Re-init conditional visibility on new fields
var visEls = clone.querySelectorAll('[data-visible-when]');
visEls.forEach(function(ve) {
try {
var cond = JSON.parse(ve.getAttribute('data-visible-when'));
if (cond && cond.field) {
// Adjust field reference for the index
var adjustedCond = Object.assign({}, cond);
adjustedCond.field = cond.field + '_' + index;
bindVisibilityWatcher(ve, adjustedCond);
}
} catch(e) {}
});
});
}
// ─────────────────────────────────────────────
// DEPENDENT DROPDOWNS
// ─────────────────────────────────────────────
function initDependentDropdowns() {
var dependents = document.querySelectorAll('[data-depends-on]');
dependents.forEach(function(childSelect) {
var parentKey = childSelect.getAttribute('data-depends-on');
var sourceUrl = childSelect.getAttribute('data-depends-url') || '';
var parentField = findFieldByKey(parentKey);
if (!parentField || !sourceUrl) return;
parentField.addEventListener('change', function() {
var parentVal = getFieldValue(parentField);
if (!parentVal) {
childSelect.innerHTML = '<option value="">-- اختر --</option>';
return;
}
var url = sourceUrl.replace('{value}', encodeURIComponent(parentVal));
fetchJson(url).then(function(options) {
childSelect.innerHTML = '<option value="">-- اختر --</option>';
if (Array.isArray(options)) {
options.forEach(function(opt) {
var option = document.createElement('option');
option.value = opt.value || '';
option.textContent = opt.label || opt.value || '';
childSelect.appendChild(option);
});
}
}).catch(function() {
// Silent fail — keep existing options
});
});
});
}
// ─────────────────────────────────────────────
// FEE CALCULATION TRIGGERS
// ─────────────────────────────────────────────
function initFeeCalculationTriggers() {
var feeFields = document.querySelectorAll('[data-fee-trigger]');
feeFields.forEach(function(field) {
var triggerConfig = field.getAttribute('data-fee-trigger');
try {
var config = JSON.parse(triggerConfig);
var watchFields = config.watch || [];
var targetField = config.target || '';
var calcUrl = config.url || '';
watchFields.forEach(function(watchKey) {
var watchEl = findFieldByKey(watchKey);
if (!watchEl) return;
watchEl.addEventListener('change', function() {
calculateFee(watchFields, targetField, calcUrl);
});
});
} catch(e) {}
});
// Also listen for age changes to trigger child/spouse fee recalculation
var ageFields = document.querySelectorAll('[id*="age_years"], [id*="child_age"], [id*="spouse_age"]');
ageFields.forEach(function(af) {
af.addEventListener('change', function() {
triggerNearestFeeCalc(af);
});
});
}
function calculateFee(watchKeys, targetFieldKey, calcUrl) {
if (!calcUrl) return;
var params = {};
watchKeys.forEach(function(key) {
var el = findFieldByKey(key);
if (el) {
params[key] = getFieldValue(el);
}
});
fetchJson(calcUrl, 'POST', params).then(function(result) {
if (result && result.fee !== undefined) {
setFieldValue(targetFieldKey, String(result.fee));
}
if (result && result.breakdown) {
var breakdownEl = findFieldByKey(targetFieldKey + '_breakdown');
if (breakdownEl) {
if (breakdownEl.tagName === 'DIV') {
breakdownEl.innerHTML = result.breakdown;
} else {
breakdownEl.value = result.breakdown;
}
}
}
}).catch(function() {
// Silent — fee calc failed, user can still submit
});
}
function triggerNearestFeeCalc(element) {
var section = element.closest('.card');
if (!section) return;
var feeField = section.querySelector('[id*="fee"]');
if (feeField && feeField.hasAttribute('data-fee-trigger')) {
feeField.dispatchEvent(new Event('recalculate'));
}
}
// ─────────────────────────────────────────────
// CLIENT-SIDE VALIDATION
// ─────────────────────────────────────────────
function initClientValidation() {
var forms = document.querySelectorAll('form[action*="/forms/submit/"]');
forms.forEach(function(form) {
form.addEventListener('submit', function(e) {
var isValid = true;
var firstError = null;
// Clear all previous client errors
form.querySelectorAll('.client-error').forEach(function(err) { err.remove(); });
form.querySelectorAll('.form-input, .form-select, .form-textarea').forEach(function(inp) {
inp.style.borderColor = '';
});
// Validate required fields that are visible
form.querySelectorAll('[required]').forEach(function(field) {
var wrapper = field.closest('.form-group');
if (wrapper && wrapper.style.display === 'none') return;
if (field.disabled) return;
var val = getFieldValue(field);
if (val === '' || val === null || val === undefined) {
isValid = false;
field.style.borderColor = '#DC2626';
showFieldError(field, 'هذا الحقل مطلوب');
if (!firstError) firstError = field;
}
});
// Validate NID fields
form.querySelectorAll('[data-nid-parser="true"]').forEach(function(nidField) {
if (nidField.disabled) return;
var wrapper = nidField.closest('.form-group');
if (wrapper && wrapper.style.display === 'none') return;
var val = nidField.value;
if (val && val.length > 0 && val.length !== 14) {
isValid = false;
nidField.style.borderColor = '#DC2626';
showFieldError(nidField, 'الرقم القومي يجب أن يكون 14 رقم');
if (!firstError) firstError = nidField;
}
});
// Validate Egyptian phone fields
form.querySelectorAll('input[type="tel"]').forEach(function(phoneField) {
if (phoneField.disabled) return;
var wrapper = phoneField.closest('.form-group');
if (wrapper && wrapper.style.display === 'none') return;
var val = phoneField.value;
if (val && val.length > 0) {
var nameAttr = phoneField.getAttribute('name') || '';
if (nameAttr.indexOf('mobile') !== -1 || nameAttr.indexOf('phone_eg') !== -1) {
if (!/^01[0-9]{9}$/.test(val)) {
isValid = false;
phoneField.style.borderColor = '#DC2626';
showFieldError(phoneField, 'رقم الهاتف المصري يجب أن يبدأ بـ 01 ويتكون من 11 رقم');
if (!firstError) firstError = phoneField;
}
}
}
});
// Validate email fields
form.querySelectorAll('input[type="email"]').forEach(function(emailField) {
if (emailField.disabled) return;
var val = emailField.value;
if (val && val.length > 0 && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
isValid = false;
emailField.style.borderColor = '#DC2626';
showFieldError(emailField, 'بريد إلكتروني غير صالح');
if (!firstError) firstError = emailField;
}
});
if (!isValid) {
e.preventDefault();
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstError.focus();
}
}
});
});
}
// ─────────────────────────────────────────────
// UTILITY FUNCTIONS
// ─────────────────────────────────────────────
function findFieldByKey(key) {
var el = document.getElementById('field-' + key);
if (el) return el;
el = document.querySelector('[name="' + key + '"]');
return el;
}
function getFieldValue(field) {
if (!field) return '';
if (field.type === 'checkbox') return field.checked ? '1' : '';
if (field.type === 'radio') {
var checked = document.querySelector('input[name="' + field.name + '"]:checked');
return checked ? checked.value : '';
}
return field.value || '';
} }
function setFieldValue(fieldKey, value) { function setFieldValue(fieldKey, value) {
...@@ -163,31 +563,87 @@ var FormsEngine = (function() { ...@@ -163,31 +563,87 @@ var FormsEngine = (function() {
if (!el) return; if (!el) return;
if (el.tagName === 'SELECT') { if (el.tagName === 'SELECT') {
var found = false;
for (var i = 0; i < el.options.length; i++) { for (var i = 0; i < el.options.length; i++) {
if (el.options[i].value === value) { if (el.options[i].value === value) {
el.selectedIndex = i; el.selectedIndex = i;
found = true;
break; break;
} }
} }
if (!found && value) {
// Value not in options — might be loaded dynamically
var opt = document.createElement('option');
opt.value = value;
opt.textContent = value;
opt.selected = true;
el.appendChild(opt);
}
} else if (el.tagName === 'DIV') { } else if (el.tagName === 'DIV') {
el.textContent = value; el.textContent = value;
} else { } else {
el.value = value; el.value = value;
} }
// Fire change event el.dispatchEvent(new Event('change', { bubbles: true }));
var evt = new Event('change', { bubbles: true });
el.dispatchEvent(evt);
} }
function initDynamicSources() { function showFieldError(field, message) {
// Dynamic sources are loaded server-side by FormRenderer clearFieldError(field);
// This is a placeholder for future AJAX-based dynamic loading var wrapper = field.closest('.form-group');
if (!wrapper) wrapper = field.parentElement;
var errDiv = document.createElement('div');
errDiv.className = 'client-error';
errDiv.style.cssText = 'color:#DC2626;font-size:12px;margin-top:4px;';
errDiv.textContent = message;
wrapper.appendChild(errDiv);
}
function clearFieldError(field) {
var wrapper = field.closest('.form-group');
if (!wrapper) wrapper = field.parentElement;
var existing = wrapper.querySelectorAll('.client-error');
existing.forEach(function(e) { e.remove(); });
field.style.borderColor = '';
}
function pad(num) {
return num < 10 ? '0' + num : String(num);
}
function fetchJson(url, method, data) {
method = method || 'GET';
var opts = {
method: method,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
};
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
if (csrfMeta) {
opts.headers['X-CSRF-TOKEN'] = csrfMeta.getAttribute('content');
}
if (method === 'POST' && data) {
opts.body = JSON.stringify(data);
}
return fetch(url, opts).then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
} }
// Public API
return { return {
init: init, init: init,
parseNid: parseNid, parseNid: parseNid,
setFieldValue: setFieldValue setFieldValue: setFieldValue,
findFieldByKey: findFieldByKey,
getFieldValue: getFieldValue,
addRepeatableRow: addRepeatableRow,
evaluateCondition: evaluateCondition
}; };
})(); })();
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment