332 lines
14 KiB
PHP
332 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* VS-01 Renderer
|
|
* Reads VS JSON schemas and produces form HTML.
|
|
* This file knows nothing about what a Vital Sign is.
|
|
* It only knows how to render what the schema describes.
|
|
* See VS-RENDERER-SPEC.md for the full contract.
|
|
*/
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// SCHEMA LOADER
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_load_schemas() {
|
|
$dir = 'addon/vs01/vital-signs/';
|
|
$files = glob($dir . 'VS-*.json');
|
|
if (!$files) return [];
|
|
sort($files);
|
|
$schemas = [];
|
|
foreach ($files as $file) {
|
|
$raw = @file_get_contents($file);
|
|
if (!$raw) {
|
|
logger('vs01_renderer: could not read schema file: ' . $file);
|
|
continue;
|
|
}
|
|
$data = json_decode($raw, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
logger('vs01_renderer: malformed schema file: ' . $file);
|
|
continue;
|
|
}
|
|
$code = $data['_meta']['code'] ?? null;
|
|
if ($code) $schemas[$code] = $data;
|
|
}
|
|
return $schemas;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// FIELD TYPE RENDERERS
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_render_field_text($field, $value = '') {
|
|
$id = vs01_h($field['id']);
|
|
$out = '<div class="vs01-field vs01-field-text mb-3" data-field-id="' . $id . '">';
|
|
$out .= '<label class="form-label" for="' . $id . '">' . vs01_h($field['label']) . '</label>';
|
|
$out .= '<input type="text" class="form-control" id="' . $id . '"
|
|
name="fields[' . $id . ']"
|
|
value="' . vs01_h($value) . '"
|
|
placeholder="' . vs01_h($field['placeholder'] ?? '') . '"
|
|
' . (!empty($field['required']) ? 'required' : '') . '>';
|
|
$out .= '</div>';
|
|
return $out;
|
|
}
|
|
|
|
function vs01_render_field_number($field, $value = '') {
|
|
$id = vs01_h($field['id']);
|
|
$out = '<div class="vs01-field vs01-field-number mb-3" data-field-id="' . $id . '">';
|
|
$out .= '<label class="form-label" for="' . $id . '">' . vs01_h($field['label']) . '</label>';
|
|
$out .= '<input type="number" class="form-control" id="' . $id . '"
|
|
name="fields[' . $id . ']"
|
|
value="' . vs01_h($value) . '"
|
|
placeholder="' . vs01_h($field['placeholder'] ?? '') . '"
|
|
' . (!empty($field['required']) ? 'required' : '') . '>';
|
|
$out .= '</div>';
|
|
return $out;
|
|
}
|
|
|
|
function vs01_render_field_date($field, $value = '') {
|
|
$id = vs01_h($field['id']);
|
|
$out = '<div class="vs01-field vs01-field-date mb-3" data-field-id="' . $id . '">';
|
|
$out .= '<label class="form-label" for="' . $id . '">' . vs01_h($field['label']) . '</label>';
|
|
$out .= '<input type="date" class="form-control" id="' . $id . '"
|
|
name="fields[' . $id . ']"
|
|
value="' . vs01_h($value) . '"
|
|
' . (!empty($field['required']) ? 'required' : '') . '>';
|
|
$out .= '</div>';
|
|
return $out;
|
|
}
|
|
|
|
function vs01_render_field_boolean($field, $value = '') {
|
|
$id = vs01_h($field['id']);
|
|
$yes = ($value === 'true') ? 'checked' : '';
|
|
$no = ($value === 'false') ? 'checked' : '';
|
|
$req = !empty($field['required']) ? 'required' : '';
|
|
$out = '<div class="vs01-field vs01-field-boolean mb-3" data-field-id="' . $id . '">';
|
|
$out .= '<fieldset class="vs01-boolean-group">';
|
|
$out .= '<legend class="form-label">' . vs01_h($field['label']) . '</legend>';
|
|
$out .= '<div class="form-check form-check-inline">
|
|
<input class="form-check-input" type="radio" id="' . $id . '_yes"
|
|
name="fields[' . $id . ']" value="true" ' . $yes . ' ' . $req . '>
|
|
<label class="form-check-label" for="' . $id . '_yes">Yes</label>
|
|
</div>';
|
|
$out .= '<div class="form-check form-check-inline">
|
|
<input class="form-check-input" type="radio" id="' . $id . '_no"
|
|
name="fields[' . $id . ']" value="false" ' . $no . '>
|
|
<label class="form-check-label" for="' . $id . '_no">No</label>
|
|
</div>';
|
|
$out .= '</fieldset></div>';
|
|
return $out;
|
|
}
|
|
|
|
function vs01_render_field_select($field, $value = '') {
|
|
$id = vs01_h($field['id']);
|
|
$options = $field['options'] ?? [];
|
|
$out = '<div class="vs01-field vs01-field-select mb-3" data-field-id="' . $id . '">';
|
|
$out .= '<label class="form-label" for="' . $id . '">' . vs01_h($field['label']) . '</label>';
|
|
$out .= '<select class="form-select" id="' . $id . '" name="fields[' . $id . ']"
|
|
' . (!empty($field['required']) ? 'required' : '') . '>';
|
|
$out .= '<option value="">— select —</option>';
|
|
foreach ($options as $opt) {
|
|
$v = vs01_h($opt['value']);
|
|
$l = vs01_h($opt['label']);
|
|
$sel = ($value === $opt['value']) ? 'selected' : '';
|
|
$out .= '<option value="' . $v . '" ' . $sel . '>' . $l . '</option>';
|
|
}
|
|
$out .= '</select></div>';
|
|
return $out;
|
|
}
|
|
|
|
function vs01_render_field_textarea($field, $value = '') {
|
|
$id = vs01_h($field['id']);
|
|
$out = '<div class="vs01-field vs01-field-textarea mb-3" data-field-id="' . $id . '">';
|
|
$out .= '<label class="form-label" for="' . $id . '">' . vs01_h($field['label']) . '</label>';
|
|
$out .= '<textarea class="form-control" id="' . $id . '"
|
|
name="fields[' . $id . ']"
|
|
placeholder="' . vs01_h($field['placeholder'] ?? '') . '"
|
|
rows="5"
|
|
' . (!empty($field['required']) ? 'required' : '') . '>'
|
|
. vs01_h($value) . '</textarea>';
|
|
$out .= '</div>';
|
|
return $out;
|
|
}
|
|
|
|
function vs01_render_field_multiselect($field, $value = '') {
|
|
$id = vs01_h($field['id']);
|
|
$options = $field['options'] ?? [];
|
|
$selected = $value ? json_decode($value, true) : [];
|
|
if (!is_array($selected)) $selected = [];
|
|
$out = '<div class="vs01-field vs01-field-multiselect mb-3" data-field-id="' . $id . '">';
|
|
$out .= '<fieldset><legend class="form-label">' . vs01_h($field['label']) . '</legend>';
|
|
foreach ($options as $opt) {
|
|
$v = vs01_h($opt['value']);
|
|
$l = vs01_h($opt['label']);
|
|
$chk = in_array($opt['value'], $selected) ? 'checked' : '';
|
|
$out .= '<div class="form-check">
|
|
<input class="form-check-input" type="checkbox"
|
|
id="' . $id . '_' . $v . '"
|
|
name="fields[' . $id . '][]"
|
|
value="' . $v . '" ' . $chk . '>
|
|
<label class="form-check-label" for="' . $id . '_' . $v . '">' . $l . '</label>
|
|
</div>';
|
|
}
|
|
$out .= '</fieldset></div>';
|
|
return $out;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// FIELD DISPATCHER
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_render_field($field, $submitted_values = []) {
|
|
$id = $field['id'] ?? '';
|
|
$type = $field['type'] ?? 'text';
|
|
// A schema field may declare a "default" (e.g. ho_total_units => 0) so the
|
|
// input isn't left blank on first render. Submitted values always win;
|
|
// this only applies on the initial, un-submitted render of the form.
|
|
$value = $submitted_values[$id] ?? (isset($field['default']) ? (string) $field['default'] : '');
|
|
|
|
switch ($type) {
|
|
case 'text': $html = vs01_render_field_text($field, $value); break;
|
|
case 'number': $html = vs01_render_field_number($field, $value); break;
|
|
case 'date': $html = vs01_render_field_date($field, $value); break;
|
|
case 'boolean': $html = vs01_render_field_boolean($field, $value); break;
|
|
case 'select': $html = vs01_render_field_select($field, $value); break;
|
|
case 'textarea': $html = vs01_render_field_textarea($field, $value); break;
|
|
case 'multiselect': $html = vs01_render_field_multiselect($field, $value); break;
|
|
default:
|
|
$html = '<div class="alert alert-danger">Unknown field type: ' . vs01_h($type) . '</div>';
|
|
}
|
|
|
|
$html .= vs01_render_diagnostic_flag($field, $value);
|
|
return $html;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// CONDITIONAL DISPLAY
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_wrap_depends_on($field, $html) {
|
|
if (empty($field['depends_on'])) return $html;
|
|
$dep_field = vs01_h($field['depends_on']['field']);
|
|
$dep_value = $field['depends_on']['value'];
|
|
$dep_value = is_array($dep_value)
|
|
? vs01_h(json_encode($dep_value))
|
|
: vs01_h((string) $dep_value);
|
|
return '<div class="vs01-conditional"
|
|
data-depends-on="' . $dep_field . '"
|
|
data-depends-value="' . $dep_value . '"
|
|
style="display:none;">'
|
|
. $html .
|
|
'</div>';
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// DIAGNOSTIC FLAGS
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_render_diagnostic_flag($field, $value) {
|
|
if (empty($field['diagnostic_flag'])) return '';
|
|
$condition = $field['flag_condition'] ?? null;
|
|
if ($condition === null) return '';
|
|
|
|
$triggered = false;
|
|
if (is_array($condition)) {
|
|
$triggered = in_array($value, $condition);
|
|
} elseif (is_bool($condition)) {
|
|
$triggered = ($value === ($condition ? 'true' : 'false'));
|
|
} else {
|
|
$triggered = ($value === (string) $condition);
|
|
}
|
|
|
|
if (!$triggered) return '';
|
|
|
|
$severity = vs01_flag_severity($field);
|
|
$label = vs01_h($field['flag_label'] ?? 'Diagnostic condition');
|
|
$note = isset($field['flag_note'])
|
|
? '<p class="vs01-flag-note mb-0 mt-1 small">' . vs01_h($field['flag_note']) . '</p>'
|
|
: '';
|
|
|
|
return '<div class="vs01-flag vs01-flag-' . $severity . ' mb-2" role="alert">
|
|
<strong>' . $label . '</strong>' . $note . '
|
|
</div>';
|
|
}
|
|
|
|
function vs01_flag_severity($field) {
|
|
$id = $field['id'] ?? '';
|
|
if (in_array($id, ['pro_selfdealing_verified', 'pro_standing_verified', 'pro_incumbent_perpetuation_risk'])) {
|
|
return 'critical';
|
|
}
|
|
if (str_starts_with($id, 'pro_')) return 'high';
|
|
return 'standard';
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// COMPOUND CONDITION BANNERS
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_render_compound_banners($association_slug) {
|
|
// TODO: query stored VS records for this association from orchestrator
|
|
// and evaluate the three compound conditions defined in VS-RENDERER-SPEC.md.
|
|
// Returns empty string until orchestrator query is implemented.
|
|
return '';
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// CROSS-VS REFERENCE NOTES
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_render_cross_reference($vs_code, $label, $association_slug) {
|
|
$url = z_root() . '/vs01/' . vs01_h($association_slug) . '/' . vs01_h($vs_code);
|
|
return '<div class="vs01-cross-ref small text-muted mt-1">
|
|
See also: <a href="' . $url . '">' . vs01_h($label) . '</a>
|
|
</div>';
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// VERIFICATION SOURCES PANEL
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_render_verification_sources($meta) {
|
|
$sources = $meta['verification_sources'] ?? [];
|
|
if (empty($sources)) return '';
|
|
$code = vs01_h($meta['code'] ?? 'vs');
|
|
$out = '<div class="vs01-verification-sources mb-3">';
|
|
$out .= '<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
data-bs-toggle="collapse"
|
|
data-bs-target="#vs01-sources-' . $code . '"
|
|
aria-expanded="false">
|
|
Where to verify this
|
|
</button>';
|
|
$out .= '<div class="collapse mt-2" id="vs01-sources-' . $code . '">';
|
|
foreach ($sources as $source) {
|
|
$label = vs01_h($source['label'] ?? '');
|
|
$url = vs01_h($source['url'] ?? '');
|
|
$instruction = vs01_h($source['instruction'] ?? '');
|
|
$out .= '<div class="vs01-source mb-2">';
|
|
$out .= '<a href="' . $url . '" target="_blank" rel="noopener" class="fw-semibold">' . $label . '</a>';
|
|
$out .= '<p class="small text-muted mb-0">' . $instruction . '</p>';
|
|
$out .= '</div>';
|
|
}
|
|
$out .= '</div></div>';
|
|
return $out;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// PERSPECTIVE NOTE (VS-07 and VS-09 homeowner slot)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_render_perspective_note($schema, $perspective_key) {
|
|
if ($perspective_key !== 'homeowner') return '';
|
|
$code = $schema['_meta']['code'] ?? '';
|
|
if (!in_array($code, ['VS-07', 'VS-09'])) return '';
|
|
$notes = $schema['_meta']['renderer_notes'] ?? '';
|
|
if (!$notes) return '';
|
|
return '<div class="vs01-perspective-note alert alert-light small mb-3">'
|
|
. vs01_h($notes) . '</div>';
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// IMMUTABLE PUBLIC RECORD
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function vs01_public_record_exists($vs_code, $association_slug) {
|
|
// TODO: query orchestrator for existing promoted public record
|
|
return false;
|
|
}
|
|
|
|
function vs01_render_public_record_readonly($perspective, $values) {
|
|
$out = '<div class="vs01-readonly">';
|
|
foreach ($perspective['fields'] ?? [] as $field) {
|
|
$id = vs01_h($field['id'] ?? '');
|
|
$label = vs01_h($field['label'] ?? '');
|
|
$value = vs01_h($values[$field['id'] ?? ''] ?? '—');
|
|
$out .= '<div class="vs01-readonly-field mb-2">';
|
|
$out .= '<span class="fw-semibold">' . $label . '</span> ';
|
|
$out .= '<span class="text-muted">' . $value . '</span>';
|
|
$out .= '</div>';
|
|
}
|
|
$out .= '</div>';
|
|
return $out;
|
|
}
|