feat(pres_mgmt): extract session search component + time window filter
- Extract session search form into ae_comp__pres_mgmt_session_search.svelte (parallels ae_comp__badge_search.svelte); removes ~145 lines from +page.svelte - Add time window filter: Clock icon toggle button reveals compact before/after selects; trusted users get 3d/7d options; active state highlighted in amber - Add passes_hide_filter to IDB fast path to mirror API qry_hidden logic and eliminate the hidden-session blink on revalidation - Add passes_time_window applied to both IDB fast path and API results - Add time window state fields to PresMgmtLocState + pres_mgmt_loc_defaults - Add contextual warning in "No sessions found" when time filter is active - badges: hide "Start Here" button for trusted_access users; tweak button shade - badges: scope placeholder CSS fix to input only (not textarea) - Add MODULE__AE_Events_PressMgmt_Launcher.md doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
464
documentation/MODULE__AE_Events_PressMgmt_Launcher.md
Normal file
464
documentation/MODULE__AE_Events_PressMgmt_Launcher.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# Aether Events — Presentation Management & Launcher
|
||||
|
||||
Notes on setup, workflow, configuration, and onsite operation for the Events Presentation
|
||||
Management module and the companion Launcher (podium display) system.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Presentation Management (Pres Mgmt) module handles the full lifecycle of conference
|
||||
content: sessions, presentations, presenters, presentation files, and room/location
|
||||
assignments. The Launcher module provides the podium display interface that runs on each
|
||||
session room's kiosk machine.
|
||||
|
||||
These two modules are deployed together — Pres Mgmt is the back office, Launcher is the
|
||||
front-of-house display. Every client show is at least slightly customized. Some clients
|
||||
have extensive presenter/presentation data; others just have sessions and files. The
|
||||
platform is flexible enough to handle the full range.
|
||||
|
||||
**Reference clients (current/repeat):**
|
||||
- **BGH** (Business Group on Health) — most basic setup; session-only, no named Presenters
|
||||
- **LCI** (Lean Construction Institute) — most complex current setup
|
||||
- **AAPOR**, **ASCM**, **CMSC** — other active/repeat clients
|
||||
|
||||
**Module paths:**
|
||||
- Pres Mgmt: `/events/[event_id]/pres_mgmt`
|
||||
- Launcher: `/events/[event_id]/launcher`
|
||||
|
||||
**Key source directories:**
|
||||
- `src/routes/events/[event_id]/(pres_mgmt)/`
|
||||
- `src/routes/events/[event_id]/(launcher)/`
|
||||
- `src/lib/ae_events/` — data types and API functions for all event objects
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Object Hierarchy
|
||||
|
||||
```
|
||||
Event
|
||||
├── Event File (walk-in/out, hold slides for the whole event)
|
||||
├── Location (physical room — assigned to Sessions, not the other way around)
|
||||
├── Track (optional grouping; rarely used — see note below)
|
||||
└── Session (time block; Location assigned here, but may be unset initially)
|
||||
├── Session File (moderator slides, group/hold slides for this session)
|
||||
└── Presentation (a talk within the session; must belong to exactly one Session)
|
||||
└── Presenter (belongs to exactly one Presentation)
|
||||
└── Presenter File (their slides/materials — the common case)
|
||||
```
|
||||
|
||||
> **Import note:** When program data is initially imported (sessions, presentations,
|
||||
> presenters), Locations are often not assigned to Sessions yet — rooms may not be
|
||||
> finalized or the venue's room list may not be set up in Aether. Location assignment
|
||||
> typically happens as a separate step once the room list is confirmed.
|
||||
|
||||
### Relationships
|
||||
|
||||
- **Session → Location:** Many-to-one. A Session is assigned to one Location; a Location
|
||||
hosts many Sessions across the event timeline. Location may be null initially.
|
||||
- **Presentation → Session:** Many-to-one. A Presentation belongs to exactly one Session.
|
||||
A Session can have many Presentations (or none, for session-only setups).
|
||||
- **Presenter → Presentation:** Many-to-one. A Presenter belongs to exactly one Presentation.
|
||||
Optionally linked to an `event_person_id` for cross-referencing the person record.
|
||||
- **Event File:** Can be attached at any level — Presenter, Presentation, Session,
|
||||
Location, or Event. See the File Attachment Levels table.
|
||||
|
||||
### File Attachment Levels
|
||||
|
||||
Files (`event_file`) can be attached at five levels:
|
||||
|
||||
| Level | When Used | Typical Content |
|
||||
|---|---|---|
|
||||
| **Presenter** | 99% of the time for individual speakers | Their PowerPoint/PDF/video |
|
||||
| **Session** | Moderator slides; group/hold content for a specific session | "Session 3 — Group Discussion.pptx" |
|
||||
| **Location** | Walk-in/out or hold slides for a room across all sessions | Looped PPTX playing between sessions |
|
||||
| **Event** | Walk-in/out or hold slides used everywhere | Looped PPTX; branding overlay |
|
||||
| **Presentation** | File attached to the presentation record itself (less common) | Varies |
|
||||
|
||||
### Key Objects
|
||||
|
||||
| Object | Table | Purpose |
|
||||
|---|---|---|
|
||||
| Session | `event_session` | Time block; Location and datetime range assigned here |
|
||||
| Location | `event_location` | Physical room; the Launcher's primary unit of display |
|
||||
| Presentation | `event_presentation` | A talk within a session; belongs to exactly one Session |
|
||||
| Presenter | `event_presenter` | Person linked to exactly one Presentation; optionally linked to `event_person` |
|
||||
| Event Person | `event_person` | Person record within the event context |
|
||||
| Event File | `event_file` | Uploaded file; attached at Presenter, Presentation, Session, Location, or Event level |
|
||||
| Event Device | `event_device` | Registered Launcher kiosk (Electron native instance) |
|
||||
| Event Track | `event_track` | Optional content grouping (see note below) |
|
||||
|
||||
### Event Tracks
|
||||
|
||||
The API supports Event Tracks — an optional grouping layer above Sessions. Used twice
|
||||
historically; could have been omitted both times. Tracks may become genuinely useful for
|
||||
larger events running many parallel Locations where thematic grouping helps navigation.
|
||||
Not in active use currently and not wired into the standard Pres Mgmt UI workflow.
|
||||
|
||||
### Session → Location
|
||||
|
||||
The Launcher's primary display unit is the Location. It shows the active Session for that
|
||||
Location based on `datetime_start` / `datetime_end` or manual selection. A Location hosts
|
||||
many Sessions over the event's run; typically only one is active at a time.
|
||||
|
||||
---
|
||||
|
||||
## Client Setup Variation
|
||||
|
||||
There are no rigid "modes" — events are configured with as much or as little structure
|
||||
as needed. The platform handles the full range:
|
||||
|
||||
**Minimal setup (BGH):**
|
||||
Sessions have room and time info. No Presentations or Presenters defined.
|
||||
Staff upload files directly at the session or location level onsite.
|
||||
|
||||
**Mid-range setup:**
|
||||
Sessions defined with named Presentations. Presenters may or may not be tracked.
|
||||
Mix of pre-uploaded and onsite files. QR codes may be used for quick session/presenter lookup.
|
||||
|
||||
**Full setup (LCI):**
|
||||
Sessions, Presentations, Presenters all defined and managed. External ID labeling
|
||||
(e.g., "LCI Member ID"). Agreement tracking for presenters. Files managed per-presenter.
|
||||
Launcher linked from Pres Mgmt views.
|
||||
|
||||
The config that drives this is `event.mod_pres_mgmt_json` — see the Configuration section.
|
||||
|
||||
---
|
||||
|
||||
## Speaker Ready Room (SRR)
|
||||
|
||||
The Speaker Ready Room is a dedicated space where presenters check in and staff manage
|
||||
content before it goes live in the session rooms. Setup varies by client:
|
||||
|
||||
- **Small/private:** Only a few client staff and OSIT. Not open to presenters at large.
|
||||
- **Open SRR:** Open to all presenters as long as sessions are running. People come and go
|
||||
all day — reviewing silently, editing with a group, practicing at a station.
|
||||
|
||||
### SRR Practice Stations
|
||||
|
||||
Stations mirror the session room setup exactly:
|
||||
- Same Mac laptop model and adapter/dongle configuration as the podiums
|
||||
- Projector and screen (same as session rooms where possible)
|
||||
- Launcher running in Native (Electron) mode — cached files open immediately
|
||||
- Full dry-run capability: load their file, start the deck, confirm everything works
|
||||
|
||||
### Remote Monitoring
|
||||
|
||||
SRR staff typically monitor the session room Launchers in real time via **VNC or RustDesk**.
|
||||
This lets one person watch multiple podium displays simultaneously without being in each room.
|
||||
|
||||
### QR Codes (Session and Presenter)
|
||||
|
||||
QR codes are available for Sessions and Presenters and have been useful onsite for quick
|
||||
lookups — scanning a code takes staff directly to the session or presenter record.
|
||||
Whether to enable this depends on the SRR flow for each show. It gets toggled on or off
|
||||
per event via config.
|
||||
|
||||
### SRR Staffing Roles
|
||||
|
||||
| Role | Access Level | Typical Tasks |
|
||||
|---|---|---|
|
||||
| OSIT Staff | `trusted_access` or higher | Upload files, edit sessions/presentations, manage devices, monitor via VNC |
|
||||
| Client Staff | `authenticated_access` | Upload files, view session list |
|
||||
| Presenter (self-service) | `authenticated_access` (if enabled) | Upload their own files via QR link |
|
||||
|
||||
### SRR Workflow — Day-of-Show
|
||||
|
||||
1. **Presenter checks in** — staff looks up their session(s) in Pres Mgmt
|
||||
2. **File upload** — staff or presenter uploads file to the correct presenter/session record
|
||||
3. **File verification** — staff opens the file on a practice station to confirm it renders
|
||||
4. **Launcher sync** — file appears in the Launcher within the next polling cycle
|
||||
5. **Presenter proceeds to room** — podium kiosk already has the file cached
|
||||
|
||||
---
|
||||
|
||||
## File Upload Workflows
|
||||
|
||||
### Pre-Show (Remote / Staff Ahead of Time)
|
||||
|
||||
Files can be uploaded anytime before the event via the Pres Mgmt web UI:
|
||||
1. Navigate to the presenter, session, or appropriate level
|
||||
2. Use the file upload panel (drag & drop or browse)
|
||||
3. File is stored server-side and immediately available to the Launcher
|
||||
|
||||
Some clients enable presenter self-upload via a direct link (requires `authenticated_access`).
|
||||
Controlled per-event via config.
|
||||
|
||||
### Day Before — SRR Setup
|
||||
|
||||
For higher-volume shows, the SRR opens the day before the event:
|
||||
- Pre-uploaded files are already loaded and can be verified
|
||||
- Early-arriving presenters check in; staff upload their files
|
||||
- Electron Launcher instances on podium Macs begin pre-caching files overnight
|
||||
- Problems (corrupt files, wrong format, wrong codec) surface with time to fix them
|
||||
|
||||
### Live Onsite Upload
|
||||
|
||||
For late arrivals and last-minute changes:
|
||||
1. Presenter arrives at SRR (or sends file via USB/email to staff)
|
||||
2. Staff uploads via Pres Mgmt web UI
|
||||
3. File propagates to Launcher within one polling cycle (~30 seconds on fast networks)
|
||||
4. VNC or RustDesk confirms the podium received the file before the presenter walks in
|
||||
|
||||
---
|
||||
|
||||
## Onsite Operation — Managing 4–12 Parallel Rooms
|
||||
|
||||
### Overview Page
|
||||
|
||||
The Pres Mgmt overview (`/events/[event_id]/pres_mgmt`) shows:
|
||||
- All sessions, filterable by location and time
|
||||
- File status per session
|
||||
- Quick links to each session's file management
|
||||
|
||||
For events with multiple parallel rooms, filtering by location and time block is essential
|
||||
for SRR staff staying on top of what's active right now.
|
||||
|
||||
### Per-Room Workflow
|
||||
|
||||
Each room/location has its own Launcher display:
|
||||
- `/events/[event_id]/launcher` → select location → Launcher for that room
|
||||
- The Launcher shows the active session based on the current time or manual selection
|
||||
- VNC/RustDesk gives SRR staff a real-time view of all podiums simultaneously
|
||||
|
||||
### Session Display Timing
|
||||
|
||||
Ideally, sessions would automatically show and hide based on `datetime_start` /
|
||||
`datetime_end` — appearing a configurable number of minutes before the session starts
|
||||
and disappearing after it ends. This is a planned/desired behavior. In practice:
|
||||
|
||||
- Some clients run tight schedules and could rely on time-based transitions
|
||||
- Others drift significantly from the published schedule; time-based auto-advance
|
||||
would cause more problems than it solves
|
||||
- Currently, session transitions can be managed manually via Launcher controls
|
||||
|
||||
> **TODO (future):** Configurable `show_before_minutes` / `hide_after_minutes` per event
|
||||
> so well-run shows can automate transitions while looser shows stay manual.
|
||||
|
||||
### Device (Laptop) Assignment
|
||||
|
||||
Each Launcher kiosk Mac is registered as an `event_device` and typically assigned to one
|
||||
Location for the duration of the event. However, laptops do get moved:
|
||||
- Venues add or lose rooms as spaces are reconfigured
|
||||
- A session room may open for one day only
|
||||
- Devices can be reassigned to a different Location in the `event_device` record as needed
|
||||
|
||||
The Electron app reads its location assignment from the API at startup, so reassigning a
|
||||
device takes effect on the next launch (or app restart).
|
||||
|
||||
---
|
||||
|
||||
## Alert Fields
|
||||
|
||||
Sessions, Presenters, and Locations each have alert fields that can display a visible
|
||||
notice in the Pres Mgmt UI and/or the Launcher.
|
||||
|
||||
Useful for:
|
||||
- "Presenter requested no recording"
|
||||
- "Room change — moved to Hall B"
|
||||
- "File not received — follow up"
|
||||
- "AV note: needs confidence monitor"
|
||||
|
||||
> **Status:** Alert fields exist but the implementation and display behavior needs review
|
||||
> and cleanup. Not a blocking issue for BGH next week — revisit for a future show.
|
||||
|
||||
---
|
||||
|
||||
## Launcher Module
|
||||
|
||||
### Operational Modes
|
||||
|
||||
| Mode | Use Case | File Handling |
|
||||
|---|---|---|
|
||||
| **Default** | Browser on any machine | Files downloaded on demand |
|
||||
| **Onsite** | Browser on event network | Faster polling; browser-managed files |
|
||||
| **Native** | Electron app on dedicated podium Mac | Background pre-cache; atomic file handover |
|
||||
|
||||
For production onsite use, **Native mode on Mac laptops** is the target. The Electron
|
||||
app pre-caches all session files in the background so presentations open instantly without
|
||||
a network round-trip at the moment of launch.
|
||||
|
||||
### Native Mode — Electron App
|
||||
|
||||
- **Repo:** `~/OSIT_dev/aether_app_native_electron/`
|
||||
- **Platform:** macOS (primary); Linux/Windows as fallback
|
||||
- **Seed config:** `seed.json` (Device ID + API key) — loaded at startup
|
||||
- **File cache:** `~/Library/Caches/OSIT/file_cache/` (hashed by SHA-256)
|
||||
- **Doc:** `documentation/PROJECT__AE_Events_Launcher_Native_integration.md`
|
||||
|
||||
The Electron app zero-configs itself:
|
||||
1. Reads `seed.json` → gets device code
|
||||
2. Calls Aether API → pulls device config and location assignment
|
||||
3. Navigates directly to the Launcher for that location
|
||||
4. Begins pre-caching session files in the background
|
||||
|
||||
### Launcher Display Views
|
||||
|
||||
| View | Shown When |
|
||||
|---|---|
|
||||
| Session view | Active session with session-level files |
|
||||
| Presentation view | Active session with named presentations |
|
||||
| Presenter view | Presentation selected; shows presenter bio/photo |
|
||||
| Poster/group view | Special layout for poster sessions |
|
||||
| Screensaver | No active session; idle state |
|
||||
|
||||
### File Opening (Native Mode) — Safe Handover
|
||||
|
||||
1. Verify SHA-256 hash in permanent cache
|
||||
2. Atomic copy to system `[tmp]` directory
|
||||
3. Rename to original filename (e.g., `Abstract_101.pptx`)
|
||||
4. OS opens the file (Keynote, PowerPoint, Preview, etc.)
|
||||
|
||||
Versioning is handled automatically: when a presenter uploads an updated file, the new
|
||||
hash is cached separately and the old one remains intact.
|
||||
|
||||
---
|
||||
|
||||
## Configuration — `mod_pres_mgmt_json`
|
||||
|
||||
The event's Pres Mgmt behavior is controlled by `event.mod_pres_mgmt_json`.
|
||||
|
||||
> **Note:** The config schema is being cleaned up — see
|
||||
> `documentation/PROJECT__AE_Events_PressMgmt_Config_Cleanup.md` for the canonical
|
||||
> `PressMgmtRemoteCfg` interface and naming conventions.
|
||||
|
||||
### Convention
|
||||
|
||||
| Prefix | Default state | Meaning |
|
||||
|---|---|---|
|
||||
| `hide__` | `false` = visible | Feature is ON by default; set `true` to suppress |
|
||||
| `show__` | `false` = hidden | Feature is OFF by default; set `true` to enable |
|
||||
|
||||
### Common Config Keys
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `lock_config` | `false` | `true` = force remote→local sync; prevents user overrides of local config |
|
||||
| `hide__session_code` | `false` | Hide session code column/field |
|
||||
| `hide__session_description` | `false` | Hide session description field |
|
||||
| `hide__session_location` | `false` | Hide location field on session view |
|
||||
| `hide__session_datetime` | `false` | Hide datetime fields |
|
||||
| `hide__presentation_code` | `false` | Hide presentation code |
|
||||
| `hide__presenter_code` | `false` | Hide presenter code |
|
||||
| `hide__location_code` | `false` | Hide location code |
|
||||
| `show__launcher_link` | `false` | Show direct Launcher link in session view |
|
||||
| `show__session_qr` | `false` | Show QR code for session (SRR lookup) |
|
||||
| `show__presenter_qr` | `false` | Show QR code for presenter (SRR lookup) |
|
||||
| `label__person_external_id` | `null` | Override label for external ID field (e.g., `"Member ID"`) |
|
||||
| `label__session_poc_name` | `null` | Override label for session POC (e.g., `"Champion"`) |
|
||||
| `file_purpose_option_kv` | `{}` | Key-value map of file purpose options (e.g., `{"ppt": "PowerPoint", "pdf": "PDF"}`) |
|
||||
|
||||
### Per-Show Config Examples
|
||||
|
||||
**BGH (session-only, minimal; no named Presentations or Presenters):**
|
||||
```json
|
||||
{
|
||||
"lock_config": false,
|
||||
"hide__presentation_code": true,
|
||||
"hide__presenter_code": true
|
||||
}
|
||||
```
|
||||
|
||||
**LCI (full setup, member ID label, Launcher link enabled):**
|
||||
```json
|
||||
{
|
||||
"lock_config": true,
|
||||
"label__person_external_id": "LCI Member ID",
|
||||
"show__launcher_link": true
|
||||
}
|
||||
```
|
||||
|
||||
> Admin must currently edit `mod_pres_mgmt_json` directly in the DB or via the event
|
||||
> settings page. A proper Config UI is planned — see `PROJECT__AE_Events_PressMgmt_Config_Cleanup.md`.
|
||||
|
||||
---
|
||||
|
||||
## Route Map
|
||||
|
||||
| URL | Purpose |
|
||||
|---|---|
|
||||
| `/events/[id]/pres_mgmt` | Overview — sessions list, search, filter by location |
|
||||
| `/events/[id]/pres_mgmt/config` | Config editor (admin only) |
|
||||
| `/events/[id]/session/[session_id]` | Session detail — files, presentations, timing, alert |
|
||||
| `/events/[id]/presenter/[presenter_id]` | Presenter detail — bio, files, agreement, alert |
|
||||
| `/events/[id]/location/[location_id]` | Location detail — session schedule for this room, alert |
|
||||
| `/events/[id]/locations` | All locations list |
|
||||
| `/events/[id]/reports` | Reports — sessions, presenters, files |
|
||||
| `/events/[id]/launcher` | Launcher home — select location |
|
||||
| `/events/[id]/launcher/[location_id]` | Launcher display for a specific room |
|
||||
|
||||
---
|
||||
|
||||
## Device Management
|
||||
|
||||
Each Electron kiosk is registered as an `event_device` record:
|
||||
- `code` — matches the device's `seed.json` code
|
||||
- `name` — human-readable (e.g., "Ballroom A Podium")
|
||||
- `data_json.location_id` — the `event_location_id` this device is assigned to
|
||||
|
||||
Devices can be managed in Pres Mgmt (`/events/[id]/device/device`). Location reassignment
|
||||
takes effect on the next Electron app launch.
|
||||
|
||||
---
|
||||
|
||||
## Access Levels
|
||||
|
||||
| Feature | Minimum Access |
|
||||
|---|---|
|
||||
| View pres_mgmt overview | `authenticated_access` |
|
||||
| Upload files | `authenticated_access` |
|
||||
| Edit sessions / presentations | `trusted_access` |
|
||||
| Edit config | `administrator_access` + `edit_mode` |
|
||||
| View Launcher display | `authenticated_access` |
|
||||
| Manual session selection in Launcher | `trusted_access` |
|
||||
| Device management | `administrator_access` |
|
||||
|
||||
---
|
||||
|
||||
## Pre-Show Checklist
|
||||
|
||||
### 1–2 Weeks Before
|
||||
|
||||
- [ ] Event created in Aether with correct dates
|
||||
- [ ] `mod_pres_mgmt_json` configured for this client's needs
|
||||
- [ ] Locations (rooms) created and named
|
||||
- [ ] Sessions created, assigned to locations, datetime ranges set
|
||||
- [ ] If using Presentations/Presenters: records imported or entered
|
||||
- [ ] File purpose options configured in `file_purpose_option_kv`
|
||||
- [ ] Launcher devices registered (`event_device` records with correct codes)
|
||||
- [ ] Device-to-location assignments confirmed
|
||||
- [ ] Decide: QR codes for Sessions / Presenters needed? Enable/disable in config
|
||||
|
||||
### Day Before (SRR Setup)
|
||||
|
||||
- [ ] Mac laptops at podiums booted and Electron app running
|
||||
- [ ] Each podium confirms it loaded the correct location's Launcher
|
||||
- [ ] SRR practice stations confirmed — projector, same Mac/dongle setup as session rooms
|
||||
- [ ] Pre-loaded files verified in Launcher (open at least one per room to test Safe Handover)
|
||||
- [ ] SRR staff briefed on upload workflow and VNC/RustDesk monitoring setup
|
||||
- [ ] VNC/RustDesk connections established to all podium displays
|
||||
|
||||
### Day of Show
|
||||
|
||||
- [ ] Confirm all session times in Aether are accurate before first session
|
||||
- [ ] Monitor SRR queue — upload files as presenters check in
|
||||
- [ ] Verify each file opens on a practice station before the presenter walks to their room
|
||||
- [ ] Monitor podium displays via VNC/RustDesk — flag any stuck or offline devices
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Session not showing in Launcher | Session datetime wrong or location not assigned | Verify session location and datetime range |
|
||||
| File uploaded but not in Launcher | Polling cycle lag; or file attached at wrong level | Wait one cycle; check that file is attached to session/location (not just a presenter record) if using session-only setup |
|
||||
| Electron app shows wrong location | Device code mismatch or stale device config | Re-check `event_device` record; restart Electron app |
|
||||
| File opens slowly at podium | Not in native cache yet | Check background sync in Launcher; pre-cache may not have completed |
|
||||
| File won't open | Corrupt upload, wrong format, or missing codec on Mac | Test on SRR practice station; re-upload or convert |
|
||||
| Session out of sync with schedule | Timing drifted; manual advance needed | Use Launcher controls to manually select the current session |
|
||||
| Alert field not showing | Alert fields need implementation review | Known — lower priority than active operations |
|
||||
| `lock_config: true` resets local changes | Expected behavior — remote config wins | Change the remote config in `mod_pres_mgmt_json` |
|
||||
| Device needs to move to different room | Location reassigned mid-event | Update `data_json.location_id` on `event_device` record; restart Electron app on that machine |
|
||||
@@ -106,6 +106,11 @@ export interface PresMgmtLocState {
|
||||
location_name_qry_str: string | null; // persisted location filter text
|
||||
refresh_interval: number; // auto-refresh interval in seconds (0 = disabled)
|
||||
|
||||
// --- Time window filter (onsite use: show only sessions near current time) ---
|
||||
enable_time_window: boolean; // false = off (show all); true = apply window
|
||||
time_window_before_minutes: number; // include sessions that started up to N min ago
|
||||
time_window_after_minutes: number; // include sessions starting within next N min
|
||||
|
||||
// --- Report display preferences (user-controlled, persisted) ---
|
||||
rpt__session_no_files: boolean; // show "sessions with no files" report section
|
||||
rpt__session_poc_agree: boolean; // show "session POC agreement" report section
|
||||
@@ -282,6 +287,11 @@ export const pres_mgmt_loc_defaults: PresMgmtLocState = {
|
||||
location_name_qry_str: null,
|
||||
refresh_interval: 0,
|
||||
|
||||
// Time window filter
|
||||
enable_time_window: false,
|
||||
time_window_before_minutes: 30,
|
||||
time_window_after_minutes: 720,
|
||||
|
||||
// Report display preferences
|
||||
rpt__session_no_files: true,
|
||||
rpt__session_poc_agree: false,
|
||||
|
||||
@@ -313,13 +313,15 @@ let step_label = $derived(
|
||||
/* Placeholder text must read as a hint, not as filled content.
|
||||
Tailwind v4 sets placeholder color too dark on light backgrounds.
|
||||
Same fix as ae_comp__badge_print_controls.svelte. */
|
||||
form input::placeholder,
|
||||
form textarea::placeholder {
|
||||
|
||||
/* form textarea::placeholder */
|
||||
form input::placeholder {
|
||||
color: #9ca3af; /* gray-400 */
|
||||
opacity: 1; /* Firefox: override default 0.54 opacity */
|
||||
}
|
||||
:global(.dark) form input::placeholder,
|
||||
:global(.dark) form textarea::placeholder {
|
||||
|
||||
/* :global(.dark) form textarea::placeholder */
|
||||
:global(.dark) form input::placeholder {
|
||||
color: #6b7280; /* gray-500 in dark mode */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -177,8 +177,9 @@ function handle_qr_scan_result(event: {
|
||||
onclick={() => document.getElementById('badge_fulltext_search_qry_str')?.focus()}
|
||||
aria-label="Start here — focus search field"
|
||||
data-testid="badge-start-btn"
|
||||
class="btn btn-sm preset-filled-secondary-300-700 font-semibold"
|
||||
class="btn btn-sm preset-filled-secondary-200-800 font-semibold"
|
||||
class:opacity-30={!!badges_loc.current.fulltext_search_qry_str}
|
||||
class:hidden={$ae_loc.trusted_access}
|
||||
>
|
||||
<StepForward size="1em" class="mx-1" />
|
||||
Start Here
|
||||
|
||||
@@ -46,12 +46,11 @@ import {
|
||||
ListChecks,
|
||||
LoaderCircle,
|
||||
Mails,
|
||||
MapPin,
|
||||
RemoveFormatting,
|
||||
Search,
|
||||
TriangleAlert,
|
||||
Upload
|
||||
} from '@lucide/svelte';
|
||||
import Comp_pres_mgmt_session_search from './ae_comp__pres_mgmt_session_search.svelte';
|
||||
// Quickly save the data passed from the parent(s) to the Svelte stores, localStorage, and other.
|
||||
// NOTE: Derived from data.account_id (prop) instead of $slct.account_id (store)
|
||||
// to prevent circular dependency loops during hydration.
|
||||
@@ -156,7 +155,11 @@ let search_params = $derived({
|
||||
.trim(),
|
||||
location: pres_mgmt_loc.current.location_name_qry_str,
|
||||
event_id: $events_slct?.event_id,
|
||||
remote_first: pres_mgmt_loc.current.qry__remote_first
|
||||
remote_first: pres_mgmt_loc.current.qry__remote_first,
|
||||
qry_hidden: pres_mgmt_loc.current.qry_hidden,
|
||||
enable_time_window: pres_mgmt_loc.current.enable_time_window,
|
||||
time_window_before_minutes: pres_mgmt_loc.current.time_window_before_minutes,
|
||||
time_window_after_minutes: pres_mgmt_loc.current.time_window_after_minutes
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -180,6 +183,35 @@ async function handle_search_refresh(params: any) {
|
||||
const event_id = params.event_id;
|
||||
const remote_first = params.remote_first;
|
||||
|
||||
// Snapshot filter params at search time — avoids stale reads across async boundaries.
|
||||
const { enable_time_window, time_window_before_minutes, time_window_after_minutes, qry_hidden } = params;
|
||||
|
||||
// Mirror the API's hide filter in the fast path so IDB results match what the
|
||||
// API will return. Without this, hidden sessions briefly appear for trusted users
|
||||
// before the API revalidation removes them (the blink).
|
||||
//
|
||||
// NOTE: only event_session.hide is checked here — NOT hide_event_launcher.
|
||||
// hide_event_launcher removes sessions from the Launcher only (used to drop past
|
||||
// blocks from the kiosk view). Those sessions remain fully accessible in pres_mgmt.
|
||||
function passes_hide_filter(session: { hide?: boolean | null }): boolean {
|
||||
if (qry_hidden === 'not_hidden') return !session.hide;
|
||||
if (qry_hidden === 'hidden') return !!session.hide;
|
||||
return true; // 'all'
|
||||
}
|
||||
|
||||
// Sessions with no start_datetime are always shown when the time filter is active
|
||||
// (e.g. unscheduled placeholder sessions at BGH).
|
||||
function passes_time_window(session: { start_datetime?: Date | string | null }): boolean {
|
||||
if (!enable_time_window) return true;
|
||||
if (!session.start_datetime) return true;
|
||||
const now = Date.now();
|
||||
const start = new Date(session.start_datetime).getTime();
|
||||
return (
|
||||
start >= now - time_window_before_minutes * 60_000 &&
|
||||
start <= now + time_window_after_minutes * 60_000
|
||||
);
|
||||
}
|
||||
|
||||
if (log_lvl)
|
||||
console.log(
|
||||
`[Session Search #${current_search_id}] Refreshing (remote=${remote_first}, event=${event_id}, str=${params.str})...`
|
||||
@@ -200,6 +232,9 @@ async function handle_search_refresh(params: any) {
|
||||
.where('event_id')
|
||||
.equals(event_id)
|
||||
.filter((session) => {
|
||||
if (!passes_hide_filter(session)) return false;
|
||||
if (!passes_time_window(session)) return false;
|
||||
|
||||
if (
|
||||
location_name &&
|
||||
session.event_location_name !== location_name
|
||||
@@ -276,6 +311,7 @@ async function handle_search_refresh(params: any) {
|
||||
|
||||
// Client-side Filter Guard: Ensure API results match local criteria (Backup filter)
|
||||
api_results = api_results.filter((session) => {
|
||||
if (!passes_time_window(session)) return false;
|
||||
if (
|
||||
location_name &&
|
||||
session.event_location_name !== location_name
|
||||
@@ -339,16 +375,6 @@ if (
|
||||
pres_mgmt_loc.current.saved_search__session_location_name;
|
||||
}
|
||||
|
||||
function handle_search_trigger() {
|
||||
pres_mgmt_loc.current.search_version++;
|
||||
}
|
||||
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -422,97 +448,8 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
</header>
|
||||
|
||||
{#if !pres_mgmt_loc.current.show_content__event_view || pres_mgmt_loc.current.show_content__event_view == 'default'}
|
||||
<div class="preset-tonal-primary rounded-xl">
|
||||
<form
|
||||
onsubmit={prevent_default(() => handle_search_trigger())}
|
||||
autocomplete="off"
|
||||
class="form flex w-full grow flex-row flex-wrap items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm ae_btn_warning mx-1"
|
||||
class:hidden={!$ae_loc.authenticated_access}
|
||||
onclick={() => {
|
||||
pres_mgmt_loc.current.location_name_qry_str = '';
|
||||
pres_mgmt_loc.current.show_content__session_search_room_name =
|
||||
!pres_mgmt_loc.current.show_content__session_search_room_name;
|
||||
handle_search_trigger();
|
||||
}}
|
||||
title="Search by location name">
|
||||
<MapPin size="1em" />
|
||||
</button>
|
||||
|
||||
<select
|
||||
name="location_name_list"
|
||||
id="session_location_name_list"
|
||||
bind:value={pres_mgmt_loc.current.location_name_qry_str}
|
||||
class="input mx-1 w-min max-w-40 min-w-fit font-mono text-xs font-bold transition-all"
|
||||
class:hidden={!$ae_loc.authenticated_access ||
|
||||
!pres_mgmt_loc.current.show_content__session_search_room_name}
|
||||
onchange={() => handle_search_trigger()}
|
||||
title="Select to filter based on the location/room name">
|
||||
{#if $lq__event_location_obj_li}
|
||||
<option value="">Location / Room</option>
|
||||
{#each $lq__event_location_obj_li as event_location_obj (event_location_obj.event_location_id)}
|
||||
<option value={event_location_obj?.name}
|
||||
>{event_location_obj.name}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
pres_mgmt_loc.current.fulltext_search_qry_str = '';
|
||||
handle_search_trigger();
|
||||
}}
|
||||
class:hidden={!pres_mgmt_loc.current.fulltext_search_qry_str}
|
||||
class="btn btn-sm ae_btn_warning mx-1"
|
||||
title="Clear search text">
|
||||
<RemoveFormatting size="1em" />
|
||||
</button>
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search for a session"
|
||||
id="session_fulltext_search_qry_str"
|
||||
bind:value={pres_mgmt_loc.current.fulltext_search_qry_str}
|
||||
class="input text-1xl ae_btn_info mx-1 w-80 font-mono font-bold transition-all hover:text-2xl"
|
||||
onkeyup={(e) => {
|
||||
if (e.key === 'Enter') handle_search_trigger();
|
||||
}}
|
||||
autofocus
|
||||
data-ignore="true" />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-lg ae_btn_primary mx-1 w-48 text-2xl font-bold"
|
||||
title="Search for a session">
|
||||
{#if $events_sess.pres_mgmt.status_qry__search == 'loading'}
|
||||
<LoaderCircle
|
||||
size="1em"
|
||||
class="text-success-800-200 mx-1 animate-spin" />
|
||||
{:else}
|
||||
<Search size="1em" class="mx-1 text-neutral-800/80" />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
|
||||
{#if $ae_loc.edit_mode}
|
||||
<label
|
||||
class="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold opacity-70 transition-all hover:opacity-100">
|
||||
<span> Remote First </span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={
|
||||
pres_mgmt_loc.current.qry__remote_first
|
||||
}
|
||||
onchange={() => handle_search_trigger()}
|
||||
class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
<Comp_pres_mgmt_session_search
|
||||
event_location_obj_li={$lq__event_location_obj_li} />
|
||||
|
||||
{#if event_session_id_li.length}
|
||||
<Comp_event_session_obj_li_wrapper
|
||||
@@ -541,6 +478,15 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
Use the search bar above to find your session.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if pres_mgmt_loc.current.enable_time_window}
|
||||
<!-- Time filter is ON — most common cause of "no results" when
|
||||
sessions exist but are scheduled outside the current window. -->
|
||||
<div class="preset-tonal-error rounded-lg p-3 text-center text-sm font-semibold">
|
||||
⏰ Time filter is ON — sessions outside the current window are hidden.
|
||||
Disable the clock button in the search bar to see all sessions.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="bg-surface-50-900/60 rounded-lg p-3">
|
||||
<span
|
||||
class="mb-2 block text-xs font-bold tracking-wide uppercase opacity-50"
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
event_location_obj_li: any[] | undefined;
|
||||
log_lvl?: number;
|
||||
}
|
||||
|
||||
let { event_location_obj_li, log_lvl = 0 }: Props = $props();
|
||||
|
||||
import { Clock, LoaderCircle, MapPin, RemoveFormatting, Search } from '@lucide/svelte';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { pres_mgmt_loc } from '$lib/stores/ae_events_stores__pres_mgmt.svelte';
|
||||
|
||||
function handle_search_trigger() {
|
||||
pres_mgmt_loc.current.search_version++;
|
||||
}
|
||||
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ae_group pres_mgmt_session_search preset-tonal-primary rounded-xl">
|
||||
<form
|
||||
onsubmit={prevent_default(() => handle_search_trigger())}
|
||||
autocomplete="off"
|
||||
class="form flex w-full grow flex-row flex-wrap items-center justify-center gap-1 px-2 py-2">
|
||||
|
||||
<!-- {#if $ae_loc.edit_mode} -->
|
||||
|
||||
<!-- Location filter toggle + select (authenticated users only) -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm mx-1"
|
||||
class:hidden={!$ae_loc.authenticated_access}
|
||||
class:opacity-10={pres_mgmt_loc.current.show_content__session_search_room_name}
|
||||
class:preset-filled-primary-300-700={pres_mgmt_loc.current.show_content__session_search_room_name}
|
||||
class:preset-filled-primary-100-900={!pres_mgmt_loc.current.show_content__session_search_room_name}
|
||||
onclick={() => {
|
||||
pres_mgmt_loc.current.location_name_qry_str = '';
|
||||
pres_mgmt_loc.current.show_content__session_search_room_name =
|
||||
!pres_mgmt_loc.current.show_content__session_search_room_name;
|
||||
handle_search_trigger();
|
||||
}}
|
||||
title="Filter by room / location">
|
||||
<MapPin size="1em" />
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
<!-- Time window toggle icon button (authenticated only).
|
||||
Highlighted when active so onsite staff can see at a glance whether the
|
||||
filter is on. The controls below only appear when enabled. -->
|
||||
|
||||
<!-- TEMP: hide clock button for now, even in edit mode, since time filtering is a bit niche and the controls are a bit complex for non-technical users. Can re-enable when we have a better UI for the time filter controls. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
pres_mgmt_loc.current.enable_time_window =
|
||||
!pres_mgmt_loc.current.enable_time_window;
|
||||
handle_search_trigger();
|
||||
}}
|
||||
title={pres_mgmt_loc.current.enable_time_window
|
||||
? 'Time filter ON — click to disable'
|
||||
: 'Show only sessions near current time'}
|
||||
class="btn btn-sm mx-1"
|
||||
class:hidden={!$ae_loc.edit_mode && 1==3}
|
||||
class:opacity-10={pres_mgmt_loc.current.enable_time_window}
|
||||
class:preset-filled-primary-300-700={pres_mgmt_loc.current.enable_time_window}
|
||||
class:preset-filled-primary-100-900={!pres_mgmt_loc.current.enable_time_window}>
|
||||
<Clock size="1em" />
|
||||
</button>
|
||||
|
||||
<!-- {/if} -->
|
||||
|
||||
<!-- Main search input -->
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="search"
|
||||
autofocus
|
||||
placeholder="Search for a session"
|
||||
id="session_fulltext_search_qry_str"
|
||||
bind:value={pres_mgmt_loc.current.fulltext_search_qry_str}
|
||||
autocomplete="off"
|
||||
data-ignore="true"
|
||||
class="input text-1xl ae_btn_info mx-1 w-80 font-mono font-bold transition-all hover:text-2xl"
|
||||
onkeyup={(e) => {
|
||||
if (e.key === 'Enter') handle_search_trigger();
|
||||
}}
|
||||
title="Search sessions by title, description, speaker, etc."
|
||||
/>
|
||||
|
||||
<!-- Search submit button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-lg ae_btn_primary mx-1 w-48 text-2xl font-bold"
|
||||
title="Search for a session">
|
||||
{#if $events_sess.pres_mgmt.status_qry__search == 'loading'}
|
||||
<LoaderCircle size="1em" class="text-success-800-200 mx-1 animate-spin" />
|
||||
{:else}
|
||||
<Search size="1em" class="mx-1 text-neutral-800/80" />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
|
||||
<!-- Clear search text button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
pres_mgmt_loc.current.fulltext_search_qry_str = '';
|
||||
handle_search_trigger();
|
||||
}}
|
||||
class:hidden={!pres_mgmt_loc.current.fulltext_search_qry_str}
|
||||
class="btn btn-sm ae_btn_warning mx-1"
|
||||
title="Clear search text">
|
||||
<RemoveFormatting size="1em" />
|
||||
</button>
|
||||
|
||||
|
||||
<!-- Remote First (edit_mode only) -->
|
||||
{#if $ae_loc.edit_mode}
|
||||
<label
|
||||
class="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold opacity-70 transition-all hover:opacity-100">
|
||||
<span> Remote First </span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={pres_mgmt_loc.current.qry__remote_first}
|
||||
onchange={() => handle_search_trigger()}
|
||||
class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if pres_mgmt_loc.current.enable_time_window ||
|
||||
pres_mgmt_loc.current.show_content__session_search_room_name}
|
||||
|
||||
<div class="w-full flex flex-wrap items-center justify-center gap-2 px-2 pb-2 text-xs">
|
||||
|
||||
<span class="flex flex-wrap items-center justify-center gap-2 text-xs">
|
||||
<select
|
||||
name="location_name_list"
|
||||
id="session_location_name_list"
|
||||
bind:value={pres_mgmt_loc.current.location_name_qry_str}
|
||||
class="input mx-1 w-min max-w-40 min-w-fit font-mono text-xs font-bold transition-all"
|
||||
class:hidden={!$ae_loc.authenticated_access ||
|
||||
!pres_mgmt_loc.current.show_content__session_search_room_name}
|
||||
onchange={() => handle_search_trigger()}
|
||||
title="Filter by location / room name">
|
||||
{#if event_location_obj_li}
|
||||
<option value="">Location / Room</option>
|
||||
{#each event_location_obj_li as loc (loc.event_location_id)}
|
||||
<option value={loc?.name}>{loc.name}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
|
||||
<!-- Location filter toggle + select (authenticated users only) -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm mx-1"
|
||||
class:hidden={!pres_mgmt_loc.current.show_content__session_search_room_name}
|
||||
class:preset-filled-primary-300-700={pres_mgmt_loc.current.show_content__session_search_room_name}
|
||||
class:preset-filled-primary-100-900={!pres_mgmt_loc.current.show_content__session_search_room_name}
|
||||
onclick={() => {
|
||||
pres_mgmt_loc.current.location_name_qry_str = '';
|
||||
pres_mgmt_loc.current.show_content__session_search_room_name =
|
||||
!pres_mgmt_loc.current.show_content__session_search_room_name;
|
||||
handle_search_trigger();
|
||||
}}
|
||||
title="Filter by room / location">
|
||||
<MapPin size="1em" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<!-- Time window controls — compact row, shown only when filter is active.
|
||||
Two simple selects (before / after current time) keep this readable for
|
||||
all experience levels without the noise of preset chip arrays. -->
|
||||
{#if pres_mgmt_loc.current.enable_time_window}
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-center gap-2 text-xs">
|
||||
<Clock size="0.875em" class="opacity-50" />
|
||||
<span class="font-semibold opacity-70">Showing sessions:</span>
|
||||
<span class="opacity-50">−</span>
|
||||
<select
|
||||
bind:value={pres_mgmt_loc.current.time_window_before_minutes}
|
||||
onchange={handle_search_trigger}
|
||||
class="select select-sm max-w-fit px-1 text-xs">
|
||||
<option value={0}>0m</option>
|
||||
<option value={25}>25m</option>
|
||||
<option value={90}>90m</option>
|
||||
<option value={120}>2h</option>
|
||||
<option value={240}>4h</option>
|
||||
<option value={480}>8h</option>
|
||||
<option value={720}>12h</option>
|
||||
<option value={1440}>24h</option>
|
||||
{#if $ae_loc.trusted_access}<option value={4320}>3d</option>{/if}
|
||||
{#if $ae_loc.trusted_access}<option value={10080}>7d</option>{/if}
|
||||
</select>
|
||||
<span class="opacity-40">to</span>
|
||||
<span class="opacity-50">+</span>
|
||||
<select
|
||||
bind:value={pres_mgmt_loc.current.time_window_after_minutes}
|
||||
onchange={handle_search_trigger}
|
||||
class="select select-sm max-w-fit px-1 text-xs">
|
||||
<option value={90}>90m</option>
|
||||
<option value={120}>2h</option>
|
||||
<option value={240}>4h</option>
|
||||
<option value={480}>8h</option>
|
||||
<option value={720}>12h</option>
|
||||
<option value={1440}>24h</option>
|
||||
{#if $ae_loc.trusted_access}<option value={4320}>3d</option>{/if}
|
||||
{#if $ae_loc.trusted_access}<option value={10080}>7d</option>{/if}
|
||||
</select>
|
||||
<span class="opacity-40">from now</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
pres_mgmt_loc.current.enable_time_window =
|
||||
!pres_mgmt_loc.current.enable_time_window;
|
||||
handle_search_trigger();
|
||||
}}
|
||||
title={pres_mgmt_loc.current.enable_time_window
|
||||
? 'Time filter ON — click to disable'
|
||||
: 'Show only sessions near current time'}
|
||||
class="btn btn-sm mx-1"
|
||||
class:hidden={!pres_mgmt_loc.current.enable_time_window}
|
||||
class:preset-filled-primary-300-700={pres_mgmt_loc.current.enable_time_window}
|
||||
class:preset-filled-primary-100-900={!pres_mgmt_loc.current.enable_time_window}>
|
||||
<Clock size="1em" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
Reference in New Issue
Block a user