google keys transferred to account poweron.center.ai
This commit is contained in:
parent
5c55312c60
commit
544f36460a
13 changed files with 1050 additions and 486 deletions
|
|
@ -185,7 +185,10 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company & legal details · May 2026</p>
|
||||
<p><strong>PowerOn AG</strong> · Birmensdorferstrasse 94 · CH-8003 Zürich · Switzerland</p>
|
||||
<p><a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||
<p style="margin-top: 1rem;">© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@
|
|||
</div>
|
||||
|
||||
<div class="last-updated">
|
||||
<strong>Last Updated:</strong> August 2025
|
||||
<strong>Last Updated:</strong> May 2026
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
|
|
@ -272,8 +272,13 @@
|
|||
<h2>Contact Us</h2>
|
||||
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
|
||||
<div class="highlight-box">
|
||||
<p><strong>Email:</strong> privacy@poweron-ai.com</p>
|
||||
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p>
|
||||
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||
<p><strong>Address:</strong><br>
|
||||
PowerOn AG<br>
|
||||
Birmensdorferstrasse 94<br>
|
||||
CH-8003 Zürich<br>
|
||||
Switzerland
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -283,7 +288,7 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@
|
|||
</div>
|
||||
|
||||
<div class="last-updated">
|
||||
<strong>Last Updated:</strong> August 2025
|
||||
<strong>Last Updated:</strong> May 2026
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
|
|
@ -315,8 +315,13 @@
|
|||
<h2>Contact Information</h2>
|
||||
<p>If you have any questions about these Terms of Service, please contact us:</p>
|
||||
<div class="highlight-box">
|
||||
<p><strong>Email:</strong> legal@poweron-ai.com</p>
|
||||
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
|
||||
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||
<p><strong>Address:</strong><br>
|
||||
PowerOn AG<br>
|
||||
Birmensdorferstrasse 94<br>
|
||||
CH-8003 Zürich<br>
|
||||
Switzerland
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -326,7 +331,7 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export interface TeamsbotSessionStats {
|
|||
export interface StartSessionRequest {
|
||||
meetingLink: string;
|
||||
botName?: string;
|
||||
moduleId?: string;
|
||||
connectionId?: string;
|
||||
joinMode?: TeamsbotJoinMode;
|
||||
sessionContext?: string;
|
||||
|
|
@ -462,6 +463,13 @@ export function createSessionStream(instanceId: string, sessionId: string): Even
|
|||
return new EventSource(url, { withCredentials: true });
|
||||
}
|
||||
|
||||
/** SSE dashboard stream: periodic { type: 'dashboardState', sessions, modules } */
|
||||
export function createDashboardStream(instanceId: string): EventSource {
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
const url = `${baseUrl}/api/teamsbot/${instanceId}/dashboard/stream`;
|
||||
return new EventSource(url, { withCredentials: true });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Debug Screenshots (SysAdmin only)
|
||||
// =========================================================================
|
||||
|
|
@ -592,6 +600,8 @@ export interface MeetingModule {
|
|||
defaultDirectorPrompts?: string;
|
||||
goals?: string;
|
||||
kpiTargets?: string;
|
||||
defaultMeetingLink?: string;
|
||||
defaultBotName?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
|
|
@ -602,6 +612,7 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
|
|||
|
||||
export async function createModule(instanceId: string, body: {
|
||||
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
|
||||
defaultMeetingLink?: string; defaultBotName?: string;
|
||||
}): Promise<MeetingModule> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
|
||||
return response.data?.module;
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
|
||||
// Feature pages - Teams Bot
|
||||
'page.feature.teamsbot.dashboard': <FaChartLine />,
|
||||
'page.feature.teamsbot.assistant': <FaHatWizard />,
|
||||
'page.feature.teamsbot.modules': <FaCubes />,
|
||||
'page.feature.teamsbot.sessions': <FaVideo />,
|
||||
'page.feature.teamsbot.settings': <FaCog />,
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,17 @@ export interface VoiceStreamCallbacks {
|
|||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/** Options for the initial `open` message on the generic STT WebSocket (Google streaming). */
|
||||
export interface SttStreamOpenOptions {
|
||||
model?: string;
|
||||
lightweight?: boolean;
|
||||
singleUtterance?: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceStreamApi {
|
||||
status: VoiceStreamStatus;
|
||||
interimText: string;
|
||||
start: (language?: string) => Promise<void>;
|
||||
start: (language?: string, sttOpenOptions?: SttStreamOpenOptions) => Promise<void>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +49,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const languageRef = useRef('de-DE');
|
||||
const sttOpenOptsRef = useRef<SttStreamOpenOptions | undefined>(undefined);
|
||||
const stoppingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
|
|
@ -94,11 +102,23 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
stoppingRef.current = false;
|
||||
}, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]);
|
||||
|
||||
const start = useCallback(async (language?: string) => {
|
||||
const _buildOpenPayload = useCallback(() => {
|
||||
const o = sttOpenOptsRef.current;
|
||||
return {
|
||||
type: 'open' as const,
|
||||
language: languageRef.current,
|
||||
model: o?.model ?? 'latest_long',
|
||||
lightweight: o?.lightweight ?? false,
|
||||
singleUtterance: o?.singleUtterance ?? false,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (language?: string, sttOpenOptions?: SttStreamOpenOptions) => {
|
||||
if (status === 'listening' || status === 'connecting') return;
|
||||
stoppingRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
languageRef.current = language || 'de-DE';
|
||||
sttOpenOptsRef.current = sttOpenOptions;
|
||||
_setStatus('connecting');
|
||||
|
||||
try {
|
||||
|
|
@ -120,7 +140,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
|
||||
ws.onopen = () => {
|
||||
if (stoppingRef.current) { ws.close(); return; }
|
||||
ws.send(JSON.stringify({ type: 'open', language: languageRef.current }));
|
||||
ws.send(JSON.stringify(_buildOpenPayload()));
|
||||
|
||||
const mimeType = _pickMimeType();
|
||||
const recorder = new MediaRecorder(streamRef.current!, { mimeType });
|
||||
|
|
@ -154,11 +174,15 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
cbRef.current.onFinal?.(msg.text);
|
||||
} else if (msg.type === 'error') {
|
||||
cbRef.current.onError?.(new Error(msg.message || msg.code || 'STT error'));
|
||||
} else if (msg.type === 'end_of_single_utterance') {
|
||||
if (!stoppingRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(_buildOpenPayload()));
|
||||
}
|
||||
} else if (msg.type === 'reconnect_required') {
|
||||
if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) {
|
||||
reconnectAttemptsRef.current++;
|
||||
_closeWs();
|
||||
start(languageRef.current).catch(() => {});
|
||||
start(languageRef.current, sttOpenOptsRef.current).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
|
|
@ -183,7 +207,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
_releaseDevices();
|
||||
throw err;
|
||||
}
|
||||
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices]);
|
||||
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices, _buildOpenPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||
import { useVoiceStream, type SttStreamOpenOptions } from '../../../hooks/useSpeechAudioCapture';
|
||||
import api from '../../../api';
|
||||
|
||||
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
||||
|
|
@ -35,6 +35,13 @@ export interface VoiceControllerCallbacks {
|
|||
|
||||
const _DEFAULT_STT_LANGUAGE = 'de-DE';
|
||||
|
||||
/** CommCoach: faster streaming STT profile + single-utterance endpointing (client re-opens stream). */
|
||||
const _commcoachSttOpen: SttStreamOpenOptions = {
|
||||
model: 'latest_short',
|
||||
lightweight: true,
|
||||
singleUtterance: true,
|
||||
};
|
||||
|
||||
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
||||
const [state, setState] = useState<VoiceState>('idle');
|
||||
const [muted, setMuted] = useState(false);
|
||||
|
|
@ -86,7 +93,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
|||
});
|
||||
|
||||
const _startStream = useCallback(() => {
|
||||
return voiceStream.start(sttLanguageRef.current);
|
||||
return voiceStream.start(sttLanguageRef.current, _commcoachSttOpen);
|
||||
}, [voiceStream]);
|
||||
|
||||
const activate = useCallback(async () => {
|
||||
|
|
|
|||
|
|
@ -1478,6 +1478,10 @@
|
|||
border-color: var(--primary-color, #4A90D9);
|
||||
}
|
||||
|
||||
.moduleRowFocused {
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.45);
|
||||
}
|
||||
|
||||
.moduleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1654,3 +1658,222 @@
|
|||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
/* --- TeamsBot Dashboard (Greenfield IA) --- */
|
||||
.tbDash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
padding: 1.25rem 1.5rem 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tbDashHero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(74, 144, 217, 0.12) 0%, var(--surface-color, #fff) 48%);
|
||||
border: 1px solid var(--border-color, #e6e6e6);
|
||||
}
|
||||
|
||||
.tbDashTitle {
|
||||
margin: 0 0 0.35rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.tbDashSubtitle {
|
||||
margin: 0;
|
||||
max-width: 520px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary, #555);
|
||||
}
|
||||
|
||||
.tbDashQuickActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tbDashBtnPrimary {
|
||||
padding: 0.65rem 1.35rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--primary-color, #4A90D9);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.tbDashBtnPrimary:hover {
|
||||
background: var(--primary-hover, #3A7BC8);
|
||||
}
|
||||
|
||||
.tbDashBtnSecondary {
|
||||
padding: 0.65rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #d0d0d0);
|
||||
background: var(--surface-color, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tbDashBtnSecondary:hover {
|
||||
border-color: var(--primary-color, #4A90D9);
|
||||
color: var(--primary-color, #4A90D9);
|
||||
}
|
||||
|
||||
.tbDashKpiGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tbDashKpiCard {
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color, #e8e8e8);
|
||||
background: var(--surface-color, #fff);
|
||||
}
|
||||
|
||||
.tbDashKpiValue {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #4A90D9);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tbDashKpiLabel {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tbDashKpiHint {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.tbDashSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.tbDashSectionHead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tbDashSectionTitle {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #222);
|
||||
}
|
||||
|
||||
.tbDashLinkBtn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--primary-color, #4A90D9);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tbDashModuleGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tbDashModuleCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color, #e6e6e6);
|
||||
background: var(--surface-color, #fafafa);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.tbDashModuleCard:hover {
|
||||
border-color: var(--primary-color, #4A90D9);
|
||||
box-shadow: 0 2px 8px rgba(74, 144, 217, 0.12);
|
||||
}
|
||||
|
||||
.tbDashModuleTitle {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary, #222);
|
||||
}
|
||||
|
||||
.tbDashModuleCount {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.tbDashSessionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.tbDashSessionRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color, #eaeaea);
|
||||
background: var(--surface-color, #fff);
|
||||
}
|
||||
|
||||
.tbDashSessionMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
|
||||
.tbDashSessionMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #666);
|
||||
flex: 2 1 220px;
|
||||
}
|
||||
|
||||
.tbDashSessionActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
*
|
||||
* Wizard: Select/create module → Meeting link → Bot selection → "Start bot"
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type { MeetingModule, TeamsbotJoinMode, UserAccountStatus } from '../../../api/teamsbotApi';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
|
|
@ -18,16 +20,26 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
const { instance, mandateId } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
const cachedUser = getUserDataCache();
|
||||
const isSysAdmin = cachedUser?.isSysAdmin === true;
|
||||
const [searchParams] = useSearchParams();
|
||||
const preselectedModuleId = searchParams.get('moduleId');
|
||||
|
||||
const [step, setStep] = useState<WizardStep>(preselectedModuleId ? 'meeting' : 'module');
|
||||
const [modules, setModules] = useState<any[]>([]);
|
||||
const [modules, setModules] = useState<MeetingModule[]>([]);
|
||||
const [moduleFilter, setModuleFilter] = useState('');
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(preselectedModuleId);
|
||||
const [newModuleTitle, setNewModuleTitle] = useState('');
|
||||
const [createNewModule, setCreateNewModule] = useState(false);
|
||||
const [meetingLink, setMeetingLink] = useState('');
|
||||
const [botName, setBotName] = useState('AI Assistant');
|
||||
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
|
||||
const [sessionContext, setSessionContext] = useState('');
|
||||
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null);
|
||||
const [showCredentialForm, setShowCredentialForm] = useState(false);
|
||||
const [credEmail, setCredEmail] = useState('');
|
||||
const [credPassword, setCredPassword] = useState('');
|
||||
const [savingCredentials, setSavingCredentials] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -45,6 +57,33 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
|
||||
useEffect(() => { _loadModules(); }, [_loadModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (joinMode === 'userAccount' && instanceId) {
|
||||
teamsbotApi.getUserAccount(instanceId).then(setUserAccount).catch(() => setUserAccount(null));
|
||||
}
|
||||
}, [joinMode, instanceId]);
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const q = moduleFilter.trim().toLowerCase();
|
||||
if (!q) return modules;
|
||||
return modules.filter(m => m.title.toLowerCase().includes(q));
|
||||
}, [modules, moduleFilter]);
|
||||
|
||||
const modulePrefillKeyRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (!selectedModuleId || createNewModule) {
|
||||
modulePrefillKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
const mod = modules.find(m => m.id === selectedModuleId);
|
||||
if (!mod) return;
|
||||
const key = `${selectedModuleId}:${mod.defaultMeetingLink ?? ''}:${mod.defaultBotName ?? ''}`;
|
||||
if (modulePrefillKeyRef.current === key) return;
|
||||
modulePrefillKeyRef.current = key;
|
||||
if (mod.defaultMeetingLink) setMeetingLink(mod.defaultMeetingLink);
|
||||
if (mod.defaultBotName) setBotName(mod.defaultBotName);
|
||||
}, [selectedModuleId, createNewModule, modules]);
|
||||
|
||||
const _handleNext = () => {
|
||||
const nextIdx = stepIdx + 1;
|
||||
if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
|
||||
|
|
@ -60,6 +99,27 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
setError(t('Meeting-Link erforderlich'));
|
||||
return;
|
||||
}
|
||||
if (joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && !credEmail) {
|
||||
setShowCredentialForm(true);
|
||||
setError(t('Bitte Microsoft-Zugangsdaten eingeben oder speichern.'));
|
||||
return;
|
||||
}
|
||||
const needsSave = joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && credEmail && credPassword;
|
||||
const needsUpdate = joinMode === 'userAccount' && showCredentialForm && credEmail && credPassword;
|
||||
if (needsSave || needsUpdate) {
|
||||
try {
|
||||
setSavingCredentials(true);
|
||||
await teamsbotApi.saveUserAccount(instanceId, credEmail, credPassword);
|
||||
setUserAccount({ hasSavedCredentials: true, email: credEmail });
|
||||
setShowCredentialForm(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('Fehler beim Speichern der Zugangsdaten'));
|
||||
setSavingCredentials(false);
|
||||
return;
|
||||
} finally {
|
||||
setSavingCredentials(false);
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -73,7 +133,9 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
meetingLink: meetingLink.trim(),
|
||||
botName,
|
||||
moduleId: moduleId || undefined,
|
||||
} as any);
|
||||
joinMode,
|
||||
sessionContext: sessionContext.trim() || undefined,
|
||||
});
|
||||
|
||||
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${result.session.id}`);
|
||||
} catch (err: any) {
|
||||
|
|
@ -106,16 +168,27 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
{t('Bestehendes Modul')}
|
||||
</label>
|
||||
{!createNewModule && (
|
||||
<select
|
||||
value={selectedModuleId || ''}
|
||||
onChange={e => setSelectedModuleId(e.target.value || null)}
|
||||
className={styles.wizardSelect}
|
||||
>
|
||||
<option value="">{t('Kein Modul (Adhoc)')}</option>
|
||||
{modules.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<>
|
||||
<input
|
||||
type="search"
|
||||
className={styles.wizardInput}
|
||||
placeholder={t('Modul suchen…')}
|
||||
value={moduleFilter}
|
||||
onChange={e => setModuleFilter(e.target.value)}
|
||||
aria-label={t('Modul suchen')}
|
||||
/>
|
||||
<select
|
||||
value={selectedModuleId || ''}
|
||||
onChange={e => setSelectedModuleId(e.target.value || null)}
|
||||
className={styles.wizardSelect}
|
||||
size={Math.min(12, Math.max(4, filteredModules.length + 1))}
|
||||
>
|
||||
<option value="">{t('Kein Modul (Adhoc)')}</option>
|
||||
{filteredModules.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
<input type="radio" checked={createNewModule} onChange={() => setCreateNewModule(true)} />
|
||||
|
|
@ -136,7 +209,7 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
|
||||
{step === 'meeting' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Meeting-Link')}</h3>
|
||||
<h3>{t('Meeting-Link und Beitritt')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
|
|
@ -145,6 +218,87 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
onChange={e => setMeetingLink(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<label className={styles.label} style={{ marginTop: '1rem' }}>{t('Join-Modus')}</label>
|
||||
<select
|
||||
className={styles.wizardSelect}
|
||||
value={joinMode}
|
||||
onChange={e => setJoinMode(e.target.value as TeamsbotJoinMode)}
|
||||
>
|
||||
{isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>}
|
||||
<option value="anonymous">{t('Anonymer Gast')}</option>
|
||||
<option value="userAccount">{t('Mein Account')}</option>
|
||||
</select>
|
||||
{joinMode === 'userAccount' && (
|
||||
<div className={styles.credentialsCard} style={{ marginTop: '0.75rem' }}>
|
||||
{userAccount?.hasSavedCredentials && !showCredentialForm ? (
|
||||
<div className={styles.credentialsInfo}>
|
||||
<span>
|
||||
{t('Gespeichert:')} <span className={styles.credentialsEmail}>{userAccount.email}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.viewButton}
|
||||
onClick={() => { setShowCredentialForm(true); setCredEmail(userAccount.email || ''); }}
|
||||
>
|
||||
{t('Ändern')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.deleteButton}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await teamsbotApi.deleteUserAccount(instanceId);
|
||||
setUserAccount({ hasSavedCredentials: false });
|
||||
setCredEmail('');
|
||||
setCredPassword('');
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
>
|
||||
{t('Entfernen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Microsoft E-Mail')}</label>
|
||||
<input
|
||||
type="email"
|
||||
className={styles.input}
|
||||
value={credEmail}
|
||||
onChange={e => setCredEmail(e.target.value)}
|
||||
disabled={savingCredentials}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Passwort')}</label>
|
||||
<input
|
||||
type="password"
|
||||
className={styles.input}
|
||||
value={credPassword}
|
||||
onChange={e => setCredPassword(e.target.value)}
|
||||
disabled={savingCredentials}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.hint}>{t('Zugangsdaten werden verschlüsselt gespeichert.')}</span>
|
||||
{userAccount?.hasSavedCredentials && (
|
||||
<button type="button" className={styles.viewButton} style={{ marginTop: '0.5rem' }} onClick={() => setShowCredentialForm(false)}>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<label className={styles.label} style={{ marginTop: '1rem' }}>{t('Sitzungskontext (optional)')}</label>
|
||||
<textarea
|
||||
className={styles.wizardTextarea}
|
||||
placeholder={t('Agenda, Hintergrund …')}
|
||||
value={sessionContext}
|
||||
onChange={e => setSessionContext(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -167,6 +321,8 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
<div><strong>{t('Modul')}:</strong> {createNewModule ? newModuleTitle : (modules.find(m => m.id === selectedModuleId)?.title || t('Adhoc'))}</div>
|
||||
<div><strong>{t('Meeting')}:</strong> {meetingLink}</div>
|
||||
<div><strong>{t('Bot')}:</strong> {botName}</div>
|
||||
<div><strong>{t('Join-Modus')}:</strong> {joinMode}</div>
|
||||
{sessionContext.trim() ? <div><strong>{t('Kontext')}:</strong> {sessionContext.trim().slice(0, 120)}{sessionContext.length > 120 ? '…' : ''}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -180,7 +336,15 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
{step !== 'confirm' ? (
|
||||
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
|
||||
) : (
|
||||
<button className={styles.btnPrimary} onClick={_handleStart} disabled={loading}>
|
||||
<button
|
||||
className={styles.btnPrimary}
|
||||
onClick={_handleStart}
|
||||
disabled={
|
||||
loading
|
||||
|| savingCredentials
|
||||
|| (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))
|
||||
}
|
||||
>
|
||||
{loading ? t('Starte...') : t('Bot starten')}
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,230 +1,135 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
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, StartSessionRequest, TeamsbotJoinMode, UserAccountStatus, MfaChallengeEvent } from '../../../api/teamsbotApi';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
import type { TeamsbotSession, MeetingModule } from '../../../api/teamsbotApi';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
const PAST_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* TeamsbotDashboardView - Overview of all Teams Bot sessions.
|
||||
* Allows starting new sessions and viewing active/past sessions.
|
||||
* Supports "Mein Account" login with saved credentials and MFA relay.
|
||||
* TeamsBot Dashboard — IA: KPIs, Modul-Aggregate, Quick-Actions, kompakte Session-Listen.
|
||||
* Neues Meeting: Assistent (Wizard). Kein paralleler Vollformular-Start hier.
|
||||
*/
|
||||
export const TeamsbotDashboardView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const { t } = useLanguage();
|
||||
const { instance, mandateId, featureCode } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
|
||||
const cachedUser = getUserDataCache();
|
||||
const _isSysAdmin = cachedUser?.isSysAdmin === true;
|
||||
|
||||
const [sessions, setSessions] = useState<TeamsbotSession[]>([]);
|
||||
const [modules, setModules] = useState<MeetingModule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// New session form
|
||||
const [meetingLink, setMeetingLink] = useState('');
|
||||
const [botName, setBotName] = useState('');
|
||||
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
|
||||
const [sessionContext, setSessionContext] = useState('');
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const sessionsRef = useRef<TeamsbotSession[]>([]);
|
||||
sessionsRef.current = sessions;
|
||||
const dashboardEsRef = useRef<EventSource | null>(null);
|
||||
const dashboardReconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// User Account (Mein Account) state
|
||||
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null);
|
||||
const [showCredentialForm, setShowCredentialForm] = useState(false);
|
||||
const [credEmail, setCredEmail] = useState('');
|
||||
const [credPassword, setCredPassword] = useState('');
|
||||
const [savingCredentials, setSavingCredentials] = useState(false);
|
||||
|
||||
// MFA state
|
||||
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [mfaSessionId, setMfaSessionId] = useState<string | null>(null);
|
||||
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
|
||||
const sseRef = useRef<EventSource | null>(null);
|
||||
|
||||
const _loadSessions = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await teamsbotApi.listSessions(instanceId);
|
||||
setSessions(result.sessions || []);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Laden der Sitzungen'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
_loadSessions();
|
||||
}, [_loadSessions]);
|
||||
|
||||
// Load user account status when joinMode changes to userAccount
|
||||
useEffect(() => {
|
||||
if (joinMode === 'userAccount' && instanceId) {
|
||||
teamsbotApi.getUserAccount(instanceId).then(setUserAccount).catch(() => setUserAccount(null));
|
||||
}
|
||||
}, [joinMode, instanceId]);
|
||||
|
||||
// Adaptive polling: 3s with active sessions, 30s otherwise
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
const hasActiveSessions = sessions.some(s => ['pending', 'joining', 'active'].includes(s.status));
|
||||
const interval = hasActiveSessions ? 3000 : 30000;
|
||||
if (instanceId) {
|
||||
pollRef.current = setInterval(() => {
|
||||
teamsbotApi.listSessions(instanceId).then(r => setSessions(r.sessions || [])).catch(() => {});
|
||||
}, interval);
|
||||
}
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, [sessions, instanceId]);
|
||||
|
||||
// Cleanup SSE on unmount
|
||||
useEffect(() => {
|
||||
return () => { sseRef.current?.close(); };
|
||||
const applyDashboardPayload = useCallback((nextSessions: TeamsbotSession[], nextModules: MeetingModule[]) => {
|
||||
setSessions(nextSessions);
|
||||
setModules(nextModules);
|
||||
sessionsRef.current = nextSessions;
|
||||
}, []);
|
||||
|
||||
const _startMfaListener = useCallback((sessionId: string) => {
|
||||
sseRef.current?.close();
|
||||
const es = teamsbotApi.createSessionStream(instanceId, sessionId);
|
||||
sseRef.current = es;
|
||||
setMfaSessionId(sessionId);
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
let cancelled = false;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
if (parsed.type === 'mfaChallenge') {
|
||||
const data = parsed.data as MfaChallengeEvent;
|
||||
if (data.mfaType === 'timeout') {
|
||||
setMfaChallenge(null);
|
||||
setMfaWaitingPush(false);
|
||||
setError(t('MFA-Zeitlimit überschritten, bitte erneut versuchen'));
|
||||
} else {
|
||||
setMfaChallenge(data);
|
||||
setMfaCode('');
|
||||
setMfaWaitingPush(data.mfaType === 'pushApproval' || data.mfaType === 'numberMatch');
|
||||
}
|
||||
} else if (parsed.type === 'mfaResolved') {
|
||||
setMfaChallenge(null);
|
||||
setMfaWaitingPush(false);
|
||||
setMfaSessionId(null);
|
||||
es.close();
|
||||
sseRef.current = null;
|
||||
_loadSessions();
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
}, [instanceId, _loadSessions, t]);
|
||||
|
||||
const _handleStartSession = async () => {
|
||||
if (!meetingLink.trim()) return;
|
||||
|
||||
// For userAccount: need credentials (saved or entered now)
|
||||
if (joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && !credEmail) {
|
||||
setShowCredentialForm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// userAccount with new/unsaved credentials: save to DB before starting
|
||||
const needsSave = joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && credEmail && credPassword;
|
||||
const needsUpdate = joinMode === 'userAccount' && showCredentialForm && credEmail && credPassword;
|
||||
if (needsSave || needsUpdate) {
|
||||
try {
|
||||
setSavingCredentials(true);
|
||||
await teamsbotApi.saveUserAccount(instanceId, credEmail, credPassword);
|
||||
setUserAccount({ hasSavedCredentials: true, email: credEmail });
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Speichern der Zugangsdaten'));
|
||||
setSavingCredentials(false);
|
||||
return;
|
||||
} finally {
|
||||
setSavingCredentials(false);
|
||||
const clearReconnect = () => {
|
||||
if (dashboardReconnectRef.current) {
|
||||
window.clearTimeout(dashboardReconnectRef.current);
|
||||
dashboardReconnectRef.current = null;
|
||||
}
|
||||
setShowCredentialForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
setIsStarting(true);
|
||||
setError(null);
|
||||
const connect = () => {
|
||||
if (cancelled) return;
|
||||
dashboardEsRef.current?.close();
|
||||
setLoading(true);
|
||||
const es = teamsbotApi.createDashboardStream(instanceId);
|
||||
dashboardEsRef.current = es;
|
||||
|
||||
try {
|
||||
const request: StartSessionRequest = {
|
||||
meetingLink: meetingLink.trim(),
|
||||
botName: botName.trim() || undefined,
|
||||
joinMode: joinMode,
|
||||
sessionContext: sessionContext.trim() || undefined,
|
||||
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);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed SSE */
|
||||
}
|
||||
};
|
||||
|
||||
const result = await teamsbotApi.startSession(instanceId, request);
|
||||
const newSessionId = result.session?.id;
|
||||
setMeetingLink('');
|
||||
setBotName('');
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
dashboardEsRef.current = null;
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
clearReconnect();
|
||||
dashboardReconnectRef.current = window.setTimeout(connect, 2500);
|
||||
};
|
||||
};
|
||||
|
||||
// Start SSE listener for MFA events if userAccount mode
|
||||
if (joinMode === 'userAccount' && newSessionId) {
|
||||
_startMfaListener(newSessionId);
|
||||
}
|
||||
connect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearReconnect();
|
||||
dashboardEsRef.current?.close();
|
||||
dashboardEsRef.current = null;
|
||||
};
|
||||
}, [instanceId, applyDashboardPayload]);
|
||||
|
||||
await _loadSessions();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Starten der Sitzung'));
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
const activeSessions = useMemo(
|
||||
() => sessions.filter((s) => ['pending', 'joining', 'active'].includes(s.status)),
|
||||
[sessions],
|
||||
);
|
||||
const pastSessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.filter((s) => ['ended', 'error', 'leaving'].includes(s.status))
|
||||
.sort((a, b) => {
|
||||
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||
return tb - ta;
|
||||
}),
|
||||
[sessions],
|
||||
);
|
||||
const pastVisible = pastSessions.slice(0, PAST_LIMIT);
|
||||
|
||||
const _handleSubmitMfaCode = async () => {
|
||||
if (!mfaSessionId || !instanceId) return;
|
||||
const needsCode = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
|
||||
try {
|
||||
await teamsbotApi.submitMfaCode(
|
||||
instanceId,
|
||||
mfaSessionId,
|
||||
needsCode ? mfaCode : '',
|
||||
needsCode ? 'code' : 'confirmed',
|
||||
);
|
||||
if (!needsCode) {
|
||||
setMfaWaitingPush(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Senden des MFA-Codes'));
|
||||
}
|
||||
};
|
||||
const moduleTitleById = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
modules.forEach((mod) => m.set(mod.id, mod.title));
|
||||
return m;
|
||||
}, [modules]);
|
||||
|
||||
const _handleDeleteUserAccount = async () => {
|
||||
try {
|
||||
await teamsbotApi.deleteUserAccount(instanceId);
|
||||
setUserAccount({ hasSavedCredentials: false });
|
||||
setCredEmail('');
|
||||
setCredPassword('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Löschen der Zugangsdaten'));
|
||||
}
|
||||
};
|
||||
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 _handleStopSession = async (sessionId: string) => {
|
||||
try {
|
||||
await teamsbotApi.stopSession(instanceId, sessionId);
|
||||
await _loadSessions();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Stoppen der Sitzung'));
|
||||
}
|
||||
};
|
||||
|
||||
const _handleDeleteSession = async (sessionId: string) => {
|
||||
try {
|
||||
await teamsbotApi.deleteSession(instanceId, sessionId);
|
||||
await _loadSessions();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Löschen der Sitzung'));
|
||||
}
|
||||
};
|
||||
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) {
|
||||
|
|
@ -240,270 +145,213 @@ export const TeamsbotDashboardView: React.FC = () => {
|
|||
|
||||
const _getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Wartend',
|
||||
joining: 'Beitritt...',
|
||||
active: 'Aktiv',
|
||||
leaving: 'Verlassen...',
|
||||
ended: 'Beendet',
|
||||
error: 'Fehler',
|
||||
pending: t('Wartend'),
|
||||
joining: t('Beitritt…'),
|
||||
active: t('Aktiv'),
|
||||
leaving: t('Verlassen…'),
|
||||
ended: t('Beendet'),
|
||||
error: t('Fehler'),
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const activeSessions = sessions.filter(s => ['pending', 'joining', 'active'].includes(s.status));
|
||||
const pastSessions = sessions.filter(s => ['ended', 'error', 'leaving'].includes(s.status));
|
||||
const _sessionPath = (sessId: string) =>
|
||||
`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${sessId}`;
|
||||
|
||||
const _needsCodeInput = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
|
||||
const _refreshLists = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const [r, m] = await Promise.all([
|
||||
teamsbotApi.listSessions(instanceId, true),
|
||||
teamsbotApi.listModules(instanceId),
|
||||
]);
|
||||
const nextSessions = r.sessions || [];
|
||||
setSessions(nextSessions);
|
||||
setModules(m || []);
|
||||
sessionsRef.current = nextSessions;
|
||||
} 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'));
|
||||
}
|
||||
};
|
||||
|
||||
const _handleDeleteSession = async (sid: string) => {
|
||||
try {
|
||||
await teamsbotApi.deleteSession(instanceId, sid);
|
||||
await _refreshLists();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Löschen'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardContainer}>
|
||||
{/* MFA Challenge Dialog */}
|
||||
{mfaChallenge && (
|
||||
<div className={styles.mfaOverlay}>
|
||||
<div className={styles.mfaDialog}>
|
||||
<div className={styles.mfaTitle}>Multi-Faktor-Authentifizierung</div>
|
||||
|
||||
{mfaChallenge.displayNumber && (
|
||||
<div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
|
||||
)}
|
||||
|
||||
<div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
|
||||
|
||||
{_needsCodeInput ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.mfaCodeInput}
|
||||
placeholder={t('Code eingeben')}
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
|
||||
/>
|
||||
<button className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
|
||||
Bestaetigen
|
||||
</button>
|
||||
</>
|
||||
) : mfaWaitingPush ? (
|
||||
<>
|
||||
<div className={styles.mfaSpinner} />
|
||||
<p style={{ fontSize: '0.85rem', color: '#888' }}>
|
||||
Warte auf Bestaetigung in der Authenticator App...
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<button className={styles.startButton} onClick={_handleSubmitMfaCode}>
|
||||
Ich habe bestaetigt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Start New Session Card */}
|
||||
<div className={styles.startSessionCard}>
|
||||
<h3 className={styles.cardTitle}>{t('Neue Botsitzung starten')}</h3>
|
||||
<p className={styles.cardDescription}>
|
||||
Fuege den Teams Meeting-Link ein, um den AI-Bot in ein Meeting einzuschleusen.
|
||||
</p>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Teams-Meetinglink')}</label>
|
||||
<input
|
||||
type="url"
|
||||
className={styles.input}
|
||||
placeholder="https://teams.microsoft.com/l/meetup-join/..."
|
||||
value={meetingLink}
|
||||
onChange={(e) => setMeetingLink(e.target.value)}
|
||||
disabled={isStarting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>Join-Modus</label>
|
||||
<select
|
||||
className={styles.select || styles.input}
|
||||
value={joinMode}
|
||||
onChange={(e) => setJoinMode(e.target.value as TeamsbotJoinMode)}
|
||||
disabled={isStarting}
|
||||
<div className={styles.tbDashQuickActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tbDashBtnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/assistant`)}
|
||||
>
|
||||
{_isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>}
|
||||
<option value="anonymous">{t('Anonymer Gast')}</option>
|
||||
<option value="userAccount">{t('Mein Account')}</option>
|
||||
</select>
|
||||
{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>
|
||||
|
||||
{/* User Account: saved credentials info or credential form */}
|
||||
{joinMode === 'userAccount' && (
|
||||
<div className={styles.credentialsCard}>
|
||||
{userAccount?.hasSavedCredentials && !showCredentialForm ? (
|
||||
<div className={styles.credentialsInfo}>
|
||||
<span>
|
||||
Gespeichert: <span className={styles.credentialsEmail}>{userAccount.email}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className={styles.viewButton}
|
||||
onClick={() => { setShowCredentialForm(true); setCredEmail(userAccount.email || ''); }}
|
||||
>
|
||||
Aendern
|
||||
</button>
|
||||
<button className={styles.deleteButton} onClick={_handleDeleteUserAccount}>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Microsoft E-Mail')}</label>
|
||||
<input
|
||||
type="email"
|
||||
className={styles.input}
|
||||
placeholder="name@example.com"
|
||||
value={credEmail}
|
||||
onChange={(e) => setCredEmail(e.target.value)}
|
||||
disabled={savingCredentials}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Passwort')}</label>
|
||||
<input
|
||||
type="password"
|
||||
className={styles.input}
|
||||
placeholder="Microsoft-Passwort"
|
||||
value={credPassword}
|
||||
onChange={(e) => setCredPassword(e.target.value)}
|
||||
disabled={savingCredentials}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: '#888', marginTop: '4px', display: 'block' }}>
|
||||
Zugangsdaten werden verschluesselt gespeichert.
|
||||
</span>
|
||||
{userAccount?.hasSavedCredentials && (
|
||||
<button
|
||||
className={styles.viewButton}
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
onClick={() => setShowCredentialForm(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Botname (optional)')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder={t('KI-Assistent')}
|
||||
value={botName}
|
||||
onChange={(e) => setBotName(e.target.value)}
|
||||
disabled={isStarting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Sitzungskontext (optional)')}</label>
|
||||
<textarea
|
||||
className={styles.textarea || styles.input}
|
||||
placeholder={t('Agenda, Hintergrundinfos, Dokumente oder …')}
|
||||
value={sessionContext}
|
||||
onChange={(e) => setSessionContext(e.target.value)}
|
||||
disabled={isStarting}
|
||||
rows={4}
|
||||
style={{ resize: 'vertical', minHeight: '80px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: '#888' }}>
|
||||
Kontext den der Bot waehrend der Sitzung nutzen kann (z.B. Meeting-Agenda, Projektinfos).
|
||||
{sessionContext.length > 0 && ` (${sessionContext.length} Zeichen)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.startButton}
|
||||
onClick={_handleStartSession}
|
||||
disabled={isStarting || !meetingLink.trim() || (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))}
|
||||
>
|
||||
{isStarting ? t('Wird gestartet') : t('Bot ins Meeting senden')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Active Sessions */}
|
||||
<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 && (
|
||||
<div className={styles.sectionContainer}>
|
||||
<h3 className={styles.sectionTitle}>{t('Aktive Sitzungen')}</h3>
|
||||
<div className={styles.sessionList}>
|
||||
<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.sessionCard}>
|
||||
<div className={styles.sessionHeader}>
|
||||
<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.sessionMeta}>
|
||||
<span>{session.transcriptSegmentCount} Segmente</span>
|
||||
<span>{session.botResponseCount} Antworten</span>
|
||||
{session.startedAt && <span>Seit: {new Date(session.startedAt).toLocaleTimeString('de-CH')}</span>}
|
||||
<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.sessionActions}>
|
||||
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>{t('Live ansehen')}</button>
|
||||
{session.status === 'active' && (
|
||||
<button className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
|
||||
Stoppen
|
||||
<div className={styles.tbDashSessionActions}>
|
||||
<button type="button" className={styles.viewButton} onClick={() => navigate(_sessionPath(session.id))}>
|
||||
{t('Live ansehen')}
|
||||
</button>
|
||||
{['active', 'joining', 'pending'].includes(session.status) && (
|
||||
<button type="button" className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
|
||||
{t('Stoppen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Past Sessions */}
|
||||
<div className={styles.sectionContainer}>
|
||||
<h3 className={styles.sectionTitle}>
|
||||
{loading ? 'Lade Sitzungen...' : `Vergangene Sitzungen (${pastSessions.length})`}
|
||||
</h3>
|
||||
{pastSessions.length === 0 && !loading && (
|
||||
<p className={styles.emptyState}>{t('Noch keine vergangenen Sitzungen')}</p>
|
||||
)}
|
||||
<div className={styles.sessionList}>
|
||||
{pastSessions.map((session) => (
|
||||
<div key={session.id} className={styles.sessionCard}>
|
||||
<div className={styles.sessionHeader}>
|
||||
<span className={styles.sessionBotName}>{session.botName}</span>
|
||||
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
|
||||
{_getStatusLabel(session.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.sessionMeta}>
|
||||
<span>{session.transcriptSegmentCount} Segmente</span>
|
||||
<span>{session.botResponseCount} Antworten</span>
|
||||
{session.startedAt && <span>{new Date(session.startedAt).toLocaleDateString('de-CH')}</span>}
|
||||
</div>
|
||||
{session.summary && (
|
||||
<div className={styles.sessionSummary}>{session.summary.substring(0, 200)}...</div>
|
||||
)}
|
||||
{session.errorMessage && (
|
||||
<div className={styles.sessionError}>{session.errorMessage}</div>
|
||||
)}
|
||||
<div className={styles.sessionActions}>
|
||||
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>Details</button>
|
||||
<button className={styles.deleteButton} onClick={() => _handleDeleteSession(session.id)}>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<section className={styles.tbDashSection}>
|
||||
<div className={styles.tbDashSectionHead}>
|
||||
<h2 className={styles.tbDashSectionTitle}>
|
||||
{loading ? t('Laden…') : t('Letzte Sitzungen')}
|
||||
</h2>
|
||||
{pastSessions.length > PAST_LIMIT && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tbDashLinkBtn}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/modules`)}
|
||||
>
|
||||
{t('Alle in Modulen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{pastVisible.length === 0 && !loading ? (
|
||||
<p className={styles.emptyState}>{t('Noch keine beendeten Sitzungen')}</p>
|
||||
) : (
|
||||
<div className={styles.tbDashSessionList}>
|
||||
{pastVisible.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.startedAt && (
|
||||
<span>{new Date(session.startedAt).toLocaleString('de-CH')}</span>
|
||||
)}
|
||||
<span>{session.transcriptSegmentCount} {t('Segmente')}</span>
|
||||
</div>
|
||||
<div className={styles.tbDashSessionActions}>
|
||||
<button type="button" className={styles.viewButton} onClick={() => navigate(_sessionPath(session.id))}>
|
||||
{t('Details')}
|
||||
</button>
|
||||
<button type="button" className={styles.deleteButton} onClick={() => _handleDeleteSession(session.id)}>
|
||||
{t('Löschen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
*
|
||||
* CRUD list of MeetingModules with expandable session lists per module.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type { MeetingModule, TeamsbotSession } from '../../../api/teamsbotApi';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
|
|
@ -29,13 +30,23 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
const { instance, mandateId } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const focusModuleId = searchParams.get('moduleId') || '';
|
||||
const moduleRowRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const [modules, setModules] = useState<any[]>([]);
|
||||
const [modules, setModules] = useState<MeetingModule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [moduleSessions, setModuleSessions] = useState<Record<string, any[]>>({});
|
||||
const [moduleSessions, setModuleSessions] = useState<Record<string, TeamsbotSession[]>>({});
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [editingModule, setEditingModule] = useState<any | null>(null);
|
||||
const [editingModule, setEditingModule] = useState<MeetingModule | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createTitle, setCreateTitle] = useState('');
|
||||
const [createSeriesType, setCreateSeriesType] = useState<string>('adhoc');
|
||||
const [createDefaultLink, setCreateDefaultLink] = useState('');
|
||||
const [createDefaultBotName, setCreateDefaultBotName] = useState('');
|
||||
const [createGoals, setCreateGoals] = useState('');
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
|
||||
const _loadModules = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -62,6 +73,20 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
}
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusModuleId || modules.length === 0) return;
|
||||
if (!modules.some((m) => m.id === focusModuleId)) return;
|
||||
setExpandedId(focusModuleId);
|
||||
_loadModuleSessions(focusModuleId);
|
||||
}, [focusModuleId, modules, _loadModuleSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusModuleId || expandedId !== focusModuleId) return;
|
||||
requestAnimationFrame(() => {
|
||||
moduleRowRefs.current[focusModuleId]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
}, [focusModuleId, expandedId]);
|
||||
|
||||
const _toggleExpand = (moduleId: string) => {
|
||||
if (expandedId === moduleId) {
|
||||
setExpandedId(null);
|
||||
|
|
@ -81,7 +106,7 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const _handleUpdate = async (moduleId: string, updates: any) => {
|
||||
const _handleUpdate = async (moduleId: string, updates: Partial<MeetingModule>) => {
|
||||
try {
|
||||
await teamsbotApi.updateModule(instanceId, moduleId, updates);
|
||||
setEditingModule(null);
|
||||
|
|
@ -91,23 +116,72 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const _handleCreateModule = async () => {
|
||||
if (!createTitle.trim()) return;
|
||||
setCreateSaving(true);
|
||||
try {
|
||||
await teamsbotApi.createModule(instanceId, {
|
||||
title: createTitle.trim(),
|
||||
seriesType: createSeriesType,
|
||||
defaultMeetingLink: createDefaultLink.trim() || undefined,
|
||||
defaultBotName: createDefaultBotName.trim() || undefined,
|
||||
goals: createGoals.trim() || undefined,
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setCreateTitle('');
|
||||
setCreateSeriesType('adhoc');
|
||||
setCreateDefaultLink('');
|
||||
setCreateDefaultBotName('');
|
||||
setCreateGoals('');
|
||||
_loadModules();
|
||||
} catch (err) {
|
||||
console.error('Create module failed:', err);
|
||||
} finally {
|
||||
setCreateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const _formatSessionDate = (startedAt?: string | number) => {
|
||||
if (startedAt == null) return '-';
|
||||
if (typeof startedAt === 'number') {
|
||||
return new Date(startedAt * 1000).toLocaleDateString('de-CH');
|
||||
}
|
||||
const ms = Date.parse(String(startedAt));
|
||||
if (!Number.isNaN(ms)) return new Date(ms).toLocaleDateString('de-CH');
|
||||
return '-';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.modulesContainer}>
|
||||
<div className={styles.modulesHeader}>
|
||||
<h2>{t('Meeting-Module')}</h2>
|
||||
<button
|
||||
className={styles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||
>
|
||||
{t('Neues Modul')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnSecondary}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
{t('Modul anlegen')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||
>
|
||||
{t('Meeting starten')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className={styles.loading}>{t('Laden...')}</div>}
|
||||
|
||||
<div className={styles.modulesList}>
|
||||
{modules.map(mod => (
|
||||
<div key={mod.id} className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''}`}>
|
||||
<div
|
||||
key={mod.id}
|
||||
ref={(el) => { moduleRowRefs.current[mod.id] = el; }}
|
||||
className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''} ${focusModuleId === mod.id ? styles.moduleRowFocused : ''}`}
|
||||
>
|
||||
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
|
||||
<span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span>
|
||||
<span className={styles.moduleTitle}>{mod.title}</span>
|
||||
|
|
@ -127,7 +201,7 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
{(moduleSessions[mod.id] || []).length === 0 ? (
|
||||
<p className={styles.noSessions}>{t('Keine Sitzungen')}</p>
|
||||
) : (
|
||||
(moduleSessions[mod.id] || []).map((sess: any) => (
|
||||
(moduleSessions[mod.id] || []).map((sess) => (
|
||||
<div
|
||||
key={sess.id}
|
||||
className={styles.sessionRow}
|
||||
|
|
@ -135,7 +209,7 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
>
|
||||
<span>{sess.botName || 'Bot'}</span>
|
||||
<span className={styles.sessionStatus}>{sess.status}</span>
|
||||
<span>{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'}</span>
|
||||
<span>{_formatSessionDate(sess.startedAt)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
@ -145,6 +219,66 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{createOpen && (
|
||||
<div className={styles.confirmOverlay}>
|
||||
<div className={styles.editDialog}>
|
||||
<h3>{t('Modul anlegen')}</h3>
|
||||
<label className={styles.label}>{t('Titel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
value={createTitle}
|
||||
onChange={e => setCreateTitle(e.target.value)}
|
||||
placeholder={t('z.B. Weekly Standup')}
|
||||
autoFocus
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Serientyp')}</label>
|
||||
<select
|
||||
className={styles.wizardSelect}
|
||||
value={createSeriesType}
|
||||
onChange={e => setCreateSeriesType(e.target.value)}
|
||||
>
|
||||
{Object.entries(SERIES_TYPE_LABELS).map(([code, lab]) => (
|
||||
<option key={code} value={code}>{lab}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
value={createDefaultLink}
|
||||
onChange={e => setCreateDefaultLink(e.target.value)}
|
||||
placeholder="https://teams.microsoft.com/..."
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
value={createDefaultBotName}
|
||||
onChange={e => setCreateDefaultBotName(e.target.value)}
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Ziele')}</label>
|
||||
<textarea
|
||||
className={styles.wizardTextarea}
|
||||
rows={2}
|
||||
value={createGoals}
|
||||
onChange={e => setCreateGoals(e.target.value)}
|
||||
/>
|
||||
<div className={styles.confirmActions}>
|
||||
<button type="button" className={styles.btnSecondary} onClick={() => setCreateOpen(false)}>{t('Abbrechen')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnPrimary}
|
||||
onClick={_handleCreateModule}
|
||||
disabled={!createTitle.trim() || createSaving}
|
||||
>
|
||||
{createSaving ? t('Speichern…') : t('Anlegen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteConfirm && (
|
||||
<div className={styles.confirmOverlay}>
|
||||
<div className={styles.confirmDialog}>
|
||||
|
|
@ -167,6 +301,22 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
className={styles.wizardInput}
|
||||
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })}
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={editingModule.defaultMeetingLink || ''}
|
||||
className={styles.wizardInput}
|
||||
placeholder="https://teams.microsoft.com/l/meetup-join/..."
|
||||
onBlur={e => setEditingModule({ ...editingModule, defaultMeetingLink: e.target.value })}
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={editingModule.defaultBotName || ''}
|
||||
className={styles.wizardInput}
|
||||
placeholder={t('z.B. AI Assistant')}
|
||||
onBlur={e => setEditingModule({ ...editingModule, defaultBotName: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
defaultValue={editingModule.goals || ''}
|
||||
className={styles.wizardTextarea}
|
||||
|
|
@ -179,6 +329,8 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
<button className={styles.btnPrimary} onClick={() => _handleUpdate(editingModule.id, {
|
||||
title: editingModule.title,
|
||||
goals: editingModule.goals,
|
||||
defaultMeetingLink: (editingModule.defaultMeetingLink || '').trim(),
|
||||
defaultBotName: (editingModule.defaultBotName || '').trim(),
|
||||
})}>{t('Speichern')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ScreenshotInfo,
|
||||
DirectorPrompt,
|
||||
DirectorPromptMode,
|
||||
MfaChallengeEvent,
|
||||
} from '../../../api/teamsbotApi';
|
||||
import {
|
||||
DIRECTOR_PROMPT_TEXT_LIMIT,
|
||||
|
|
@ -80,6 +81,10 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
// the gateway. Director prompts can only be processed once botConnected=true.)
|
||||
const [botConnected, setBotConnected] = useState(false);
|
||||
|
||||
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
|
||||
|
||||
// UDB Sidebar state
|
||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||
|
|
@ -325,6 +330,31 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
case 'ping':
|
||||
break;
|
||||
|
||||
case 'mfaChallenge': {
|
||||
const data = sseEvent.data as MfaChallengeEvent;
|
||||
if (data.mfaType === 'timeout') {
|
||||
setMfaChallenge(null);
|
||||
setMfaWaitingPush(false);
|
||||
setError(t('MFA-Zeitlimit überschritten, bitte erneut versuchen'));
|
||||
} else {
|
||||
setMfaChallenge(data);
|
||||
setMfaCode('');
|
||||
setMfaWaitingPush(data.mfaType === 'pushApproval' || data.mfaType === 'numberMatch');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mfaResolved': {
|
||||
setMfaChallenge(null);
|
||||
setMfaWaitingPush(false);
|
||||
teamsbotApi.getSession(instanceId, sessionId).then((result) => {
|
||||
setSession(result.session);
|
||||
if (result.transcripts) setTranscripts(result.transcripts);
|
||||
if (result.botResponses) setBotResponses(result.botResponses);
|
||||
}).catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
_dlog('SSE-ERR', String(err));
|
||||
|
|
@ -358,8 +388,35 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instanceId, sessionId, reconnectTick]);
|
||||
|
||||
// Polling fallback: refresh session data every 5s when SSE is not connected.
|
||||
// Uses isActive (boolean) instead of session object to prevent interval resets.
|
||||
// Keep session switcher labels in sync when the selected session updates (SSE, poll, etc.).
|
||||
useEffect(() => {
|
||||
if (!session?.id) return;
|
||||
setAllSessions((prev) => {
|
||||
const idx = prev.findIndex((s) => s.id === session.id);
|
||||
if (idx < 0) return prev;
|
||||
const row = prev[idx];
|
||||
if (
|
||||
row.status === session.status
|
||||
&& row.botName === session.botName
|
||||
&& row.startedAt === session.startedAt
|
||||
&& row.endedAt === session.endedAt
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
status: session.status,
|
||||
botName: session.botName,
|
||||
startedAt: session.startedAt,
|
||||
endedAt: session.endedAt,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
}, [session?.id, session?.status, session?.botName, session?.startedAt, session?.endedAt]);
|
||||
|
||||
// Polling: while joining/pending, poll even if SSE is connected (status may not arrive on stream).
|
||||
// For active-only, poll only when SSE is down (previous behavior).
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session?.status]);
|
||||
const isLiveRef = useRef(isLive);
|
||||
|
|
@ -367,22 +424,24 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
useEffect(() => {
|
||||
if (!instanceId || !sessionId) return;
|
||||
if (!isActive) return;
|
||||
const intervalMs = session?.status === 'active' ? 5000 : 2500;
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (isLiveRef.current) return;
|
||||
const st = sessionStatusRef.current;
|
||||
const pollWhileLive = st === 'pending' || st === 'joining';
|
||||
if (!pollWhileLive && isLiveRef.current) return;
|
||||
try {
|
||||
const result = await teamsbotApi.getSession(instanceId, sessionId);
|
||||
setSession(result.session);
|
||||
if (result.transcripts) setTranscripts(result.transcripts);
|
||||
if (result.botResponses) setBotResponses(result.botResponses);
|
||||
// If session became active and SSE is dead, trigger reconnect
|
||||
const newStatus = result.session?.status;
|
||||
if (newStatus && ['active', 'joining', 'pending'].includes(newStatus) && !eventSourceRef.current) {
|
||||
setReconnectTick(v => v + 1);
|
||||
setReconnectTick((v) => v + 1);
|
||||
}
|
||||
} catch {}
|
||||
}, 5000);
|
||||
} catch { /* ignore */ }
|
||||
}, intervalMs);
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, [isActive, instanceId, sessionId]);
|
||||
}, [isActive, instanceId, sessionId, session?.status]);
|
||||
|
||||
// Auto-scroll transcript
|
||||
useEffect(() => {
|
||||
|
|
@ -398,6 +457,25 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const _mfaNeedsCodeInput = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
|
||||
|
||||
const _handleSubmitMfaCode = async () => {
|
||||
if (!instanceId || !sessionId) return;
|
||||
try {
|
||||
await teamsbotApi.submitMfaCode(
|
||||
instanceId,
|
||||
sessionId,
|
||||
_mfaNeedsCodeInput ? mfaCode : '',
|
||||
_mfaNeedsCodeInput ? 'code' : 'confirmed',
|
||||
);
|
||||
if (!_mfaNeedsCodeInput) {
|
||||
setMfaWaitingPush(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Senden des MFA-Codes'));
|
||||
}
|
||||
};
|
||||
|
||||
const _formatTime = (timestamp: string) => {
|
||||
try {
|
||||
const dt = new Date(timestamp);
|
||||
|
|
@ -632,13 +710,16 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
|
||||
{t('Starte ein neues Meeting ueber den Assistenten oder die Module-Seite.')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button className={styles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||
>{t('Zum Assistenten')}</button>
|
||||
<button className={styles.btnSecondary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/modules`)}
|
||||
>{t('Zu den Modulen')}</button>
|
||||
<button className={styles.btnSecondary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/dashboard`)}
|
||||
>{t('Zum Dashboard')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -650,6 +731,45 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div className={styles.sessionContainer}>
|
||||
{mfaChallenge && (
|
||||
<div className={styles.mfaOverlay}>
|
||||
<div className={styles.mfaDialog}>
|
||||
<div className={styles.mfaTitle}>{t('Multi-Faktor-Authentifizierung')}</div>
|
||||
{mfaChallenge.displayNumber && (
|
||||
<div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
|
||||
)}
|
||||
<div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
|
||||
{_mfaNeedsCodeInput ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.mfaCodeInput}
|
||||
placeholder={t('Code eingeben')}
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
|
||||
/>
|
||||
<button type="button" className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
|
||||
{t('Bestätigen')}
|
||||
</button>
|
||||
</>
|
||||
) : mfaWaitingPush ? (
|
||||
<>
|
||||
<div className={styles.mfaSpinner} />
|
||||
<p style={{ fontSize: '0.85rem', color: '#888' }}>
|
||||
{t('Warte auf Bestätigung in der Authenticator-App …')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className={styles.startButton} onClick={_handleSubmitMfaCode}>
|
||||
{t('Ich habe bestätigt')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Switcher (if multiple sessions exist) */}
|
||||
{allSessions.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
label: 'Teams Bot',
|
||||
icon: 'headset_mic',
|
||||
views: [
|
||||
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
|
||||
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
|
||||
{ code: 'assistant', label: 'Assistent', path: 'assistant' },
|
||||
{ code: 'modules', label: 'Module', path: 'modules' },
|
||||
{ code: 'sessions', label: 'Live-Session', path: 'sessions' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue