This commit is contained in:
2026-06-06 09:16:49 -04:00
parent 2346a67789
commit dad39132e6
3 changed files with 581 additions and 97 deletions

View File

@@ -150,6 +150,9 @@ function assoc_profile_content() {
if (function_exists('head_add_css')) { if (function_exists('head_add_css')) {
head_add_css('/addon/assoc_profile/view/css/assoc_profile.css'); head_add_css('/addon/assoc_profile/view/css/assoc_profile.css');
} }
if (function_exists('head_add_js')) {
head_add_js('/addon/assoc_profile/view/js/assoc_profile.js');
}
$action = argv(1) ?? ''; $action = argv(1) ?? '';
@@ -158,29 +161,28 @@ function assoc_profile_content() {
} }
if (!assoc_is_operator()) { if (!assoc_is_operator()) {
return '<div class="alert alert-danger">Operator access required.</div>'; return '<div class="civicinfra-notice civicinfra-warning">Operator access required.</div>';
} }
$sub = argv(2) ?? ''; $sub = argv(2) ?? '';
$slug = argv(3) ?? ''; $slug = argv(3) ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
return assoc_handle_post(); return assoc_handle_post();
} }
// Manage index
if (!$sub) return assoc_render_manage_index(); if (!$sub) return assoc_render_manage_index();
// Association actions
if ($sub === 'assoc') { if ($sub === 'assoc') {
if (!$slug) return assoc_render_add_association_form(); if (!$slug) return assoc_render_add_association_form();
return assoc_render_edit_association_form($slug); return assoc_render_edit_association_form($slug);
} }
// Field actions
if ($sub === 'fields') return assoc_render_fields_form(); if ($sub === 'fields') return assoc_render_fields_form();
return '<div class="alert alert-warning">Unknown management action.</div>'; if ($sub === 'import') return assoc_render_import_form();
return '<div class="civicinfra-notice civicinfra-warning">Unknown management action.</div>';
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -189,19 +191,23 @@ function assoc_profile_content() {
function assoc_handle_post() { function assoc_handle_post() {
if (!assoc_verify_csrf()) { if (!assoc_verify_csrf()) {
return '<div class="alert alert-danger">Invalid form token.</div>'; return '<div class="civicinfra-notice civicinfra-warning">Invalid form token.</div>';
} }
$action = $_POST['assoc_action'] ?? ''; $action = $_POST['assoc_action'] ?? '';
switch ($action) { switch ($action) {
case 'save_association': return assoc_save_association(); case 'save_association': return assoc_save_association();
case 'add_association': return assoc_add_association(); case 'add_association': return assoc_add_association();
case 'save_fields': return assoc_save_fields(); case 'delete_association':return assoc_delete_association();
case 'add_field': return assoc_add_field(); case 'save_fields': return assoc_save_fields();
case 'remove_field': return assoc_remove_field(); case 'add_field': return assoc_add_field();
case 'remove_field': return assoc_remove_field();
case 'export_selected': return assoc_handle_export($_POST['export_slugs'] ?? '');
case 'import_upload': return assoc_handle_import_upload();
case 'import_confirm': return assoc_handle_import_confirm();
default: default:
return '<div class="alert alert-danger">Unknown action.</div>'; return '<div class="civicinfra-notice civicinfra-warning">Unknown action.</div>';
} }
} }
@@ -256,7 +262,29 @@ function assoc_add_association() {
if (assoc_write_registry($registry)) { if (assoc_write_registry($registry)) {
goaway(z_root() . '/assoc_profile/manage/assoc/' . $slug); goaway(z_root() . '/assoc_profile/manage/assoc/' . $slug);
} }
return '<div class="alert alert-danger">Failed to create association.</div>'; return '<div class="civicinfra-notice civicinfra-warning">Failed to create association.</div>';
}
// ----------------------------------------------------------------------------
// DELETE ASSOCIATION
// ----------------------------------------------------------------------------
function assoc_delete_association() {
$slug = substr(strip_tags($_POST['assoc_slug'] ?? ''), 0, 128);
if (!$slug) return '<div class="civicinfra-notice civicinfra-warning">Missing slug.</div>';
$registry = assoc_load_registry();
if (!isset($registry[$slug])) {
return '<div class="civicinfra-notice civicinfra-warning">Association not found.</div>';
}
$name = $registry[$slug]['assoc_legal_name'] ?? $slug;
unset($registry[$slug]);
if (assoc_write_registry($registry)) {
goaway(z_root() . '/assoc_profile/manage');
}
return '<div class="civicinfra-notice civicinfra-warning">Delete failed. Check server logs.</div>';
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@@ -2,72 +2,201 @@
/** /**
* Association Profile — Management Renderer * Association Profile — Management Renderer
* Render functions for /assoc_profile/manage interface.
* Operator-only. Called from assoc_profile.php content router. * Operator-only. Called from assoc_profile.php content router.
* v0.2.0 — search, filter, paginate, bulk export, import with diff, delete.
*/ */
// ----------------------------------------------------------------------------
// MANAGE INDEX
// ----------------------------------------------------------------------------
function assoc_render_manage_index() { function assoc_render_manage_index() {
$registry = assoc_load_registry(); $registry = assoc_load_registry();
$out = '<div class="assoc-manage">'; $def = assoc_load_fields();
$out .= '<h2>Association Profile — Management</h2>'; $fields = $def['fields'] ?? [];
$out .= '<div class="mb-3">';
$out .= '<a href="' . z_root() . '/assoc_profile/manage/assoc" class="btn btn-primary btn-sm me-2">Add Association</a>'; // Build filter option sets from registry data
$out .= '<a href="' . z_root() . '/assoc_profile/manage/fields" class="btn btn-outline-secondary btn-sm">Manage Fields</a>'; $counties = [];
$out .= '</div>'; $types = [];
if (empty($registry)) { $statuses = [];
$out .= '<p class="text-muted">No associations registered.</p>'; foreach ($registry as $slug => $entry) {
} else { if (!empty($entry['assoc_county'])) $counties[$entry['assoc_county']] = true;
$out .= '<table class="table table-sm table-hover">'; if (!empty($entry['assoc_type'])) $types[$entry['assoc_type']] = true;
$out .= '<thead><tr><th>Slug</th><th>Legal Name</th><th>Type</th><th>Units</th><th>Updated</th><th></th></tr></thead><tbody>'; if (!empty($entry['assoc_sos_standing'])) $statuses[$entry['assoc_sos_standing']] = true;
foreach ($registry as $slug => $entry) {
$out .= '<tr>';
$out .= '<td><code>' . assoc_h($slug) . '</code></td>';
$out .= '<td>' . assoc_h($entry['assoc_legal_name'] ?? '—') . '</td>';
$out .= '<td>' . assoc_h($entry['assoc_type'] ?? '—') . '</td>';
$out .= '<td>' . assoc_h($entry['assoc_units'] ?? '—') . '</td>';
$out .= '<td>' . assoc_h($entry['assoc_updated'] ?? '—') . '</td>';
$out .= '<td><a href="' . z_root() . '/assoc_profile/manage/assoc/' . assoc_h($slug) . '" class="btn btn-sm btn-outline-primary">Edit</a></td>';
$out .= '</tr>';
}
$out .= '</tbody></table>';
} }
ksort($counties); ksort($types); ksort($statuses);
$total = count($registry);
$out = '<div class="assoc-manage">';
// Header
$out .= '<header class="civicinfra-manage-header">';
$out .= '<h1 class="civicinfra-manage-title">Associations</h1>';
$out .= '<div class="civicinfra-manage-actions">';
$out .= '<a href="' . z_root() . '/assoc_profile/manage/assoc" class="btn btn-primary btn-sm">+ Add Association</a>';
$out .= '<a href="' . z_root() . '/assoc_profile/manage/fields" class="btn btn-outline-secondary btn-sm">Manage Fields</a>';
$out .= '<a href="' . z_root() . '/assoc_profile/manage/import" class="btn btn-outline-secondary btn-sm">Import</a>';
$out .= '</div></header>';
if (empty($registry)) {
$out .= '<div class="civicinfra-notice">No associations registered. <a href="' . z_root() . '/assoc_profile/manage/assoc">Add the first one.</a></div>';
$out .= '</div>';
return $out;
}
// Search and filter bar
$out .= '<div class="assoc-search-bar" role="search">';
$out .= '<div><label class="form-label mb-1" for="assoc-search">Search</label>';
$out .= '<input type="search" id="assoc-search" class="form-control form-control-sm" placeholder="Name or slug…" style="min-width:16rem;"></div>';
$out .= '<div><label class="form-label mb-1" for="assoc-filter-county">County</label>';
$out .= '<select id="assoc-filter-county" class="form-select form-select-sm"><option value="">All counties</option>';
foreach ($counties as $c => $_) { $out .= '<option value="' . assoc_h($c) . '">' . assoc_h($c) . '</option>'; }
$out .= '</select></div>';
$out .= '<div><label class="form-label mb-1" for="assoc-filter-type">Type</label>';
$out .= '<select id="assoc-filter-type" class="form-select form-select-sm"><option value="">All types</option>';
foreach ($types as $t => $_) { $out .= '<option value="' . assoc_h($t) . '">' . assoc_h($t === 'UNK' ? 'Unknown' : $t) . '</option>'; }
$out .= '</select></div>';
$out .= '<div><label class="form-label mb-1" for="assoc-filter-status">Standing</label>';
$out .= '<select id="assoc-filter-status" class="form-select form-select-sm"><option value="">All</option>';
foreach ($statuses as $s => $_) { $out .= '<option value="' . assoc_h($s) . '">' . assoc_h($s === 'UNK' ? 'Unknown' : $s) . '</option>'; }
$out .= '</select></div>';
$out .= '</div>';
// Bulk bar (hidden until selection)
$out .= '<div id="assoc-bulk-bar" class="assoc-bulk-bar" style="display:none;">';
$out .= '<span id="assoc-bulk-count" class="me-2">0 selected</span>';
$out .= '<button type="button" id="assoc-export-selected" class="btn btn-sm btn-outline-secondary">Export selected</button>';
$out .= '</div>';
// Hidden export form
$out .= '<form id="assoc-export-form" method="post" action="' . z_root() . '/assoc_profile/manage" style="display:none;">';
$out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="export_selected">';
$out .= '<input type="hidden" id="assoc-export-slugs" name="export_slugs" value="">';
$out .= '</form>';
// Table
$out .= '<div class="civicinfra-data-table">';
$out .= '<table class="table table-sm mb-0" id="assoc-manage-table">';
$out .= '<thead><tr>';
$out .= '<th scope="col" style="width:2rem;"><input type="checkbox" id="assoc-select-all" class="form-check-input" title="Select all visible"></th>';
$out .= '<th scope="col">Slug</th>';
$out .= '<th scope="col">Legal Name</th>';
$out .= '<th scope="col">Type</th>';
$out .= '<th scope="col">County</th>';
$out .= '<th scope="col">Units</th>';
$out .= '<th scope="col">Standing</th>';
$out .= '<th scope="col">Updated</th>';
$out .= '<th scope="col"></th>';
$out .= '</tr></thead><tbody>';
foreach ($registry as $slug => $entry) {
$type = $entry['assoc_type'] ?? '';
$county = $entry['assoc_county'] ?? '';
$status = $entry['assoc_sos_standing'] ?? '';
$status_class = 'civicinfra-status-unknown';
if ($status === 'Active') $status_class = 'civicinfra-status-active';
if ($status === 'Dissolved') $status_class = 'civicinfra-status-alert';
$out .= '<tr data-slug="' . assoc_h($slug) . '"'
. ' data-name="' . assoc_h($entry['assoc_legal_name'] ?? '') . '"'
. ' data-county="' . assoc_h($county) . '"'
. ' data-type="' . assoc_h($type) . '"'
. ' data-status="' . assoc_h($status) . '">';
$out .= '<td><input type="checkbox" name="assoc_select[]" value="' . assoc_h($slug) . '" class="form-check-input"></td>';
$out .= '<td><code class="civicinfra-record-id">' . assoc_h($slug) . '</code></td>';
$out .= '<td>' . assoc_h($entry['assoc_legal_name'] ?? '—') . '</td>';
$out .= '<td>' . assoc_h($type ?: '—') . '</td>';
$out .= '<td>' . assoc_h($county ?: '—') . '</td>';
$out .= '<td>' . assoc_h($entry['assoc_units'] ?? '—') . '</td>';
$out .= '<td><span class="civicinfra-status ' . $status_class . '">' . assoc_h($status ?: 'UNK') . '</span></td>';
$out .= '<td class="civicinfra-record-meta">' . assoc_h($entry['assoc_updated'] ?? '—') . '</td>';
$out .= '<td><a href="' . z_root() . '/assoc_profile/manage/assoc/' . assoc_h($slug) . '" class="btn btn-sm btn-outline-primary">Edit</a></td>';
$out .= '</tr>';
}
$out .= '</tbody></table></div>';
// Pagination controls (JS-driven)
$out .= '<div class="assoc-pagination">';
$out .= '<span id="assoc-page-info" class="civicinfra-record-meta">' . $total . ' associations</span>';
$out .= '<div class="d-flex gap-2">';
$out .= '<button id="assoc-page-prev" class="btn btn-sm btn-outline-secondary" disabled>&larr; Prev</button>';
$out .= '<button id="assoc-page-next" class="btn btn-sm btn-outline-secondary">Next &rarr;</button>';
$out .= '</div></div>';
$out .= '</div>'; $out .= '</div>';
return $out; return $out;
} }
// ----------------------------------------------------------------------------
// ADD ASSOCIATION
// ----------------------------------------------------------------------------
function assoc_render_add_association_form() { function assoc_render_add_association_form() {
$out = '<div class="assoc-manage">'; $out = '<div class="assoc-manage">';
$out .= '<h2>Add Association</h2>'; $out .= '<header class="civicinfra-manage-header">';
$out .= '<h1 class="civicinfra-manage-title">Add Association</h1>';
$out .= '<div class="civicinfra-manage-actions">';
$out .= '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary btn-sm">&larr; Back</a>';
$out .= '</div></header>';
$out .= '<div class="civicinfra-form-section">';
$out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">'; $out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">';
$out .= assoc_csrf_token(); $out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="add_association">'; $out .= '<input type="hidden" name="assoc_action" value="add_association">';
$out .= '<div class="mb-3">'; $out .= '<div class="mb-3">';
$out .= '<label class="form-label">Slug <small class="text-muted">(lowercase, hyphens only — cannot be changed after creation)</small></label>'; $out .= '<label class="form-label" for="new_slug">Slug</label>';
$out .= '<input type="text" class="form-control" name="new_slug" placeholder="e.g. arbors-bgca" required pattern="[a-z0-9\-]+">'; $out .= '<p class="civicinfra-help-text mb-1">Lowercase, hyphens only. Cannot be changed after creation. Used in URLs and as the key in config.json.</p>';
$out .= '<input type="text" id="new_slug" class="form-control" name="new_slug"
placeholder="e.g. arbors-bgca" required pattern="[a-z0-9\-]+"
style="max-width:24rem;">';
$out .= '</div>'; $out .= '</div>';
$out .= '<button type="submit" class="btn btn-primary">Create Association</button> '; $out .= '<button type="submit" class="btn btn-primary">Create Association</button>';
$out .= '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary">Cancel</a>'; $out .= '</form></div></div>';
$out .= '</form></div>';
return $out; return $out;
} }
// ----------------------------------------------------------------------------
// EDIT ASSOCIATION
// ----------------------------------------------------------------------------
function assoc_render_edit_association_form($slug) { function assoc_render_edit_association_form($slug) {
$registry = assoc_load_registry(); $registry = assoc_load_registry();
if (!isset($registry[$slug])) { if (!isset($registry[$slug])) {
return '<div class="alert alert-warning">Association not found: ' . assoc_h($slug) . '</div>'; return '<div class="civicinfra-notice civicinfra-warning">Association not found: <code>' . assoc_h($slug) . '</code></div>';
}
$entry = $registry[$slug];
$def = assoc_load_fields();
$groups = $def['groups'] ?? [];
$fields = $def['fields'] ?? [];
$by_group = [];
foreach ($fields as $f) {
$by_group[$f['group']][] = $f;
} }
$entry = $registry[$slug];
$def = assoc_load_fields();
$groups = $def['groups'] ?? [];
$fields = $def['fields'] ?? [];
$by_group = [];
foreach ($fields as $f) { $by_group[$f['group']][] = $f; }
$name = $entry['assoc_legal_name'] ?? $slug;
$out = '<div class="assoc-manage">'; $out = '<div class="assoc-manage">';
$out .= '<h2>' . assoc_h($entry['assoc_legal_name'] ?? $slug) . '</h2>'; $out .= '<header class="civicinfra-manage-header">';
$out .= '<p class="text-muted"><code>' . assoc_h($slug) . '</code></p>'; $out .= '<div>';
$out .= '<nav aria-label="breadcrumb"><ol class="breadcrumb mb-1">';
$out .= '<li class="breadcrumb-item"><a href="' . z_root() . '/assoc_profile/manage">Associations</a></li>';
$out .= '<li class="breadcrumb-item active">' . assoc_h($name) . '</li>';
$out .= '</ol></nav>';
$out .= '<h1 class="civicinfra-manage-title">' . assoc_h($name) . '</h1>';
$out .= '<p class="civicinfra-record-meta mt-1"><code class="civicinfra-record-id">' . assoc_h($slug) . '</code></p>';
$out .= '</div>';
$out .= '<div class="civicinfra-manage-actions">';
$out .= '<a href="' . z_root() . '/profile/' . assoc_h($slug) . '" class="btn btn-outline-secondary btn-sm" target="_blank">View profile</a>';
$out .= '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary btn-sm">&larr; Back</a>';
$out .= '</div></header>';
$out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">'; $out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">';
$out .= assoc_csrf_token(); $out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="save_association">'; $out .= '<input type="hidden" name="assoc_action" value="save_association">';
@@ -76,7 +205,10 @@ function assoc_render_edit_association_form($slug) {
foreach ($groups as $group) { foreach ($groups as $group) {
$group_fields = $by_group[$group] ?? []; $group_fields = $by_group[$group] ?? [];
if (empty($group_fields)) continue; if (empty($group_fields)) continue;
$out .= '<h5 class="assoc-group-label mt-4">' . assoc_h($group) . '</h5>';
$out .= '<section class="civicinfra-form-section">';
$out .= '<h2 class="civicinfra-record-heading">' . assoc_h($group) . '</h2>';
foreach ($group_fields as $f) { foreach ($group_fields as $f) {
$nick = $f['nickname']; $nick = $f['nickname'];
$val = $entry[$nick] ?? ($f['default'] ?? ''); $val = $entry[$nick] ?? ($f['default'] ?? '');
@@ -85,19 +217,20 @@ function assoc_render_edit_association_form($slug) {
$help = $f['help'] ?? ''; $help = $f['help'] ?? '';
if ($type === 'readonly') { if ($type === 'readonly') {
$out .= '<div class="mb-2">'; $out .= '<div class="mb-3">';
$out .= '<label class="form-label text-muted">' . assoc_h($label) . '</label>'; $out .= '<p class="civicinfra-help-text mb-0">' . assoc_h($label) . '</p>';
$out .= '<div class="form-control-plaintext ps-0">' . assoc_h($val ?: '—') . '</div>'; $out .= '<p class="mb-0">' . assoc_h($val ?: '—') . '</p>';
$out .= '</div>'; $out .= '</div>';
continue; continue;
} }
$out .= '<div class="mb-2">'; $out .= '<div class="mb-3">';
$out .= '<label class="form-label" for="' . assoc_h($nick) . '">' . assoc_h($label) . '</label>'; $out .= '<label class="form-label" for="' . assoc_h($nick) . '">' . assoc_h($label) . '</label>';
if ($help) $out .= '<small class="text-muted d-block mb-1">' . assoc_h($help) . '</small>'; if ($help) $out .= '<p class="civicinfra-help-text mb-1">' . assoc_h($help) . '</p>';
if ($type === 'select' && !empty($f['options'])) { if ($type === 'select' && !empty($f['options'])) {
$out .= '<select class="form-select form-select-sm" id="' . assoc_h($nick) . '" name="' . assoc_h($nick) . '">'; $out .= '<select class="form-select form-select-sm" id="' . assoc_h($nick) . '"
name="' . assoc_h($nick) . '" style="max-width:20rem;">';
foreach ($f['options'] as $opt) { foreach ($f['options'] as $opt) {
$sel = ($val === $opt) ? 'selected' : ''; $sel = ($val === $opt) ? 'selected' : '';
$disp = ($opt === 'UNK') ? 'Unknown — not yet verified' : $opt; $disp = ($opt === 'UNK') ? 'Unknown — not yet verified' : $opt;
@@ -105,94 +238,408 @@ function assoc_render_edit_association_form($slug) {
} }
$out .= '</select>'; $out .= '</select>';
} else { } else {
$width = in_array($nick, ['assoc_legal_name','assoc_street','assoc_cai_listing',
'assoc_bbb_standing','assoc_idfpr_complaint',
'assoc_litigation_active','assoc_liens_active',
'assoc_mgmt_contact']) ? '100%' : '24rem';
$out .= '<input type="text" class="form-control form-control-sm" $out .= '<input type="text" class="form-control form-control-sm"
id="' . assoc_h($nick) . '" name="' . assoc_h($nick) . '" id="' . assoc_h($nick) . '" name="' . assoc_h($nick) . '"
value="' . assoc_h($val) . '">'; value="' . assoc_h($val) . '"
style="max-width:' . $width . ';">';
} }
$out .= '</div>'; $out .= '</div>';
} }
$out .= '</section>';
} }
$out .= '<div class="mt-4">'; $out .= '<div class="d-flex gap-2 mt-4">';
$out .= '<button type="submit" class="btn btn-primary">Save</button> '; $out .= '<button type="submit" class="btn btn-primary">Save</button>';
$out .= '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary">Back</a>'; $out .= '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary">Cancel</a>';
$out .= '</div></form></div>'; $out .= '</div></form>';
// Delete section
$out .= '<div class="assoc-delete-confirm">';
$out .= '<h5>Delete Association</h5>';
$out .= '<p class="civicinfra-help-text">This removes <strong>' . assoc_h($name) . '</strong> from the registry permanently. ';
$out .= 'You must also remove the corresponding entry from <code>vs01/config.json</code> manually.</p>';
$out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage"
onsubmit="return confirm(\'Permanently delete ' . addslashes(assoc_h($name)) . '? This cannot be undone.\')">';
$out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="delete_association">';
$out .= '<input type="hidden" name="assoc_slug" value="' . assoc_h($slug) . '">';
$out .= '<button type="submit" class="btn btn-danger btn-sm">Delete ' . assoc_h($name) . '</button>';
$out .= '</form></div>';
$out .= '</div>';
return $out; return $out;
} }
// ----------------------------------------------------------------------------
// FIELDS MANAGEMENT
// ----------------------------------------------------------------------------
function assoc_render_fields_form() { function assoc_render_fields_form() {
$def = assoc_load_fields(); $def = assoc_load_fields();
$groups = $def['groups'] ?? []; $groups = $def['groups'] ?? [];
$fields = $def['fields'] ?? []; $fields = $def['fields'] ?? [];
$out = '<div class="assoc-manage">'; $out = '<div class="assoc-manage">';
$out .= '<h2>Manage Fields</h2>'; $out .= '<header class="civicinfra-manage-header">';
$out .= '<p class="text-muted small">Changes to labels and groups take effect immediately. Adding a field backfills all existing associations with the default value. Removing a field deletes its data from all associations — this cannot be undone.</p>'; $out .= '<h1 class="civicinfra-manage-title">Manage Fields</h1>';
$out .= '<div class="civicinfra-manage-actions">';
$out .= '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary btn-sm">&larr; Back</a>';
$out .= '</div></header>';
$out .= '<div class="civicinfra-notice mb-4">';
$out .= 'Adding a field backfills all existing associations with the default value. ';
$out .= 'Removing a field deletes its data from <strong>all</strong> associations and cannot be undone.';
$out .= '</div>';
// Existing fields
$out .= '<section class="civicinfra-form-section">';
$out .= '<h2 class="civicinfra-record-heading">Existing Fields</h2>';
$out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">'; $out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">';
$out .= assoc_csrf_token(); $out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="save_fields">'; $out .= '<input type="hidden" name="assoc_action" value="save_fields">';
$out .= '<table class="table table-sm">'; $out .= '<div style="overflow-x:auto;">';
$out .= '<thead><tr><th>Nickname</th><th>Label</th><th>Type</th><th>Group</th><th>Help</th><th></th></tr></thead><tbody>'; $out .= '<table class="table table-sm civicinfra-data-table">';
$out .= '<thead><tr>
<th>Nickname</th>
<th>Label</th>
<th>Type</th>
<th>Group</th>
<th>Help text</th>
<th></th>
</tr></thead><tbody>';
foreach ($fields as $f) { foreach ($fields as $f) {
$nick = $f['nickname']; $nick = $f['nickname'];
$type = $f['type'] ?? 'text';
$out .= '<tr>'; $out .= '<tr>';
$out .= '<td><code>' . assoc_h($nick) . '</code></td>'; $out .= '<td><code class="civicinfra-record-id">' . assoc_h($nick) . '</code></td>';
$out .= '<td><input type="text" class="form-control form-control-sm" name="label_' . assoc_h($nick) . '" value="' . assoc_h($f['label'] ?? '') . '"></td>'; $out .= '<td><input type="text" class="form-control form-control-sm"
$out .= '<td>' . assoc_h($f['type'] ?? 'text') . '</td>'; name="label_' . assoc_h($nick) . '"
$out .= '<td><input type="text" class="form-control form-control-sm" name="group_' . assoc_h($nick) . '" value="' . assoc_h($f['group'] ?? '') . '"></td>'; value="' . assoc_h($f['label'] ?? '') . '"
$out .= '<td><input type="text" class="form-control form-control-sm" name="help_' . assoc_h($nick) . '" value="' . assoc_h($f['help'] ?? '') . '"></td>'; style="min-width:10rem;"></td>';
$out .= '<td><span class="civicinfra-affordance">' . assoc_h($type) . '</span></td>';
$out .= '<td><input type="text" class="form-control form-control-sm"
name="group_' . assoc_h($nick) . '"
value="' . assoc_h($f['group'] ?? '') . '"
style="min-width:8rem;"></td>';
$out .= '<td><input type="text" class="form-control form-control-sm"
name="help_' . assoc_h($nick) . '"
value="' . assoc_h($f['help'] ?? '') . '"
style="min-width:14rem;"></td>';
$out .= '<td>'; $out .= '<td>';
if (($f['type'] ?? '') !== 'readonly') { if ($type !== 'readonly') {
$out .= '<button type="submit" form="rm-' . assoc_h($nick) . '" class="btn btn-danger btn-sm" $out .= '<button type="submit" form="rm-' . assoc_h($nick) . '" class="btn btn-danger btn-sm"
onclick="return confirm(\'Remove field ' . assoc_h($nick) . ' from ALL associations? Cannot be undone.\')">Remove</button>'; onclick="return confirm(\'Remove ' . assoc_h($nick) . ' from ALL associations? Cannot be undone.\')">Remove</button>';
} }
$out .= '</td></tr>'; $out .= '</td></tr>';
} }
$out .= '</tbody></table>'; $out .= '</tbody></table></div>';
$out .= '<button type="submit" class="btn btn-primary btn-sm">Save Label/Group Changes</button>'; $out .= '<div class="mt-3"><button type="submit" class="btn btn-primary btn-sm">Save Changes</button></div>';
$out .= '</form>'; $out .= '</form></section>';
// Hidden remove forms
foreach ($fields as $f) { foreach ($fields as $f) {
if (($f['type'] ?? '') === 'readonly') continue; if (($f['type'] ?? '') === 'readonly') continue;
$nick = $f['nickname']; $nick = $f['nickname'];
$out .= '<form id="rm-' . assoc_h($nick) . '" method="post" action="' . z_root() . '/assoc_profile/manage">'; $out .= '<form id="rm-' . assoc_h($nick) . '" method="post" action="' . z_root() . '/assoc_profile/manage" style="display:none;">';
$out .= assoc_csrf_token(); $out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="remove_field">'; $out .= '<input type="hidden" name="assoc_action" value="remove_field">';
$out .= '<input type="hidden" name="remove_nickname" value="' . assoc_h($nick) . '">'; $out .= '<input type="hidden" name="remove_nickname" value="' . assoc_h($nick) . '">';
$out .= '</form>'; $out .= '</form>';
} }
$out .= '<hr class="mt-4"><h5>Add New Field</h5>'; // Add new field
$out .= '<section class="civicinfra-form-section">';
$out .= '<h2 class="civicinfra-record-heading">Add New Field</h2>';
$out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">'; $out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">';
$out .= assoc_csrf_token(); $out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="add_field">'; $out .= '<input type="hidden" name="assoc_action" value="add_field">';
$out .= '<div class="row g-2">'; $out .= '<div class="row g-3">';
$out .= '<div class="col-md-2"><label class="form-label">Nickname</label><input type="text" class="form-control form-control-sm" name="new_nickname" placeholder="assoc_fieldname"></div>'; $out .= '<div class="col-md-2"><label class="form-label">Nickname</label>
$out .= '<div class="col-md-2"><label class="form-label">Label</label><input type="text" class="form-control form-control-sm" name="new_label"></div>'; <input type="text" class="form-control form-control-sm" name="new_nickname" placeholder="assoc_fieldname"></div>';
$out .= '<div class="col-md-2"><label class="form-label">Label</label>
<input type="text" class="form-control form-control-sm" name="new_label"></div>';
$out .= '<div class="col-md-1"><label class="form-label">Type</label> $out .= '<div class="col-md-1"><label class="form-label">Type</label>
<select class="form-select form-select-sm" name="new_type"> <select class="form-select form-select-sm" name="new_type">
<option value="text">text</option> <option value="text">text</option>
<option value="select">select</option> <option value="select">select</option>
</select></div>'; </select></div>';
$out .= '<div class="col-md-2"><label class="form-label">Group</label><input type="text" class="form-control form-control-sm" name="new_group"></div>'; $out .= '<div class="col-md-2"><label class="form-label">Group</label>
$out .= '<div class="col-md-1"><label class="form-label">Default</label><input type="text" class="form-control form-control-sm" name="new_default" placeholder="UNK"></div>'; <input type="text" class="form-control form-control-sm" name="new_group" list="assoc-group-list">';
$out .= '<div class="col-md-2"><label class="form-label">Options <small>(select only, one per line)</small></label><textarea class="form-control form-control-sm" name="new_options" rows="3"></textarea></div>'; $out .= '<datalist id="assoc-group-list">';
$out .= '<div class="col-md-2"><label class="form-label">Help text</label><input type="text" class="form-control form-control-sm" name="new_help"></div>'; foreach ($groups as $g) { $out .= '<option value="' . assoc_h($g) . '">'; }
$out .= '</datalist></div>';
$out .= '<div class="col-md-1"><label class="form-label">Default</label>
<input type="text" class="form-control form-control-sm" name="new_default" placeholder="UNK"></div>';
$out .= '<div class="col-md-2"><label class="form-label">Options <span class="civicinfra-help-text">(select only, one per line)</span></label>
<textarea class="form-control form-control-sm" name="new_options" rows="3"></textarea></div>';
$out .= '<div class="col-md-2"><label class="form-label">Help text</label>
<input type="text" class="form-control form-control-sm" name="new_help"></div>';
$out .= '</div>'; $out .= '</div>';
$out .= '<div class="mt-2"><button type="submit" class="btn btn-success btn-sm">Add Field</button></div>'; $out .= '<div class="mt-3"><button type="submit" class="btn btn-primary btn-sm">Add Field</button></div>';
$out .= '</form>'; $out .= '</form></section>';
$out .= '<hr class="mt-4"><h5>Group Order</h5>'; // Group order
$out .= '<section class="civicinfra-form-section">';
$out .= '<h2 class="civicinfra-record-heading">Group Order</h2>';
$out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">'; $out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">';
$out .= assoc_csrf_token(); $out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="save_fields">'; $out .= '<input type="hidden" name="assoc_action" value="save_fields">';
$out .= '<div class="mb-2"><label class="form-label">Groups <small class="text-muted">(one per line)</small></label>'; $out .= '<div class="mb-3">';
$out .= '<textarea class="form-control form-control-sm" name="groups" rows="' . count($groups) . '">' . assoc_h(implode("\n", $groups)) . '</textarea></div>'; $out .= '<label class="form-label" for="group-order">Groups <span class="civicinfra-help-text">(one per line, in display order)</span></label>';
$out .= '<textarea id="group-order" class="form-control form-control-sm" name="groups" rows="' . count($groups) . '" style="max-width:24rem;font-family:var(--bs-font-monospace)">'
. assoc_h(implode("\n", $groups)) . '</textarea>';
$out .= '</div>';
$out .= '<button type="submit" class="btn btn-primary btn-sm">Save Group Order</button>'; $out .= '<button type="submit" class="btn btn-primary btn-sm">Save Group Order</button>';
$out .= '</form>'; $out .= '</form></section>';
$out .= '<div class="mt-3"><a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary btn-sm">Back</a></div>';
$out .= '</div>'; $out .= '</div>';
return $out; return $out;
} }
// ----------------------------------------------------------------------------
// EXPORT (download JSON)
// ----------------------------------------------------------------------------
function assoc_handle_export($slugs_csv) {
$registry = assoc_load_registry();
$slugs = array_filter(array_map('trim', explode(',', $slugs_csv)));
$export = [];
foreach ($slugs as $slug) {
if (isset($registry[$slug])) {
$export[$slug] = $registry[$slug];
}
}
if (empty($export)) {
return '<div class="civicinfra-notice civicinfra-warning">No matching associations found for export.</div>';
}
$json = json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
$filename = 'assoc-export-' . date('Y-m-d') . '.json';
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($json));
echo $json;
exit;
}
// ----------------------------------------------------------------------------
// IMPORT PAGE
// ----------------------------------------------------------------------------
function assoc_render_import_form() {
$out = '<div class="assoc-manage">';
$out .= '<header class="civicinfra-manage-header">';
$out .= '<h1 class="civicinfra-manage-title">Import Associations</h1>';
$out .= '<div class="civicinfra-manage-actions">';
$out .= '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary btn-sm">&larr; Back</a>';
$out .= '</div></header>';
$out .= '<div class="civicinfra-notice mb-4">';
$out .= 'Upload a JSON export file. New associations will be added. Existing associations with changed data will be shown for your review before any changes are applied.';
$out .= '</div>';
$out .= '<section class="civicinfra-form-section">';
$out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage"
enctype="multipart/form-data">';
$out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="import_upload">';
$out .= '<div class="mb-3">';
$out .= '<label class="form-label" for="import_file">Association registry JSON file</label>';
$out .= '<input type="file" class="form-control form-control-sm" id="import_file"
name="import_file" accept=".json" required style="max-width:24rem;">';
$out .= '</div>';
$out .= '<button type="submit" class="btn btn-primary">Upload and Review</button>';
$out .= '</form></section></div>';
return $out;
}
// ----------------------------------------------------------------------------
// IMPORT — HANDLE UPLOAD AND PRODUCE DIFF
// ----------------------------------------------------------------------------
function assoc_handle_import_upload() {
if (empty($_FILES['import_file']['tmp_name'])) {
return '<div class="civicinfra-notice civicinfra-warning">No file received.</div>';
}
$raw = @file_get_contents($_FILES['import_file']['tmp_name']);
if (!$raw) {
return '<div class="civicinfra-notice civicinfra-warning">Could not read uploaded file.</div>';
}
$incoming = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return '<div class="civicinfra-notice civicinfra-warning">Invalid JSON in uploaded file.</div>';
}
// Strip meta keys
foreach (['_note','_version','_slug_format','_select_values'] as $k) unset($incoming[$k]);
$registry = assoc_load_registry();
$new = [];
$changed = [];
$same = [];
foreach ($incoming as $slug => $entry) {
if (!isset($registry[$slug])) {
$new[$slug] = $entry;
} else {
$diffs = assoc_diff_entry($registry[$slug], $entry);
if (!empty($diffs)) {
$changed[$slug] = ['current' => $registry[$slug], 'incoming' => $entry, 'diffs' => $diffs];
} else {
$same[$slug] = true;
}
}
}
if (empty($new) && empty($changed)) {
return '<div class="civicinfra-notice">No changes found. The uploaded file matches the current registry.</div>'
. '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary btn-sm mt-3">&larr; Back</a>';
}
// Encode the incoming data for the confirmation form
$encoded = base64_encode(json_encode($incoming));
$out = '<div class="assoc-manage">';
$out .= '<header class="civicinfra-manage-header">';
$out .= '<h1 class="civicinfra-manage-title">Review Import</h1>';
$out .= '</header>';
if (!empty($new)) {
$out .= '<section class="civicinfra-form-section mb-4">';
$out .= '<h2 class="civicinfra-record-heading">New — ' . count($new) . ' association' . (count($new) > 1 ? 's' : '') . '</h2>';
$out .= '<p class="civicinfra-help-text">These do not exist in the current registry and will be added.</p>';
$out .= '<ul class="civicinfra-channel-list">';
foreach ($new as $slug => $entry) {
$out .= '<li><span class="civicinfra-channel-label">' . assoc_h($entry['assoc_legal_name'] ?? $slug) . '</span>';
$out .= ' <code class="civicinfra-record-id">' . assoc_h($slug) . '</code></li>';
}
$out .= '</ul></section>';
}
if (!empty($changed)) {
$out .= '<section class="civicinfra-form-section mb-4">';
$out .= '<h2 class="civicinfra-record-heading">Changed — ' . count($changed) . ' association' . (count($changed) > 1 ? 's' : '') . '</h2>';
$out .= '<p class="civicinfra-help-text">These exist in the registry with different values. Choose to import or skip each one.</p>';
$out .= '<div id="assoc-diff-container">';
foreach ($changed as $slug => $data) {
$name = $data['current']['assoc_legal_name'] ?? $slug;
$out .= '<div class="assoc-diff-entry assoc-diff-entry-changed">';
$out .= '<div class="assoc-diff-entry-header">';
$out .= '<span class="civicinfra-channel-label">' . assoc_h($name) . '</span>';
$out .= '<code class="civicinfra-record-id">' . assoc_h($slug) . '</code>';
$out .= '<div class="d-flex gap-2 ms-auto">';
$out .= '<label class="d-flex align-items-center gap-1 fw-normal">
<input type="radio" name="diff_action[]" value="import" data-slug="' . assoc_h($slug) . '" checked> Import changes
</label>';
$out .= '<label class="d-flex align-items-center gap-1 fw-normal">
<input type="radio" name="diff_action[]" value="skip" data-slug="' . assoc_h($slug) . '"> Skip
</label>';
$out .= '</div></div>';
$out .= '<div class="civicinfra-diff-block">';
foreach ($data['diffs'] as $field => $diff) {
$out .= '<span class="civicinfra-diff-remove">' . assoc_h($field) . ': ' . assoc_h($diff['current']) . '</span>';
$out .= '<span class="civicinfra-diff-add">' . assoc_h($field) . ': ' . assoc_h($diff['incoming']) . '</span>';
}
$out .= '</div></div>';
}
$out .= '</div></section>';
}
if (!empty($same)) {
$out .= '<p class="civicinfra-record-meta mb-4">' . count($same) . ' association' . (count($same) > 1 ? 's' : '') . ' unchanged — skipped.</p>';
}
$out .= '<form method="post" action="' . z_root() . '/assoc_profile/manage">';
$out .= assoc_csrf_token();
$out .= '<input type="hidden" name="assoc_action" value="import_confirm">';
$out .= '<input type="hidden" name="import_data" value="' . assoc_h($encoded) . '">';
// Collect diff decisions
foreach ($changed as $slug => $data) {
$out .= '<input type="hidden" name="diff_decisions[' . assoc_h($slug) . ']" value="import" id="diff-decision-' . assoc_h($slug) . '">';
}
$out .= '<div class="d-flex gap-2 mt-2">';
$out .= '<button type="submit" class="btn btn-primary">Apply Import</button>';
$out .= '<a href="' . z_root() . '/assoc_profile/manage/import" class="btn btn-outline-secondary">Upload different file</a>';
$out .= '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary">Cancel</a>';
$out .= '</div></form></div>';
return $out;
}
// ----------------------------------------------------------------------------
// IMPORT — CONFIRM AND APPLY
// ----------------------------------------------------------------------------
function assoc_handle_import_confirm() {
$encoded = $_POST['import_data'] ?? '';
$incoming = json_decode(base64_decode($encoded), true);
if (!$incoming) {
return '<div class="civicinfra-notice civicinfra-warning">Import data missing or corrupted.</div>';
}
$decisions = $_POST['diff_decisions'] ?? [];
$registry = assoc_load_registry();
$added = 0;
$updated = 0;
$skipped = 0;
foreach ($incoming as $slug => $entry) {
if (!isset($registry[$slug])) {
// New — always add
$entry['assoc_registered'] = $entry['assoc_registered'] ?? date('Y-m-d');
$entry['assoc_updated'] = date('Y-m-d');
$registry[$slug] = $entry;
$added++;
} else {
// Existing — check decision
$decision = $decisions[$slug] ?? 'import';
if ($decision === 'skip') {
$skipped++;
} else {
$entry['assoc_updated'] = date('Y-m-d');
$registry[$slug] = $entry;
$updated++;
}
}
}
if (!assoc_write_registry($registry)) {
return '<div class="civicinfra-notice civicinfra-warning">Failed to write registry. Check server logs.</div>';
}
$out = '<div class="civicinfra-notice">';
$out .= '<strong>Import complete.</strong> ';
if ($added) $out .= $added . ' added. ';
if ($updated) $out .= $updated . ' updated. ';
if ($skipped) $out .= $skipped . ' skipped.';
$out .= '</div>';
$out .= '<a href="' . z_root() . '/assoc_profile/manage" class="btn btn-outline-secondary btn-sm mt-3">&larr; Back to Associations</a>';
return $out;
}
// ----------------------------------------------------------------------------
// DIFF HELPER
// ----------------------------------------------------------------------------
function assoc_diff_entry($current, $incoming) {
$diffs = [];
$keys = array_unique(array_merge(array_keys($current), array_keys($incoming)));
foreach ($keys as $key) {
$cv = $current[$key] ?? '';
$iv = $incoming[$key] ?? '';
if ($cv !== $iv) {
$diffs[$key] = ['current' => $cv, 'incoming' => $iv];
}
}
return $diffs;
}

View File

@@ -1,5 +1,14 @@
[template]default[/template] [template]default[/template]
[region=aside]
[widget=vs01][/widget]
[/region]
[region=content] [region=content]
$content $content
[/region] [/region]
[region=right_aside]
[widget=notifications][/widget]
[widget=newmember][/widget]
[/region]