feat(badges): public_access kiosk mode + manager access improvements

- Public (attendee) kiosk: unprinted badges link to /review; printed
  badges show green "Checked in · Nx · First/Last" row (non-clickable)
- Public attendees no longer see Print button (staff-only action)
- Printed badges sort to end of list for public non-trusted users
- Manager access: Print (reprint) and Email Link buttons always visible
  without requiring Edit Mode; main row behavior unchanged
- Empty state wording: context-aware — "Enter your name above to find
  your badge" for public users with no query vs "No badges found" after
  an actual search
- Docs: Epson C3500 fanfold section filled in (was empty placeholder);
  style_href/duplex implementation status corrected in badge templates
  doc; Axonius C3500 layout TODO marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-02 16:15:08 -04:00
parent 72c8f9b502
commit 60bdd2fdba
5 changed files with 112 additions and 42 deletions

View File

@@ -42,6 +42,7 @@ let copy_status: Record<string, 'idle' | 'copied'> = $state({});
// Access level shortcuts
let is_trusted = $derived($ae_loc.trusted_access === true);
let is_admin = $derived($ae_loc.administrator_access === true);
let is_manager = $derived($ae_loc.manager_access === true);
let is_public = $derived($ae_loc.public_access === true); // public passcode or higher — may print first prints
let is_edit_mode = $derived($ae_loc.edit_mode === true);
@@ -113,12 +114,24 @@ let visible_badge_obj_li = $derived(
if (ps === 'not_printed') return (item.print_count ?? 0) < 1 && hide_ok;
return hide_ok; // 'all'
}
// Public (kiosk) / authenticated / anonymous: only unprinted, never hidden.
// Badge kiosks run at public_access — attendees should only see their own
// unprinted badge, never a list of already-printed ones.
// Public/Attendee (public_access, not trusted): show all non-hidden.
// Printed badges appear read-only so attendees can see their check-in status.
if (is_public) return !item.hide;
// Below public (anonymous, authenticated): only unprinted, never hidden.
return (item.print_count ?? 0) < 1 && !item.hide;
});
// For public (attendee) kiosk: sort unprinted first so attendees find their
// own badge at the top; already-checked-in records fall to the bottom.
if (is_public && !is_trusted && filtered.length > 1) {
filtered.sort((a: any, b: any) => {
const a_p = (a.print_count ?? 0) > 0;
const b_p = (b.print_count ?? 0) > 0;
if (a_p !== b_p) return a_p ? 1 : -1;
return 0; // preserve existing name order within each group
});
}
if (log_lvl)
console.log(
`visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}`
@@ -154,9 +167,13 @@ let visible_badge_obj_li = $derived(
event_badge_obj?.full_name_override ??
event_badge_obj?.full_name ??
`${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim()}
{@const can_print = (is_public && !is_printed) || (is_trusted && is_edit_mode)}
{@const is_attendee_view = is_public && !is_trusted}
{@const is_attendee_printed = is_attendee_view && is_printed}
{@const can_print = is_trusted && (!is_printed || is_edit_mode)}
{@const row_clickable = can_print || (is_attendee_view && !is_printed)}
{@const print_href = `/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}/print`}
{@const review_href = build_review_url(event_badge_obj)}
{@const row_href = is_trusted ? print_href : review_href}
<li
class="
@@ -169,18 +186,13 @@ let visible_badge_obj_li = $derived(
class="flex w-full flex-row flex-wrap items-center justify-between gap-2">
<!-- Left cluster: main action (name + email + affiliations) -->
<div class="flex min-w-0 grow flex-row flex-wrap items-center gap-x-3 gap-y-1">
{#if can_print}
{#if row_clickable}
<a
href={print_href}
href={row_href}
class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-3 justify-start px-3 py-2 text-left text-lg font-bold flex-1 transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg"
title="
Print badge for
{display_name}
email: <{is_trusted ? event_badge_obj?.email : obscure_email(event_badge_obj?.email)}>
{event_badge_obj?.affiliations_override ?? event_badge_obj?.affiliations}
type: {event_badge_obj?.badge_type}
id: {event_badge_obj.event_badge_id}
"
title={is_trusted
? `Print badge for ${display_name} · ${event_badge_obj?.affiliations_override ?? event_badge_obj?.affiliations ?? ''} · ${event_badge_obj?.badge_type ?? ''} · id: ${event_badge_obj.event_badge_id}`
: `Review your badge info`}
>
{#if event_badge_obj?.hide}
<EyeOff size="1.1em" class="text-gray-400" />
@@ -208,6 +220,23 @@ let visible_badge_obj_li = $derived(
{/if}
</div>
</a>
{:else if is_attendee_printed}
<!-- Public attendee, badge already printed: show check-in status, no action -->
<div class="flex items-center gap-3 justify-start px-3 py-2 flex-1 min-w-0 rounded-lg bg-success-500/10">
<Check size="1.1em" class="text-success-600 dark:text-success-400 shrink-0" />
<div class="flex flex-col min-w-0">
<span class="truncate font-semibold">{display_name}</span>
<span class="text-xs text-success-700 dark:text-success-300 opacity-80">
Checked in &middot; {print_count}&times;
{#if event_badge_obj.print_first_datetime}
&middot; First: {ae_util.iso_datetime_formatter(event_badge_obj.print_first_datetime, 'datetime_iso_12_no_seconds')}
{/if}
{#if event_badge_obj.print_last_datetime}
&middot; Last: {ae_util.iso_datetime_formatter(event_badge_obj.print_last_datetime, 'datetime_iso_12_no_seconds')}
{/if}
</span>
</div>
</div>
{:else}
<div
class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-3 justify-start px-3 py-2 text-left text-lg font-bold flex-1 transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg cursor-not-allowed"
@@ -256,8 +285,8 @@ let visible_badge_obj_li = $derived(
<!-- Right: up to 4 action buttons -->
<div class="flex shrink-0 flex-row items-center gap-1">
<!-- 1. Print Badge: Public+ first print; Trusted + Edit Mode for reprint -->
{#if (is_public && !is_printed) || (is_trusted && is_edit_mode)}
<!-- 1. Print Badge: Trusted — first print anytime, reprint in Edit Mode or Manager+ -->
{#if is_trusted && (!is_printed || is_edit_mode || is_manager)}
<a
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}/print`}
class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-1 px-3 py-2 text-base font-bold transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg border border-primary-200-800
@@ -312,9 +341,8 @@ let visible_badge_obj_li = $derived(
</button>
{/if}
<!-- 4. Email Review Link: Administrator + Edit Mode only
Temporarily restricted — TODO: restore broader access after Axonius 2026 -->
{#if is_admin && is_edit_mode}
<!-- 4. Email Review Link: Manager+ always; Administrator + Edit Mode otherwise -->
{#if is_admin && (is_edit_mode || is_manager)}
<button
type="button"
class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-1 px-3 py-2 text-base font-bold transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg border border-primary-200-800"
@@ -382,10 +410,11 @@ let visible_badge_obj_li = $derived(
<div
class="flex flex-col items-center justify-center p-20 text-center opacity-50">
<FileSearch size="3em" class="mx-auto mb-2 opacity-20" />
<p>
No badges found matching your criteria. Try adjusting your
filters.
</p>
{#if !is_trusted && !(badges_loc.current.fulltext_search_qry_str ?? '').trim()}
<p>Enter your name above to find your badge.</p>
{:else}
<p>No badges found matching your criteria. Try adjusting your filters.</p>
{/if}
</div>
{/if}
</section>