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:
174
documentation/AE__Permissions_and_Security.md
Normal file
174
documentation/AE__Permissions_and_Security.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Aether — Permissions and Security
|
||||
|
||||
**Last updated:** 2026-02-27
|
||||
**Source of truth:** `src/lib/ae_utils/ae_utils__perm_checks.ts`, `src/lib/stores/ae_stores.ts`
|
||||
|
||||
---
|
||||
|
||||
## Access Level Hierarchy
|
||||
|
||||
Highest to lowest. Each level **inherits all access from every level below it**.
|
||||
|
||||
| Level | `access_type` string | Typical Use |
|
||||
| --- | --- | --- |
|
||||
| Super | `super` | OSIT internal — full system access |
|
||||
| Manager | `manager` | Account managers |
|
||||
| Administrator | `administrator` | Event/account admins |
|
||||
| Trusted | `trusted` | **Onsite staff** — site passcode or AE login |
|
||||
| Public | `public` | Site-wide passcode granted |
|
||||
| Authenticated | `authenticated` | Identity verified (e.g. IDAA Novi UUID) |
|
||||
| Anonymous | `anonymous` | Default — not signed in |
|
||||
|
||||
> **Note on Public vs Authenticated:** `public` is a *site-wide* unlock (anyone with the passcode). `authenticated` verifies a *specific identity*. In the hierarchy, public outranks authenticated because it implies broader site access.
|
||||
|
||||
---
|
||||
|
||||
## `$ae_loc` Store — Permission Flags
|
||||
|
||||
`$ae_loc` is a `persisted()` store (backed by localStorage). Key fields:
|
||||
|
||||
```typescript
|
||||
$ae_loc.access_type // string: current access type ('anonymous', 'trusted', etc.)
|
||||
|
||||
// Cumulative boolean flags (true = "you have AT LEAST this level")
|
||||
$ae_loc.anonymous_access // always true
|
||||
$ae_loc.authenticated_access // true from authenticated and above
|
||||
$ae_loc.public_access // true from public and above
|
||||
$ae_loc.trusted_access // true from trusted and above ← most-used gate
|
||||
$ae_loc.administrator_access // true from administrator and above
|
||||
$ae_loc.manager_access // true from manager and above
|
||||
$ae_loc.super_access // true only at super
|
||||
|
||||
// Exclusive check flags (true = "you are EXACTLY this level")
|
||||
$ae_loc.trusted_check // true only if access_type === 'trusted'
|
||||
$ae_loc.administrator_check // etc.
|
||||
// (rarely needed — prefer the _access flags)
|
||||
|
||||
// Behavior flags
|
||||
$ae_loc.edit_mode // boolean — user preference, see below
|
||||
$ae_loc.adv_mode // boolean — advanced mode toggle
|
||||
```
|
||||
|
||||
### Additional intermediate levels (in permission checks, not in hierarchy order)
|
||||
`support`, `assistant`, `verified`, `provisional` — appear in `_access` flags but are not part of the canonical `access_level_order`. Treat as internal/intermediate.
|
||||
|
||||
---
|
||||
|
||||
## Edit Mode — Critical Rules
|
||||
|
||||
`$ae_loc.edit_mode` is a **user preference**, not a permission level.
|
||||
|
||||
**Rules that must never be broken:**
|
||||
1. **Components must never write to `$ae_loc.edit_mode`** — only the system menu toggle and sign-out/permission-drop handlers may change it.
|
||||
2. Edit mode is only available to `trusted` and above in 95% of modules (the toggle is hidden from lower-access users).
|
||||
3. Edit mode persists across navigation — it is NOT reset by page loads or component mounts.
|
||||
4. Sign-out and permission drops to below `authenticated` should reset `edit_mode` to `false`.
|
||||
|
||||
> **Background:** A bug was fixed (2026-02-27) where `ae_comp__badge_obj_view.svelte` was writing `$ae_loc.edit_mode = false` in a data-loading `$effect`, silently overriding the user's preference on every navigation to the badge print page.
|
||||
|
||||
---
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
| Method | Grants | Used For |
|
||||
| --- | --- | --- |
|
||||
| Site passcode (`site_access_code_kv`) | `trusted`, `public`, or `authenticated` | Onsite staff and event attendees |
|
||||
| AE Username + Password | `trusted` and above | Staff with AE accounts |
|
||||
| Novi UUID | `authenticated` | IDAA members (Novi membership system) |
|
||||
|
||||
Passcodes are stored per-level in `$ae_loc.site_access_code_kv`:
|
||||
```typescript
|
||||
site_access_code_kv: {
|
||||
administrator: null, // highest passcode tier
|
||||
trusted: null, // onsite staff passcode
|
||||
public: 'public1980', // example
|
||||
authenticated: 'auth1980'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### `process_permission_checks(access_type: string)`
|
||||
Returns a full permission object (`_check` and `_access` flags) for a given access type string. Used when access type changes to update `$ae_loc`.
|
||||
|
||||
```typescript
|
||||
import { process_permission_checks } from '$lib/ae_utils/ae_utils__perm_checks';
|
||||
const checks = process_permission_checks('trusted');
|
||||
// checks.trusted_access === true
|
||||
// checks.administrator_access === false
|
||||
```
|
||||
|
||||
### `compare_access_levels(level_a, level_b)`
|
||||
Returns `1` if `level_a` is higher, `-1` if lower, `0` if equal. Useful for threshold comparisons.
|
||||
|
||||
---
|
||||
|
||||
## Privacy and Security Rules
|
||||
|
||||
### IDAA — International Doctors in Alcoholics Anonymous
|
||||
- **ALL IDAA content is private. Always. No exceptions.**
|
||||
- BB (Bulletin Board / Posts), Archives, Recovery Meetings — all require authentication.
|
||||
- IDAA users authenticate via Novi UUID at `authenticated` level or higher.
|
||||
- A prior agent accidentally exposed IDAA BB data publicly — treat any IDAA exposure as Sev-1.
|
||||
|
||||
### Journals
|
||||
- Private personal data. Always authenticated. Passcode/encryption features exist.
|
||||
- Never expose journal content publicly.
|
||||
|
||||
### `PUBLIC_AE_API_SECRET_KEY`
|
||||
- Ongoing Sev-1 audit. Do not introduce new usages.
|
||||
- Prefer per-request API key headers (`x-aether-api-key` + `x-account-id`).
|
||||
|
||||
### Email Display
|
||||
Non-trusted users must never see a full email address. Obscure using:
|
||||
```typescript
|
||||
// joh***@example.com
|
||||
function obscure_email(email: string): string {
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return email;
|
||||
return `${email.slice(0, Math.min(3, at))}***${email.slice(at)}`;
|
||||
}
|
||||
```
|
||||
This pattern lives in `ae_comp__badge_obj_li.svelte` — move to `ae_utils` if needed elsewhere.
|
||||
|
||||
---
|
||||
|
||||
## Module-Specific Permission Patterns
|
||||
|
||||
### Events — Badges
|
||||
|
||||
| Scenario | Visibility | Print Action | Review Actions |
|
||||
| --- | --- | --- | --- |
|
||||
| Anonymous / below trusted | Unprinted only | None (name display only) | Email Review Link button (→ email API) |
|
||||
| Trusted, not Edit Mode | Unprinted only | Clickable (first print) | Email Review Link button |
|
||||
| Trusted, Edit Mode | All non-hidden | Clickable incl. reprint; shows `Nx` count | Email Review Link + direct Review Link (clipboard) |
|
||||
|
||||
- Print count badge: shown as `Nx` (e.g. `2×`) next to the printer icon when `print_count >= 1`
|
||||
- Edit mode for badges: limited to `trusted_access` users (toggle hidden from lower levels)
|
||||
- `person_passcode` field (for attendee-gated review URL): **not yet in DB** as of 2026-02-27
|
||||
|
||||
### IDAA
|
||||
- Auth gate test must be the **first test** in any test file — privacy enforcement is a hard requirement.
|
||||
- Default required permission: `trusted_access` or higher for module access.
|
||||
|
||||
---
|
||||
|
||||
## Common Template Patterns
|
||||
|
||||
```svelte
|
||||
<!-- Gate on trusted access -->
|
||||
{#if $ae_loc.trusted_access}
|
||||
|
||||
<!-- Gate on edit mode (always check trusted too — edit mode alone is insufficient) -->
|
||||
{#if $ae_loc.trusted_access && $ae_loc.edit_mode}
|
||||
|
||||
<!-- Gate on administrator -->
|
||||
{#if $ae_loc.administrator_access}
|
||||
|
||||
<!-- Show full vs obscured email -->
|
||||
{$ae_loc.trusted_access ? email : obscure_email(email)}
|
||||
```
|
||||
|
||||
> Never gate purely on `$ae_loc.edit_mode` without also checking a permission level. Edit mode is a UI preference, not a permission grant.
|
||||
@@ -3,7 +3,7 @@
|
||||
**Module Path:** `src/routes/events/[event_id]/(badges)/badges/`
|
||||
**API Module:** `src/lib/ae_events/ae_events__event_badge.ts`
|
||||
**Database:** `db_events.badge` (Dexie IndexedDB table)
|
||||
**Last Updated:** 2026-02-26 (rev 3)
|
||||
**Last Updated:** 2026-02-27 (rev 5)
|
||||
|
||||
---
|
||||
|
||||
@@ -64,17 +64,23 @@ display_name = badge.full_name_override || badge.full_name || "-- no name --"
|
||||
// ✅ Display still shows "Bob Smith"
|
||||
```
|
||||
|
||||
### Override Fields (7 total)
|
||||
### Override Fields
|
||||
|
||||
| Regular Field | Override Field | Purpose | Editable By |
|
||||
|---|---|---|---|
|
||||
| `pronouns` | `pronouns_override` | Preferred pronouns | Staff, Attendee |
|
||||
| `professional_title` | `professional_title_override` | Job title display | Staff, Attendee |
|
||||
| `full_name` | `full_name_override` | Preferred name, pronouns | Staff, Attendee |
|
||||
| `full_name` | `full_name_override` | Preferred name display | Staff, Attendee |
|
||||
| `affiliations` | `affiliations_override` | Organization display | Staff, Attendee |
|
||||
| `phone` | `phone_override` | Phone number | Staff, Attendee |
|
||||
| `email` | `email_override` | Contact email override | Staff only |
|
||||
| `location` | `location_override` | City/State/Country display | Staff, Attendee |
|
||||
| `badge_type` | `badge_type_override` | Badge category label (varies per Event/Template) | Staff only |
|
||||
| `badge_type_code` | `badge_type_code_override` | Badge access level code (varies per Event/Template) | Staff only |
|
||||
| `badge_type` | `badge_type_override` | Badge category label text | Staff only |
|
||||
| `badge_type_code` | `badge_type_code_override` | Badge access level code | Staff only |
|
||||
| `registration_type` | `registration_type_override` | Registration category label text | Staff only |
|
||||
| `registration_type_code` | `registration_type_code_override` | Registration category code | Staff only |
|
||||
|
||||
> **Note:** `phone`, `phone_override`, `pronouns_override`, `registration_type`, `registration_type_code`, `registration_type_override`, `registration_type_code_override` may need to be confirmed against the DB schema via `ae_describe event_badge` and added to `properties_to_save` in `ae_events__event_badge.ts` if not already present.
|
||||
|
||||
### Sync Safety Rules
|
||||
|
||||
@@ -149,18 +155,39 @@ def sync_badge_from_external(external_badge_data, existing_badge):
|
||||
6. **Manager** — All administrator + event configuration
|
||||
7. **Super** — All manager + cross-event operations
|
||||
|
||||
### Current Implementation (v3)
|
||||
### Current Implementation (v3) — 2026-02-27
|
||||
|
||||
**Quick Edit Feature** ([badge_id]/+page.svelte → ae_comp__badge_obj_view.svelte)
|
||||
#### Badge Search Results Visibility
|
||||
|
||||
**Edit Mode Trigger:**
|
||||
- URL hash `#review` enables edit mode
|
||||
- Sets `$ae_loc.edit_mode = true`
|
||||
- Shows Save/Cancel buttons
|
||||
| Access Level | Sees |
|
||||
| --- | --- |
|
||||
| Below Trusted (incl. anonymous) | Only badges where `print_count < 1` and not hidden |
|
||||
| Trusted, not Edit Mode | Only badges where `print_count < 1` and not hidden |
|
||||
| Trusted + Edit Mode | All badges where `hide === false` (including already-printed) |
|
||||
|
||||
**Currently Editable Fields:**
|
||||
#### Print Button Behavior (per result row)
|
||||
|
||||
| Access Level | Print Action |
|
||||
| --- | --- |
|
||||
| Below Trusted | No print action — name shown with User icon, non-interactive |
|
||||
| Trusted, `print_count < 1` | Clickable link → `/print` page, Printer icon |
|
||||
| Trusted, `print_count >= 1`, not Edit Mode | Disabled (already printed safety lock), shows `Nx` count |
|
||||
| Trusted, `print_count >= 1`, Edit Mode | Clickable reprint — shows `Nx` count badge next to icon |
|
||||
|
||||
Print count displayed as `[Printer][2×] Name` when `print_count >= 1`.
|
||||
|
||||
#### Review Area Buttons (per result row, up to 3 buttons total)
|
||||
|
||||
| Button | Visible To | Behavior |
|
||||
| --- | --- | --- |
|
||||
| Email Review Link | All users | Placeholder `alert()` — will trigger email API |
|
||||
| Review Link (clipboard) | Trusted + Edit Mode only | Copies `/review` URL to clipboard; shows `Copied!` feedback |
|
||||
| *(direct Review link)* | *(future)* | *(not yet implemented as separate nav button)* |
|
||||
|
||||
#### Badge Edit Form (`ae_comp__badge_obj_view.svelte`)
|
||||
|
||||
**Currently editable fields (local `edit_mode_active`, not global `edit_mode`):**
|
||||
```typescript
|
||||
// Lines 90-96 in ae_comp__badge_obj_view.svelte
|
||||
editable_full_name_override: string | null
|
||||
editable_professional_title_override: string | null
|
||||
editable_affiliations_override: string | null // textarea
|
||||
@@ -170,37 +197,109 @@ editable_email: string | null
|
||||
editable_badge_type_code: string | null
|
||||
```
|
||||
|
||||
**UI Components:**
|
||||
- Input fields shown when `edit_mode_active === true`
|
||||
- Save button calls `handle_save_changes()` (Line 365)
|
||||
- Cancel button calls `handle_cancel_changes()` (Line 450)
|
||||
- Only changed fields sent to API (`update_ae_obj__event_badge`)
|
||||
- Save button → `handle_save_changes()` — only changed fields sent to API
|
||||
- Cancel button → `handle_cancel_changes()` — reverts to IDB values
|
||||
- **IMPORTANT:** This component must NEVER write to `$ae_loc.edit_mode` — it uses its own local `edit_mode_active` flag only. (Bug fixed 2026-02-27)
|
||||
|
||||
### Future Planned Enhancement
|
||||
#### Badge Review Form (`ae_comp__badge_review_form.svelte`)
|
||||
|
||||
**Event-Level Configuration:** `event.mod_badges_json.edit_permissions`
|
||||
Form-based review (NOT a badge render). Used by the `/review` page.
|
||||
- `can_edit_fields: string[]` prop controls which fields are editable per user level
|
||||
- `['*']` = administrator (all fields)
|
||||
- `is_staff: boolean` prop shows/hides the source-data panel
|
||||
- Fields show "(overridden)" label when an override value differs from the base field
|
||||
|
||||
#### Badge Review Page — Header Buttons (implemented 2026-02-27)
|
||||
|
||||
| Button | Visible To | Behavior |
|
||||
| --- | --- | --- |
|
||||
| Back → Search (ArrowLeft) | Staff (`has_staff_access`) only | `<a href="/events/{id}/badges">` |
|
||||
| Print (Printer icon) | Trusted+, not printed OR Trusted+Edit if printed | `<a href="/print">`, shows `Nx` count if reprinting |
|
||||
| Copy Link (clipboard) | Trusted + Edit Mode only | Copies review URL to clipboard; `Copied!` feedback for 2s |
|
||||
| Email Link (Mail icon) | All if not printed; Trusted+Edit if printed | Placeholder `alert()` — email API pending |
|
||||
|
||||
#### Badge Print Page — Header Buttons (implemented 2026-02-27)
|
||||
|
||||
| Button | Visible To | Behavior |
|
||||
| --- | --- | --- |
|
||||
| Back → Search (ArrowLeft) | Always (when badge loaded) | `<a href="/events/{id}/badges">` |
|
||||
| Print Now (Printer icon) | Trusted+, not printed OR Trusted+Edit if printed | Calls `window.print()` directly (convenience duplicate); print count tracked by component button |
|
||||
| Review (Eye icon) | Trusted + Edit Mode only | `<a href="/review">` nav link |
|
||||
| Email Link (Mail icon) | All if not printed; Trusted+Edit if printed | Placeholder `alert()` — email API pending |
|
||||
|
||||
#### Badge Review Page — Display Sections (planned, not yet built)
|
||||
|
||||
In addition to the editable form, the review page will display:
|
||||
|
||||
1. **Print status** — print count + first/last print timestamps (read-only)
|
||||
2. **QR Code** — the attendee's badge QR code for scanning at the badge kiosk (for automatic badge search + print flow). QR generation code may be recoverable from the legacy AE Badge version.
|
||||
3. **Options** (`other_1_code` through `other_8_code`) — shown as `[✓] Option X` if the field has a value; hidden if empty
|
||||
4. **Tickets** (`ticket_1_code` through `ticket_8_code`) — shown as `[✓] Ticket X` if the field has a value; hidden if empty
|
||||
|
||||
#### Default Field Permissions (hardcoded for now — Axonius first show, mid-April 2026)
|
||||
|
||||
These are hardcoded in `review/+page.svelte` pending connection to `mod_badges_json.edit_permissions`.
|
||||
|
||||
**Attendee (passcode-authenticated / anonymous with link):**
|
||||
```typescript
|
||||
[
|
||||
'pronouns_override',
|
||||
'full_name_override',
|
||||
'professional_title_override',
|
||||
'affiliations_override',
|
||||
'phone_override',
|
||||
'location_override',
|
||||
'allow_tracking', // Exhibitor Leads opt-in
|
||||
'agree_to_tc', // Terms & Conditions placeholder
|
||||
]
|
||||
```
|
||||
|
||||
**Trusted Staff and above:**
|
||||
```typescript
|
||||
[
|
||||
'pronouns_override',
|
||||
'full_name_override',
|
||||
'professional_title_override',
|
||||
'affiliations_override',
|
||||
'email_override',
|
||||
'phone_override',
|
||||
'location_override',
|
||||
'badge_type_code_override', // + badge_type_override (text label)
|
||||
'registration_type_code_override', // + registration_type_override (text label)
|
||||
'option_1' ... 'option_8', // i.e. other_1_code ... other_8_code
|
||||
'ticket_1_code' ... 'ticket_8_code',
|
||||
'allow_tracking',
|
||||
'agree_to_tc',
|
||||
'hide',
|
||||
'priority',
|
||||
'notes',
|
||||
]
|
||||
```
|
||||
|
||||
**Administrator** — `can_edit_fields = ['*']` (all fields)
|
||||
|
||||
**Badge type options (hardcoded for now):** `member`, `non-member`, `guest`, `exhibitor`, `staff`, `test`
|
||||
(In future: read from Event Badge Template's configured list)
|
||||
|
||||
**Registration type options:** Same list as badge type for now — identical select options.
|
||||
|
||||
#### Future: Per-Event Configuration
|
||||
|
||||
`event.mod_badges_json.edit_permissions` — placeholder settings UI exists in
|
||||
`ae_comp__event_settings_badges_form.svelte`. Review page uses hardcoded defaults for now.
|
||||
The settings form and review page are not yet connected.
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": {
|
||||
"can_edit": ["full_name_override", "professional_title_override", "affiliations_override", "location_override"],
|
||||
"requires_approval": false
|
||||
"can_edit": ["pronouns_override", "full_name_override", "professional_title_override", "affiliations_override", "phone_override", "location_override", "allow_tracking", "agree_to_tc"]
|
||||
},
|
||||
"trusted": {
|
||||
"can_edit": ["full_name_override", "professional_title_override", "affiliations_override", "location_override", "email_override"],
|
||||
"can_view_all": true,
|
||||
"requires_approval": false
|
||||
},
|
||||
"administrator": {
|
||||
"can_edit": "*",
|
||||
"can_bulk_edit": true,
|
||||
"can_delete": true
|
||||
"can_edit": ["*attendee_fields", "email_override", "badge_type_code_override", "registration_type_code_override", "option_x", "ticket_x_code", "allow_tracking", "agree_to_tc", "hide", "priority", "notes"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** Placeholder UI exists, JSON config not yet implemented.
|
||||
|
||||
---
|
||||
|
||||
## Search & Filter Capabilities
|
||||
@@ -377,15 +476,14 @@ async function handle_print_badge() {
|
||||
Button has `data-testid="badge-print-btn"` and shows loading/done/error states with icon feedback.
|
||||
|
||||
### Print Workflow
|
||||
1. **Pre-Print:** Check `print_count` to warn if already printed
|
||||
2. **Print:** `window.print()` — standard browser print dialog. Works well in Chrome, Chromium, and Firefox. Chrome is the recommended browser for onsite badge printing (most stable in production).
|
||||
3. **Post-Print:** Handled by `handle_print_badge()` — count + timestamps updated
|
||||
4. **Audit:** Print history available for staff review
|
||||
1. **Pre-Print:** Badge print page (`/print`) shows "Already printed N times" warning in screen-only header if `print_count >= 1`
|
||||
2. **Record:** `handle_print_badge()` updates `print_count`, `print_last_datetime`, and `print_first_datetime` (first print only) via API before printing
|
||||
3. **Print:** `window.print()` — standard browser print dialog, wired and working (2026-02-27)
|
||||
4. **Redirect:** After 1 second, `goto(/events/{id}/badges)` returns to search
|
||||
5. **Audit:** `print_first_datetime` and `print_last_datetime` visible in Edit Mode debug row
|
||||
|
||||
**Browser vs Electron:** Badge printing does NOT require the Electron native app. The standard browser print dialog works well across Chrome, Chromium, and Firefox. The Electron native app is specialized for the **Events Pres Mgmt Launcher only** and should not be assumed available for badge stations.
|
||||
|
||||
**Current Status:** Button records print event and updates IDB. The `window.print()` call still needs to be wired to the button — see Future Enhancements.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
@@ -483,18 +581,42 @@ delete_ae_obj_id__event_badge({ event_badge_id, event_id, method })
|
||||
### Route Structure
|
||||
```
|
||||
/events/[event_id]/(badges)/badges/
|
||||
├── +layout.svelte # Layout wrapper (minimal)
|
||||
├── +page.svelte # Badge list + search
|
||||
├── ae_comp__badge_search.svelte # Search form + filters
|
||||
├── ae_comp__badge_obj_li.svelte # Badge list display
|
||||
├── +layout.svelte # Layout wrapper (minimal)
|
||||
├── +page.svelte # Badge list + search
|
||||
├── ae_comp__badge_search.svelte # Search form + filters
|
||||
├── ae_comp__badge_obj_li.svelte # Badge list display (results)
|
||||
├── ae_comp__badge_create_form.svelte # (Not actively used)
|
||||
├── ae_comp__badge_upload_form.svelte # Bulk CSV upload
|
||||
└── [badge_id]/
|
||||
├── +page.svelte # Badge detail container
|
||||
├── +page.ts # Badge loader (non-blocking)
|
||||
└── ae_comp__badge_obj_view.svelte # Badge display + edit
|
||||
├── ae_comp__badge_obj_view.svelte # Badge rendering + staff edit + print button
|
||||
├── ae_comp__badge_review_form.svelte # Form-based field review/edit (attendee + staff)
|
||||
├── print/
|
||||
│ ├── +page.ts # Non-blocking badge loader (inc_template: true)
|
||||
│ └── +page.svelte # Print-focused page — screen header + badge render
|
||||
└── review/
|
||||
├── +page.ts # Non-blocking badge loader (inc_template: false)
|
||||
└── +page.svelte # Passcode-gated review page
|
||||
```
|
||||
|
||||
> **Note:** The old `[badge_id]/+page.svelte` placeholder was removed (2026-02-27). The name link in the search results list now goes directly to `/print`.
|
||||
|
||||
#### Badge Print Page (`/print`)
|
||||
- Screen-only header (`print:hidden`): "Back to Search" link + "Already printed N times" warning
|
||||
- Badge rendered via `ae_comp__badge_obj_view` with `is_review_mode={false}`
|
||||
- Print button inside `ae_comp__badge_obj_view` handles count update → `window.print()` → redirect to search
|
||||
- Page `<title>` includes badge name + event name
|
||||
|
||||
#### Badge Review Page (`/review`)
|
||||
- Passcode-gated for attendees — URL `?passcode=...` matched against `badge.person_passcode`
|
||||
- **Note:** `person_passcode` field is not yet in the DB (as of 2026-02-27). Review page accessible to staff via `trusted_access` without a passcode.
|
||||
- Access hierarchy (checked in order):
|
||||
1. Administrator → full access (`can_edit_fields = ['*']`)
|
||||
2. Trusted Staff → staff field set
|
||||
3. Attendee with valid passcode → attendee field set
|
||||
4. No access → passcode entry form shown
|
||||
- Uses `ae_comp__badge_review_form.svelte` (NOT badge render)
|
||||
- "Back to Search" link shown for staff only
|
||||
|
||||
### Key Components
|
||||
|
||||
**Badge List Page** (`+page.svelte`)
|
||||
@@ -577,19 +699,45 @@ None — all current badge tests passing as of 2026-02-26 (f5e98b8c).
|
||||
## Known Issues & Future Enhancements
|
||||
|
||||
### Known Issues
|
||||
1. **Test Infrastructure:** Mock API routes not connecting to page requests
|
||||
2. **Session Cold-Start:** Potential race condition on first load (same as pres mgmt module)
|
||||
3. **Type Definitions:** Some TypeScript errors on external package types (pre-existing)
|
||||
1. **Session Cold-Start:** Potential race condition on first load (same as pres mgmt module)
|
||||
2. **Type Definitions:** Some pre-existing TypeScript errors on external package types (not introduced by badge work)
|
||||
3. **`person_passcode` not in DB:** Attendee-gated review URL (`?passcode=...`) cannot function until this field is added to the `event_badge` schema. The review page falls back to passcode entry form for non-staff.
|
||||
4. **Print page CSS:** Badge print rendering and `@page` print styles not yet fine-tuned — expected to need work
|
||||
5. **`mod_badges_json.edit_permissions` not connected:** Settings UI exists but review page uses hardcoded field defaults
|
||||
|
||||
### Future Enhancements
|
||||
1. **Access-Based Edit Permissions:** Implement JSON config for field-level access control
|
||||
2. **Print Functionality:** Wire up `window.print()` to the print button. Standard browser print API — works well in Chrome/Chromium/Firefox for badge label printing. Electron is NOT needed.
|
||||
3. **Batch Operations:** Bulk update, bulk print, bulk export
|
||||
4. **Audit Log:** Track who edited which fields and when
|
||||
5. **Photo Badges:** Support badge photo upload and display
|
||||
6. **Custom Badge Layouts:** Dynamic template selection per badge type
|
||||
7. **Real-Time Sync:** WebSocket updates for multi-device badge printing stations
|
||||
8. **Approval Workflow:** Require manager approval for certain field changes
|
||||
### Implemented (2026-02-27)
|
||||
- ✅ `window.print()` wired to print button (records count first, then prints, then redirects)
|
||||
- ✅ Dedicated `/print` page — replaces old `[badge_id]/+page.svelte` placeholder
|
||||
- ✅ Dedicated `/review` page — passcode-gated, access-tiered
|
||||
- ✅ `ae_comp__badge_review_form.svelte` — stub created, full form fields pending
|
||||
- ✅ Badge search results visibility rules (unprinted-only for non-edit, all for trusted+edit)
|
||||
- ✅ Badge list: 4 action buttons per row (Print, Review nav, Copy Link, Email Link) — all Lucide icons
|
||||
- ✅ Print page: 3 action buttons in header (Print Now, Review nav, Email Link) — all Lucide icons
|
||||
- ✅ Review page: 3 action buttons in header (Print nav, Copy Link, Email Link) — all Lucide icons
|
||||
- ✅ Print button: not shown when already printed (unless Edit Mode)
|
||||
- ✅ Print count shown as `Nx` badge next to printer icon
|
||||
- ✅ Email obscuring for non-trusted users
|
||||
- ✅ Email Review Link button (placeholder alert — email API pending)
|
||||
- ✅ Direct Review Link clipboard copy (trusted + Edit Mode only)
|
||||
- ✅ Fixed: components no longer write to `$ae_loc.edit_mode`
|
||||
- ✅ Settings UI for `edit_permissions` per event (`ae_comp__event_settings_badges_form.svelte`)
|
||||
- ✅ All badge module icons converted to Lucide (Font Awesome removed from badge routes)
|
||||
|
||||
### Still Needed — HIGH PRIORITY (first show: April 2026)
|
||||
1. **Badge Review Form — actual fields:** `ae_comp__badge_review_form.svelte` is a stub. Needs full field rendering, edit inputs, save/cancel, and the display-only sections (QR code, print status, option/ticket checkmarks). See `PROJECT__AE_Events_Badges_Review_Print.md` for full spec.
|
||||
2. **Badge Print Page — font size controls:** Screen-only controls to adjust font size for name, professional title, affiliations, and location sections before printing. See project brief.
|
||||
|
||||
### Still Needed — MEDIUM PRIORITY
|
||||
1. **Email API for review links:** `send_review_email()` is a placeholder `alert()`. Needs actual email send endpoint.
|
||||
2. **`person_passcode` DB field:** Add to `event_badge` schema to enable attendee-gated review URLs.
|
||||
3. **Connect `edit_permissions` config:** Read `mod_badges_json.edit_permissions` in review page instead of hardcoded defaults.
|
||||
4. **Print page CSS / `@page` styles:** Badge rendering, sizing, and print-specific stylesheet.
|
||||
|
||||
### Still Needed — FUTURE / LOW PRIORITY
|
||||
1. **Batch Operations:** Bulk update, bulk print, bulk export
|
||||
2. **Audit Log:** Track who edited which fields and when
|
||||
3. **Photo Badges:** Support badge photo upload and display
|
||||
4. **Real-Time Sync:** WebSocket updates for multi-device badge printing stations
|
||||
|
||||
---
|
||||
|
||||
@@ -646,6 +794,6 @@ db_events.badge.toArray().then(console.log)
|
||||
|
||||
---
|
||||
|
||||
**Document Status:** ✅ Complete
|
||||
**Last Verified:** 2026-02-26 (rev 3 — all badge tests passing)
|
||||
**Verified Against:** Code commit f5e98b8c (all data integrity tests passing)
|
||||
**Document Status:** 🔄 In Progress
|
||||
**Last Verified:** 2026-02-27 (rev 5 — field permissions spec added, header buttons implemented, review form fields pending)
|
||||
**Verified Against:** Code as of 2026-02-27 (branch ae_app_3x_llm)
|
||||
|
||||
339
documentation/PROJECT__AE_Events_Badges_Review_Print.md
Normal file
339
documentation/PROJECT__AE_Events_Badges_Review_Print.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# PROJECT: AE Events Badges — Review Form & Print Font Controls
|
||||
|
||||
**Created:** 2026-02-27
|
||||
**Branch:** `ae_app_3x_llm`
|
||||
**Priority:** HIGH — first live event is Axonius, NYC, mid-April 2026
|
||||
**Owner:** Scott Idem / One Sky IT
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Events Badges module is mostly complete for navigation and search. Two key pieces of
|
||||
functional UI remain unbuilt and are needed before the first show:
|
||||
|
||||
1. **Badge Review Form** — `ae_comp__badge_review_form.svelte` is currently a stub. It
|
||||
needs actual field rendering, edit inputs gated by access level, save/cancel API calls,
|
||||
and display-only sections (QR code, print status, option/ticket checkmarks).
|
||||
|
||||
2. **Badge Print Font Controls** — The print page header needs screen-only controls
|
||||
(hidden during `window.print()`) to bump font sizes for the name, professional title,
|
||||
affiliations, and location sections before printing. These only affect the `ae_comp__badge_obj_view.svelte` render — not the page layout/template structural dimensions.
|
||||
|
||||
Read `documentation/MODULE__AE_Events_Badges.md` for full module context before starting.
|
||||
|
||||
---
|
||||
|
||||
## MANDATORY: Before You Start
|
||||
|
||||
1. Run `ae_describe event_badge` (MCP tool) to confirm which fields actually exist in the
|
||||
DB. Several fields in the spec below may need to be added to `properties_to_save` in
|
||||
`src/lib/ae_events/ae_events__event_badge.ts` if they are not already saved to IDB.
|
||||
|
||||
2. Fields to specifically confirm exist in `event_badge` schema:
|
||||
- `pronouns`, `pronouns_override`
|
||||
- `phone`, `phone_override`
|
||||
- `allow_tracking`
|
||||
- `agree_to_tc`
|
||||
- `other_1_code` through `other_8_code` (the "option" fields)
|
||||
- `ticket_1_code` through `ticket_8_code`
|
||||
- `registration_type`, `registration_type_code`
|
||||
- `registration_type_override`, `registration_type_code_override`
|
||||
|
||||
3. Run `npx svelte-check` before committing. Baseline is **77 errors** (all pre-existing,
|
||||
none in the badge module files). Do not introduce new errors.
|
||||
|
||||
4. Do NOT write to `$ae_loc.edit_mode` from any badge component. This was a critical
|
||||
bug (fixed 2026-02-27). See `documentation/AE__Permissions_and_Security.md`.
|
||||
|
||||
---
|
||||
|
||||
## TASK 1: Badge Review Form (HIGH PRIORITY)
|
||||
|
||||
### File to build
|
||||
`src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_review_form.svelte`
|
||||
|
||||
This component is already imported and used by `review/+page.svelte`. Props it receives:
|
||||
|
||||
```typescript
|
||||
interface Props {
|
||||
event_id: string;
|
||||
event_badge_id: string;
|
||||
lq__event_badge_obj: any; // Svelte 5 store from liveQuery
|
||||
can_edit_fields: string[]; // Which fields this user can edit
|
||||
is_staff: boolean; // True if trusted_access or higher
|
||||
log_lvl?: number;
|
||||
}
|
||||
```
|
||||
|
||||
`can_edit_fields` values:
|
||||
- `['*']` — administrator (all fields)
|
||||
- Array of field names — specific editable fields
|
||||
- `[]` — read-only (shouldn't normally reach this component, but handle it)
|
||||
|
||||
### Helper
|
||||
|
||||
Use a helper derived inside the component:
|
||||
```typescript
|
||||
function can_edit(field: string): boolean {
|
||||
return can_edit_fields.includes('*') || can_edit_fields.includes(field);
|
||||
}
|
||||
```
|
||||
|
||||
### Save / Cancel Pattern
|
||||
|
||||
Follow the Journals module pattern (`src/lib/ae_journals/`). Key points:
|
||||
- Use `import { events_func } from '$lib/ae_events_functions'`
|
||||
- Call `events_func.update_ae_obj__event_badge({ event_badge_id, event_id, data_kv })`
|
||||
- Only send changed fields in `data_kv` (compare against `$lq__event_badge_obj` values)
|
||||
- Show save/cancel buttons only when something has changed (`has_changes` derived)
|
||||
- Show a success/error state briefly after save (1-2 seconds, then reset)
|
||||
- Cancel resets local state back to `$lq__event_badge_obj` values
|
||||
- Use `data-testid="badge-review-save-btn"` and `data-testid="badge-review-cancel-btn"`
|
||||
|
||||
### Save API Call
|
||||
|
||||
```typescript
|
||||
await events_func.update_ae_obj__event_badge({
|
||||
api_cfg: $ae_api, // from ae_loc store or passed as prop — check how ae_comp__badge_obj_view.svelte does it
|
||||
event_badge_id: event_badge_id,
|
||||
event_id: event_id,
|
||||
data_kv: { /* only changed fields */ }
|
||||
});
|
||||
```
|
||||
|
||||
Check `ae_comp__badge_obj_view.svelte` for the existing save pattern — it already works
|
||||
and can be used as reference.
|
||||
|
||||
---
|
||||
|
||||
### Section 1: Display-Only Status Bar (all access levels)
|
||||
|
||||
Always show at top of form. Read-only. No edit controls.
|
||||
|
||||
```
|
||||
Print Status: [Not yet printed] OR [Printed 3× — first: Jan 5 2026, last: Jan 5 2026]
|
||||
```
|
||||
|
||||
Use `$lq__event_badge_obj.print_count`, `print_first_datetime`, `print_last_datetime`.
|
||||
Format datetimes with `ae_util.iso_datetime_formatter(dt, 'datetime_iso_12_no_seconds')`.
|
||||
Import `ae_util` from `$lib/ae_utils/ae_utils`.
|
||||
|
||||
---
|
||||
|
||||
### Section 2: QR Code (all access levels)
|
||||
|
||||
Display the attendee's badge QR code. This is the same QR code shown on the printed badge
|
||||
itself — scanning it at the badge station triggers automatic badge search and print.
|
||||
|
||||
**Check the legacy AE Badge version for existing QR generation code.** Look in:
|
||||
- `src/lib/ae_events/` for any QR-related utilities
|
||||
- `ae_comp__badge_obj_view.svelte` — the badge render component almost certainly generates
|
||||
a QR code already for the printed badge. Reuse that logic/component if possible.
|
||||
|
||||
The QR code value should encode the badge ID or a URL that resolves to the badge.
|
||||
|
||||
---
|
||||
|
||||
### Section 3: Editable Fields
|
||||
|
||||
Render each field as: read-only display when `!can_edit(field)`, or an `<input>` /
|
||||
`<select>` / `<textarea>` when `can_edit(field)`.
|
||||
|
||||
Show `(overridden)` label next to override fields when the override value differs from
|
||||
the base field value.
|
||||
|
||||
#### Attendee-Editable Fields (shown to all access levels with link)
|
||||
|
||||
| Field | Input Type | Notes |
|
||||
|---|---|---|
|
||||
| `pronouns_override` | text input | Fallback display: `pronouns` |
|
||||
| `full_name_override` | text input | Fallback display: `full_name` |
|
||||
| `professional_title_override` | text input | Fallback display: `professional_title` |
|
||||
| `affiliations_override` | textarea | Fallback display: `affiliations` |
|
||||
| `phone_override` | text input (tel) | Fallback display: `phone` |
|
||||
| `location_override` | text input | Fallback display: `location` |
|
||||
| `allow_tracking` | checkbox | Label: "Allow exhibitor lead scanning" |
|
||||
| `agree_to_tc` | checkbox | Label: "I agree to the Terms and Conditions" + placeholder T&C text block |
|
||||
|
||||
#### Staff-Only Additional Fields (shown when `is_staff === true`)
|
||||
|
||||
| Field | Input Type | Notes |
|
||||
|---|---|---|
|
||||
| `email_override` | email input | Fallback display: `email` |
|
||||
| `badge_type_code_override` | select | Options: member, non-member, guest, exhibitor, staff, test; also updates `badge_type_override` text |
|
||||
| `registration_type_code_override` | select | Same options as badge_type for now; also updates `registration_type_override` |
|
||||
| `hide` | checkbox | Label: "Hidden from search results" |
|
||||
| `priority` | number input | |
|
||||
| `notes` | textarea | |
|
||||
|
||||
#### Staff-Only: Options & Tickets (read-edit, shown when `is_staff === true`)
|
||||
|
||||
**Other/Options** (`other_1_code` through `other_8_code`):
|
||||
- If field has a value: show as editable text input with label "Option X"
|
||||
- If field is empty/null: show faintly as "Option X (empty)" — staff can still set it
|
||||
- These represent event-specific add-ons or membership indicators
|
||||
|
||||
**Tickets** (`ticket_1_code` through `ticket_8_code`):
|
||||
- Same pattern as options above, label "Ticket X"
|
||||
|
||||
#### Attendee-Only: Options & Tickets (display only)
|
||||
|
||||
When `!is_staff` and the field has a value: show `[✓] Option X` or `[✓] Ticket X`.
|
||||
When the field is empty: hide entirely (attendees don't see empty slots).
|
||||
|
||||
---
|
||||
|
||||
### Section 4: Terms & Conditions Block (all, only when `agree_to_tc` in can_edit_fields)
|
||||
|
||||
Placeholder text for now:
|
||||
```
|
||||
By checking this box, I confirm that the information on my badge is correct to the best
|
||||
of my knowledge. I agree that this badge may be used for identification purposes during
|
||||
the event and that my attendance may be recorded by exhibitors using the lead scanning
|
||||
feature if I permit it.
|
||||
```
|
||||
|
||||
Show this before the `agree_to_tc` checkbox. If `agree_to_tc` is not in `can_edit_fields`,
|
||||
hide the entire block.
|
||||
|
||||
---
|
||||
|
||||
### Field State Pattern (Svelte 5 runes)
|
||||
|
||||
```typescript
|
||||
// Initialize local editable state from badge object
|
||||
let local_full_name_override = $state($lq__event_badge_obj?.full_name_override ?? '');
|
||||
let local_pronouns_override = $state($lq__event_badge_obj?.pronouns_override ?? '');
|
||||
// ... etc for each editable field
|
||||
|
||||
// Detect changes
|
||||
let has_changes = $derived(
|
||||
local_full_name_override !== ($lq__event_badge_obj?.full_name_override ?? '')
|
||||
|| local_pronouns_override !== ($lq__event_badge_obj?.pronouns_override ?? '')
|
||||
// ... etc
|
||||
);
|
||||
|
||||
// Build changed-fields-only payload
|
||||
function build_save_payload(): Record<string, any> {
|
||||
const payload: Record<string, any> = {};
|
||||
if (local_full_name_override !== ($lq__event_badge_obj?.full_name_override ?? ''))
|
||||
payload.full_name_override = local_full_name_override || null; // empty string → null
|
||||
// ... etc
|
||||
return payload;
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Empty string inputs should save as `null` (clears the override, falls back
|
||||
to base field). Use `value || null` in the payload.
|
||||
|
||||
---
|
||||
|
||||
## TASK 2: Badge Print Font Size Controls (MEDIUM PRIORITY)
|
||||
|
||||
### Where to add
|
||||
|
||||
`src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte`
|
||||
|
||||
Add a screen-only (`print:hidden`) control panel between the header and the badge render.
|
||||
This panel lets staff adjust font sizes for the four text-heavy sections before clicking Print.
|
||||
|
||||
### Controls needed
|
||||
|
||||
```
|
||||
Font Size Controls (screen only, hidden during print):
|
||||
[Name] [−] [14px] [+]
|
||||
[Title] [−] [12px] [+]
|
||||
[Affiliations] [−] [11px] [+]
|
||||
[Location] [−] [10px] [+]
|
||||
```
|
||||
|
||||
- Start with sensible defaults (match what `ae_comp__badge_obj_view.svelte` currently uses)
|
||||
- Min/max per field (e.g., 8px–24px for name, 7px–18px for others)
|
||||
- Pass the sizes as props into `ae_comp__badge_obj_view`
|
||||
|
||||
### Props to add to `ae_comp__badge_obj_view.svelte`
|
||||
|
||||
`ae_comp__badge_obj_view.svelte` currently has internal font size logic. It needs to
|
||||
accept optional override props:
|
||||
|
||||
```typescript
|
||||
// New optional props:
|
||||
font_size_name?: number; // px
|
||||
font_size_title?: number; // px
|
||||
font_size_affiliations?: number; // px
|
||||
font_size_location?: number; // px
|
||||
```
|
||||
|
||||
When these props are provided, use them instead of the internally computed sizes.
|
||||
When not provided, fall back to existing auto-sizing behavior.
|
||||
|
||||
**IMPORTANT:** Do NOT touch structural dimensions (overall badge width/height, header/footer
|
||||
sizes, template layout). Only the text content font sizes.
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `[badge_id]/ae_comp__badge_review_form.svelte` | **BUILD THIS** — review form stub |
|
||||
| `[badge_id]/ae_comp__badge_obj_view.svelte` | Badge render + print button; add font size props |
|
||||
| `[badge_id]/print/+page.svelte` | Print page; add font size control panel |
|
||||
| `[badge_id]/review/+page.svelte` | Review page; already wired, passes `can_edit_fields` |
|
||||
| `src/lib/ae_events/ae_events__event_badge.ts` | API functions: `update_ae_obj__event_badge` |
|
||||
| `src/lib/ae_events/db_events.ts` | Dexie schema — `properties_to_save` for badge |
|
||||
| `src/lib/ae_utils/ae_utils.ts` | `ae_util.iso_datetime_formatter()` |
|
||||
| `documentation/MODULE__AE_Events_Badges.md` | Full module reference |
|
||||
| `documentation/AE__Permissions_and_Security.md` | Permission flags, edit_mode rules |
|
||||
| `documentation/GUIDE__AE_API_V3_for_Frontend.md` | V3 API reference |
|
||||
|
||||
## Access Level Reference
|
||||
|
||||
```typescript
|
||||
// From $ae_loc store (persisted localStorage)
|
||||
$ae_loc.trusted_access // true = trusted and above (onsite staff)
|
||||
$ae_loc.administrator_access // true = administrator and above
|
||||
$ae_loc.edit_mode // boolean — user preference toggle (NEVER write to this from components)
|
||||
```
|
||||
|
||||
`is_staff` prop on the review form = `administrator_access || trusted_access`.
|
||||
|
||||
---
|
||||
|
||||
## Patterns to Follow
|
||||
|
||||
- **Canonical module reference:** `src/lib/ae_journals/` — most complete, most advanced
|
||||
- **Svelte 5 runes:** `$state`, `$derived`, `$derived.by()`, `$effect` — no legacy `$:` syntax
|
||||
- **Icons:** Lucide Svelte only — `import { Save, X, Check, ... } from 'lucide-svelte'`
|
||||
- **No Font Awesome** (`fas fa-*`) anywhere in the badge module
|
||||
- **Styling:** Tailwind CSS v4 + Skeleton UI utility classes (`btn`, `preset-tonal-*`, `input`, `card`)
|
||||
- **Commits:** Atomic — one component per commit; run `npx svelte-check` before every commit
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
- Do NOT touch `@page` CSS or badge template structural dimensions — print layout is out of scope
|
||||
- Do NOT write to `$ae_loc.edit_mode` from any component
|
||||
- Do NOT connect `mod_badges_json.edit_permissions` yet — hardcoded field lists are intentional for now
|
||||
- Do NOT implement the email API — `send_review_email()` placeholder stays as `alert()`
|
||||
- Do NOT add `person_passcode` DB field — out of scope for this sprint
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Run existing badge tests after any changes:
|
||||
```bash
|
||||
npm run test:unit
|
||||
npx playwright test tests/events/badges/
|
||||
```
|
||||
|
||||
Baseline: all badge tests passing as of 2026-02-26 (`f5e98b8c`).
|
||||
|
||||
Add `data-testid` attributes to key interactive elements:
|
||||
- `badge-review-save-btn`
|
||||
- `badge-review-cancel-btn`
|
||||
- `badge-review-full-name-input`
|
||||
- `badge-review-agree-to-tc-checkbox`
|
||||
@@ -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