feat: badge print controls — quick print btn, compacted spacing, collapsible sections, overflow fix

- Add Quick Print button (30%) alongside canonical Print Badge (70%): calls window.print()
  only — no count increment, no navigation back to search
- Compact panel spacing: reduce space-y, pt/pb on card headers, standalone row py, font_ctrl py
- Add collapsible Attendee/Staff section groups reusing ctrl-accordion CSS pattern;
  attendee starts open, staff starts collapsed — auto-collapses the other on expand
- Add overflow-x-hidden to print page panel container to kill horizontal scrollbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-19 18:47:35 -04:00
parent fdd8691e2e
commit f628e7e3fc
2 changed files with 117 additions and 50 deletions

View File

@@ -22,7 +22,7 @@
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events_functions';
import { browser } from '$app/environment';
import { Check, ChevronDown, Eye, EyeOff, Info, LoaderCircle, Pencil, Printer, RotateCcw, X } from '@lucide/svelte';
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Info, LoaderCircle, Pencil, Printer, RotateCcw, X } from '@lucide/svelte';
interface Props {
event_id: string;
event_badge_id: string;
@@ -98,6 +98,22 @@
// — 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);
// --- 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).
let attendee_open = $state(true);
let staff_open = $state(false);
function toggle_section(which: 'attendee' | 'staff') {
if (which === 'attendee') {
attendee_open = !attendee_open;
if (attendee_open) staff_open = false;
} else {
staff_open = !staff_open;
if (staff_open) attendee_open = false;
}
}
// --- Print ---
let print_count = $derived($lq__event_badge_obj?.print_count ?? 0);
let is_printed = $derived(print_count >= 1);
@@ -405,7 +421,7 @@
key === 'name' ? font_size_name :
key === 'title' ? font_size_title :
key === 'affiliations' ? font_size_affiliations : font_size_location}
<div class="flex items-center gap-1 border-t border-gray-100 dark:border-gray-800 px-2 py-1"
<div class="flex items-center gap-1 border-t border-gray-100 dark:border-gray-800 px-2 py-0.5"
role="group"
aria-label="{key} font size controls"
>
@@ -505,34 +521,52 @@
Main panel
============================================================ -->
<!-- Print button — canonical action: increments print_count, fires window.print(),
then navigates back to badge search. Trusted+ only; edit mode allows reprints. -->
<!-- Print button row — canonical (70%) + quick print (30%).
Canonical: increments print_count, fires window.print(), navigates back to search.
Quick print: just window.print() — no count increment, no navigation.
Both: Trusted+ only; edit mode allows reprints. -->
{#if can_print}
<!-- Tinted card visually lifts the print action above the field list -->
<div class="mb-3 p-2 rounded-lg bg-primary-50/40 dark:bg-primary-950/20 border border-primary-200/50 dark:border-primary-800/30">
<button
type="button"
class="btn w-full flex items-center justify-center gap-2"
class:preset-filled-primary={print_status === 'idle'}
class:preset-tonal-surface={print_status === 'loading'}
class:preset-filled-success={print_status === 'done'}
class:preset-tonal-error={print_status === 'error'}
onclick={handle_print_badge}
disabled={print_status === 'loading' || print_status === 'done'}
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge now'}
data-testid="badge-print-btn"
>
{#if print_status === 'loading'}
<LoaderCircle size="14" class="animate-spin" /> Printing…
{:else if print_status === 'done'}
<Check size="14" /> Printed!
{:else if print_status === 'error'}
Error — try again
{:else}
<Printer size="14" />
{is_printed ? `Reprint (${print_count}×)` : 'Print Badge'}
{/if}
</button>
<div class="mb-2 p-2 rounded-lg bg-primary-50/40 dark:bg-primary-950/20">
<div class="flex gap-1.5">
<!-- Canonical Print Badge: 70% — tracks count, returns to search -->
<button
type="button"
class="btn flex-7 flex items-center justify-center gap-2 border border-primary-200/50 dark:border-primary-800/30"
class:preset-filled-primary={print_status === 'idle'}
class:preset-tonal-surface={print_status === 'loading'}
class:preset-filled-success={print_status === 'done'}
class:preset-tonal-error={print_status === 'error'}
onclick={handle_print_badge}
disabled={print_status === 'loading' || print_status === 'done'}
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge now'}
data-testid="badge-print-btn"
>
{#if print_status === 'loading'}
<LoaderCircle size="14" class="animate-spin" /> Printing…
{:else if print_status === 'done'}
<Check size="14" /> Printed!
{:else if print_status === 'error'}
Error — try again
{:else}
<Printer size="14" />
{is_printed ? `Reprint (${print_count}×)` : 'Print Badge'}
{/if}
</button>
<!-- Quick print: 30% — window.print() only, no count, no navigation.
Use when you need to reprint mid-workflow without resetting search state
or when the count should not change (e.g. a test print). -->
<button
type="button"
class="btn flex-3 preset-tonal-surface flex items-center justify-center gap-1 border border-primary-200/50 dark:border-primary-800/30 text-xs"
onclick={() => window.print()}
title="Print without tracking no count increment, stays on this page"
>
<Printer size="12" />
Print
</button>
</div>
{#if is_printed && print_status === 'idle'}
<p class="text-[10px] text-amber-600 dark:text-amber-400 text-center mt-1">
Already printed {print_count}×
@@ -541,15 +575,31 @@
</div>
{/if}
<div class="space-y-1.5 text-sm">
<div class="space-y-1 text-sm">
<p class="text-[9px] uppercase tracking-widest text-gray-300 dark:text-gray-600 font-semibold px-0.5 pb-0.5">Attendee info</p>
<!-- Attendee section header — clickable, collapses staff when opened -->
<button
type="button"
class="w-full flex items-center justify-between px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onclick={() => toggle_section('attendee')}
aria-expanded={attendee_open}
>
<span class="text-[9px] uppercase tracking-widest text-gray-400 dark:text-gray-500 font-semibold">Attendee info</span>
{#if attendee_open}
<ChevronDown size="10" class="opacity-40" />
{:else}
<ChevronRight size="10" class="opacity-40" />
{/if}
</button>
<div class="ctrl-accordion" class:open={attendee_open}>
<div class="ctrl-accordion-inner space-y-1">
<!-- === NAME ===
Font controls: always visible (all can adjust for readability at kiosk).
Edit form: Trusted+ only — accordion opens only when is_trusted. -->
<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.5 pb-1">
<div class="flex items-center gap-2 px-2 pt-1 pb-0.5">
<div class="flex-1 min-w-0">
<p class="field-label">Name</p>
{#if get_display('full_name_override', 'full_name')}
@@ -608,7 +658,7 @@
<!-- === PROFESSIONAL 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.5 pb-1">
<div class="flex items-center gap-2 px-2 pt-1 pb-0.5">
<div class="flex-1 min-w-0">
<p class="field-label">Title</p>
{#if get_display('professional_title_override', 'professional_title')}
@@ -661,7 +711,7 @@
<!-- === 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.5 pb-1">
<div class="flex items-center gap-2 px-2 pt-1 pb-0.5">
<div class="flex-1 min-w-0">
<p class="field-label">Affiliations</p>
{#if get_display('affiliations_override', 'affiliations')}
@@ -714,7 +764,7 @@
<!-- === 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.5 pb-1">
<div class="flex items-center gap-2 px-2 pt-1 pb-0.5">
<div class="flex-1 min-w-0">
<p class="field-label">Location</p>
{#if get_display('location_override', 'location')}
@@ -767,7 +817,7 @@
<!-- === ALLOW TRACKING (Lead Scanning) === -->
<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.5">
<div class="flex items-center gap-2 px-2 py-1">
<div class="flex-1 min-w-0">
<p class="field-label">Lead Scanning</p>
<p class="leading-snug text-xs">
@@ -851,7 +901,7 @@
<!-- === 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.5">
<div class="flex items-center gap-2 px-2 py-1">
<div class="flex-1 min-w-0">
<p class="field-label">Pronouns</p>
{#if get_display('pronouns_override', 'pronouns')}
@@ -898,19 +948,33 @@
</div>
</div>
</div><!-- ctrl-accordion-inner attendee -->
</div><!-- ctrl-accordion attendee -->
<!-- === STAFF-ONLY FIELDS === -->
{#if is_trusted}
<!-- Divider between attendee and staff fields -->
<div class="pt-1.5 pb-0.5 flex items-center gap-1.5">
<span class="flex-1 border-t border-gray-200 dark:border-gray-700"></span>
<span class="text-[9px] uppercase tracking-widest text-gray-300 dark:text-gray-600 font-semibold whitespace-nowrap">Staff adjustments</span>
<span class="flex-1 border-t border-gray-200 dark:border-gray-700"></span>
</div>
<!-- Staff section header — clickable, collapses attendee when opened -->
<button
type="button"
class="w-full flex items-center justify-between px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onclick={() => toggle_section('staff')}
aria-expanded={staff_open}
>
<span class="text-[9px] uppercase tracking-widest text-gray-400 dark:text-gray-500 font-semibold">Staff adjustments</span>
{#if staff_open}
<ChevronDown size="10" class="opacity-40" />
{:else}
<ChevronRight size="10" class="opacity-40" />
{/if}
</button>
<div class="ctrl-accordion" class:open={staff_open}>
<div class="ctrl-accordion-inner space-y-1 pt-0.5">
<!-- Chrome visibility toggle: hides page header + sys bar for a clean workspace.
Keyboard shortcut [H] does the same thing from anywhere on the page. -->
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex items-center gap-2 px-2 py-1">
<button
type="button"
class="btn btn-sm w-full justify-between"
@@ -933,7 +997,7 @@
</div>
<!-- Name layout: two-line (preferred) vs single-line -->
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex items-center gap-2 px-2 py-1">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
@@ -945,7 +1009,7 @@
</div>
<!-- Banner width toggle: 100% (production) vs natural size (dev/calibration) -->
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex items-center gap-2 px-2 py-1">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
@@ -957,7 +1021,7 @@
</div>
<!-- Debug Outlines Toggle (for print testing) -->
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex items-center gap-2 px-2 py-1">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
@@ -973,8 +1037,8 @@
Stored in localStorage by the parent page — each workstation keeps
its own values across sessions without any server config change.
Positive X = shift right; positive Y = shift down. 0.5mm steps. -->
<div class="px-2 py-1.5">
<p class="field-label mb-1.5">
<div class="px-2 py-1">
<p class="field-label mb-1">
Print position
<span class="text-[9px] font-normal normal-case tracking-normal text-gray-400 ml-1">saved to browser</span>
</p>
@@ -1043,7 +1107,7 @@
<!-- === BADGE TYPE === (only when template defines badge_type_list) -->
{#if badge_type_code_li.length > 0}
<div class="field-card rounded-lg overflow-hidden" class:field-card--active={active_field === 'badge_type'}>
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex items-center gap-2 px-2 py-1">
<div class="flex-1 min-w-0">
<p class="field-label">Badge Type</p>
{#if badge_type_display}
@@ -1101,6 +1165,9 @@
</div>
{/if}
</div><!-- ctrl-accordion-inner staff -->
</div><!-- ctrl-accordion staff -->
{/if}<!-- end is_trusted -->
</div>

View File

@@ -389,7 +389,7 @@
Fixed right-0 means width growth expands leftward into the badge area.
top-20 clears the page header (~80px). Independently scrollable.
bottom-24: clears the sys bar (fixed bottom-12 h-9 = 84px from bottom). -->
<div class="print:hidden fixed right-0 top-20 bottom-24 overflow-y-auto z-30
<div class="print:hidden fixed right-0 top-20 bottom-24 overflow-y-auto overflow-x-hidden z-30
border-l border-gray-200 dark:border-gray-700
bg-white dark:bg-zinc-900 p-2
transition-all duration-200"