Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
C
Clubphp
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Clubphp
Commits
384f655a
Commit
384f655a
authored
Apr 07, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 2 files via Son of Anton
parent
f90db6f4
Changes
2
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
660 additions
and
115 deletions
+660
-115
FormRenderer.php
app/Modules/Forms/Services/FormRenderer.php
+115
-26
forms-engine.js
public/assets/js/forms-engine.js
+545
-89
No files found.
app/Modules/Forms/Services/FormRenderer.php
View file @
384f655a
...
@@ -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
[];
}
}
...
...
public/assets/js/forms-engine.js
View file @
384f655a
/**
/**
* 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
);
var
watchField
=
document
.
getElementById
(
'field-'
+
condition
.
field
);
}
catch
(
e
)
{
if
(
!
watchField
)
{
// Bad JSON — skip
watchField
=
document
.
querySelector
(
'[name="'
+
condition
.
field
+
'"]'
);
}
}
});
}
function
bindVisibilityWatcher
(
el
,
condition
)
{
var
watchField
=
findFieldByKey
(
condition
.
field
);
if
(
!
watchField
)
return
;
if
(
!
watchField
)
return
;
function
evaluate
()
{
function
evaluate
()
{
var
actual
=
watchField
.
value
;
var
visible
=
evaluateCondition
(
condition
,
getFieldValue
(
watchField
));
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'
;
el
.
style
.
display
=
visible
?
''
:
'none'
;
var
inputs
=
el
.
querySelectorAll
(
'input, select, textarea'
);
inputs
.
forEach
(
function
(
inp
)
{
// When hidden, strip required so form can submit
if
(
!
visible
)
{
if
(
!
visible
)
{
el
.
querySelectorAll
(
'[required]'
).
forEach
(
function
(
inp
)
{
inp
.
setAttribute
(
'data-was-required'
,
'true'
);
inp
.
removeAttribute
(
'required'
);
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
(
'change'
,
evaluate
);
watchField
.
addEventListener
(
'input'
,
evaluate
);
watchField
.
addEventListener
(
'input'
,
evaluate
);
evaluate
();
evaluate
();
}
catch
(
e
)
{
// Silently ignore bad JSON
}
}
});
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
));
if
(
month
<
1
||
month
>
12
||
day
<
1
||
day
>
31
)
{
showFieldError
(
inputEl
,
'تاريخ الميلاد في الرقم القومي غير صالح'
);
inputEl
.
style
.
borderColor
=
'#DC2626'
;
return
;
}
var
monthInt
=
parseInt
(
month
);
// Validate actual date
var
dayInt
=
parseInt
(
day
);
var
testDate
=
new
Date
(
year
,
month
-
1
,
day
);
if
(
monthInt
<
1
||
monthInt
>
12
||
dayInt
<
1
||
dayInt
>
31
)
return
;
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
// Determine field prefix from input name (handles repeatable: child_national_id -> child_)
setFieldValue
(
'date_of_birth'
,
dob
);
var
inputName
=
inputEl
.
getAttribute
(
'name'
)
||
inputEl
.
id
.
replace
(
'field-'
,
''
);
setFieldValue
(
'child_dob'
,
dob
);
var
prefix
=
''
;
setFieldValue
(
'spouse_dob'
,
dob
);
if
(
inputName
.
indexOf
(
'national_id'
)
>
0
)
{
setFieldValue
(
'seasonal_dob'
,
dob
);
prefix
=
inputName
.
replace
(
'national_id'
,
''
);
}
// Auto-fill age
setFieldValue
(
'age_years'
,
ageYears
.
toString
());
setFieldValue
(
'age_months'
,
ageMonths
.
toString
());
setFieldValue
(
'child_age'
,
ageYears
.
toString
());
setFieldValue
(
'spouse_age'
,
ageYears
.
toString
());
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
showFieldError
(
field
,
message
)
{
clearFieldError
(
field
);
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
initDynamicSources
()
{
function
pad
(
num
)
{
// Dynamic sources are loaded server-side by FormRenderer
return
num
<
10
?
'0'
+
num
:
String
(
num
);
// This is a placeholder for future AJAX-based dynamic loading
}
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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment