280 lines
10 KiB
TypeScript
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;
|