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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user