Add Jitsi reports to IDAA
This commit is contained in:
@@ -85,8 +85,8 @@ src/routes/idaa/
|
||||
│ │ └── [event_id]/
|
||||
│ │ ├── +page.svelte # Meeting detail page — renders view OR edit based on session flag
|
||||
│ │ └── +page.ts
|
||||
│ └── video_conferences/ # Jitsi video conference integration
|
||||
└── jitsi_reports/ # External Jitsi reporting
|
||||
│ ├── video_conferences/ # Jitsi video conference integration
|
||||
│ └── jitsi_reports/ # Jitsi meeting activity log report (trusted_access only)
|
||||
```
|
||||
|
||||
> **Note:** Recovery Meetings has **two UI entry points**:
|
||||
@@ -183,6 +183,12 @@ fetch(`${api_root_url}/customers/${uuid}`, {
|
||||
|
||||
- **`novi_bb_base_url`**: (optional) Base URL used to build links for Bulletin Board notification emails.
|
||||
|
||||
- **`jitsi_exclude_names`**: (optional) Array of display name strings to exclude from Jitsi Reports.
|
||||
Used to hide known staff and test accounts from the activity report so participant counts and lists
|
||||
reflect real member activity only. Names are matched case-insensitively against `final_participants[].displayName`.
|
||||
Example: `["Scott I.", "Michelle V.", "Brie K."]`. The filter is applied before the "Real meetings only"
|
||||
threshold check — a session with only excluded participants is treated as having 0 real participants.
|
||||
|
||||
- **Email config values** (`noreply_email`, `noreply_name`, `admin_email`, `admin_name`): used by functions that send notification emails (BB posts, comments, recovery meetings).
|
||||
|
||||
### Stores / runtime fields set by verification
|
||||
@@ -446,6 +452,94 @@ Moderation permissions are controlled by `novi_jitsi_mod_li` in the IDAA store.
|
||||
|
||||
---
|
||||
|
||||
## Module 5: Jitsi Reports
|
||||
|
||||
**Route:** `/idaa/jitsi_reports/`
|
||||
**Access:** `trusted_access` or `novi_verified` — same gate as the rest of `(idaa)/`
|
||||
**Data source:** `activity_log` table — `jitsi_meeting_event` and `jitsi_meeting_stats` log types
|
||||
**Library function:** `qry__jitsi_report()` in `src/lib/ae_reports/reports_functions.ts`
|
||||
|
||||
An admin/staff reporting tool that aggregates raw Jitsi activity logs into human-readable meeting sessions. It is **not** a member-facing page — IDAA members do not see it.
|
||||
|
||||
### View Modes
|
||||
|
||||
Two display modes, toggled via a button in the page header:
|
||||
|
||||
| Mode | Description |
|
||||
| --- | --- |
|
||||
| **Grouped by Room** (default) | One collapsible section per `room_name`. Each section contains a compact table: Date / Time / Duration / Attendees / Participant List. Mirrors the output of the offline Python script (`create_jitsi_report.py`). |
|
||||
| **Flat List** | Original card-per-session accordion layout. Better for drilling into event timelines and raw participant lists. |
|
||||
|
||||
Both modes use the same filtered data set — switching views does not reset filters.
|
||||
|
||||
### Filters
|
||||
|
||||
| Filter | Default | Logic |
|
||||
| --- | --- | --- |
|
||||
| **Real meetings only** | off | Show only sessions where `real_participant_count >= 2` OR `duration > 5 min`. Applied **after** staff exclusion (see below). |
|
||||
| **Min. Participants** | 0 | Minimum `real_participant_count` to display a session. |
|
||||
| **Room Name** | (empty) | Case-insensitive substring match against `room_name`. |
|
||||
| **From / To** | (empty) | Date range applied to `start_time`. "To" date includes the full end of day. |
|
||||
|
||||
A "Reset Filters" button appears whenever any filter is non-default.
|
||||
|
||||
### Staff / Test Exclusion
|
||||
|
||||
**Problem:** Staff and test accounts (Scott, Michelle, Brie) join real member meetings for setup, testing, and tech support. Their presence inflates participant counts and pollutes the participant list.
|
||||
|
||||
**Solution:** A configurable exclusion list in `site_cfg_json`:
|
||||
```json
|
||||
{ "jitsi_exclude_names": ["Scott I.", "Michelle V.", "Brie K."] }
|
||||
```
|
||||
|
||||
**How it works (client-side only, no backend change needed):**
|
||||
|
||||
1. On load, the page reads `$ae_loc.site_cfg_json?.jitsi_exclude_names` (string array, defaults to `[]`).
|
||||
2. For every session, a `real_participants` derived list is computed by filtering `final_participants` against the exclusion list (case-insensitive display name match).
|
||||
3. `real_participant_count = real_participants.length` — this count drives all filters, stats, and the participant list column in grouped view.
|
||||
4. The raw `final_participant_count` from the API is never shown to the user once an exclusion list is configured.
|
||||
|
||||
**Why display-name matching (not Novi UUID):** Jitsi participant data (`meta_json.participants`) only contains `displayName` and `role` — the Novi UUID is not passed through to the activity log. UUID-based exclusion would require a Jitsi config change plus a backend schema update and is deferred. Display names for OSIT staff are stable and controlled.
|
||||
|
||||
### Summary Stats
|
||||
|
||||
Shown above the meeting list when data is loaded. Stats reflect the **filtered + exclusion-applied** view:
|
||||
|
||||
- **Meetings Shown** — count of sessions passing all filters
|
||||
- **Total Participants** — sum of `real_participant_count` across all shown sessions
|
||||
- **Avg Duration** — mean session duration (HH:MM:SS)
|
||||
- **Total Duration** — sum of all session durations (HH:MM:SS)
|
||||
|
||||
In grouped view, each room header also shows its own subtotals (meeting count, unique participants).
|
||||
|
||||
### Jitsi URL Builder
|
||||
|
||||
Collapsible panel, visible to `trusted_access` users only. Generates properly-formatted Jitsi meeting URLs for IDAA rooms. Component: `ae_idaa_comp__jitsi_url_builder.svelte`.
|
||||
|
||||
### Export
|
||||
|
||||
CSV and JSON export buttons in the page header export the **currently filtered + exclusion-applied** data set.
|
||||
|
||||
### Room Name Fragmentation
|
||||
|
||||
The same logical meeting can appear as multiple rooms (e.g. `IDAA-BIPOC-Meeting`, `IDAA-BIPOC-Meeting-2026`, `IDAA-BIPOC-Meeting-March-31`) because the Jitsi URL builder appends a date suffix to generate unique per-session room names. In grouped view, these appear as separate groups. A future normalization pass (strip trailing date suffixes) could optionally merge them — not implemented yet.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```text
|
||||
activity_log table
|
||||
└── qry__jitsi_report() # reports_functions.ts — fetches + aggregates by meeting_id
|
||||
└── MeetingReport[] # { meeting_id, room_name, start_time, final_duration,
|
||||
# final_participants, final_participant_count, events }
|
||||
└── jitsi_reports/+page.svelte
|
||||
├── apply exclusion list → real_participants / real_participant_count
|
||||
├── apply filters → meetings_filtered
|
||||
├── derive grouped view → Map<room_name, MeetingReport[]>
|
||||
└── render flat or grouped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management (`ae_idaa_stores.ts`)
|
||||
|
||||
Four stores manage all IDAA state:
|
||||
@@ -582,6 +676,7 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
|
||||
| Bulletin Board | ❌ None | Priority — most sensitive module |
|
||||
| Recovery Meetings | ✅ Substantial | `tests/idaa_recovery_meeting_edit.test.ts` — form render, field interactions, PATCH payload verification (all sections), real backend save, creation linkage (Novi UUID in POST body) |
|
||||
| Video Conferences | ❌ None | Jitsi complexity, lower priority |
|
||||
| Jitsi Reports | ❌ None | Admin-only tool; lower privacy risk than member modules |
|
||||
|
||||
**Pending:** BB Post and Post Comment creation linkage tests (pattern established in Recovery Meetings test).
|
||||
|
||||
@@ -634,4 +729,4 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
|
||||
---
|
||||
|
||||
**Document Status:** ✅ Current
|
||||
**Last Verified:** 2026-04-07 — updated for Novi UUID triple-linkage enforcement, staff editing rules, Contact 1 convention, test coverage
|
||||
**Last Verified:** 2026-05-05 — added Module 5: Jitsi Reports (grouped view, real-meetings filter, staff exclusion via `jitsi_exclude_names`); fixed route tree (`jitsi_reports/` is inside `(idaa)/`)
|
||||
|
||||
@@ -7,6 +7,23 @@ import {
|
||||
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
|
||||
import { db_core } from '$lib/ae_core/db_core';
|
||||
|
||||
// MariaDB TEXT columns come back as JSON strings from the API — parse safely.
|
||||
function safe_parse_meta(raw: unknown): Record<string, unknown> {
|
||||
if (!raw) return {};
|
||||
if (typeof raw === 'object') return raw as Record<string, unknown>;
|
||||
try {
|
||||
return JSON.parse(raw as string) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parse_duration_seconds(d: string): number {
|
||||
if (!d || !d.includes(':')) return 0;
|
||||
const [h, m, s] = d.split(':').map(Number);
|
||||
return (h || 0) * 3600 + (m || 0) * 60 + (s || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Queries all Jitsi-related activity logs and processes them into a structured report,
|
||||
* grouped by meeting ID.
|
||||
@@ -96,50 +113,121 @@ export async function qry__jitsi_report({
|
||||
}
|
||||
|
||||
// Step 2: Process the flat list into a structured report.
|
||||
//
|
||||
// Participants come from two sources — both are needed for a complete list:
|
||||
// 1. jitsi_meeting_participant_joined events: meta.full_name (all who ever joined)
|
||||
// 2. jitsi_meeting_init / stats / end snapshots: meta.participants[].displayName+role
|
||||
// Source 2 has role info; source 1 may catch participants who left before the snapshot.
|
||||
// We merge both into a Map<displayName, role> per meeting, deduplicating by name.
|
||||
//
|
||||
// Duration: take the MAX across all init/stats/end events — periodic stats may
|
||||
// have a higher value than the final init summary in some Jitsi configurations.
|
||||
|
||||
const meetings = new Map<string, any>();
|
||||
// Per-meeting participant map: displayName → role
|
||||
const participant_maps = new Map<string, Map<string, string>>();
|
||||
// Per-meeting max observed duration in seconds
|
||||
const max_duration_secs = new Map<string, number>();
|
||||
|
||||
for (const log of flat_log_list) {
|
||||
const meeting_id = log.external_client_id;
|
||||
if (!meeting_id) continue;
|
||||
if (!log.name?.startsWith('jitsi_')) continue;
|
||||
|
||||
// Make sure the name field is prefixed with "jitsi_"
|
||||
if (!log.name.startsWith('jitsi_')) continue;
|
||||
|
||||
// Ensure a base entry for the meeting exists
|
||||
if (!meetings.has(meeting_id)) {
|
||||
meetings.set(meeting_id, {
|
||||
meeting_id: meeting_id,
|
||||
meeting_id,
|
||||
room_name: 'Unknown',
|
||||
start_time: log.created_on, // Fallback start time
|
||||
start_time: log.created_on,
|
||||
final_duration: '00:00:00',
|
||||
final_participants: [],
|
||||
final_participant_count: 0,
|
||||
events: []
|
||||
});
|
||||
participant_maps.set(meeting_id, new Map());
|
||||
max_duration_secs.set(meeting_id, 0);
|
||||
}
|
||||
|
||||
const meeting_report = meetings.get(meeting_id);
|
||||
const p_map = participant_maps.get(meeting_id)!;
|
||||
const meta = safe_parse_meta(log.meta_json);
|
||||
|
||||
if (log.action === 'jitsi_meeting_init') {
|
||||
// This is the main log entry, containing the final state.
|
||||
meeting_report.room_name = log.description;
|
||||
meeting_report.start_time = log.created_on; // The init log has the true start time
|
||||
if (log.meta_json) {
|
||||
meeting_report.final_duration = log.meta_json.duration;
|
||||
meeting_report.final_participants = log.meta_json.participants;
|
||||
meeting_report.final_participant_count =
|
||||
log.meta_json.participant_count;
|
||||
// Strip "Event in room: " prefix Jitsi sometimes prepends to the description
|
||||
meeting_report.room_name =
|
||||
(log.description ?? '').replace(/^Event in room:\s*/i, '').trim() ||
|
||||
'Unknown';
|
||||
meeting_report.start_time = log.created_on;
|
||||
}
|
||||
|
||||
// Parse duration from init, stats, or end — keep the maximum seen
|
||||
if (
|
||||
log.action === 'jitsi_meeting_init' ||
|
||||
log.action === 'jitsi_meeting_stats' ||
|
||||
log.action === 'jitsi_meeting_end'
|
||||
) {
|
||||
const dur_str = ((meta.duration ?? meta.final_duration) as string) || '';
|
||||
if (dur_str) {
|
||||
const secs = parse_duration_seconds(dur_str);
|
||||
if (secs > (max_duration_secs.get(meeting_id) ?? 0)) {
|
||||
max_duration_secs.set(meeting_id, secs);
|
||||
meeting_report.final_duration = dur_str;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This is a discrete event log.
|
||||
|
||||
// Merge snapshot participant list (has role info — preferred source)
|
||||
const snapshot = meta.participants as
|
||||
| Array<{ displayName?: string; role?: string }>
|
||||
| undefined;
|
||||
if (Array.isArray(snapshot)) {
|
||||
for (const p of snapshot) {
|
||||
if (!p.displayName) continue;
|
||||
// Only overwrite an existing entry if we're upgrading from participant → moderator
|
||||
const existing_role = p_map.get(p.displayName);
|
||||
if (!existing_role || (existing_role !== 'moderator' && p.role === 'moderator')) {
|
||||
p_map.set(p.displayName, p.role ?? 'participant');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect participants from join events (may catch people who left before the snapshot)
|
||||
if (log.action === 'jitsi_meeting_participant_joined') {
|
||||
const full_name = (meta.full_name as string) || '';
|
||||
if (full_name && !p_map.has(full_name)) {
|
||||
p_map.set(full_name, (meta.role as string) ?? 'participant');
|
||||
}
|
||||
}
|
||||
|
||||
// Discrete events for the timeline (all non-init actions)
|
||||
if (log.action !== 'jitsi_meeting_init') {
|
||||
meeting_report.events.push({
|
||||
timestamp: log.created_on,
|
||||
action: log.action,
|
||||
details: log.meta_json
|
||||
details: {
|
||||
full_name: (meta.full_name as string) ?? undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Compile final participant lists from the deduplicated maps
|
||||
for (const [meeting_id, p_map] of participant_maps) {
|
||||
const meeting_report = meetings.get(meeting_id);
|
||||
if (!meeting_report) continue;
|
||||
if (p_map.size > 0) {
|
||||
// Sort: moderators first, then alphabetically
|
||||
meeting_report.final_participants = Array.from(p_map.entries())
|
||||
.map(([displayName, role]) => ({ displayName, role }))
|
||||
.sort((a: any, b: any) => {
|
||||
if (a.role === 'moderator' && b.role !== 'moderator') return -1;
|
||||
if (a.role !== 'moderator' && b.role === 'moderator') return 1;
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
});
|
||||
meeting_report.final_participant_count = p_map.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort events within each meeting chronologically
|
||||
for (const report of meetings.values()) {
|
||||
report.events.sort(
|
||||
|
||||
@@ -27,12 +27,16 @@ interface MeetingReport {
|
||||
events: MeetingEvent[];
|
||||
}
|
||||
|
||||
// Extends MeetingReport with exclusion-applied participant data
|
||||
interface MeetingReportEnriched extends MeetingReport {
|
||||
real_participants: MeetingParticipant[];
|
||||
real_participant_count: number;
|
||||
}
|
||||
|
||||
// --- Data state ---
|
||||
// Resolve the streamed promise into reactive state so we can filter and export it.
|
||||
let meetings_all = $state<MeetingReport[]>([]);
|
||||
let meetings_loading = $state(true);
|
||||
let meetings_error = $state<string | null>(null);
|
||||
|
||||
let meetings_load_started = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
@@ -65,33 +69,96 @@ $effect(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Staff/test exclusion ---
|
||||
// Read display names to exclude from site_cfg_json.jitsi_exclude_names.
|
||||
// Matched case-insensitively against MeetingParticipant.displayName.
|
||||
let exclude_names = $derived<string[]>(
|
||||
(($ae_loc.site_cfg_json as Record<string, unknown>)
|
||||
?.jitsi_exclude_names as string[] | undefined)
|
||||
?.map((n) => n.toLowerCase().trim()) ?? []
|
||||
);
|
||||
|
||||
// Apply exclusion to every meeting — produces the "real" participant list
|
||||
// used by all downstream filters, stats, and display.
|
||||
let meetings_enriched = $derived<MeetingReportEnriched[]>(
|
||||
meetings_all.map((m) => {
|
||||
// final_participants can be null/undefined if meta_json.participants was null in the API response
|
||||
const all_participants: MeetingParticipant[] = m.final_participants ?? [];
|
||||
const real_participants =
|
||||
exclude_names.length > 0
|
||||
? all_participants.filter(
|
||||
(p) =>
|
||||
!exclude_names.includes(
|
||||
p.displayName.toLowerCase().trim()
|
||||
)
|
||||
)
|
||||
: all_participants;
|
||||
return {
|
||||
...m,
|
||||
real_participants,
|
||||
real_participant_count: real_participants.length
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// --- View mode ---
|
||||
let group_by_room = $state(true);
|
||||
|
||||
// --- Filter state ---
|
||||
// Default 0 so historical logs (which lack an init record and have count=0) are visible.
|
||||
// Once logging is fully established, this can be raised to 1 to hide empty/test meetings.
|
||||
let filter_real_only = $state(false);
|
||||
let filter_min_participants = $state(0);
|
||||
let filter_room_name = $state('');
|
||||
let filter_date_from = $state('');
|
||||
let filter_date_to = $state('');
|
||||
|
||||
let filters_are_modified = $derived(
|
||||
filter_min_participants !== 0 ||
|
||||
filter_real_only ||
|
||||
filter_min_participants !== 0 ||
|
||||
filter_room_name !== '' ||
|
||||
filter_date_from !== '' ||
|
||||
filter_date_to !== ''
|
||||
);
|
||||
|
||||
function reset_filters() {
|
||||
filter_real_only = false;
|
||||
filter_min_participants = 0;
|
||||
filter_room_name = '';
|
||||
filter_date_from = '';
|
||||
filter_date_to = '';
|
||||
}
|
||||
|
||||
// --- Duration helpers ---
|
||||
function parse_duration_seconds(d: string): number {
|
||||
if (!d) return 0;
|
||||
const parts = d.split(':').map(Number);
|
||||
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
|
||||
}
|
||||
|
||||
function format_seconds(total: number): string {
|
||||
const h = Math.floor(total / 3600).toString().padStart(2, '0');
|
||||
const m = Math.floor((total % 3600) / 60).toString().padStart(2, '0');
|
||||
const s = Math.floor(total % 60).toString().padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
function compute_end_time(start_time: string, duration: string): string {
|
||||
if (!start_time || !duration) return '—';
|
||||
const start = new Date(start_time);
|
||||
const secs = parse_duration_seconds(duration);
|
||||
if (secs === 0) return '—';
|
||||
const end = new Date(start.getTime() + secs * 1000);
|
||||
return end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// --- Derived: filtered meetings ---
|
||||
// All counts and thresholds use real_participant_count (post-exclusion).
|
||||
let meetings_filtered = $derived.by(() => {
|
||||
return meetings_all.filter((m) => {
|
||||
if ((m.final_participant_count ?? 0) < filter_min_participants)
|
||||
return false;
|
||||
return meetings_enriched.filter((m) => {
|
||||
if (filter_real_only) {
|
||||
const dur_secs = parse_duration_seconds(m.final_duration);
|
||||
if (m.real_participant_count < 2 && dur_secs <= 300) return false;
|
||||
}
|
||||
if (m.real_participant_count < filter_min_participants) return false;
|
||||
if (
|
||||
filter_room_name &&
|
||||
!m.room_name?.toLowerCase().includes(filter_room_name.toLowerCase())
|
||||
@@ -102,7 +169,6 @@ let meetings_filtered = $derived.by(() => {
|
||||
return false;
|
||||
}
|
||||
if (filter_date_to) {
|
||||
// Include full end-of-day by appending T23:59:59 to the date string
|
||||
if (
|
||||
Date.parse(m.start_time) >
|
||||
Date.parse(filter_date_to + 'T23:59:59.999')
|
||||
@@ -113,30 +179,36 @@ let meetings_filtered = $derived.by(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Derived: grouped by room ---
|
||||
// Rooms sorted by most-recent session desc; sessions within each room sorted asc.
|
||||
let meetings_grouped = $derived.by<Map<string, MeetingReportEnriched[]>>(() => {
|
||||
const groups = new Map<string, MeetingReportEnriched[]>();
|
||||
for (const m of meetings_filtered) {
|
||||
const room = m.room_name ?? 'Unknown';
|
||||
if (!groups.has(room)) groups.set(room, []);
|
||||
groups.get(room)!.push(m);
|
||||
}
|
||||
for (const sessions of groups.values()) {
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_time).getTime() -
|
||||
new Date(b.start_time).getTime()
|
||||
);
|
||||
}
|
||||
return new Map(
|
||||
[...groups.entries()].sort((a, b) => {
|
||||
const a_last = a[1].at(-1)?.start_time ?? '';
|
||||
const b_last = b[1].at(-1)?.start_time ?? '';
|
||||
return new Date(b_last).getTime() - new Date(a_last).getTime();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// --- Summary stats ---
|
||||
function parse_duration_seconds(d: string): number {
|
||||
if (!d) return 0;
|
||||
const parts = d.split(':').map(Number);
|
||||
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
|
||||
}
|
||||
|
||||
function format_seconds(total: number): string {
|
||||
const h = Math.floor(total / 3600)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const s = Math.floor(total % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
let summary = $derived.by(() => {
|
||||
const count = meetings_filtered.length;
|
||||
const total_participants = meetings_filtered.reduce(
|
||||
(sum, m) => sum + (m.final_participant_count ?? 0),
|
||||
(sum, m) => sum + m.real_participant_count,
|
||||
0
|
||||
);
|
||||
const total_secs = meetings_filtered.reduce(
|
||||
@@ -152,14 +224,41 @@ let summary = $derived.by(() => {
|
||||
};
|
||||
});
|
||||
|
||||
// --- Accordion state ---
|
||||
let open_accordions = $state<{ [key: string]: boolean }>({});
|
||||
let show_url_builder = $state(false);
|
||||
function room_stats(sessions: MeetingReportEnriched[]) {
|
||||
const total_secs = sessions.reduce(
|
||||
(sum, m) => sum + parse_duration_seconds(m.final_duration),
|
||||
0
|
||||
);
|
||||
const unique_names = new Set(
|
||||
sessions.flatMap((m) => m.real_participants.map((p) => p.displayName))
|
||||
);
|
||||
return {
|
||||
meeting_count: sessions.length,
|
||||
total_duration: format_seconds(total_secs),
|
||||
unique_participant_count: unique_names.size
|
||||
};
|
||||
}
|
||||
|
||||
// --- Accordion / room open state ---
|
||||
let open_accordions = $state<Record<string, boolean>>({});
|
||||
// Rooms default to open; only track explicitly-closed rooms.
|
||||
let closed_rooms = $state<Record<string, boolean>>({});
|
||||
|
||||
function toggle_accordion(meeting_id: string) {
|
||||
open_accordions[meeting_id] = !open_accordions[meeting_id];
|
||||
}
|
||||
|
||||
function toggle_room(room_name: string) {
|
||||
closed_rooms[room_name] = !closed_rooms[room_name];
|
||||
}
|
||||
|
||||
function is_room_open(room_name: string): boolean {
|
||||
return !closed_rooms[room_name];
|
||||
}
|
||||
|
||||
// --- URL Builder ---
|
||||
let show_url_builder = $state(false);
|
||||
|
||||
// --- Export ---
|
||||
function download_file(content: string, filename: string, mime: string) {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
@@ -180,7 +279,8 @@ function export_csv() {
|
||||
'Room Name',
|
||||
'Start Time',
|
||||
'Duration',
|
||||
'Participant Count'
|
||||
'Participant Count',
|
||||
'Participants'
|
||||
]
|
||||
];
|
||||
for (const m of meetings_filtered) {
|
||||
@@ -189,7 +289,8 @@ function export_csv() {
|
||||
m.room_name ?? '',
|
||||
m.start_time ? new Date(m.start_time).toISOString() : '',
|
||||
m.final_duration ?? '',
|
||||
String(m.final_participant_count ?? 0)
|
||||
String(m.real_participant_count),
|
||||
m.real_participants.map((p) => p.displayName).join('; ')
|
||||
]);
|
||||
}
|
||||
const csv = rows
|
||||
@@ -202,7 +303,15 @@ function export_csv() {
|
||||
|
||||
function export_json() {
|
||||
download_file(
|
||||
JSON.stringify(meetings_filtered, null, 2),
|
||||
JSON.stringify(
|
||||
meetings_filtered.map((m) => ({
|
||||
...m,
|
||||
final_participants: m.real_participants,
|
||||
final_participant_count: m.real_participant_count
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
'jitsi_meeting_report.json',
|
||||
'application/json'
|
||||
);
|
||||
@@ -214,10 +323,33 @@ function export_json() {
|
||||
</svelte:head>
|
||||
|
||||
<div class="w-full max-w-5xl space-y-4 p-4">
|
||||
<!-- Page header + export buttons -->
|
||||
<!-- Page header: view toggle + export buttons -->
|
||||
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<h1 class="text-xl font-bold">Jitsi Meeting Reports</h1>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- View mode toggle -->
|
||||
<div class="border-surface-200-800 flex overflow-hidden rounded-lg border text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (group_by_room = true)}
|
||||
title="Group sessions by room"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 font-medium transition-colors duration-150 {group_by_room
|
||||
? 'preset-filled-primary'
|
||||
: 'opacity-60 hover:opacity-90 hover:bg-surface-100-900'}">
|
||||
<span class="fas fa-layer-group text-xs" aria-hidden="true"></span>
|
||||
<span class="hidden sm:inline">By Room</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (group_by_room = false)}
|
||||
title="Show all sessions as a flat list"
|
||||
class="border-surface-200-800 flex items-center gap-1.5 border-l px-3 py-1.5 font-medium transition-colors duration-150 {!group_by_room
|
||||
? 'preset-filled-primary'
|
||||
: 'opacity-60 hover:opacity-90 hover:bg-surface-100-900'}">
|
||||
<span class="fas fa-list text-xs" aria-hidden="true"></span>
|
||||
<span class="hidden sm:inline">Flat List</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={export_csv}
|
||||
@@ -225,7 +357,7 @@ function export_json() {
|
||||
title="Export filtered meetings as CSV"
|
||||
class="btn btn-sm preset-tonal-primary disabled:opacity-40">
|
||||
<span class="fas fa-file-csv" aria-hidden="true"></span>
|
||||
Export CSV
|
||||
<span class="ml-1 hidden sm:inline">CSV</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -234,7 +366,7 @@ function export_json() {
|
||||
title="Export filtered meetings as JSON"
|
||||
class="btn btn-sm preset-tonal-surface border-surface-200-800 border disabled:opacity-40">
|
||||
<span class="fas fa-file-code" aria-hidden="true"></span>
|
||||
Export JSON
|
||||
<span class="ml-1 hidden sm:inline">JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,6 +400,16 @@ function export_json() {
|
||||
<!-- Filter bar -->
|
||||
<div
|
||||
class="bg-surface-100-900 border-surface-200-800 flex flex-row flex-wrap items-end gap-3 rounded-xl border p-3">
|
||||
<!-- Real meetings only toggle -->
|
||||
<label class="flex cursor-pointer items-center gap-2 self-end pb-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={filter_real_only}
|
||||
class="checkbox checkbox-sm" />
|
||||
<span class="text-sm font-medium whitespace-nowrap"
|
||||
>Real meetings only</span>
|
||||
</label>
|
||||
<div class="bg-surface-200-800 hidden h-8 w-px self-end sm:block"></div>
|
||||
<div>
|
||||
<label
|
||||
for="filter_min_p"
|
||||
@@ -392,196 +534,383 @@ function export_json() {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meeting list -->
|
||||
{#if meetings_filtered.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each meetings_filtered as meeting (meeting.meeting_id)}
|
||||
<div
|
||||
class="bg-surface-50-900 border-surface-200-800 overflow-hidden rounded-xl border">
|
||||
<!-- Accordion header -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
GROUPED BY ROOM VIEW
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
{#if group_by_room}
|
||||
{#if meetings_grouped.size > 0}
|
||||
<div class="space-y-2">
|
||||
{#each meetings_grouped as [room_name, sessions] (room_name)}
|
||||
{@const stats = room_stats(sessions)}
|
||||
<div
|
||||
class="hover:bg-surface-100-900 cursor-pointer p-3 transition-colors duration-200"
|
||||
onclick={() =>
|
||||
toggle_accordion(meeting.meeting_id)}>
|
||||
<div class="flex items-center gap-2">
|
||||
class="bg-surface-50-900 border-surface-200-800 overflow-hidden rounded-xl border">
|
||||
<!-- Room header -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle_room(room_name)}
|
||||
class="hover:bg-surface-100-900 flex w-full items-center justify-between gap-2 p-3 text-left transition-colors duration-200">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-semibold">
|
||||
{meeting.room_name}
|
||||
{room_name}
|
||||
</div>
|
||||
<div class="text-sm opacity-60">
|
||||
{new Date(
|
||||
meeting.start_time
|
||||
).toLocaleString()}
|
||||
<div
|
||||
class="mt-0.5 flex flex-wrap gap-3 text-xs opacity-60">
|
||||
<span>
|
||||
<span
|
||||
class="fas fa-calendar-alt mr-1"
|
||||
aria-hidden="true"></span>
|
||||
{stats.meeting_count}
|
||||
{stats.meeting_count === 1
|
||||
? 'session'
|
||||
: 'sessions'}
|
||||
</span>
|
||||
<span>
|
||||
<span
|
||||
class="fas fa-users mr-1"
|
||||
aria-hidden="true"></span>
|
||||
{stats.unique_participant_count} unique
|
||||
{stats.unique_participant_count === 1
|
||||
? 'participant'
|
||||
: 'participants'}
|
||||
</span>
|
||||
<span class="font-mono">
|
||||
<span
|
||||
class="fas fa-clock mr-1"
|
||||
aria-hidden="true"></span>
|
||||
{stats.total_duration} total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="fas flex-none pl-2 text-xs opacity-60"
|
||||
class:fa-chevron-down={is_room_open(
|
||||
room_name
|
||||
)}
|
||||
class:fa-chevron-up={!is_room_open(
|
||||
room_name
|
||||
)}
|
||||
aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<!-- Room session table -->
|
||||
{#if is_room_open(room_name)}
|
||||
<div
|
||||
class="hidden flex-none items-center gap-4 text-sm opacity-60 sm:flex">
|
||||
<span title="Duration">
|
||||
class="border-surface-200-800 overflow-x-auto border-t">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="bg-surface-100-900 border-surface-200-800 border-b">
|
||||
<th
|
||||
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
|
||||
>Date</th>
|
||||
<th
|
||||
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
|
||||
>Start</th>
|
||||
<th
|
||||
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
|
||||
>End</th>
|
||||
<th
|
||||
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
|
||||
>Duration</th>
|
||||
<th
|
||||
class="px-3 py-2 text-center font-medium whitespace-nowrap opacity-60"
|
||||
title="Participant count (after staff exclusion)">#</th>
|
||||
<th
|
||||
class="px-3 py-2 text-left font-medium opacity-60"
|
||||
>Participants</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sessions as m (m.meeting_id)}
|
||||
{@const mods = m.real_participants.filter((p) => p.role === 'moderator')}
|
||||
{@const others = m.real_participants.filter((p) => p.role !== 'moderator')}
|
||||
{@const all_names = m.real_participants.map((p) => `${p.displayName} (${p.role})`).join('\n')}
|
||||
<tr
|
||||
class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200">
|
||||
<td
|
||||
class="px-3 py-2 whitespace-nowrap">
|
||||
{new Date(
|
||||
m.start_time
|
||||
).toLocaleDateString()}
|
||||
</td>
|
||||
<td
|
||||
class="px-3 py-2 whitespace-nowrap">
|
||||
{new Date(
|
||||
m.start_time
|
||||
).toLocaleTimeString(
|
||||
[],
|
||||
{
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
class="px-3 py-2 whitespace-nowrap">
|
||||
{compute_end_time(m.start_time, m.final_duration)}
|
||||
</td>
|
||||
<td
|
||||
class="px-3 py-2 font-mono whitespace-nowrap">
|
||||
{m.final_duration}
|
||||
</td>
|
||||
<td
|
||||
class="px-3 py-2 text-center"
|
||||
title={all_names || 'No participants'}>
|
||||
{m.real_participant_count}
|
||||
</td>
|
||||
<td class="px-3 py-2" title={all_names || 'No participants'}>
|
||||
{#if m.real_participant_count === 0}
|
||||
<span class="opacity-40">—</span>
|
||||
{:else}
|
||||
<div class="space-y-0.5 text-xs">
|
||||
{#if mods.length > 0}
|
||||
<div>
|
||||
<span class="mr-1 opacity-40">Mod:</span>
|
||||
<span class="font-semibold">{mods.map((p) => p.displayName).join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if others.length > 0}
|
||||
<div class="opacity-80">
|
||||
{others.slice(0, 5).map((p) => p.displayName).join(', ')}{others.length > 5 ? ` +${others.length - 5} more` : ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-6 text-center">
|
||||
{#if meetings_all.length > 0}
|
||||
<div class="font-semibold">
|
||||
No meetings match the current filters
|
||||
</div>
|
||||
<p class="mt-1 text-sm opacity-60">
|
||||
Try adjusting the filters or clearing the date
|
||||
range.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset_filters}
|
||||
class="btn btn-sm preset-tonal-surface border-surface-200-800 mt-3 border">
|
||||
<span class="fas fa-times mr-1" aria-hidden="true"
|
||||
></span>
|
||||
Reset Filters
|
||||
</button>
|
||||
{:else}
|
||||
<div class="font-semibold">No Meeting Reports Found</div>
|
||||
<p class="mt-1 text-sm opacity-60">
|
||||
There are no Jitsi activity logs to display.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
FLAT LIST VIEW
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
{:else}
|
||||
{#if meetings_filtered.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each meetings_filtered as meeting (meeting.meeting_id)}
|
||||
<div
|
||||
class="bg-surface-50-900 border-surface-200-800 overflow-hidden rounded-xl border">
|
||||
<!-- Accordion header -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="hover:bg-surface-100-900 cursor-pointer p-3 transition-colors duration-200"
|
||||
onclick={() =>
|
||||
toggle_accordion(meeting.meeting_id)}>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-semibold">
|
||||
{meeting.room_name}
|
||||
</div>
|
||||
<div class="text-sm opacity-60">
|
||||
{new Date(meeting.start_time).toLocaleString()}
|
||||
{#if meeting.final_duration && meeting.final_duration !== '00:00:00'}
|
||||
<span class="mx-1 opacity-50">→</span>{compute_end_time(meeting.start_time, meeting.final_duration)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="hidden flex-none items-center gap-4 text-sm opacity-60 sm:flex">
|
||||
<span title="Duration">
|
||||
<span
|
||||
class="fas fa-clock mr-1"
|
||||
aria-hidden="true"></span>
|
||||
{meeting.final_duration}
|
||||
</span>
|
||||
<span title="Participant count">
|
||||
<span
|
||||
class="fas fa-users mr-1"
|
||||
aria-hidden="true"></span>
|
||||
{meeting.real_participant_count}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-none pl-2">
|
||||
<span
|
||||
class="fas inline-block transition-transform duration-200"
|
||||
class:fa-chevron-down={!open_accordions[
|
||||
meeting.meeting_id
|
||||
]}
|
||||
class:fa-chevron-up={open_accordions[
|
||||
meeting.meeting_id
|
||||
]}
|
||||
aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile stats row -->
|
||||
<div
|
||||
class="mt-1 flex gap-4 text-sm opacity-60 sm:hidden">
|
||||
<span>
|
||||
<span
|
||||
class="fas fa-clock mr-1"
|
||||
aria-hidden="true"></span>
|
||||
{meeting.final_duration}
|
||||
</span>
|
||||
<span title="Participant count">
|
||||
<span>
|
||||
<span
|
||||
class="fas fa-users mr-1"
|
||||
aria-hidden="true"></span>
|
||||
{meeting.final_participant_count}
|
||||
{meeting.real_participant_count}
|
||||
{meeting.real_participant_count === 1
|
||||
? 'participant'
|
||||
: 'participants'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-none pl-2">
|
||||
<span
|
||||
class="fas inline-block transition-transform duration-200"
|
||||
class:fa-chevron-down={!open_accordions[
|
||||
meeting.meeting_id
|
||||
]}
|
||||
class:fa-chevron-up={open_accordions[
|
||||
meeting.meeting_id
|
||||
]}
|
||||
aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile stats row -->
|
||||
<div
|
||||
class="mt-1 flex gap-4 text-sm opacity-60 sm:hidden">
|
||||
<span
|
||||
><span
|
||||
class="fas fa-clock mr-1"
|
||||
aria-hidden="true"></span
|
||||
>{meeting.final_duration}</span>
|
||||
<span>
|
||||
<span
|
||||
class="fas fa-users mr-1"
|
||||
aria-hidden="true"></span>
|
||||
{meeting.final_participant_count}
|
||||
{meeting.final_participant_count === 1
|
||||
? 'participant'
|
||||
: 'participants'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accordion body -->
|
||||
{#if open_accordions[meeting.meeting_id]}
|
||||
<div
|
||||
class="border-surface-200-800 grid grid-cols-1 gap-4 border-t p-4 md:grid-cols-2">
|
||||
<!-- Event Timeline -->
|
||||
<div>
|
||||
<div
|
||||
class="mb-2 text-xs tracking-wide uppercase opacity-40">
|
||||
Event Timeline
|
||||
</div>
|
||||
{#if meeting.events && meeting.events.length > 0}
|
||||
<ul class="space-y-1">
|
||||
{#each meeting.events as event, i (i)}
|
||||
<li
|
||||
class="flex items-start gap-2 text-sm">
|
||||
<span
|
||||
class="mt-0.5 font-mono text-xs whitespace-nowrap opacity-60">
|
||||
[{new Date(
|
||||
event.timestamp
|
||||
).toLocaleTimeString()}]
|
||||
</span>
|
||||
<span>
|
||||
<!-- Accordion body -->
|
||||
{#if open_accordions[meeting.meeting_id]}
|
||||
<div
|
||||
class="border-surface-200-800 grid grid-cols-1 gap-4 border-t p-4 md:grid-cols-2">
|
||||
<!-- Event Timeline -->
|
||||
<div>
|
||||
<div
|
||||
class="mb-2 text-xs tracking-wide uppercase opacity-40">
|
||||
Event Timeline
|
||||
</div>
|
||||
{#if meeting.events && meeting.events.length > 0}
|
||||
<ul class="space-y-1">
|
||||
{#each meeting.events as event, i (i)}
|
||||
<li
|
||||
class="flex items-start gap-2 text-sm">
|
||||
<span
|
||||
class="font-semibold">
|
||||
{ae_util.to_title_case(
|
||||
event.action.replace(
|
||||
'jitsi_meeting_',
|
||||
''
|
||||
)
|
||||
)}
|
||||
class="mt-0.5 font-mono text-xs whitespace-nowrap opacity-60">
|
||||
[{new Date(
|
||||
event.timestamp
|
||||
).toLocaleTimeString()}]
|
||||
</span>
|
||||
{#if event.details?.full_name}
|
||||
<span>
|
||||
<span
|
||||
class="opacity-60">
|
||||
— {event.details
|
||||
.full_name}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm italic opacity-60">
|
||||
No discrete events recorded.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Final Participants -->
|
||||
<div>
|
||||
<div
|
||||
class="mb-2 text-xs tracking-wide uppercase opacity-40">
|
||||
Final Participants ({meeting.final_participant_count})
|
||||
</div>
|
||||
{#if meeting.final_participants && meeting.final_participants.length > 0}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="border-surface-200-800 border-b">
|
||||
<th
|
||||
class="py-1 text-left font-medium opacity-60"
|
||||
>Name</th>
|
||||
<th
|
||||
class="py-1 text-left font-medium opacity-60"
|
||||
>Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each meeting.final_participants as participant (participant.displayName)}
|
||||
<tr
|
||||
class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200">
|
||||
<td class="py-1"
|
||||
>{participant.displayName}</td>
|
||||
<td class="py-1"
|
||||
>{ae_util.to_title_case(
|
||||
participant.role
|
||||
)}</td>
|
||||
</tr>
|
||||
class="font-semibold">
|
||||
{ae_util.to_title_case(
|
||||
event.action.replace(
|
||||
'jitsi_meeting_',
|
||||
''
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
{#if event.details?.full_name}
|
||||
<span
|
||||
class="opacity-60">
|
||||
— {event
|
||||
.details
|
||||
.full_name}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="text-sm italic opacity-60">
|
||||
No participant data available.
|
||||
</p>
|
||||
{/if}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm italic opacity-60">
|
||||
No discrete events recorded.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Final Participants (exclusion-applied) -->
|
||||
<div>
|
||||
<div
|
||||
class="mb-2 text-xs tracking-wide uppercase opacity-40">
|
||||
Final Participants ({meeting.real_participant_count})
|
||||
</div>
|
||||
{#if meeting.real_participants && meeting.real_participants.length > 0}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="border-surface-200-800 border-b">
|
||||
<th
|
||||
class="py-1 text-left font-medium opacity-60"
|
||||
>Name</th>
|
||||
<th
|
||||
class="py-1 text-left font-medium opacity-60"
|
||||
>Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each meeting.real_participants as participant (participant.displayName)}
|
||||
<tr
|
||||
class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200">
|
||||
<td class="py-1"
|
||||
>{participant.displayName}</td>
|
||||
<td class="py-1"
|
||||
>{ae_util.to_title_case(
|
||||
participant.role
|
||||
)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="text-sm italic opacity-60">
|
||||
No participant data available.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-6 text-center">
|
||||
{#if meetings_all.length > 0}
|
||||
<div class="font-semibold">
|
||||
No meetings match the current filters
|
||||
</div>
|
||||
<p class="mt-1 text-sm opacity-60">
|
||||
Try lowering the minimum participants or clearing the
|
||||
date range.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset_filters}
|
||||
class="btn btn-sm preset-tonal-surface border-surface-200-800 mt-3 border">
|
||||
<span class="fas fa-times mr-1" aria-hidden="true"
|
||||
></span>
|
||||
Reset Filters
|
||||
</button>
|
||||
{:else}
|
||||
<div class="font-semibold">No Meeting Reports Found</div>
|
||||
<p class="mt-1 text-sm opacity-60">
|
||||
There are no Jitsi activity logs to display.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-6 text-center">
|
||||
{#if meetings_all.length > 0}
|
||||
<div class="font-semibold">
|
||||
No meetings match the current filters
|
||||
</div>
|
||||
<p class="mt-1 text-sm opacity-60">
|
||||
Try lowering the minimum participants or clearing
|
||||
the date range.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset_filters}
|
||||
class="btn btn-sm preset-tonal-surface border-surface-200-800 mt-3 border">
|
||||
<span class="fas fa-times mr-1" aria-hidden="true"
|
||||
></span>
|
||||
Reset Filters
|
||||
</button>
|
||||
{:else}
|
||||
<div class="font-semibold">No Meeting Reports Found</div>
|
||||
<p class="mt-1 text-sm opacity-60">
|
||||
There are no Jitsi activity logs to display.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user