badges(config): fix duplicate keys and initialize draft when mod_badges_json missing; update settings button style

This commit is contained in:
Scott Idem
2026-04-02 17:06:23 -04:00
parent 1935564645
commit 4a5b4bf7cd
2 changed files with 504 additions and 38 deletions

View File

@@ -0,0 +1,472 @@
<script lang="ts">
/**
* Badges Config Page
* Route: /events/[event_id]/(badges)/badges/config
*
* Admin UI for managing event.mod_badges_json (BadgesRemoteCfg).
* Access: administrator_access only (passcode fields present — stricter than pres_mgmt).
*
* Save pattern: load → merge draft → PATCH event via V3 API.
*/
import { untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
import { events_slct } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { api } from '$lib/api/api';
import type { BadgesRemoteCfg } from '$lib/stores/ae_events_stores__badges_defaults';
import {
default_authenticated_can_edit,
default_trusted_can_edit
} from '$lib/stores/ae_events_stores__badges_defaults';
import {
AlertTriangle,
ArrowLeft,
Check,
ChevronDown,
ChevronUp,
Lock,
Save,
Settings
} from '@lucide/svelte';
interface Props {
data: any;
}
let { data }: Props = $props();
let event_id = $derived($events_slct.event_id ?? '');
let lq__event_obj = $derived(
liveQuery(async () => {
if (!event_id) return null;
return await db_events.event.get(event_id);
})
);
// ---------------------------------------------------------------------------
// Known editable field keys (used to render checkboxes in permission sections)
// ---------------------------------------------------------------------------
const attendee_field_options: { key: string; label: string }[] = [
{ key: 'pronouns_override', label: 'Pronouns' },
{ key: 'full_name_override', label: 'Full Name' },
{ key: 'professional_title_override', label: 'Professional Title' },
{ key: 'affiliations_override', label: 'Affiliations' },
{ key: 'phone_override', label: 'Phone' },
{ key: 'location_override', label: 'Location' },
{ key: 'allow_tracking', label: 'Allow Tracking (Exhibitor Leads)' },
{ key: 'agree_to_tc', label: 'Agree to Terms & Conditions (placeholder)' }
];
const staff_extra_field_options: { key: string; label: string }[] = [
{ key: 'email_override', label: 'Email' },
{ key: 'badge_type_code_override', label: 'Badge Type Code' },
{ key: 'registration_type_code_override', label: 'Registration Type Code' },
{ key: 'other_1_code', label: 'Option 1' },
{ key: 'other_2_code', label: 'Option 2' },
{ key: 'other_3_code', label: 'Option 3' },
{ key: 'other_4_code', label: 'Option 4' },
{ key: 'other_5_code', label: 'Option 5' },
{ key: 'other_6_code', label: 'Option 6' },
{ key: 'other_7_code', label: 'Option 7' },
{ key: 'other_8_code', label: 'Option 8' },
{ key: 'ticket_1_code', label: 'Ticket 1' },
{ key: 'ticket_2_code', label: 'Ticket 2' },
{ key: 'ticket_3_code', label: 'Ticket 3' },
{ key: 'ticket_4_code', label: 'Ticket 4' },
{ key: 'ticket_5_code', label: 'Ticket 5' },
{ key: 'ticket_6_code', label: 'Ticket 6' },
{ key: 'ticket_7_code', label: 'Ticket 7' },
{ key: 'ticket_8_code', label: 'Ticket 8' },
{ key: 'allow_tracking', label: 'Allow Tracking' },
{ key: 'agree_to_tc', label: 'Agree to Terms (placeholder)' },
{ key: 'hide', label: 'Hide (archive)' },
{ key: 'priority', label: 'Priority' },
{ key: 'notes', label: 'Notes' }
];
// All field options shown in the staff section (attendee fields first, then extras).
// Exclude agree_to_tc and allow_tracking from the attendee slice — both appear in
// staff_extra_field_options, so keeping them here would create duplicate keys.
const all_staff_field_options = [
...attendee_field_options.filter((f) => f.key !== 'agree_to_tc' && f.key !== 'allow_tracking'),
...staff_extra_field_options
];
// ---------------------------------------------------------------------------
// Draft state — initialized from the live event config
// ---------------------------------------------------------------------------
const cfg_defaults: BadgesRemoteCfg = {
badge_id_only_search: false,
enable_mass_print: false,
enable_add_badge_btn: false,
enable_upload_badge_li_btn: false,
enable_search_qr: false,
qr_type: null,
trusted_passcode: null,
administrator_passcode: null,
edit_permissions: {
authenticated: { can_edit: [...default_authenticated_can_edit] },
trusted: { can_edit: [...default_trusted_can_edit] },
administrator: { can_edit: '*' }
}
};
let draft: BadgesRemoteCfg = $state({ ...cfg_defaults });
// edit_permissions is a nested object — deep-clone it separately
let draft_auth_fields: string[] = $state([...default_authenticated_can_edit]);
let draft_trusted_fields: string[] = $state([...default_trusted_can_edit]);
let draft_initialized = $state(false);
let initial_json = $state('');
$effect(() => {
// Initialize once the event object is available — even if mod_badges_json is null/unset,
// so admins can configure a fresh event rather than getting stuck on "Loading...".
const event_obj = $lq__event_obj;
if (event_obj && !draft_initialized) {
const event_cfg = event_obj.mod_badges_json;
untrack(() => {
draft = { ...cfg_defaults, ...(event_cfg ?? {}) };
// Normalize edit_permissions to ensure sub-objects exist
draft.edit_permissions = {
authenticated: event_cfg?.edit_permissions?.authenticated ?? {
can_edit: [...default_authenticated_can_edit]
},
trusted: event_cfg?.edit_permissions?.trusted ?? {
can_edit: [...default_trusted_can_edit]
},
administrator: { can_edit: '*' }
};
const auth_cfg = draft.edit_permissions.authenticated?.can_edit;
draft_auth_fields = Array.isArray(auth_cfg)
? [...auth_cfg]
: [...default_authenticated_can_edit];
const trusted_cfg = draft.edit_permissions.trusted?.can_edit;
draft_trusted_fields = Array.isArray(trusted_cfg)
? [...trusted_cfg]
: [...default_trusted_can_edit];
initial_json = JSON.stringify({
...draft,
_auth: draft_auth_fields,
_trusted: draft_trusted_fields
});
draft_initialized = true;
});
}
});
let is_dirty = $derived(
draft_initialized &&
JSON.stringify({
...draft,
_auth: draft_auth_fields,
_trusted: draft_trusted_fields
}) !== initial_json
);
// Toggle a field in the auth checkbox list
function toggle_auth_field(key: string) {
if (draft_auth_fields.includes(key)) {
draft_auth_fields = draft_auth_fields.filter((f) => f !== key);
} else {
draft_auth_fields = [...draft_auth_fields, key];
}
}
// Toggle a field in the trusted checkbox list
function toggle_trusted_field(key: string) {
if (draft_trusted_fields.includes(key)) {
draft_trusted_fields = draft_trusted_fields.filter((f) => f !== key);
} else {
draft_trusted_fields = [...draft_trusted_fields, key];
}
}
// ---------------------------------------------------------------------------
// Save
// ---------------------------------------------------------------------------
let save_status: 'idle' | 'saving' | 'success' | 'error' = $state('idle');
async function save() {
if (!event_id) return;
save_status = 'saving';
try {
const payload: BadgesRemoteCfg = {
...draft,
edit_permissions: {
authenticated: { can_edit: draft_auth_fields },
trusted: { can_edit: draft_trusted_fields },
administrator: { can_edit: '*' }
}
};
await api.update_ae_obj({
api_cfg: $ae_api,
obj_type: 'event',
obj_id: event_id,
fields: { mod_badges_json: payload },
log_lvl: 1
});
await events_func.load_ae_obj_id__event({
api_cfg: $ae_api,
event_id: event_id,
log_lvl: 1
});
initial_json = JSON.stringify({
...draft,
_auth: draft_auth_fields,
_trusted: draft_trusted_fields
});
save_status = 'success';
setTimeout(() => (save_status = 'idle'), 3000);
} catch (e) {
console.error('Failed to save badges config', e);
save_status = 'error';
setTimeout(() => (save_status = 'idle'), 5000);
}
}
// Section collapse state
let sections: Record<string, boolean> = $state({
ui: true,
qr: true,
passcodes: true,
auth_fields: true,
trusted_fields: true
});
function toggle(key: string) {
sections[key] = !sections[key];
}
</script>
<svelte:head>
<title>Badges Config</title>
</svelte:head>
{#if !$ae_loc.administrator_access}
<div class="p-8 text-center opacity-50">
<Lock size="3em" class="mx-auto mb-2" />
<p>Administrator access required.</p>
</div>
{:else}
<div class="mx-auto w-full max-w-3xl space-y-4 px-2 py-4">
<!-- Header -->
<header class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<a href="/events/{event_id}/badges"
class="btn btn-sm preset-tonal-surface"
title="Back to Badges">
<ArrowLeft size="1em" />
</a>
<Settings size="1.2em" class="text-primary-500" />
<h1 class="text-xl font-bold">Badges Config</h1>
</div>
<div class="flex items-center gap-2">
{#if save_status === 'success'}
<span class="badge preset-tonal-success flex items-center gap-1">
<Check size="1em" /> Saved
</span>
{:else if save_status === 'error'}
<span class="badge preset-tonal-error flex items-center gap-1">
<AlertTriangle size="1em" /> Error
</span>
{/if}
<button
type="button"
class="btn preset-filled-primary-500"
onclick={save}
disabled={!is_dirty || save_status === 'saving'}>
<Save size="1em" class="mr-1" />
{save_status === 'saving' ? 'Saving...' : 'Save'}
</button>
</div>
</header>
<p class="text-surface-500 text-sm">
Changes here update <code>event.mod_badges_json</code> and take effect on the
next page load. Field permission changes affect which badge fields each access
level may edit on the Badge Review page.
</p>
{#if !draft_initialized}
<p class="text-surface-400 italic">Loading event config...</p>
{:else}
<!-- ================================================================ -->
<!-- SEARCH & UI -->
<!-- ================================================================ -->
<section class="border-surface-200-800 rounded-xl border">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold"
onclick={() => toggle('ui')}>
<span>Search &amp; UI Controls</span>
{#if sections.ui}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections.ui}
<div class="border-surface-200-800 grid grid-cols-2 gap-3 border-t px-4 py-3">
{#each [
{ field: 'badge_id_only_search' as const, label: 'Badge ID Only Search' },
{ field: 'enable_mass_print' as const, label: 'Enable Mass Print' },
{ field: 'enable_add_badge_btn' as const, label: 'Enable Add Badge Button' },
{ field: 'enable_upload_badge_li_btn' as const, label: 'Enable Upload Badge List Button' },
{ field: 'enable_search_qr' as const, label: 'Enable QR Scan Search' }
] as item (item.field)}
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox"
bind:checked={draft[item.field]} />
<span class="text-sm">{item.label}</span>
</label>
{/each}
</div>
{/if}
</section>
<!-- ================================================================ -->
<!-- QR CONFIG -->
<!-- ================================================================ -->
<section class="border-surface-200-800 rounded-xl border">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold"
onclick={() => toggle('qr')}>
<span>QR Code Config</span>
{#if sections.qr}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections.qr}
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">QR Type</span>
<input
type="text"
class="input input-sm"
placeholder="e.g. badge_id, url (leave blank for default)"
bind:value={draft.qr_type} />
<span class="text-surface-400 text-xs">
Controls the QR payload format. Verify against the scan component before changing.
</span>
</label>
</div>
{/if}
</section>
<!-- ================================================================ -->
<!-- PASSCODES -->
<!-- ================================================================ -->
<section class="border-surface-200-800 rounded-xl border">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold"
onclick={() => toggle('passcodes')}>
<span class="flex items-center gap-2">
<Lock size="1em" class="text-warning-500" />
Access Passcodes
<span class="text-surface-400 text-xs font-normal">(administrator only)</span>
</span>
{#if sections.passcodes}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections.passcodes}
<div class="border-surface-200-800 grid grid-cols-1 gap-3 border-t px-4 py-3 sm:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Trusted Staff Passcode</span>
<input
type="text"
class="input input-sm font-mono"
placeholder="leave blank to disable"
bind:value={draft.trusted_passcode} />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Administrator Passcode</span>
<input
type="text"
class="input input-sm font-mono"
placeholder="leave blank to disable"
bind:value={draft.administrator_passcode} />
</label>
</div>
{/if}
</section>
<!-- ================================================================ -->
<!-- ATTENDEE EDITABLE FIELDS -->
<!-- ================================================================ -->
<section class="border-surface-200-800 rounded-xl border">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold"
onclick={() => toggle('auth_fields')}>
<span>
Attendee Editable Fields
<span class="text-surface-400 text-xs font-normal">(passcode-authenticated)</span>
</span>
{#if sections.auth_fields}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections.auth_fields}
<div class="border-surface-200-800 grid grid-cols-2 gap-2 border-t px-4 py-3">
{#each attendee_field_options as field (field.key)}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="checkbox"
checked={draft_auth_fields.includes(field.key)}
onchange={() => toggle_auth_field(field.key)} />
<span>{field.label}</span>
</label>
{/each}
</div>
{/if}
</section>
<!-- ================================================================ -->
<!-- STAFF EDITABLE FIELDS -->
<!-- ================================================================ -->
<section class="border-surface-200-800 rounded-xl border">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold"
onclick={() => toggle('trusted_fields')}>
<span>
Staff Editable Fields
<span class="text-surface-400 text-xs font-normal">(trusted access)</span>
</span>
{#if sections.trusted_fields}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
</button>
{#if sections.trusted_fields}
<div class="border-surface-200-800 grid grid-cols-2 gap-2 border-t px-4 py-3">
{#each all_staff_field_options as field (field.key)}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="checkbox"
checked={draft_trusted_fields.includes(field.key)}
onchange={() => toggle_trusted_field(field.key)} />
<span>{field.label}</span>
</label>
{/each}
<p class="col-span-2 mt-1 text-xs text-surface-400 italic">
Administrators always have access to all fields — not configurable here.
</p>
</div>
{/if}
</section>
<!-- Bottom save button -->
<div class="flex justify-end">
<button
type="button"
class="btn preset-filled-primary-500"
onclick={save}
disabled={!is_dirty || save_status === 'saving'}>
<Save size="1em" class="mr-1" />
{save_status === 'saving' ? 'Saving...' : 'Save Config'}
</button>
</div>
{/if}
</div>
{/if}

View File

@@ -17,7 +17,6 @@ import { ae_loc, ae_api } from '$lib/stores/ae_stores';
import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte';
import Ae_comp_event_settings_form from './ae_comp__event_settings_form.svelte';
import Ae_comp_event_settings_basic_form from './ae_comp__event_settings_basic_form.svelte';
import Ae_comp_event_settings_badges_form from './ae_comp__event_settings_badges_form.svelte';
import Ae_comp_event_settings_abstracts_form from './ae_comp__event_settings_abstracts_form.svelte';
import { Modal } from 'flowbite-svelte';
import Comp_badge_create_form from '../(badges)/badges/ae_comp__badge_create_form.svelte';
@@ -26,7 +25,6 @@ import Comp_badge_upload_form from '../(badges)/badges/ae_comp__badge_upload_for
let event_id = page.params.event_id as string;
let event_obj: Event | undefined | null = $state(null);
let cfg_json_view = $state('form');
let badges_json_view = $state('form');
let abstracts_json_view = $state('form');
// Temp string values for CodeMirror binding
@@ -273,42 +271,38 @@ async function handle_save(field_name: string, data: any) {
<details class="details">
<summary class="summary">Badges (mod_badges_json)</summary>
<div class="p-4">
<div class="flex justify-end">
<button
type="button"
class="btn btn-sm"
onclick={() => (badges_json_view = 'form')}
>Form</button>
<button
type="button"
class="btn btn-sm"
onclick={() => (badges_json_view = 'json')}
>JSON</button>
</div>
{#if badges_json_view === 'form'}
<Ae_comp_event_settings_badges_form
bind:mod_badges_json={event_obj.mod_badges_json}
onsave={(data: any) =>
handle_save('mod_badges_json', data)} />
{:else}
<AE_Comp_Editor_CodeMirror
readonly={false}
content={tmp_badges_json_str}
bind:new_content={tmp_badges_json_str}
show_line_numbers={true}
placeholder="JSON config"
class_li="p-1 preset-outlined-success-400-600 shadow-lg rounded-lg" />
<button
type="button"
class="btn preset-tonal-primary"
onclick={() => {
handle_save(
'mod_badges_json',
tmp_badges_json_str
);
}}>Save</button>
{/if}
<div class="p-4 space-y-3">
<p class="text-sm text-surface-500">
Manage badge search controls, QR config, access passcodes, and
per-event field edit permissions.
</p>
<a
href="/events/{event_id}/badges/config"
class="btn preset-tonal-primary">
Go to Badges Config →
</a>
<!-- Raw JSON fallback for debugging / emergency edits -->
<details class="mt-2">
<summary class="cursor-pointer text-xs text-surface-400">Raw JSON (advanced)</summary>
<div class="mt-2 space-y-2">
<AE_Comp_Editor_CodeMirror
readonly={false}
content={tmp_badges_json_str}
bind:new_content={tmp_badges_json_str}
show_line_numbers={true}
placeholder="JSON config"
class_li="p-1 preset-outlined-success-400-600 shadow-lg rounded-lg" />
<button
type="button"
class="btn preset-tonal-primary"
onclick={() => {
handle_save(
'mod_badges_json',
tmp_badges_json_str
);
}}>Save Raw JSON</button>
</div>
</details>
</div>
</details>