feat(badges): add CSV export to badge reports

Adds an "Export CSV" report to the badge reports page. Generates
a clean CSV client-side from Dexie cache — no backend call needed.

- 3 filter presets: Printed+Clean (default), Printed all, All badges
- Printed+Clean mirrors the manually-cleaned Axonius export (printed,
  non-hidden, non-test badge type)
- Timezone selector: Eastern (default), Local, UTC — addresses the
  UTC→Eastern conversion needed for post-event client exports
- 24 columns: identity fields, override pairs, print status, created_on
- UTF-8 BOM for direct Excel open without import wizard
- Auto-generated filename from event name + date + filter suffix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-10 16:00:18 -04:00
parent b9d70b616f
commit 89c1decf8d
2 changed files with 312 additions and 2 deletions

View File

@@ -13,13 +13,14 @@ 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 { ArrowLeft, TrendingUp, Type, Gauge, LoaderCircle } from '@lucide/svelte';
import { ArrowLeft, TrendingUp, Type, Gauge, FileSpreadsheet, LoaderCircle } from '@lucide/svelte';
import Reports_badge_long_names from './reports_badge_long_names.svelte';
import Reports_badge_print_throughput from './reports_badge_print_throughput.svelte';
import Reports_badge_export from './reports_badge_export.svelte';
let event_id = $derived(page.params.event_id);
type ReportKey = 'long_names' | 'print_throughput';
type ReportKey = 'long_names' | 'print_throughput' | 'export';
let active_report: ReportKey | null = $state(null);
let lq__event_obj = $derived(
@@ -107,6 +108,18 @@ let reprint_count = $derived(
<Gauge size="1em" />
Print Throughput
</button>
<button
type="button"
onclick={() => (active_report = 'export')}
class="btn btn-sm border transition-all"
class:preset-filled-primary={active_report === 'export'}
class:border-primary-600={active_report === 'export'}
class:preset-tonal-primary={active_report !== 'export'}
class:border-primary-400={active_report !== 'export'}
title="Export badge data to CSV for post-event reporting">
<FileSpreadsheet size="1em" />
Export CSV
</button>
</div>
{#if $lq__badge_li === undefined}
@@ -118,6 +131,12 @@ let reprint_count = $derived(
<Reports_badge_long_names badge_li={$lq__badge_li} event_id={event_id ?? ''} {log_lvl} />
{:else if active_report === 'print_throughput'}
<Reports_badge_print_throughput badge_li={$lq__badge_li} event_id={event_id ?? ''} {log_lvl} />
{:else if active_report === 'export'}
<Reports_badge_export
badge_li={$lq__badge_li}
event_id={event_id ?? ''}
event_name={$lq__event_obj?.name ?? ''}
{log_lvl} />
{:else}
<p class="py-4 text-sm text-surface-600-400">Select a report above to view results.</p>
{/if}

View File

@@ -0,0 +1,291 @@
<script lang="ts">
interface Props {
badge_li: any[];
event_id: string;
event_name?: string;
log_lvl?: number;
}
let { badge_li, event_id, event_name = '', log_lvl = 0 }: Props = $props();
import { Download, FileSpreadsheet } from '@lucide/svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
// ---------------------------------------------------------------------------
// Filter
// ---------------------------------------------------------------------------
type FilterMode = 'printed_clean' | 'printed' | 'all';
let filter_mode: FilterMode = $state('printed_clean');
// ---------------------------------------------------------------------------
// Timezone
// ---------------------------------------------------------------------------
type TzMode = 'eastern' | 'local' | 'utc';
let tz_mode: TzMode = $state('eastern');
const TZ_LABEL: Record<TzMode, string> = {
eastern: 'Eastern (US)',
local: 'Local (browser)',
utc: 'UTC'
};
function get_tz(): string | undefined {
if (tz_mode === 'eastern') return 'America/New_York';
if (tz_mode === 'utc') return 'UTC';
return undefined; // browser local
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Backend returns naive UTC strings with no TZ indicator — append Z so
// the browser parses them as UTC before converting to the chosen timezone.
function parse_utc(dt: string | null | undefined): Date | null {
if (!dt) return null;
const s = dt.match(/Z$|[+-]\d{2}:?\d{2}$/) ? dt : dt + 'Z';
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
function fmt_dt(dt: string | null | undefined): string {
const d = parse_utc(dt);
if (!d) return '';
const tz = get_tz();
try {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
...(tz ? { timeZone: tz } : {})
})
.format(d)
.replace(',', '');
} catch {
return d.toISOString();
}
}
function csv_cell(v: any): string {
if (v === null || v === undefined) return '';
const s = String(v);
if (s.includes(',') || s.includes('\n') || s.includes('"')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
// ---------------------------------------------------------------------------
// Filter
// ---------------------------------------------------------------------------
let filtered_badges = $derived.by(() => {
if (!badge_li?.length) return [];
switch (filter_mode) {
case 'printed_clean':
// Mirrors what was sent to Axonius: printed, not hidden, not test badge type
return badge_li.filter(
(b) =>
(b.print_count ?? 0) >= 1 &&
!b.hide &&
(b.badge_type_code ?? '').toLowerCase() !== 'test'
);
case 'printed':
return badge_li.filter((b) => (b.print_count ?? 0) >= 1);
default:
return badge_li;
}
});
let print_count_total = $derived(
filtered_badges.filter((b) => (b.print_count ?? 0) >= 1).length
);
// ---------------------------------------------------------------------------
// CSV columns
// ---------------------------------------------------------------------------
interface ColDef {
header: string;
value: (b: any) => any;
}
const COLUMNS: ColDef[] = [
{ header: 'external_id', value: (b) => b.external_id ?? '' },
{ header: 'given_name', value: (b) => b.given_name ?? '' },
{ header: 'family_name', value: (b) => b.family_name ?? '' },
{ header: 'full_name', value: (b) => b.full_name ?? '' },
{ header: 'full_name_override', value: (b) => b.full_name_override ?? '' },
{ header: 'professional_title', value: (b) => b.professional_title ?? '' },
{ header: 'professional_title_override', value: (b) => b.professional_title_override ?? '' },
{ header: 'affiliations', value: (b) => b.affiliations ?? '' },
{ header: 'affiliations_override', value: (b) => b.affiliations_override ?? '' },
{ header: 'email', value: (b) => b.email ?? '' },
{ header: 'city', value: (b) => b.city ?? '' },
{ header: 'state_province', value: (b) => b.state_province ?? '' },
{ header: 'country', value: (b) => b.country ?? '' },
{ header: 'location', value: (b) => b.location ?? '' },
{ header: 'location_override', value: (b) => b.location_override ?? '' },
{ header: 'badge_type_code', value: (b) => b.badge_type_code ?? '' },
{ header: 'badge_type', value: (b) => b.badge_type ?? '' },
{ header: 'allow_tracking', value: (b) => (b.allow_tracking ? 1 : 0) },
{ header: 'agree_to_tc', value: (b) => (b.agree_to_tc ? 1 : 0) },
{ header: 'print_first_datetime', value: (b) => fmt_dt(b.print_first_datetime) },
{ header: 'print_last_datetime', value: (b) => fmt_dt(b.print_last_datetime) },
{ header: 'print_count', value: (b) => b.print_count ?? 0 },
{ header: 'created_on', value: (b) => fmt_dt(b.created_on) },
{ header: 'event_badge_id', value: (b) => b.event_badge_id ?? '' }
];
// ---------------------------------------------------------------------------
// Download
// ---------------------------------------------------------------------------
let is_downloading = $state(false);
function build_filename(): string {
const name_slug = ae_util
.clean_filename(event_name || 'event')
.replace(/\./g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.substring(0, 30);
const today = new Date().toISOString().substring(0, 10);
const filter_tag =
filter_mode === 'printed_clean'
? '_badges_printed'
: filter_mode === 'printed'
? '_badges_printed_all'
: '_badges_all';
return `${name_slug}${filter_tag}_${today}.csv`;
}
function download_csv() {
if (is_downloading) return;
is_downloading = true;
try {
const rows = filtered_badges;
const header_row = COLUMNS.map((c) => csv_cell(c.header)).join(',');
const data_rows = rows.map((b) => COLUMNS.map((c) => csv_cell(c.value(b))).join(','));
// UTF-8 BOM so Excel opens it correctly without needing an import wizard
const csv_content = '' + [header_row, ...data_rows].join('\r\n');
const blob = new Blob([csv_content], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = build_filename();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} finally {
is_downloading = false;
}
}
</script>
<div class="space-y-4">
<!-- Filter options -->
<div class="flex flex-row flex-wrap items-start gap-4">
<div class="space-y-1">
<p class="text-xs font-semibold uppercase tracking-wide text-surface-500-400">Records</p>
<div class="flex flex-row items-center gap-1">
{#each ([['printed_clean', 'Printed + Clean'], ['printed', 'Printed (all)'], ['all', 'All badges']] as [FilterMode, string][]) as [mode, label] (mode)}
<button
type="button"
onclick={() => (filter_mode = mode)}
class="btn btn-sm border transition-all"
class:preset-filled-primary={filter_mode === mode}
class:border-primary-600={filter_mode === mode}
class:preset-tonal-surface={filter_mode !== mode}
class:border-surface-300-700={filter_mode !== mode}
title={mode === 'printed_clean'
? 'Printed badges, excluding hidden records and test badge types — matches the standard client export'
: mode === 'printed'
? 'All printed badges regardless of hide flag or badge type'
: 'All badge records in local cache, including unprinted'}>
{label}
</button>
{/each}
</div>
</div>
<div class="space-y-1">
<p class="text-xs font-semibold uppercase tracking-wide text-surface-500-400">Timestamps</p>
<div class="flex flex-row items-center gap-1">
{#each (['eastern', 'local', 'utc'] as TzMode[]) as tz (tz)}
<button
type="button"
onclick={() => (tz_mode = tz)}
class="btn btn-sm border transition-all"
class:preset-filled-primary={tz_mode === tz}
class:border-primary-600={tz_mode === tz}
class:preset-tonal-surface={tz_mode !== tz}
class:border-surface-300-700={tz_mode !== tz}>
{TZ_LABEL[tz]}
</button>
{/each}
</div>
</div>
</div>
<!-- Summary + download button -->
<div class="flex flex-row flex-wrap items-center gap-3">
<div class="text-sm text-surface-600-400">
{#if filtered_badges.length === 0}
No records match this filter.
{:else}
{filtered_badges.length} record{filtered_badges.length !== 1 ? 's' : ''}
{#if filter_mode !== 'all'}
({print_count_total} printed)
{/if}
· {COLUMNS.length} columns · timestamps in {TZ_LABEL[tz_mode]}
{/if}
</div>
<button
type="button"
onclick={download_csv}
disabled={is_downloading || filtered_badges.length === 0}
class="btn btn-sm preset-filled-primary border border-primary-600 flex items-center gap-1.5 disabled:opacity-50">
<FileSpreadsheet size="1em" />
Download CSV
{#if filtered_badges.length > 0}
<span class="opacity-75">({filtered_badges.length})</span>
{/if}
</button>
</div>
<!-- Column list for reference -->
<details class="text-xs text-surface-500-400">
<summary class="cursor-pointer hover:text-surface-700-300 transition-colors">
Columns included ({COLUMNS.length})
</summary>
<div class="mt-1.5 flex flex-row flex-wrap gap-1 pl-2">
{#each COLUMNS as col (col.header)}
<span
class="rounded border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800/50 px-1.5 py-0.5 font-mono"
class:text-primary-600={col.header.endsWith('_override')}
class:dark:text-primary-400={col.header.endsWith('_override')}
title={col.header.endsWith('_override') ? 'Override field — value set by staff or attendee edit' : ''}>
{col.header}
</span>
{/each}
</div>
<p class="mt-1 pl-2 text-primary-600 dark:text-primary-400">
Fields highlighted in primary color are override fields — values set by staff or attendee before printing.
</p>
</details>
<!-- Filter explanation -->
<p class="text-xs text-surface-500-400">
{#if filter_mode === 'printed_clean'}
<strong>Printed + Clean:</strong> Printed badges with non-hidden records and no test badge types. This is the standard export sent to clients.
{:else if filter_mode === 'printed'}
<strong>Printed (all):</strong> All badges with at least one print, including hidden records and test types.
{:else}
<strong>All badges:</strong> Every badge record in the local cache, including unprinted, hidden, and test records.
{/if}
Rows without a value in <code>external_id</code> were added manually (not via CSV import).
</p>
</div>