ui-nyla/src/pages/views/teamsbot/TeamsbotDashboardView.tsx
2026-05-12 17:49:44 +02:00

280 lines
10 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotSession, MeetingModule } from '../../../api/teamsbotApi';
import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
/**
* TeamsBot Dashboard — IA: KPIs, Modul-Aggregate, Quick-Actions.
* Neues Meeting: Assistent (Wizard). Sessions sind via Module erreichbar.
*/
export const TeamsbotDashboardView: React.FC = () => {
const { t } = useLanguage();
const { instance, mandateId, featureCode } = useCurrentInstance();
const instanceId = instance?.id || '';
const navigate = useNavigate();
const [sessions, setSessions] = useState<TeamsbotSession[]>([]);
const [modules, setModules] = useState<MeetingModule[]>([]);
const [error, setError] = useState<string | null>(null);
const dashboardEsRef = useRef<EventSource | null>(null);
const dashboardReconnectRef = useRef<number | null>(null);
const applyDashboardPayload = useCallback((nextSessions: TeamsbotSession[], nextModules: MeetingModule[]) => {
setSessions(nextSessions);
setModules(nextModules);
}, []);
useEffect(() => {
if (!instanceId) return;
let cancelled = false;
const clearReconnect = () => {
if (dashboardReconnectRef.current) {
window.clearTimeout(dashboardReconnectRef.current);
dashboardReconnectRef.current = null;
}
};
const connect = () => {
if (cancelled) return;
dashboardEsRef.current?.close();
const es = teamsbotApi.createDashboardStream(instanceId);
dashboardEsRef.current = es;
es.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as {
type?: string;
sessions?: TeamsbotSession[];
modules?: MeetingModule[];
};
if (msg.type === 'dashboardState' && Array.isArray(msg.sessions) && Array.isArray(msg.modules)) {
applyDashboardPayload(msg.sessions, msg.modules);
setError(null);
}
} catch {
/* ignore malformed SSE */
}
};
es.onerror = () => {
es.close();
dashboardEsRef.current = null;
if (cancelled) return;
clearReconnect();
dashboardReconnectRef.current = window.setTimeout(connect, 2500);
};
};
connect();
return () => {
cancelled = true;
clearReconnect();
dashboardEsRef.current?.close();
dashboardEsRef.current = null;
};
}, [instanceId, applyDashboardPayload]);
const activeSessions = useMemo(
() => sessions.filter((s) => ['pending', 'joining', 'active'].includes(s.status)),
[sessions],
);
const moduleTitleById = useMemo(() => {
const m = new Map<string, string>();
modules.forEach((mod) => m.set(mod.id, mod.title));
return m;
}, [modules]);
const topModules = useMemo(() => {
const counts = new Map<string, number>();
sessions.forEach((s) => {
const mid = s.moduleId || '_adhoc';
counts.set(mid, (counts.get(mid) || 0) + 1);
});
const rows = Array.from(counts.entries())
.map(([moduleId, sessionCount]) => ({
moduleId,
sessionCount,
title: moduleId === '_adhoc' ? t('Adhoc / ohne Modul') : (moduleTitleById.get(moduleId) || t('Unbekanntes Modul')),
}))
.sort((a, b) => b.sessionCount - a.sessionCount)
.slice(0, 6);
return rows;
}, [sessions, moduleTitleById, t]);
const totalSegments = useMemo(() => sessions.reduce((acc, s) => acc + (s.transcriptSegmentCount || 0), 0), [sessions]);
const totalResponses = useMemo(() => sessions.reduce((acc, s) => acc + (s.botResponseCount || 0), 0), [sessions]);
const _getStatusBadgeClass = (status: string) => {
switch (status) {
case 'active': return styles.statusActive;
case 'joining': return styles.statusJoining;
case 'pending': return styles.statusPending;
case 'ended': return styles.statusEnded;
case 'error': return styles.statusError;
case 'leaving': return styles.statusLeaving;
default: return '';
}
};
const _getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
pending: t('Wartend'),
joining: t('Beitritt…'),
active: t('Aktiv'),
leaving: t('Verlassen…'),
ended: t('Beendet'),
error: t('Fehler'),
};
return labels[status] || status;
};
const _sessionPath = (sessId: string) =>
`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${sessId}`;
const _refreshLists = useCallback(async () => {
if (!instanceId) return;
try {
const [r, m] = await Promise.all([
teamsbotApi.listSessions(instanceId, true),
teamsbotApi.listModules(instanceId),
]);
setSessions(r.sessions || []);
setModules(m || []);
} catch { /* ignore */ }
}, [instanceId]);
const _handleStopSession = async (sid: string) => {
try {
await teamsbotApi.stopSession(instanceId, sid);
await _refreshLists();
} catch (err: any) {
setError(err.message || t('Fehler beim Stoppen'));
}
};
return (
<div className={styles.tbDash}>
<header className={styles.tbDashHero}>
<div className={styles.tbDashHeroText}>
<h1 className={styles.tbDashTitle}>{t('Teams Bot')}</h1>
<p className={styles.tbDashSubtitle}>
{t('Dashboard mit Übersicht, Modulen und Live-Sitzung — neues Meeting über den Assistenten starten.')}
</p>
</div>
<div className={styles.tbDashQuickActions}>
<button
type="button"
className={styles.tbDashBtnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/assistant`)}
>
{t('Neues Meeting')}
</button>
<button
type="button"
className={styles.tbDashBtnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/modules`)}
>
{t('Module')}
</button>
<button
type="button"
className={styles.tbDashBtnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions`)}
>
{t('Live-Session')}
</button>
</div>
</header>
{error && <div className={styles.errorBanner}>{error}</div>}
<section className={styles.tbDashKpiGrid} aria-label={t('Kennzahlen')}>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{modules.length}</div>
<div className={styles.tbDashKpiLabel}>{t('Meeting-Module')}</div>
</div>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{activeSessions.length}</div>
<div className={styles.tbDashKpiLabel}>{t('Aktive Sitzungen')}</div>
</div>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{sessions.length}</div>
<div className={styles.tbDashKpiLabel}>{t('Sitzungen gesamt')}</div>
</div>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{totalSegments}</div>
<div className={styles.tbDashKpiLabel}>{t('Transkript-Segmente')}</div>
<div className={styles.tbDashKpiHint}>{totalResponses} {t('Bot-Antworten')}</div>
</div>
</section>
<section className={styles.tbDashSection}>
<h2 className={styles.tbDashSectionTitle}>{t('Module nach Aktivität')}</h2>
{topModules.length === 0 ? (
<p className={styles.emptyState}>{t('Noch keine Sitzungen — starte ein Meeting im Assistenten.')}</p>
) : (
<div className={styles.tbDashModuleGrid}>
{topModules.map((row) => (
<button
key={row.moduleId}
type="button"
className={styles.tbDashModuleCard}
onClick={() => navigate(
row.moduleId === '_adhoc'
? `/mandates/${mandateId}/${featureCode}/${instanceId}/modules`
: `/mandates/${mandateId}/${featureCode}/${instanceId}/modules?moduleId=${encodeURIComponent(row.moduleId)}`,
)}
>
<span className={styles.tbDashModuleTitle}>{row.title}</span>
<span className={styles.tbDashModuleCount}>{row.sessionCount} {t('Sitzungen')}</span>
</button>
))}
</div>
)}
</section>
{activeSessions.length > 0 && (
<section className={styles.tbDashSection}>
<h2 className={styles.tbDashSectionTitle}>{t('Aktive Sitzungen')}</h2>
<div className={styles.tbDashSessionList}>
{activeSessions.map((session) => (
<div key={session.id} className={styles.tbDashSessionRow}>
<div className={styles.tbDashSessionMain}>
<span className={styles.sessionBotName}>{session.botName}</span>
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
{_getStatusLabel(session.status)}
</span>
</div>
<div className={styles.tbDashSessionMeta}>
{session.moduleId && (
<span>{moduleTitleById.get(session.moduleId) || session.moduleId}</span>
)}
<span>{session.transcriptSegmentCount} {t('Segmente')}</span>
<span>{session.botResponseCount} {t('Antworten')}</span>
</div>
<div className={styles.tbDashSessionActions}>
<button type="button" className={styles.viewButton} onClick={() => navigate(_sessionPath(session.id))}>
{t('Live ansehen')}
</button>
{!['ended', 'error', 'leaving'].includes(session.status) && (
<button type="button" className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
{t('Stoppen')}
</button>
)}
</div>
</div>
))}
</div>
</section>
)}
</div>
);
};
export default TeamsbotDashboardView;