feat: badge print controls — per-template field visibility and edit access via other_json.controls_cfg

Add field_shown() and field_editable() functions driven by event_badge_template.other_json:
  controls_cfg: { shown?: string[], auth_editable?: string[] }

Access rules:
  - No authenticated_access → display-only, no edit buttons shown
  - authenticated only → can edit fields in auth_editable (default: title/affiliations/location/allow_tracking/pronouns)
  - trusted + edit_mode → always sees and edits all fields, ignores config

Each attendee field card (name, title, affiliations, location, allow_tracking, pronouns)
is now wrapped in {#if field_shown()} and its edit button/accordion gated by field_editable().
No backend changes needed — other_json is an existing longtext JSON column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-19 19:04:40 -04:00
parent f628e7e3fc
commit da3b8dcf46

View File

@@ -86,9 +86,9 @@
// --- Access level ---
// trusted_access is true for Trusted and every level above it (Administrator,
// Manager, Super). No need to OR in administrator_access — it's already covered.
// Basic authenticated users (trusted_access === false) can only edit
// professional_title_override, affiliations_override, and location_override.
let is_trusted = $derived($ae_loc.trusted_access === true);
// Minimum bar to edit any field. Anonymous/public users see values but cannot edit.
let is_auth = $derived($ae_loc.authenticated_access === true);
// IMPORTANT: $ae_loc.edit_mode is the GLOBAL AE Edit Mode — a UI preference that
// reveals editable fields, debug info, and advanced options across the whole app.
@@ -98,6 +98,46 @@
// — used here to allow reprinting an already-printed badge when global edit mode is active.
let is_global_edit_mode = $derived($ae_loc.edit_mode === true);
// --- Per-template controls config ---
// Stored in event_badge_template.other_json as { controls_cfg: { shown?, auth_editable? } }.
// shown: array of field keys to render; omit/null = all shown.
// auth_editable: fields editable by authenticated (non-trusted) users; omit/null = defaults below.
// trusted + edit_mode always overrides config — sees and edits all fields.
// WHY: allows locking down a production template so attendees can only touch the
// fields the event coordinator explicitly permits, without touching the backend.
interface ControlsCfg { shown?: string[]; auth_editable?: string[]; }
let template_controls_cfg = $derived((() => {
try {
const parsed = JSON.parse($lq__event_badge_template_obj?.other_json ?? '{}');
return (parsed?.controls_cfg ?? null) as ControlsCfg | null;
} catch { return null; }
})());
// Default auth-editable fields when the template has no explicit config.
// Covers the fields an attendee most commonly needs to fix at the badge table.
const DEFAULT_AUTH_EDITABLE = ['title', 'affiliations', 'location', 'allow_tracking', 'pronouns'];
/** Is this field card shown in the panel at all? trusted+edit always sees all fields. */
function field_shown(field: string): boolean {
if (is_trusted && is_global_edit_mode) return true;
const cfg = template_controls_cfg;
if (!cfg?.shown) return true;
return cfg.shown.includes(field);
}
/**
* Can the current user edit this field?
* - No authenticated_access → never (display only).
* - trusted + edit_mode → always.
* - authenticated only → field must be in template's auth_editable list (or defaults).
*/
function field_editable(field: string): boolean {
if (!is_auth) return false;
if (is_trusted && is_global_edit_mode) return true;
const auth_editable = template_controls_cfg?.auth_editable ?? DEFAULT_AUTH_EDITABLE;
return auth_editable.includes(field);
}
// --- Section collapse state ---
// Attendee fields start open (primary use); staff starts closed to save space.
// Toggling one collapses the other (auto-collapse, same pattern as launcher sections).
@@ -597,7 +637,9 @@
<!-- === NAME ===
Font controls: always visible (all can adjust for readability at kiosk).
Edit form: Trusted+ only — accordion opens only when is_trusted. -->
Edit form: gated by field_editable('name') — by default trusted+edit only;
can be opened to authenticated users via template controls_cfg.auth_editable. -->
{#if field_shown('name')}
<div class="field-card rounded-lg overflow-hidden" class:field-card--active={active_field === 'name'}>
<div class="flex items-center gap-2 px-2 pt-1 pb-0.5">
<div class="flex-1 min-w-0">
@@ -605,10 +647,10 @@
{#if get_display('full_name_override', 'full_name')}
<p class="font-bold leading-snug truncate">{get_display('full_name_override', 'full_name')}</p>
{:else}
<p class="text-gray-400 italic text-xs">{is_trusted ? 'Tap ✎ to add' : 'Not set'}</p>
<p class="text-gray-400 italic text-xs">{field_editable('name') ? 'Tap ✎ to add' : 'Not set'}</p>
{/if}
</div>
{#if is_trusted}
{#if field_editable('name')}
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
@@ -623,9 +665,8 @@
{/if}
</div>
{@render font_ctrl('name')}
<!-- Accordion drawer: grid-template-rows 0fr→1fr animates height smoothly.
Always in DOM, only opens if is_trusted (edit button present above). -->
<div class="ctrl-accordion" class:open={active_field === 'name' && is_trusted}>
<!-- Accordion drawer: grid-template-rows 0fr→1fr animates height smoothly. -->
<div class="ctrl-accordion" class:open={active_field === 'name' && field_editable('name')}>
<div class="ctrl-accordion-inner">
<div id="field-form-name" class="px-2 pb-2 pt-1.5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
{#if is_global_edit_mode}
@@ -655,8 +696,10 @@
</div>
</div>
</div>
{/if}<!-- field_shown: name -->
<!-- === PROFESSIONAL TITLE === -->
{#if field_shown('title')}
<div class="field-card rounded-lg overflow-hidden" class:field-card--active={active_field === 'title'}>
<div class="flex items-center gap-2 px-2 pt-1 pb-0.5">
<div class="flex-1 min-w-0">
@@ -667,20 +710,22 @@
<p class="text-gray-400 italic text-xs">Tap ✎ to add</p>
{/if}
</div>
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('title')}
aria-expanded={active_field === 'title'}
aria-controls="field-form-title"
title="Edit professional title"
aria-label="Edit professional title"
>
{#if active_field === 'title'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{#if field_editable('title')}
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('title')}
aria-expanded={active_field === 'title'}
aria-controls="field-form-title"
title="Edit professional title"
aria-label="Edit professional title"
>
{#if active_field === 'title'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{/if}
</div>
{@render font_ctrl('title')}
<div class="ctrl-accordion" class:open={active_field === 'title'}>
<div class="ctrl-accordion" class:open={active_field === 'title' && field_editable('title')}>
<div class="ctrl-accordion-inner">
<div id="field-form-title" class="px-2 pb-2 pt-1.5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
{#if is_global_edit_mode}
@@ -708,8 +753,10 @@
</div>
</div>
</div>
{/if}<!-- field_shown: title -->
<!-- === AFFILIATIONS === -->
{#if field_shown('affiliations')}
<div class="field-card rounded-lg overflow-hidden" class:field-card--active={active_field === 'affiliations'}>
<div class="flex items-center gap-2 px-2 pt-1 pb-0.5">
<div class="flex-1 min-w-0">
@@ -720,20 +767,22 @@
<p class="text-gray-400 italic text-xs">Tap ✎ to add</p>
{/if}
</div>
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('affiliations')}
aria-expanded={active_field === 'affiliations'}
aria-controls="field-form-affiliations"
title="Edit affiliations"
aria-label="Edit affiliations"
>
{#if active_field === 'affiliations'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{#if field_editable('affiliations')}
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('affiliations')}
aria-expanded={active_field === 'affiliations'}
aria-controls="field-form-affiliations"
title="Edit affiliations"
aria-label="Edit affiliations"
>
{#if active_field === 'affiliations'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{/if}
</div>
{@render font_ctrl('affiliations')}
<div class="ctrl-accordion" class:open={active_field === 'affiliations'}>
<div class="ctrl-accordion" class:open={active_field === 'affiliations' && field_editable('affiliations')}>
<div class="ctrl-accordion-inner">
<div id="field-form-affiliations" class="px-2 pb-2 pt-1.5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
{#if is_global_edit_mode}
@@ -761,8 +810,10 @@
</div>
</div>
</div>
{/if}<!-- field_shown: affiliations -->
<!-- === LOCATION === -->
{#if field_shown('location')}
<div class="field-card rounded-lg overflow-hidden" class:field-card--active={active_field === 'location'}>
<div class="flex items-center gap-2 px-2 pt-1 pb-0.5">
<div class="flex-1 min-w-0">
@@ -773,20 +824,22 @@
<p class="text-gray-400 italic text-xs">Tap ✎ to add</p>
{/if}
</div>
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('location')}
aria-expanded={active_field === 'location'}
aria-controls="field-form-location"
title="Edit location"
aria-label="Edit location"
>
{#if active_field === 'location'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{#if field_editable('location')}
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('location')}
aria-expanded={active_field === 'location'}
aria-controls="field-form-location"
title="Edit location"
aria-label="Edit location"
>
{#if active_field === 'location'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{/if}
</div>
{@render font_ctrl('location')}
<div class="ctrl-accordion" class:open={active_field === 'location'}>
<div class="ctrl-accordion" class:open={active_field === 'location' && field_editable('location')}>
<div class="ctrl-accordion-inner">
<div id="field-form-location" class="px-2 pb-2 pt-1.5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
{#if is_global_edit_mode}
@@ -814,8 +867,10 @@
</div>
</div>
</div>
{/if}<!-- field_shown: location -->
<!-- === ALLOW TRACKING (Lead Scanning) === -->
{#if field_shown('allow_tracking')}
<div class="field-card rounded-lg overflow-hidden" class:field-card--active={active_field === 'allow_tracking'}>
<div class="flex items-center gap-2 px-2 py-1">
<div class="flex-1 min-w-0">
@@ -824,19 +879,21 @@
{$lq__event_badge_obj?.allow_tracking ? 'Allowed' : 'Not allowed'}
</p>
</div>
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('allow_tracking')}
aria-expanded={active_field === 'allow_tracking'}
aria-controls="field-form-allow-tracking"
title="Edit lead scanning preference"
aria-label="Edit lead scanning"
>
{#if active_field === 'allow_tracking'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{#if field_editable('allow_tracking')}
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('allow_tracking')}
aria-expanded={active_field === 'allow_tracking'}
aria-controls="field-form-allow-tracking"
title="Edit lead scanning preference"
aria-label="Edit lead scanning"
>
{#if active_field === 'allow_tracking'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{/if}
</div>
<div class="ctrl-accordion" class:open={allow_tracking_open}>
<div class="ctrl-accordion" class:open={allow_tracking_open && field_editable('allow_tracking')}>
<div class="ctrl-accordion-inner">
<div id="field-form-allow-tracking" class="px-2 pb-2 pt-1.5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<label class="flex items-center gap-2 cursor-pointer select-none">
@@ -898,8 +955,10 @@
</div>
</div>
</div>
{/if}<!-- field_shown: allow_tracking -->
<!-- === PRONOUNS === -->
{#if field_shown('pronouns')}
<div class="field-card rounded-lg overflow-hidden" class:field-card--active={active_field === 'pronouns'}>
<div class="flex items-center gap-2 px-2 py-1">
<div class="flex-1 min-w-0">
@@ -910,19 +969,21 @@
<p class="text-gray-400 italic text-xs">Tap ✎ to add</p>
{/if}
</div>
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('pronouns')}
aria-expanded={active_field === 'pronouns'}
aria-controls="field-form-pronouns"
title="Edit pronouns"
aria-label="Edit pronouns"
>
{#if active_field === 'pronouns'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{#if field_editable('pronouns')}
<button
type="button"
class="btn btn-xs preset-tonal-primary shrink-0 transition-colors"
onclick={() => toggle_field('pronouns')}
aria-expanded={active_field === 'pronouns'}
aria-controls="field-form-pronouns"
title="Edit pronouns"
aria-label="Edit pronouns"
>
{#if active_field === 'pronouns'}<ChevronDown size="12" />{:else}<Pencil size="12" />{/if}
</button>
{/if}
</div>
<div class="ctrl-accordion" class:open={active_field === 'pronouns'}>
<div class="ctrl-accordion" class:open={active_field === 'pronouns' && field_editable('pronouns')}>
<div class="ctrl-accordion-inner">
<div id="field-form-pronouns" class="px-2 pb-2 pt-1.5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<input
@@ -947,6 +1008,7 @@
</div>
</div>
</div>
{/if}<!-- field_shown: pronouns -->
</div><!-- ctrl-accordion-inner attendee -->
</div><!-- ctrl-accordion attendee -->