feat(badges): print/review pages, 4-button list, Lucide icons, permissions doc
Badge search results list (ae_comp__badge_obj_li): - 4 action buttons per row: Print, Review (nav link), Copy Link (clipboard), Email Link - Visibility rules: unprinted-only for non-edit mode; all non-hidden for trusted+edit - Plain name display (User/EyeOff icon) — name is no longer a print link - Obscured email for non-trusted users - Debug row (ID, CR, UP, PC, FP, LP) in edit mode - All icons converted to Lucide (Font Awesome removed) Badge print page (/print): - 3 header action buttons: Print Now, Review (nav), Email Link - Removed old [badge_id]/+page.svelte placeholder (moved to trash) - Added is_trusted, is_edit_mode, print state derived vars - "Already printed Nx — last [timestamp]" warning inline with name - Removed unused imports (browser, onMount, events_slct) Badge review page (/review): - 3 header action buttons: Print (nav), Copy Link (clipboard), Email Link - Added events_loc for email placeholder + title event name - Added is_edit_mode, print_count, is_printed, copy_status - FA icons replaced with Lucide (ShieldCheck, UserCheck, User) - Title now includes event name (was missing) Infrastructure: - print/+page.ts and review/+page.ts added (non-blocking badge loaders) - ae_comp__badge_review_form.svelte stub created (fields pending) - Fixed: components no longer write to $ae_loc.edit_mode (critical bug) Docs: - NEW: AE__Permissions_and_Security.md — full permissions hierarchy reference - NEW: PROJECT__AE_Events_Badges_Review_Print.md — agent task brief for review form + print font controls - UPDATED: MODULE__AE_Events_Badges.md rev 5 — field permissions spec, header buttons, still-needed list by priority Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
# Aether (AE) Event Badges Module (v3)
|
||||
# Aether (AE) Events - Badges Module (v3)
|
||||
|
||||
This directory contains the files for the new Event Badges module (v3). Detailed documentation to follow.
|
||||
This directory contains the files for the new Event Badges module (v3).
|
||||
|
||||
Detailed documentation can be found here:
|
||||
@documentation/MODULE__AE_Events_Badges.md
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
interface Props {
|
||||
/** @type {import('./$types').PageData} */
|
||||
data: any;
|
||||
log_lvl?: number;
|
||||
}
|
||||
|
||||
let { data, log_lvl = 0 }: Props = $props();
|
||||
|
||||
// *** Import Svelte specific
|
||||
// import { goto } from '$app/navigation';
|
||||
|
||||
// *** Import other supporting libraries
|
||||
import { browser } from '$app/environment';
|
||||
import { liveQuery } from 'dexie';
|
||||
|
||||
// *** Import Aether specific variables and functions
|
||||
// import type { key_val } from '$lib/ae_stores';
|
||||
// import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
// import { ae_snip, ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/ae_stores';
|
||||
import {
|
||||
events_loc,
|
||||
events_sess,
|
||||
events_slct,
|
||||
events_trigger
|
||||
} from '$lib/stores/ae_events_stores';
|
||||
// import { events_func } from '$lib/ae_events_functions';
|
||||
|
||||
import Comp_badge_obj_view from './ae_comp__badge_obj_view.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { LoaderCircle } from 'lucide-svelte';
|
||||
|
||||
// *** Variables
|
||||
// Use page.params for robust reactivity in Svelte 5
|
||||
let event_badge_id = $derived(page.params.badge_id);
|
||||
|
||||
// Track if we are waiting for initial IDB result
|
||||
let is_loading_idb = $state(true);
|
||||
|
||||
// let url_test_val = $derived(data.url.searchParams.get('test_val'));
|
||||
// $effect(() => {
|
||||
// console.log(`URL test_val = ${url_test_val}`);
|
||||
// });
|
||||
|
||||
let lq__event_badge_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!event_badge_id) return null;
|
||||
if (log_lvl) {
|
||||
console.log(
|
||||
`*** LiveQuery: lq__event_badge_obj *** event_badge_id=${event_badge_id}`
|
||||
);
|
||||
}
|
||||
let results = await db_events.badge.get(event_badge_id);
|
||||
|
||||
if (log_lvl) {
|
||||
console.log(
|
||||
`*** LiveQuery: lq__event_badge_obj *** results=`,
|
||||
results
|
||||
);
|
||||
}
|
||||
return results;
|
||||
})
|
||||
);
|
||||
|
||||
// SIDE EFFECT: Update loading state when the observable value changes
|
||||
$effect(() => {
|
||||
if ($lq__event_badge_obj !== undefined) {
|
||||
untrack(() => is_loading_idb = false);
|
||||
}
|
||||
});
|
||||
|
||||
let lq__event_badge_template_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
let results = await db_events.badge_template.get(
|
||||
$lq__event_badge_obj?.event_badge_template_id ?? ''
|
||||
); // null or undefined does not reset things like '' does
|
||||
|
||||
if (log_lvl) {
|
||||
console.log(
|
||||
`*** LiveQuery: lq__event_badge_template_obj *** event_badge_template_id=${
|
||||
$lq__event_badge_obj?.event_badge_template_id ?? ''
|
||||
}`,
|
||||
results
|
||||
);
|
||||
}
|
||||
|
||||
// Check if results are different than the current session version stored under $events_slct
|
||||
// if ($events_slct.event_badge_obj && results) {
|
||||
// if (JSON.stringify($events_slct.event_badge_obj) !== JSON.stringify(results)) {
|
||||
// $events_slct.event_badge_obj = { ...results };
|
||||
// }
|
||||
// }
|
||||
|
||||
return results;
|
||||
})
|
||||
);
|
||||
|
||||
let is_review_mode: boolean = $state(false);
|
||||
|
||||
// *** Functions and Logic
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
let lq__event_obj: any = $state(undefined);
|
||||
|
||||
onMount(() => {
|
||||
const observable = liveQuery(() =>
|
||||
db_events.event.get($events_slct?.event_id ?? '')
|
||||
);
|
||||
const subscription = observable.subscribe((value) => {
|
||||
lq__event_obj = value;
|
||||
});
|
||||
|
||||
if (browser && window.location.hash === '#review') {
|
||||
is_review_mode = true;
|
||||
$ae_loc.edit_mode = true;
|
||||
} else {
|
||||
is_review_mode = false;
|
||||
$ae_loc.edit_mode = false;
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
Æ: Badge -
|
||||
{$lq__event_badge_obj?.given_name ?? '-- not set --'}
|
||||
{$lq__event_badge_obj?.family_name
|
||||
? $lq__event_badge_obj?.family_name.charAt(0) + '.'
|
||||
: ''}
|
||||
- Badges v3 -
|
||||
{$events_loc?.title}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- badge ID +page: Where is here??? -->
|
||||
|
||||
<!-- event {data.params.event_id} / badge {data.params.badge_id} -->
|
||||
|
||||
{#if $lq__event_badge_obj}
|
||||
<header
|
||||
class="
|
||||
w-full
|
||||
flex flex-row gap-1 items-center justify-between
|
||||
border-b border-gray-300
|
||||
mb-2
|
||||
pb-2
|
||||
"
|
||||
>
|
||||
<h2 class="text-2xl font-bold">
|
||||
Badge:
|
||||
{#if $lq__event_badge_obj.full_name}
|
||||
{$lq__event_badge_obj.full_name}
|
||||
{:else if $lq__event_badge_obj.given_name}
|
||||
{$lq__event_badge_obj.given_name}
|
||||
{:else}
|
||||
-- no name --
|
||||
{/if}
|
||||
</h2>
|
||||
<a
|
||||
href={`/events/${$lq__event_badge_obj.event_id}/badges`}
|
||||
class="text-sm italic text-blue-600 hover:underline"
|
||||
>
|
||||
<span class="fas fa-search"></span>
|
||||
Back to Search
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{#if $lq__event_badge_obj && $lq__event_badge_obj.event_id && event_badge_id}
|
||||
<Comp_badge_obj_view
|
||||
event_id={$lq__event_badge_obj.event_id as string}
|
||||
event_badge_id={event_badge_id as string}
|
||||
{lq__event_badge_obj}
|
||||
{is_review_mode}
|
||||
{lq__event_badge_template_obj}
|
||||
/>
|
||||
{/if}
|
||||
{:else if is_loading_idb || !event_badge_id}
|
||||
<div class="flex flex-col items-center justify-center p-20 gap-4 opacity-50">
|
||||
<LoaderCircle size="3em" class="animate-spin" />
|
||||
<p class="text-xl font-bold text-center">Loading Badge Details...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card p-8 variant-soft-error border border-error-500/30 text-center space-y-4">
|
||||
<h2 class="h2 font-black text-error-500">Badge Not Found</h2>
|
||||
<p class="opacity-70">No record found locally for ID: <span class="font-mono">{event_badge_id}</span></p>
|
||||
<button class="btn variant-filled-primary" onclick={() => window.location.reload()}>
|
||||
<span class="fas fa-sync mr-2"></span> Force Refresh
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -125,12 +125,12 @@
|
||||
editable_badge_type_code =
|
||||
$lq__event_badge_obj.badge_type_code ?? null;
|
||||
|
||||
// Only set the local edit state — never touch $ae_loc.edit_mode here.
|
||||
// $ae_loc.edit_mode is a user preference; this component must not override it.
|
||||
if (is_review_mode) {
|
||||
edit_mode_active = true;
|
||||
$ae_loc.edit_mode = true;
|
||||
} else {
|
||||
edit_mode_active = false; // Ensure it starts off if not in review mode
|
||||
$ae_loc.edit_mode = false;
|
||||
edit_mode_active = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -420,7 +420,6 @@
|
||||
update_complete = true;
|
||||
if (!is_review_mode) {
|
||||
edit_mode_active = false;
|
||||
$ae_loc.edit_mode = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -437,7 +436,6 @@
|
||||
update_complete = true;
|
||||
if (!is_review_mode) {
|
||||
edit_mode_active = false;
|
||||
$ae_loc.edit_mode = false;
|
||||
}
|
||||
// Optionally, refresh the $lq__event_badge_obj if needed, though Dexie might handle it
|
||||
} catch (error) {
|
||||
@@ -469,7 +467,6 @@
|
||||
}
|
||||
if (!is_review_mode) {
|
||||
edit_mode_active = false;
|
||||
$ae_loc.edit_mode = false;
|
||||
}
|
||||
update_status = 'idle';
|
||||
update_complete = true;
|
||||
@@ -509,6 +506,9 @@
|
||||
print_status = 'done';
|
||||
console.log(`Badge printed. Count: ${data_to_update.print_count}`);
|
||||
|
||||
// Trigger browser print dialog (records the print count first so it's always logged)
|
||||
if (browser) window.print();
|
||||
|
||||
// Brief success flash, then return to badge search
|
||||
setTimeout(() => {
|
||||
goto(`/events/${event_id}/badges`);
|
||||
@@ -658,7 +658,6 @@ onkeypress={() => {
|
||||
"
|
||||
onclick={() => {
|
||||
edit_mode_active = true;
|
||||
$ae_loc.edit_mode = true;
|
||||
}}
|
||||
title="Edit Badge Information"
|
||||
data-testid="badge-edit-btn"
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Badge Review Form
|
||||
*
|
||||
* A form-based view of badge fields for attendees and staff to review/edit.
|
||||
* Does NOT render the badge layout — that is handled by ae_comp__badge_obj_view.svelte.
|
||||
*
|
||||
* Access:
|
||||
* - Attendees (passcode-validated): can edit fields listed in can_edit_fields
|
||||
* - Trusted staff: can_edit_fields + additional staff-only fields
|
||||
* - Administrators: all override fields
|
||||
*
|
||||
* Field display is driven by the `can_edit_fields` prop, which comes from
|
||||
* event.mod_badges_json.edit_permissions (see ae_comp__event_settings_badges_form.svelte).
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
event_id: string;
|
||||
event_badge_id: string;
|
||||
lq__event_badge_obj?: any;
|
||||
can_edit_fields?: string[]; // Whitelist of field names attendee/staff may edit
|
||||
is_staff?: boolean; // True = staff view (shows read-only source fields too)
|
||||
log_lvl?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
event_id,
|
||||
event_badge_id,
|
||||
lq__event_badge_obj,
|
||||
can_edit_fields = ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override'],
|
||||
is_staff = false,
|
||||
log_lvl = 0
|
||||
}: Props = $props();
|
||||
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events_functions';
|
||||
|
||||
// *** Editable field state (only fields in can_edit_fields are active)
|
||||
let editable_full_name_override: string = $state('');
|
||||
let editable_professional_title_override: string = $state('');
|
||||
let editable_affiliations_override: string = $state('');
|
||||
let editable_location_override: string = $state('');
|
||||
let editable_email: string = $state('');
|
||||
let editable_badge_type_code: string = $state('');
|
||||
|
||||
let save_status: 'idle' | 'loading' | 'done' | 'error' = $state('idle');
|
||||
|
||||
// Initialize from badge object
|
||||
$effect(() => {
|
||||
if ($lq__event_badge_obj) {
|
||||
editable_full_name_override =
|
||||
$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '';
|
||||
editable_professional_title_override =
|
||||
$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '';
|
||||
editable_affiliations_override =
|
||||
$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '';
|
||||
editable_location_override =
|
||||
$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '';
|
||||
editable_email =
|
||||
$lq__event_badge_obj.email ?? '';
|
||||
editable_badge_type_code =
|
||||
$lq__event_badge_obj.badge_type_code ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
// Derived: is this field editable for the current user?
|
||||
function can_edit(field: string): boolean {
|
||||
return can_edit_fields.includes('*') || can_edit_fields.includes(field);
|
||||
}
|
||||
|
||||
function has_override(base_field: string, override_field: string): boolean {
|
||||
return !!(
|
||||
$lq__event_badge_obj?.[override_field] &&
|
||||
$lq__event_badge_obj[override_field] !== $lq__event_badge_obj[base_field]
|
||||
);
|
||||
}
|
||||
|
||||
async function handle_save() {
|
||||
if (!$lq__event_badge_obj?.event_badge_id) return;
|
||||
|
||||
save_status = 'loading';
|
||||
const data_to_update: key_val = {};
|
||||
|
||||
// Only include fields in can_edit_fields that have changed
|
||||
if (can_edit('full_name_override')) {
|
||||
const original = $lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '';
|
||||
if (editable_full_name_override !== original) {
|
||||
data_to_update.full_name_override = editable_full_name_override || null;
|
||||
}
|
||||
}
|
||||
if (can_edit('professional_title_override')) {
|
||||
const original = $lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '';
|
||||
if (editable_professional_title_override !== original) {
|
||||
data_to_update.professional_title_override = editable_professional_title_override || null;
|
||||
}
|
||||
}
|
||||
if (can_edit('affiliations_override')) {
|
||||
const original = $lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '';
|
||||
if (editable_affiliations_override !== original) {
|
||||
data_to_update.affiliations_override = editable_affiliations_override || null;
|
||||
}
|
||||
}
|
||||
if (can_edit('location_override')) {
|
||||
const original = $lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '';
|
||||
if (editable_location_override !== original) {
|
||||
data_to_update.location_override = editable_location_override || null;
|
||||
}
|
||||
}
|
||||
if (can_edit('email')) {
|
||||
if (editable_email !== ($lq__event_badge_obj.email ?? '')) {
|
||||
data_to_update.email = editable_email || null;
|
||||
}
|
||||
}
|
||||
if (can_edit('badge_type_code')) {
|
||||
if (editable_badge_type_code !== ($lq__event_badge_obj.badge_type_code ?? '')) {
|
||||
data_to_update.badge_type_code = editable_badge_type_code || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(data_to_update).length === 0) {
|
||||
save_status = 'done';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await events_func.update_ae_obj__event_badge({
|
||||
api_cfg: $ae_api,
|
||||
event_id,
|
||||
event_badge_id: $lq__event_badge_obj.event_badge_id,
|
||||
data_kv: data_to_update,
|
||||
log_lvl
|
||||
});
|
||||
save_status = 'done';
|
||||
} catch (error) {
|
||||
console.error('Error saving badge review changes:', error);
|
||||
save_status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
function handle_cancel() {
|
||||
if ($lq__event_badge_obj) {
|
||||
editable_full_name_override =
|
||||
$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '';
|
||||
editable_professional_title_override =
|
||||
$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '';
|
||||
editable_affiliations_override =
|
||||
$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '';
|
||||
editable_location_override =
|
||||
$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '';
|
||||
editable_email = $lq__event_badge_obj.email ?? '';
|
||||
editable_badge_type_code = $lq__event_badge_obj.badge_type_code ?? '';
|
||||
}
|
||||
save_status = 'idle';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $lq__event_badge_obj}
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
|
||||
<!-- Source data header (staff only) -->
|
||||
{#if is_staff}
|
||||
<div class="card p-3 variant-soft-surface text-sm space-y-1">
|
||||
<p class="font-semibold text-xs uppercase tracking-wide text-gray-500">Source Data (from registration system — read only)</p>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs">
|
||||
<span class="text-gray-500">Given Name</span>
|
||||
<span>{$lq__event_badge_obj.given_name ?? '—'}</span>
|
||||
<span class="text-gray-500">Family Name</span>
|
||||
<span>{$lq__event_badge_obj.family_name ?? '—'}</span>
|
||||
<span class="text-gray-500">Full Name</span>
|
||||
<span>{$lq__event_badge_obj.full_name ?? '—'}</span>
|
||||
<span class="text-gray-500">Professional Title</span>
|
||||
<span>{$lq__event_badge_obj.professional_title ?? '—'}</span>
|
||||
<span class="text-gray-500">Affiliations</span>
|
||||
<span>{$lq__event_badge_obj.affiliations ?? '—'}</span>
|
||||
<span class="text-gray-500">Location</span>
|
||||
<span>{$lq__event_badge_obj.location ?? '—'}</span>
|
||||
<span class="text-gray-500">Badge Type</span>
|
||||
<span>{$lq__event_badge_obj.badge_type_code ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Editable fields -->
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-500 italic">
|
||||
Fields below are what will appear on your printed badge.
|
||||
{#if can_edit_fields.length > 0}
|
||||
You may edit the highlighted fields.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- Full Name -->
|
||||
{#if can_edit('full_name_override') || is_staff}
|
||||
<div class="space-y-1">
|
||||
<label for="badge-review-full-name" class="block text-sm font-medium">
|
||||
Full Name (on badge)
|
||||
{#if has_override('full_name', 'full_name_override')}
|
||||
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if can_edit('full_name_override')}
|
||||
<input
|
||||
id="badge-review-full-name"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
bind:value={editable_full_name_override}
|
||||
placeholder={$lq__event_badge_obj.full_name ?? 'Full name'}
|
||||
data-testid="badge-review-full-name-input"
|
||||
/>
|
||||
{:else}
|
||||
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
|
||||
{$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '—'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Professional Title -->
|
||||
{#if can_edit('professional_title_override') || is_staff}
|
||||
<div class="space-y-1">
|
||||
<label for="badge-review-pro-title" class="block text-sm font-medium">
|
||||
Professional Title (on badge)
|
||||
{#if has_override('professional_title', 'professional_title_override')}
|
||||
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if can_edit('professional_title_override')}
|
||||
<input
|
||||
id="badge-review-pro-title"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
bind:value={editable_professional_title_override}
|
||||
placeholder={$lq__event_badge_obj.professional_title ?? 'Professional title'}
|
||||
data-testid="badge-review-pro-title-input"
|
||||
/>
|
||||
{:else}
|
||||
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
|
||||
{$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '—'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Affiliations -->
|
||||
{#if can_edit('affiliations_override') || is_staff}
|
||||
<div class="space-y-1">
|
||||
<label for="badge-review-affiliations" class="block text-sm font-medium">
|
||||
Affiliations / Organization (on badge)
|
||||
{#if has_override('affiliations', 'affiliations_override')}
|
||||
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if can_edit('affiliations_override')}
|
||||
<textarea
|
||||
id="badge-review-affiliations"
|
||||
class="textarea w-full"
|
||||
rows="2"
|
||||
bind:value={editable_affiliations_override}
|
||||
placeholder={$lq__event_badge_obj.affiliations ?? 'Affiliations / organization'}
|
||||
data-testid="badge-review-affiliations-input"
|
||||
></textarea>
|
||||
{:else}
|
||||
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
|
||||
{$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '—'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Location -->
|
||||
{#if can_edit('location_override') || is_staff}
|
||||
<div class="space-y-1">
|
||||
<label for="badge-review-location" class="block text-sm font-medium">
|
||||
Location (on badge)
|
||||
{#if has_override('location', 'location_override')}
|
||||
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if can_edit('location_override')}
|
||||
<input
|
||||
id="badge-review-location"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
bind:value={editable_location_override}
|
||||
placeholder={$lq__event_badge_obj.location ?? 'City, State/Province, Country'}
|
||||
data-testid="badge-review-location-input"
|
||||
/>
|
||||
{:else}
|
||||
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
|
||||
{$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '—'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Email (staff only by default) -->
|
||||
{#if can_edit('email')}
|
||||
<div class="space-y-1">
|
||||
<label for="badge-review-email" class="block text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="badge-review-email"
|
||||
type="email"
|
||||
class="input w-full"
|
||||
bind:value={editable_email}
|
||||
placeholder="Email address"
|
||||
data-testid="badge-review-email-input"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Badge Type Code (staff only by default) -->
|
||||
{#if can_edit('badge_type_code')}
|
||||
<div class="space-y-1">
|
||||
<label for="badge-review-badge-type" class="block text-sm font-medium">
|
||||
Badge Type Code
|
||||
</label>
|
||||
<input
|
||||
id="badge-review-badge-type"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
bind:value={editable_badge_type_code}
|
||||
placeholder="e.g. current_member, guest, staff"
|
||||
data-testid="badge-review-badge-type-input"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Save / Cancel -->
|
||||
{#if can_edit_fields.length > 0}
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-filled-primary"
|
||||
class:preset-tonal-primary={save_status === 'loading'}
|
||||
class:preset-filled-success-500={save_status === 'done'}
|
||||
class:preset-filled-error-500={save_status === 'error'}
|
||||
disabled={save_status === 'loading'}
|
||||
onclick={handle_save}
|
||||
data-testid="badge-review-save-btn"
|
||||
>
|
||||
{#if save_status === 'loading'}
|
||||
<span class="fas fa-spinner fa-spin mr-2"></span> Saving…
|
||||
{:else if save_status === 'done'}
|
||||
<span class="fas fa-check mr-2"></span> Saved
|
||||
{:else if save_status === 'error'}
|
||||
<span class="fas fa-exclamation-triangle mr-2"></span> Error — try again
|
||||
{:else}
|
||||
<span class="fas fa-save mr-2"></span> Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if save_status !== 'done'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-tonal-surface"
|
||||
onclick={handle_cancel}
|
||||
data-testid="badge-review-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Print info (read-only) -->
|
||||
{#if is_staff && $lq__event_badge_obj.print_count !== undefined}
|
||||
<div class="text-xs text-gray-500 space-y-0.5 border-t border-gray-200 pt-3 mt-2">
|
||||
<p>Print count: <strong>{$lq__event_badge_obj.print_count ?? 0}</strong></p>
|
||||
{#if $lq__event_badge_obj.print_first_datetime}
|
||||
<p>First printed: {new Date($lq__event_badge_obj.print_first_datetime).toLocaleString()}</p>
|
||||
{/if}
|
||||
{#if $lq__event_badge_obj.print_last_datetime}
|
||||
<p>Last printed: {new Date($lq__event_badge_obj.print_last_datetime).toLocaleString()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,7 +1,199 @@
|
||||
<script lang="ts">
|
||||
// Page for printing badges
|
||||
interface Props {
|
||||
/** @type {import('./$types').PageData} */
|
||||
data: any;
|
||||
log_lvl?: number;
|
||||
}
|
||||
|
||||
let { data, log_lvl = 0 }: Props = $props();
|
||||
|
||||
import { untrack } from 'svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { page } from '$app/state';
|
||||
import { ArrowLeft, Eye, LoaderCircle, Mail, Printer } from 'lucide-svelte';
|
||||
|
||||
import Comp_badge_obj_view from '../ae_comp__badge_obj_view.svelte';
|
||||
|
||||
let event_badge_id = $derived(page.params.badge_id);
|
||||
let event_id = $derived(page.params.event_id);
|
||||
|
||||
let is_loading_idb = $state(true);
|
||||
|
||||
let lq__event_badge_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!event_badge_id) return null;
|
||||
return await db_events.badge.get(event_badge_id);
|
||||
})
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if ($lq__event_badge_obj !== undefined) {
|
||||
untrack(() => (is_loading_idb = false));
|
||||
}
|
||||
});
|
||||
|
||||
let lq__event_badge_template_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
return await db_events.badge_template.get(
|
||||
$lq__event_badge_obj?.event_badge_template_id ?? ''
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Access level shortcuts
|
||||
let is_trusted = $derived($ae_loc.trusted_access === true);
|
||||
let is_edit_mode = $derived($ae_loc.edit_mode === true);
|
||||
|
||||
// Print state derived from badge
|
||||
let print_count = $derived($lq__event_badge_obj?.print_count ?? 0);
|
||||
let is_printed = $derived(print_count >= 1);
|
||||
|
||||
function build_review_url(): string {
|
||||
// TODO: append ?passcode=... when person_passcode is added to the event_badge schema
|
||||
return `/events/${$lq__event_badge_obj?.event_id}/badges/${$lq__event_badge_obj?.event_badge_id}/review`;
|
||||
}
|
||||
|
||||
function obscure_email(email: string | null | undefined): string {
|
||||
if (!email) return '';
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return email;
|
||||
return `${email.slice(0, Math.min(3, at))}***${email.slice(at)}`;
|
||||
}
|
||||
|
||||
// TODO: replace alert with actual email API call when available
|
||||
function send_review_email() {
|
||||
const badge = $lq__event_badge_obj;
|
||||
const name =
|
||||
badge?.full_name_override
|
||||
?? badge?.full_name
|
||||
?? `${badge?.given_name ?? ''} ${badge?.family_name ?? ''}`.trim();
|
||||
const email = is_trusted
|
||||
? (badge?.email ?? '(no email on file)')
|
||||
: obscure_email(badge?.email);
|
||||
const event_name = $events_loc?.title ?? 'this event';
|
||||
alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`);
|
||||
}
|
||||
|
||||
// Print mode: display only — ae_comp__badge_obj_view handles print count + window.print()
|
||||
// The "Print Now" header button below only triggers window.print() for convenience;
|
||||
// print count is incremented by the print button inside ae_comp__badge_obj_view.
|
||||
</script>
|
||||
|
||||
<h1 class="h1">Print Badges</h1>
|
||||
<svelte:head>
|
||||
<title>
|
||||
Æ: Print Badge —
|
||||
{$lq__event_badge_obj?.full_name_override ?? $lq__event_badge_obj?.full_name ?? '—'}
|
||||
{$events_loc?.title ? ` — ${$events_loc.title}` : ''}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<p>This page will be used for printing badges.</p>
|
||||
{#if $lq__event_badge_obj && $lq__event_badge_obj.event_id && event_badge_id}
|
||||
|
||||
<!-- Print chrome: hidden when browser prints (print:hidden) -->
|
||||
<header class="print:hidden w-full flex flex-row flex-wrap gap-2 items-center justify-between border-b border-gray-300 mb-3 pb-2">
|
||||
|
||||
<!-- Left: Back to Search + name + already-printed warning -->
|
||||
<div class="flex flex-row gap-2 items-center min-w-0">
|
||||
<a
|
||||
href={`/events/${$lq__event_badge_obj.event_id}/badges`}
|
||||
class="btn btn-sm preset-tonal-surface flex items-center gap-1 shrink-0"
|
||||
title="Back to badge search"
|
||||
>
|
||||
<ArrowLeft size="1em" />
|
||||
<span class="hidden sm:inline">Search</span>
|
||||
</a>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<h2 class="text-base font-bold truncate">
|
||||
{$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '— no name —'}
|
||||
</h2>
|
||||
{#if is_printed}
|
||||
<p class="text-xs text-amber-600 font-medium">
|
||||
Printed {print_count}×
|
||||
{#if $lq__event_badge_obj.print_last_datetime}
|
||||
— last {new Date($lq__event_badge_obj.print_last_datetime).toLocaleString()}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Action buttons -->
|
||||
<div class="flex flex-row gap-1 items-center shrink-0">
|
||||
|
||||
<!-- 1. Print Now: Trusted+, not printed OR Edit Mode (reprint) -->
|
||||
{#if is_trusted && (!is_printed || is_edit_mode)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-primary flex items-center gap-1"
|
||||
onclick={() => window.print()}
|
||||
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge now'}
|
||||
>
|
||||
<Printer size="1em" />
|
||||
{#if is_printed}
|
||||
<span class="font-bold text-xs">{print_count}×</span>
|
||||
{/if}
|
||||
<span class="hidden sm:inline">Print Now</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- 2. Direct Review link: Trusted + Edit Mode -->
|
||||
{#if is_trusted && is_edit_mode}
|
||||
<a
|
||||
href={build_review_url()}
|
||||
class="btn btn-sm preset-tonal-secondary flex items-center gap-1"
|
||||
title="Open badge review page"
|
||||
>
|
||||
<Eye size="1em" />
|
||||
<span class="hidden sm:inline">Review</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- 3. Email Review Link: all if not printed; Trusted+Edit if printed
|
||||
TODO: replace alert with actual email API call -->
|
||||
{#if !is_printed || (is_trusted && is_edit_mode)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-surface flex items-center gap-1"
|
||||
onclick={() => send_review_email()}
|
||||
title="Email a review link to this attendee"
|
||||
>
|
||||
<Mail size="1em" />
|
||||
<span class="hidden sm:inline">Email Link</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Badge render — ae_comp__badge_obj_view handles print count + window.print() -->
|
||||
<Comp_badge_obj_view
|
||||
event_id={$lq__event_badge_obj.event_id as string}
|
||||
event_badge_id={event_badge_id as string}
|
||||
{lq__event_badge_obj}
|
||||
{lq__event_badge_template_obj}
|
||||
is_review_mode={false}
|
||||
/>
|
||||
|
||||
{:else if is_loading_idb || !event_badge_id}
|
||||
<div class="flex flex-col items-center justify-center p-20 gap-4 opacity-50">
|
||||
<LoaderCircle size="3em" class="animate-spin" />
|
||||
<p class="text-xl font-bold text-center">Loading Badge...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card p-8 text-center space-y-4">
|
||||
<h2 class="text-2xl font-bold text-error-500">Badge Not Found</h2>
|
||||
<p class="opacity-70">No record found for ID: <span class="font-mono">{event_badge_id}</span></p>
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button class="btn preset-filled-primary" onclick={() => window.location.reload()}>
|
||||
Retry
|
||||
</button>
|
||||
<a href={`/events/${event_id}/badges`} class="btn preset-tonal-surface">
|
||||
Back to Search
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
console.log(`Events - Badges [badge_id]/print +page.ts start`);
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { events_func } from '$lib/ae_events_functions';
|
||||
|
||||
// Loads badge + template for the print page (non-blocking background refresh)
|
||||
export async function load({ params, parent }) {
|
||||
const log_lvl: number = 0;
|
||||
|
||||
const parent_data = await parent();
|
||||
const account_id = parent_data.account_id;
|
||||
const ae_acct = parent_data[account_id];
|
||||
|
||||
if (browser) {
|
||||
const event_id = params.event_id;
|
||||
const event_badge_id = params.badge_id;
|
||||
|
||||
if (event_badge_id) {
|
||||
events_func.load_ae_obj_id__event_badge({
|
||||
api_cfg: ae_acct.api,
|
||||
event_badge_id: event_badge_id,
|
||||
event_id: event_id,
|
||||
inc_template: true,
|
||||
log_lvl: log_lvl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { params };
|
||||
}
|
||||
@@ -1,7 +1,358 @@
|
||||
<script lang="ts">
|
||||
// Page for reviewing badges
|
||||
/**
|
||||
* Badge Review Page
|
||||
*
|
||||
* Access modes (checked in order):
|
||||
* 1. Administrator ($ae_loc.administrator_access) — full edit access, no passcode needed
|
||||
* 2. Trusted staff ($ae_loc.trusted_access) — trusted-level fields, no passcode needed
|
||||
* 3. Attendee (URL ?passcode=... matches badge.person_passcode) — attendee-level fields
|
||||
* 4. Passcode entry form — if none of the above, prompt for passcode
|
||||
*
|
||||
* Editable fields per level come from event.mod_badges_json.edit_permissions.
|
||||
* Defaults apply if not configured in event settings.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
/** @type {import('./$types').PageData} */
|
||||
data: any;
|
||||
log_lvl?: number;
|
||||
}
|
||||
|
||||
let { data, log_lvl = 0 }: Props = $props();
|
||||
|
||||
import { untrack } from 'svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Link,
|
||||
LoaderCircle,
|
||||
Mail,
|
||||
Printer,
|
||||
ShieldCheck,
|
||||
User,
|
||||
UserCheck
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import Comp_badge_review_form from '../ae_comp__badge_review_form.svelte';
|
||||
|
||||
let event_badge_id = $derived(page.params.badge_id);
|
||||
let event_id = $derived(page.params.event_id);
|
||||
|
||||
let is_loading_idb = $state(true);
|
||||
|
||||
// *** LiveQuery: badge object
|
||||
let lq__event_badge_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!event_badge_id) return null;
|
||||
return await db_events.badge.get(event_badge_id);
|
||||
})
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if ($lq__event_badge_obj !== undefined) {
|
||||
untrack(() => (is_loading_idb = false));
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Load event.mod_badges_json.edit_permissions for per-event field config.
|
||||
// Hardcoded defaults for now — revisit after basic flow is working.
|
||||
const default_authenticated_fields = [
|
||||
'full_name_override',
|
||||
'professional_title_override',
|
||||
'affiliations_override',
|
||||
'location_override'
|
||||
];
|
||||
const default_trusted_fields = [
|
||||
'full_name_override',
|
||||
'professional_title_override',
|
||||
'affiliations_override',
|
||||
'location_override',
|
||||
'email',
|
||||
'badge_type_code'
|
||||
];
|
||||
|
||||
// *** Passcode logic
|
||||
let url_passcode = $derived(page.url?.searchParams?.get('passcode') ?? '');
|
||||
let entered_passcode = $state('');
|
||||
let passcode_checked = $state(false);
|
||||
let passcode_valid = $state(false);
|
||||
let passcode_error = $state('');
|
||||
|
||||
// Auto-validate URL passcode once badge is loaded
|
||||
$effect(() => {
|
||||
if (url_passcode && $lq__event_badge_obj && !passcode_checked) {
|
||||
untrack(() => {
|
||||
check_passcode(url_passcode);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function check_passcode(code: string) {
|
||||
passcode_checked = true;
|
||||
const badge_passcode = $lq__event_badge_obj?.person_passcode;
|
||||
if (!badge_passcode) {
|
||||
// No passcode set on badge — deny access to prevent unintentional open access
|
||||
passcode_valid = false;
|
||||
passcode_error = 'This badge does not have a review link enabled. Please contact event staff.';
|
||||
} else if (code && code === badge_passcode) {
|
||||
passcode_valid = true;
|
||||
passcode_error = '';
|
||||
} else {
|
||||
passcode_valid = false;
|
||||
passcode_error = 'Incorrect passcode. Please check your email link or contact event staff.';
|
||||
}
|
||||
}
|
||||
|
||||
// *** Access level shortcuts
|
||||
let is_administrator = $derived($ae_loc?.administrator_access === true);
|
||||
let is_trusted = $derived($ae_loc?.trusted_access === true);
|
||||
let is_edit_mode = $derived($ae_loc?.edit_mode === true);
|
||||
let has_staff_access = $derived(is_administrator || is_trusted);
|
||||
let has_attendee_access = $derived(passcode_valid);
|
||||
let has_any_access = $derived(has_staff_access || has_attendee_access);
|
||||
|
||||
// *** Print state derived from badge
|
||||
let print_count = $derived($lq__event_badge_obj?.print_count ?? 0);
|
||||
let is_printed = $derived(print_count >= 1);
|
||||
|
||||
// *** Copy review link state
|
||||
let copy_status: 'idle' | 'copied' = $state('idle');
|
||||
|
||||
function build_review_url(): string {
|
||||
// TODO: append ?passcode=... when person_passcode is added to the event_badge schema
|
||||
return `/events/${$lq__event_badge_obj?.event_id}/badges/${$lq__event_badge_obj?.event_badge_id}/review`;
|
||||
}
|
||||
|
||||
async function copy_review_link() {
|
||||
const full_url = window.location.origin + build_review_url();
|
||||
try {
|
||||
await navigator.clipboard.writeText(full_url);
|
||||
copy_status = 'copied';
|
||||
setTimeout(() => {
|
||||
copy_status = 'idle';
|
||||
}, 2000);
|
||||
} catch {
|
||||
console.error('Clipboard write failed');
|
||||
}
|
||||
}
|
||||
|
||||
function obscure_email(email: string | null | undefined): string {
|
||||
if (!email) return '';
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return email;
|
||||
return `${email.slice(0, Math.min(3, at))}***${email.slice(at)}`;
|
||||
}
|
||||
|
||||
// TODO: replace alert with actual email API call when available
|
||||
function send_review_email() {
|
||||
const badge = $lq__event_badge_obj;
|
||||
const name =
|
||||
badge?.full_name_override
|
||||
?? badge?.full_name
|
||||
?? `${badge?.given_name ?? ''} ${badge?.family_name ?? ''}`.trim();
|
||||
const email = is_trusted
|
||||
? (badge?.email ?? '(no email on file)')
|
||||
: obscure_email(badge?.email);
|
||||
const event_name = $events_loc?.title ?? 'this event';
|
||||
alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`);
|
||||
}
|
||||
|
||||
// *** Resolve editable field list based on access level
|
||||
// Uses $derived.by() to return the array directly (not a function).
|
||||
// TODO: Read from event.mod_badges_json.edit_permissions for per-event config.
|
||||
let can_edit_fields: string[] = $derived.by(() => {
|
||||
if (is_administrator) return ['*'];
|
||||
if (is_trusted) return default_trusted_fields;
|
||||
if (has_attendee_access) return default_authenticated_fields;
|
||||
return [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1 class="h1">Review Badges</h1>
|
||||
<svelte:head>
|
||||
<title>
|
||||
Æ: Review Badge —
|
||||
{$lq__event_badge_obj?.full_name_override ?? $lq__event_badge_obj?.full_name ?? '—'}
|
||||
{$events_loc?.title ? ` — ${$events_loc.title}` : ''}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<p>This page will be used for reviewing badges.</p>
|
||||
{#if $lq__event_badge_obj}
|
||||
|
||||
<!-- Page header -->
|
||||
<header class="w-full flex flex-row flex-wrap gap-2 items-center justify-between border-b border-gray-300 mb-4 pb-2">
|
||||
|
||||
<!-- Left: Back to Search (staff only) + title + name -->
|
||||
<div class="flex flex-row gap-2 items-center min-w-0">
|
||||
{#if has_staff_access}
|
||||
<a
|
||||
href={`/events/${$lq__event_badge_obj.event_id}/badges`}
|
||||
class="btn btn-sm preset-tonal-surface flex items-center gap-1 shrink-0"
|
||||
title="Back to badge search"
|
||||
>
|
||||
<ArrowLeft size="1em" />
|
||||
<span class="hidden sm:inline">Search</span>
|
||||
</a>
|
||||
{/if}
|
||||
<div class="flex flex-col min-w-0">
|
||||
<h2 class="text-base font-bold">Review Badge</h2>
|
||||
<p class="text-sm text-gray-500 truncate">
|
||||
{$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '— no name —'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Action buttons -->
|
||||
<div class="flex flex-row gap-1 items-center shrink-0">
|
||||
|
||||
<!-- 1. Print Badge: Trusted+, not printed OR Edit Mode (reprint) -->
|
||||
{#if is_trusted && (!is_printed || is_edit_mode)}
|
||||
<a
|
||||
href={`/events/${$lq__event_badge_obj.event_id}/badges/${$lq__event_badge_obj.event_badge_id}/print`}
|
||||
class="btn btn-sm preset-tonal-primary flex items-center gap-1"
|
||||
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge'}
|
||||
>
|
||||
<Printer size="1em" />
|
||||
{#if is_printed}
|
||||
<span class="font-bold text-xs">{print_count}×</span>
|
||||
{/if}
|
||||
<span class="hidden sm:inline">Print</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- 2. Copy review link to clipboard: Trusted + Edit Mode -->
|
||||
{#if is_trusted && is_edit_mode}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm flex items-center gap-1"
|
||||
class:preset-tonal-secondary={copy_status !== 'copied'}
|
||||
class:preset-filled-success-500={copy_status === 'copied'}
|
||||
onclick={() => copy_review_link()}
|
||||
title="Copy review link to clipboard"
|
||||
>
|
||||
{#if copy_status === 'copied'}
|
||||
<Check size="1em" />
|
||||
<span class="hidden sm:inline">Copied!</span>
|
||||
{:else}
|
||||
<Link size="1em" />
|
||||
<span class="hidden sm:inline">Copy Link</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- 3. Email Review Link: all if not printed; Trusted+Edit if printed
|
||||
TODO: replace alert with actual email API call -->
|
||||
{#if !is_printed || (is_trusted && is_edit_mode)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-surface flex items-center gap-1"
|
||||
onclick={() => send_review_email()}
|
||||
title="Email a review link to this attendee"
|
||||
>
|
||||
<Mail size="1em" />
|
||||
<span class="hidden sm:inline">Email Link</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if has_any_access}
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Access level indicator -->
|
||||
{#if is_administrator}
|
||||
<p class="text-xs text-gray-400 flex items-center gap-1">
|
||||
<ShieldCheck size="1em" class="text-purple-500" />
|
||||
Administrator access — all fields editable
|
||||
</p>
|
||||
{:else if is_trusted}
|
||||
<p class="text-xs text-gray-400 flex items-center gap-1">
|
||||
<UserCheck size="1em" class="text-blue-500" />
|
||||
Staff access — extended fields editable
|
||||
</p>
|
||||
{:else if has_attendee_access}
|
||||
<p class="text-xs text-gray-400 flex items-center gap-1">
|
||||
<User size="1em" class="text-green-500" />
|
||||
Reviewing your badge information
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Comp_badge_review_form
|
||||
event_id={event_id as string}
|
||||
event_badge_id={event_badge_id as string}
|
||||
{lq__event_badge_obj}
|
||||
{can_edit_fields}
|
||||
is_staff={has_staff_access}
|
||||
{log_lvl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{:else if !passcode_checked && !url_passcode}
|
||||
<!-- Passcode entry (attendee navigates directly, no URL passcode) -->
|
||||
<div class="card p-6 space-y-4 max-w-sm">
|
||||
<h3 class="text-lg font-semibold">Enter Your Passcode</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Enter the passcode from your badge review email to view and update your badge information.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<label for="passcode-entry" class="block text-sm font-medium">Passcode</label>
|
||||
<input
|
||||
id="passcode-entry"
|
||||
type="text"
|
||||
class="input w-full font-mono tracking-widest"
|
||||
bind:value={entered_passcode}
|
||||
placeholder="Enter passcode"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') check_passcode(entered_passcode); }}
|
||||
data-testid="badge-review-passcode-input"
|
||||
/>
|
||||
</div>
|
||||
{#if passcode_error}
|
||||
<p class="text-sm text-error-500">{passcode_error}</p>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-filled-primary w-full"
|
||||
onclick={() => check_passcode(entered_passcode)}
|
||||
data-testid="badge-review-passcode-submit"
|
||||
>
|
||||
Access My Badge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if passcode_checked && !passcode_valid}
|
||||
<!-- Invalid passcode -->
|
||||
<div class="card p-6 space-y-4 max-w-sm">
|
||||
<div class="flex items-center gap-2 text-error-500">
|
||||
<h3 class="text-lg font-semibold">Access Denied</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700">{passcode_error}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-tonal-surface w-full"
|
||||
onclick={() => { passcode_checked = false; passcode_error = ''; entered_passcode = ''; }}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if is_loading_idb || !event_badge_id}
|
||||
<div class="flex flex-col items-center justify-center p-20 gap-4 opacity-50">
|
||||
<LoaderCircle size="3em" class="animate-spin" />
|
||||
<p class="text-xl font-bold text-center">Loading Badge Information…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card p-8 text-center space-y-4">
|
||||
<h2 class="text-2xl font-bold text-error-500">Badge Not Found</h2>
|
||||
<p class="opacity-70">No record found for ID: <span class="font-mono">{event_badge_id}</span></p>
|
||||
<button class="btn preset-filled-primary" onclick={() => window.location.reload()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
console.log(`Events - Badges [badge_id]/review +page.ts start`);
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { events_func } from '$lib/ae_events_functions';
|
||||
|
||||
// Loads badge + template for the review page (non-blocking background refresh)
|
||||
export async function load({ params, parent }) {
|
||||
const log_lvl: number = 0;
|
||||
|
||||
const parent_data = await parent();
|
||||
const account_id = parent_data.account_id;
|
||||
const ae_acct = parent_data[account_id];
|
||||
|
||||
if (browser) {
|
||||
const event_id = params.event_id;
|
||||
const event_badge_id = params.badge_id;
|
||||
|
||||
if (event_badge_id) {
|
||||
events_func.load_ae_obj_id__event_badge({
|
||||
api_cfg: ae_acct.api,
|
||||
event_badge_id: event_badge_id,
|
||||
event_id: event_id,
|
||||
inc_template: false, // Review form doesn't need template
|
||||
log_lvl: log_lvl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { params };
|
||||
}
|
||||
@@ -19,40 +19,93 @@
|
||||
hide_badge_type = false
|
||||
}: Props = $props();
|
||||
|
||||
// import { type Badge as BadgeType } from '$lib/ae_events/db_events';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import {
|
||||
LoaderCircle,
|
||||
Badge,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Mail,
|
||||
MapPin,
|
||||
Tags,
|
||||
FileSearch
|
||||
FileSearch,
|
||||
Link,
|
||||
Printer,
|
||||
User
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// Derived list of visible items (Standardized Pattern 2026-01-27)
|
||||
// Track per-badge copy state for the "Review Link" clipboard button
|
||||
let copy_status: Record<string, 'idle' | 'copied'> = $state({});
|
||||
|
||||
// Access level shortcuts
|
||||
let is_trusted = $derived($ae_loc.trusted_access === true);
|
||||
let is_edit_mode = $derived($ae_loc.edit_mode === true);
|
||||
|
||||
/**
|
||||
* Obscures an email address for display to non-trusted users.
|
||||
* e.g. john.doe@example.com → joh***@example.com
|
||||
*/
|
||||
function obscure_email(email: string | null | undefined): string {
|
||||
if (!email) return '';
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return email;
|
||||
const visible = email.slice(0, Math.min(3, at));
|
||||
return `${visible}***${email.slice(at)}`;
|
||||
}
|
||||
|
||||
function build_review_url(event_badge_obj: any): string {
|
||||
// TODO: append ?passcode=... when person_passcode is added to the event_badge schema
|
||||
return `/events/${event_badge_obj.event_id}/badges/${event_badge_obj.event_badge_id}/review`;
|
||||
}
|
||||
|
||||
async function copy_review_link(event_badge_obj: any) {
|
||||
const url = build_review_url(event_badge_obj);
|
||||
const full_url = window.location.origin + url;
|
||||
try {
|
||||
await navigator.clipboard.writeText(full_url);
|
||||
copy_status[event_badge_obj.event_badge_id] = 'copied';
|
||||
setTimeout(() => {
|
||||
copy_status[event_badge_obj.event_badge_id] = 'idle';
|
||||
}, 2000);
|
||||
} catch {
|
||||
console.error('Clipboard write failed');
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: replace alert with actual email API call when available
|
||||
function send_review_email(event_badge_obj: any) {
|
||||
const name =
|
||||
event_badge_obj?.full_name_override
|
||||
?? event_badge_obj?.full_name
|
||||
?? `${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim();
|
||||
const email = is_trusted
|
||||
? (event_badge_obj?.email ?? '(no email on file)')
|
||||
: obscure_email(event_badge_obj?.email);
|
||||
const event_name = $events_loc?.title ?? 'this event';
|
||||
alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`);
|
||||
}
|
||||
|
||||
let visible_badge_obj_li = $derived(
|
||||
(() => {
|
||||
const list = $lq__event_badge_obj_li;
|
||||
|
||||
if (list === undefined || list === null) return null;
|
||||
if (!Array.isArray(list)) return [];
|
||||
|
||||
const filtered = list.filter((item: any) => {
|
||||
if (!item) return false;
|
||||
// ADMIN/TRUSTED: See everything
|
||||
if ($ae_loc.trusted_access) return true;
|
||||
// PUBLIC: Filter hidden
|
||||
return !item.hide;
|
||||
if (is_trusted && is_edit_mode) {
|
||||
// Edit Mode: show all non-hidden, including already-printed
|
||||
return !item.hide;
|
||||
}
|
||||
// Everyone else (non-trusted or trusted not in edit mode):
|
||||
// Only show badges that have not been printed yet
|
||||
return (item.print_count ?? 0) < 1 && !item.hide;
|
||||
});
|
||||
|
||||
if (log_lvl)
|
||||
console.log(
|
||||
`visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}`
|
||||
);
|
||||
console.log(`visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}`);
|
||||
return filtered;
|
||||
})()
|
||||
);
|
||||
@@ -67,21 +120,22 @@
|
||||
<p>Loading badges...</p>
|
||||
</div>
|
||||
{:else if visible_badge_obj_li.length > 0}
|
||||
<header
|
||||
class="w-full flex flex-row gap-2 items-center justify-start mb-2 px-2"
|
||||
>
|
||||
<header class="w-full flex flex-row gap-2 items-center justify-start mb-2 px-2">
|
||||
<h2 class="text-sm text-gray-500 font-normal">Results:</h2>
|
||||
<span
|
||||
class="badge preset-tonal-success font-bold text-lg px-3 py-1"
|
||||
>
|
||||
{visible_badge_obj_li.length}<span
|
||||
class="text-gray-400 dark:text-gray-600">×</span
|
||||
>
|
||||
<span class="badge preset-tonal-success font-bold text-lg px-3 py-1">
|
||||
{visible_badge_obj_li.length}<span class="text-gray-400 dark:text-gray-600">×</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ul class="w-full space-y-1">
|
||||
{#each visible_badge_obj_li as event_badge_obj (event_badge_obj.event_badge_id)}
|
||||
{@const print_count = event_badge_obj.print_count ?? 0}
|
||||
{@const is_printed = print_count >= 1}
|
||||
{@const display_name =
|
||||
event_badge_obj?.full_name_override
|
||||
?? event_badge_obj?.full_name
|
||||
?? `${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim()}
|
||||
|
||||
<li
|
||||
class="
|
||||
border border-surface-200 dark:border-surface-700 rounded-lg p-2
|
||||
@@ -90,149 +144,157 @@
|
||||
hover:border-primary-500 transition-colors
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row flex-wrap gap-2 items-center justify-between w-full"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row flex-wrap gap-2 items-center justify-start grow"
|
||||
>
|
||||
<a
|
||||
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}`}
|
||||
class="flex flex-row gap-2 items-center justify-start min-w-fit font-bold text-lg hover:text-primary-500"
|
||||
>
|
||||
<div class="flex flex-row flex-wrap gap-2 items-center justify-between w-full">
|
||||
|
||||
<!-- Left cluster: name (display only) + info chips -->
|
||||
<div class="flex flex-row flex-wrap gap-x-3 gap-y-1 items-center grow min-w-0">
|
||||
|
||||
<!-- Name: always plain display — print action is a separate button -->
|
||||
<span class="flex flex-row gap-1.5 items-center font-bold text-lg shrink-0">
|
||||
{#if event_badge_obj?.hide}
|
||||
<EyeOff
|
||||
size="1.2em"
|
||||
class="text-gray-400"
|
||||
/>
|
||||
<EyeOff size="1.1em" class="text-gray-400" />
|
||||
{:else}
|
||||
<Badge size="1.2em" />
|
||||
<User size="1.1em" class="text-surface-400" />
|
||||
{/if}
|
||||
<span>{display_name}</span>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
{#if event_badge_obj?.full_name_override}
|
||||
{event_badge_obj?.full_name_override}
|
||||
{:else if event_badge_obj?.full_name}
|
||||
{event_badge_obj?.full_name}
|
||||
{:else}
|
||||
{event_badge_obj?.given_name}
|
||||
{event_badge_obj?.family_name}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if event_badge_obj?.print_count >= 1}
|
||||
<span
|
||||
class="badge preset-filled-success-500 flex items-center gap-1 text-xs py-0 px-1"
|
||||
>
|
||||
<Check size="1em" />
|
||||
{event_badge_obj.print_count}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if show_sensitive_fields}
|
||||
<span
|
||||
class="text-xs text-surface-400 flex items-center gap-1"
|
||||
>
|
||||
<!-- Email chip — obscured for non-trusted -->
|
||||
{#if show_sensitive_fields && event_badge_obj?.email}
|
||||
<span class="text-xs text-surface-400 flex items-center gap-1">
|
||||
<Mail size="1em" />
|
||||
{#if $ae_loc.trusted_access}
|
||||
{event_badge_obj?.email}
|
||||
{:else}
|
||||
{event_badge_obj?.email?.replace(
|
||||
/^(.{3}).*@/,
|
||||
'$1...@'
|
||||
) ?? ''}
|
||||
{/if}
|
||||
{is_trusted
|
||||
? event_badge_obj.email
|
||||
: obscure_email(event_badge_obj.email)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if !hide_affiliations && event_badge_obj.affiliations}
|
||||
<span
|
||||
class="text-xs text-surface-400 flex items-center gap-1"
|
||||
>
|
||||
<!-- Affiliations chip (prefers override) -->
|
||||
{#if !hide_affiliations && (event_badge_obj?.affiliations_override ?? event_badge_obj?.affiliations)}
|
||||
<span class="text-xs text-surface-400 flex items-center gap-1">
|
||||
<MapPin size="1em" />
|
||||
{event_badge_obj.affiliations}
|
||||
{event_badge_obj.affiliations_override ?? event_badge_obj.affiliations}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if !hide_badge_type && event_badge_obj.badge_type}
|
||||
<span
|
||||
class="text-xs italic text-primary-500 bg-primary-500/10 px-2 rounded-token flex items-center gap-1"
|
||||
>
|
||||
<!-- Badge type chip -->
|
||||
{#if !hide_badge_type && event_badge_obj?.badge_type}
|
||||
<span class="text-xs italic text-primary-500 bg-primary-500/10 px-2 rounded-token flex items-center gap-1">
|
||||
<Tags size="1em" />
|
||||
{event_badge_obj.badge_type}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $ae_loc.trusted_access}
|
||||
<a
|
||||
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}#review`}
|
||||
class="btn btn-sm preset-tonal-primary"
|
||||
>
|
||||
Review
|
||||
</a>
|
||||
{/if}
|
||||
<!-- Right: up to 4 action buttons -->
|
||||
<div class="flex flex-row gap-1 items-center shrink-0">
|
||||
|
||||
<!-- 1. Print Badge: Trusted+, not yet printed OR in Edit Mode (reprint) -->
|
||||
{#if is_trusted && (!is_printed || is_edit_mode)}
|
||||
<a
|
||||
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}/print`}
|
||||
class="btn btn-sm preset-tonal-primary flex items-center gap-1"
|
||||
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge'}
|
||||
>
|
||||
<Printer size="1em" />
|
||||
{#if is_printed}
|
||||
<span class="font-bold text-xs">{print_count}×</span>
|
||||
{/if}
|
||||
<span class="hidden sm:inline">Print</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- 2. Direct Review link: Trusted + Edit Mode (navigates to /review) -->
|
||||
{#if is_trusted && is_edit_mode}
|
||||
<a
|
||||
href={build_review_url(event_badge_obj)}
|
||||
class="btn btn-sm preset-tonal-secondary flex items-center gap-1"
|
||||
title="Open badge review page"
|
||||
>
|
||||
<Eye size="1em" />
|
||||
<span class="hidden sm:inline">Review</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- 3. Copy review link to clipboard: Trusted + Edit Mode -->
|
||||
{#if is_trusted && is_edit_mode}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm flex items-center gap-1"
|
||||
class:preset-tonal-secondary={copy_status[event_badge_obj.event_badge_id] !== 'copied'}
|
||||
class:preset-filled-success-500={copy_status[event_badge_obj.event_badge_id] === 'copied'}
|
||||
onclick={() => copy_review_link(event_badge_obj)}
|
||||
title="Copy review link to clipboard"
|
||||
>
|
||||
{#if copy_status[event_badge_obj.event_badge_id] === 'copied'}
|
||||
<Check size="1em" />
|
||||
<span class="hidden sm:inline">Copied!</span>
|
||||
{:else}
|
||||
<Link size="1em" />
|
||||
<span class="hidden sm:inline">Copy Link</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- 4. Email Review Link: all if not printed; Trusted + Edit Mode if printed
|
||||
TODO: replace alert with actual email API call -->
|
||||
{#if !is_printed || (is_trusted && is_edit_mode)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-surface flex items-center gap-1"
|
||||
onclick={() => send_review_email(event_badge_obj)}
|
||||
title="Email a review link to this attendee"
|
||||
>
|
||||
<Mail size="1em" />
|
||||
<span class="hidden sm:inline">Email Link</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $ae_loc.edit_mode}
|
||||
<!-- Debug/metadata row — Edit Mode staff only -->
|
||||
{#if is_edit_mode && is_trusted}
|
||||
<div
|
||||
class="flex flex-row flex-wrap gap-x-4 gap-y-1 items-center justify-start w-full mt-1 p-1.5 bg-surface-200/50 dark:bg-surface-800/50 rounded text-[10px] font-mono border border-surface-300 dark:border-surface-700 opacity-80"
|
||||
>
|
||||
<span class="flex items-center gap-1"
|
||||
><span class="font-bold opacity-50">ID:</span>
|
||||
{event_badge_obj?.event_badge_id}</span
|
||||
>
|
||||
<span class="flex items-center gap-1"
|
||||
><span class="font-bold opacity-50">CR:</span>
|
||||
{ae_util.iso_datetime_formatter(
|
||||
event_badge_obj.created_on,
|
||||
'datetime_iso_12_no_seconds'
|
||||
)}</span
|
||||
>
|
||||
<span class="flex items-center gap-1"
|
||||
><span class="font-bold opacity-50">UP:</span>
|
||||
{ae_util.iso_datetime_formatter(
|
||||
event_badge_obj.updated_on,
|
||||
'datetime_iso_12_no_seconds'
|
||||
)}</span
|
||||
>
|
||||
{#if event_badge_obj.print_first_datetime}
|
||||
<span class="flex items-center gap-1"
|
||||
><span class="font-bold opacity-50"
|
||||
>FP:</span
|
||||
>
|
||||
{ae_util.iso_datetime_formatter(
|
||||
event_badge_obj.print_first_datetime,
|
||||
'datetime_iso_12_no_seconds'
|
||||
)}</span
|
||||
>
|
||||
{/if}
|
||||
{#if event_badge_obj.print_last_datetime}
|
||||
<span class="flex items-center gap-1"
|
||||
><span class="font-bold opacity-50"
|
||||
>LP:</span
|
||||
>
|
||||
{ae_util.iso_datetime_formatter(
|
||||
event_badge_obj.print_last_datetime,
|
||||
'datetime_iso_12_no_seconds'
|
||||
)}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-bold opacity-50">ID:</span>
|
||||
{event_badge_obj?.event_badge_id}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-bold opacity-50">CR:</span>
|
||||
{ae_util.iso_datetime_formatter(event_badge_obj.created_on, 'datetime_iso_12_no_seconds')}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-bold opacity-50">UP:</span>
|
||||
{ae_util.iso_datetime_formatter(event_badge_obj.updated_on, 'datetime_iso_12_no_seconds')}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-bold opacity-50">PC:</span>
|
||||
{print_count}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-bold opacity-50">FP:</span>
|
||||
{event_badge_obj.print_first_datetime
|
||||
? ae_util.iso_datetime_formatter(event_badge_obj.print_first_datetime, 'datetime_iso_12_no_seconds')
|
||||
: '—'}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-bold opacity-50">LP:</span>
|
||||
{event_badge_obj.print_last_datetime
|
||||
? ae_util.iso_datetime_formatter(event_badge_obj.print_last_datetime, 'datetime_iso_12_no_seconds')
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-20 opacity-50 text-center"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center p-20 opacity-50 text-center">
|
||||
<FileSearch size="3em" class="mb-2 opacity-20 mx-auto" />
|
||||
<p>
|
||||
No badges found matching your criteria. Try adjusting your
|
||||
filters.
|
||||
</p>
|
||||
<p>No badges found matching your criteria. Try adjusting your filters.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -92,12 +92,12 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if $ae_loc.administrator_access}
|
||||
{#if $ae_loc.administrator_access && $ae_loc.edit_mode}
|
||||
<section class="card p-6 variant-soft-warning border-l-4 border-warning-500 mt-12 bg-surface-100 dark:bg-surface-800 shadow-lg">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="fas fa-tools text-3xl"></span>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">Administrative Access</h3>
|
||||
<h3 class="text-xl font-bold">Event Admin Settings</h3>
|
||||
<p>You have elevated privileges. Use the menu above to access advanced settings and reports.</p>
|
||||
</div>
|
||||
<a href="/events/{$events_slct.event_id}/settings" class="btn ae_btn_warning ml-auto">
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
let show_upload_badge_modal: boolean = $state(false);
|
||||
|
||||
// Guard: Only allow administrators in edit mode
|
||||
if (!$ae_loc.administrator_access || !$ae_loc.edit_mode) {
|
||||
if (!$ae_loc.administrator_access) {
|
||||
if (browser) {
|
||||
alert(
|
||||
'Access Denied: Administrative privileges and Edit Mode required.'
|
||||
|
||||
@@ -8,6 +8,73 @@
|
||||
|
||||
let { mod_badges_json = $bindable({}), onsave }: Props = $props();
|
||||
|
||||
/**
|
||||
* edit_permissions — controls which fields each access level may edit in the badge review form.
|
||||
* Stored as mod_badges_json.edit_permissions.
|
||||
*
|
||||
* Structure:
|
||||
* authenticated.can_edit — fields attendees (passcode-validated) may edit
|
||||
* trusted.can_edit — fields trusted staff may edit
|
||||
* administrator.can_edit — '*' (all) or a specific field list
|
||||
*
|
||||
* Default attendee fields: full_name_override, professional_title_override,
|
||||
* affiliations_override, location_override
|
||||
* Default trusted fields: above + email, badge_type_code
|
||||
*/
|
||||
|
||||
const all_attendee_fields = [
|
||||
{ key: 'full_name_override', label: 'Full Name (override)' },
|
||||
{ key: 'professional_title_override', label: 'Professional Title (override)' },
|
||||
{ key: 'affiliations_override', label: 'Affiliations (override)' },
|
||||
{ key: 'location_override', label: 'Location (override)' }
|
||||
];
|
||||
|
||||
const all_staff_fields = [
|
||||
...all_attendee_fields,
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'badge_type_code', label: 'Badge Type Code' }
|
||||
];
|
||||
|
||||
// Ensure edit_permissions sub-object exists
|
||||
function ensure_permissions() {
|
||||
if (!mod_badges_json) return;
|
||||
if (!mod_badges_json.edit_permissions) {
|
||||
mod_badges_json.edit_permissions = {};
|
||||
}
|
||||
if (!mod_badges_json.edit_permissions.authenticated) {
|
||||
mod_badges_json.edit_permissions.authenticated = {
|
||||
can_edit: ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override']
|
||||
};
|
||||
}
|
||||
if (!mod_badges_json.edit_permissions.trusted) {
|
||||
mod_badges_json.edit_permissions.trusted = {
|
||||
can_edit: ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override', 'email', 'badge_type_code']
|
||||
};
|
||||
}
|
||||
if (!mod_badges_json.edit_permissions.administrator) {
|
||||
mod_badges_json.edit_permissions.administrator = { can_edit: '*' };
|
||||
}
|
||||
}
|
||||
|
||||
function is_field_enabled(level: 'authenticated' | 'trusted', field_key: string): boolean {
|
||||
const cfg = mod_badges_json?.edit_permissions?.[level]?.can_edit;
|
||||
if (!cfg) return false;
|
||||
if (cfg === '*') return true;
|
||||
return Array.isArray(cfg) && cfg.includes(field_key);
|
||||
}
|
||||
|
||||
function toggle_field(level: 'authenticated' | 'trusted', field_key: string) {
|
||||
ensure_permissions();
|
||||
if (!mod_badges_json?.edit_permissions?.[level]) return;
|
||||
let fields: string[] = mod_badges_json.edit_permissions[level].can_edit;
|
||||
if (!Array.isArray(fields)) fields = [];
|
||||
if (fields.includes(field_key)) {
|
||||
mod_badges_json.edit_permissions[level].can_edit = fields.filter((f: string) => f !== field_key);
|
||||
} else {
|
||||
mod_badges_json.edit_permissions[level].can_edit = [...fields, field_key];
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (onsave && mod_badges_json) onsave(mod_badges_json);
|
||||
}
|
||||
@@ -100,7 +167,61 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<details class="space-y-3">
|
||||
<summary class="cursor-pointer font-medium text-sm">
|
||||
Badge Review — Editable Field Permissions
|
||||
</summary>
|
||||
<div class="space-y-4 pt-2 pl-2">
|
||||
<p class="text-xs text-gray-500">
|
||||
Controls which fields each access level may edit on the Badge Review page.
|
||||
Staff (Trusted) defaults include all attendee fields plus Email and Badge Type Code.
|
||||
Administrators can always edit everything.
|
||||
</p>
|
||||
|
||||
<!-- Attendee (passcode-validated) -->
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">Attendees (passcode link)</p>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
{#each all_attendee_fields as field}
|
||||
<label class="label flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
checked={is_field_enabled('authenticated', field.key)}
|
||||
onchange={() => toggle_field('authenticated', field.key)}
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Staff (Trusted) -->
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">Staff (Trusted access)</p>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
{#each all_staff_fields as field}
|
||||
<label class="label flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
checked={is_field_enabled('trusted', field.key)}
|
||||
onchange={() => toggle_field('trusted', field.key)}
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Administrator note -->
|
||||
<p class="text-xs text-gray-400 italic">
|
||||
Administrators always have access to all fields (not configurable).
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
{/if} <!-- end {#if mod_badges_json} -->
|
||||
|
||||
<button type="button" class="btn preset-tonal-primary" onclick={save}
|
||||
>Save</button
|
||||
|
||||
Reference in New Issue
Block a user