diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts index 0c5246f..9a29be1 100644 --- a/src/api/commcoachApi.ts +++ b/src/api/commcoachApi.ts @@ -29,6 +29,7 @@ export interface CoachingSession { contextId: string; userId: string; status: string; + personaId?: string; summary?: string; durationSeconds: number; messageCount: number; @@ -38,6 +39,39 @@ export interface CoachingSession { endedAt?: string; } +export interface CoachingPersona { + id: string; + userId: string; + key: string; + label: string; + description: string; + gender?: string; + category: string; + isActive: boolean; +} + +export interface CoachingDocument { + id: string; + contextId: string; + fileName: string; + mimeType: string; + fileSize: number; + extractedText?: string; + summary?: string; + fileRef?: string; + createdAt?: string; +} + +export interface CoachingBadge { + id: string; + userId: string; + badgeKey: string; + label?: string; + description?: string; + icon?: string; + awardedAt?: string; +} + export interface CoachingMessage { id: string; sessionId: string; @@ -99,7 +133,10 @@ export interface DashboardData { recentScores: CoachingScore[]; openTasks: number; completedTasks: number; - contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string }>; + goalProgress?: number; + badges?: CoachingBadge[]; + level?: { number: number; label: string; totalSessions: number }; + contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>; } export interface SSEEvent { @@ -179,10 +216,12 @@ export async function startSessionStreamApi( onEvent: (event: SSEEvent) => void, onError?: (error: Error) => void, onComplete?: () => void, + personaId?: string, ): Promise { try { const baseURL = api.defaults.baseURL || ''; - const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`; + const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : ''; + const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`; const headers: Record = { 'Content-Type': 'application/json' }; const authToken = localStorage.getItem('authToken'); @@ -473,3 +512,93 @@ export async function testVoiceApi(request: ApiRequestFunction, instanceId: stri const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body }); return data; } + +// ============================================================================ +// Persona API (Iteration 2) +// ============================================================================ + +export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' }); + return data.personas || []; +} + +export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: { + label: string; description: string; gender?: string; systemPromptOverride?: string; +}): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'post', data: body }); + return data.persona; +} + +export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise { + await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' }); +} + +// ============================================================================ +// Document API (Iteration 2) +// ============================================================================ + +export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' }); + return data.documents || []; +} + +export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise { + const baseURL = api.defaults.baseURL || ''; + const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`; + const formData = new FormData(); + formData.append('file', file); + + const headers: Record = {}; + const authToken = localStorage.getItem('authToken'); + if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/); + if (pathMatch) { + headers['X-Mandate-Id'] = pathMatch[1]; + headers['X-Instance-Id'] = pathMatch[3]; + } + if (!getCSRFToken()) generateAndStoreCSRFToken(); + addCSRFTokenToHeaders(headers); + + const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' }); + if (!response.ok) throw new Error(`Upload failed: ${response.status}`); + const data = await response.json(); + return data.document; +} + +export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise { + await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' }); +} + +// ============================================================================ +// Badge API (Iteration 2) +// ============================================================================ + +export async function getBadgesApi(request: ApiRequestFunction, instanceId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/badges`, method: 'get' }); + return data.badges || []; +} + +// ============================================================================ +// Export API (Iteration 2) +// ============================================================================ + +export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string { + const baseURL = api.defaults.baseURL || ''; + return `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/export?format=${format}`; +} + +export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string { + const baseURL = api.defaults.baseURL || ''; + return `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/export?format=${format}`; +} + +// ============================================================================ +// Score History API (Iteration 2) +// ============================================================================ + +export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise>> { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' }); + return data.history || {}; +} diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts index 98db6ee..df9ab1d 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -26,6 +26,7 @@ export interface CommcoachHookReturn { messages: CoachingMessage[]; isStreaming: boolean; streamingStatus: string | null; + streamingMessage: string | null; tasks: CoachingTask[]; scores: CoachingScore[]; @@ -35,11 +36,11 @@ export interface CommcoachHookReturn { inputValue: string; setInputValue: (v: string) => void; - selectContext: (contextId: string) => Promise; + selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise; createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise; archiveContext: (contextId: string) => Promise; - startSession: () => Promise; + startSession: (personaId?: string) => Promise; sendMessage: (content: string) => Promise; sendAudio: (audioBlob: Blob) => Promise; completeSession: () => Promise; @@ -48,12 +49,18 @@ export interface CommcoachHookReturn { isMuted: boolean; setMuted: (muted: boolean) => void; stopTts: () => void; + resumeTts: () => void; + wasInterrupted: boolean; isTtsPlayingRef: MutableRefObject; + actionLoading: string | null; + toggleTaskStatus: (taskId: string, currentStatus: string) => Promise; addTask: (title: string, description?: string) => Promise; removeTask: (taskId: string) => Promise; + onDocumentCreatedRef: MutableRefObject<((doc: any) => void) | null>; + refreshContexts: () => Promise; } @@ -70,6 +77,7 @@ export function useCommcoach(): CommcoachHookReturn { const [messages, setMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); const [streamingStatus, setStreamingStatus] = useState(null); + const [streamingMessage, setStreamingMessage] = useState(null); const [tasks, setTasks] = useState([]); const [scores, setScores] = useState([]); @@ -79,10 +87,14 @@ export function useCommcoach(): CommcoachHookReturn { const [inputValue, setInputValue] = useState(''); const [isMuted, setIsMuted] = useState(false); + const [wasInterrupted, setWasInterrupted] = useState(false); + const [actionLoading, setActionLoading] = useState(null); const isMountedRef = useRef(true); const currentAudioRef = useRef(null); const isTtsPlayingRef = useRef(false); + const lastTtsAudioRef = useRef(null); + const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); @@ -106,6 +118,8 @@ export function useCommcoach(): CommcoachHookReturn { currentAudioRef.current.pause(); currentAudioRef.current = null; } + lastTtsAudioRef.current = audioB64; + setWasInterrupted(false); isTtsPlayingRef.current = true; try { const audio = new Audio(`data:audio/mp3;base64,${audioB64}`); @@ -123,12 +137,22 @@ export function useCommcoach(): CommcoachHookReturn { const stopTts = useCallback(() => { if (currentAudioRef.current) { currentAudioRef.current.pause(); - currentAudioRef.current = null; + } + if (isTtsPlayingRef.current) { + setWasInterrupted(true); } isTtsPlayingRef.current = false; }, []); - const selectContext = useCallback(async (contextId: string) => { + const resumeTts = useCallback(() => { + if (currentAudioRef.current && currentAudioRef.current.paused) { + isTtsPlayingRef.current = true; + setWasInterrupted(false); + currentAudioRef.current.play().catch(() => { isTtsPlayingRef.current = false; }); + } + }, []); + + const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => { if (!instanceId) return; setSelectedContextId(contextId); setError(null); @@ -140,6 +164,12 @@ export function useCommcoach(): CommcoachHookReturn { setScores(detail.scores || []); setSessions(detail.sessions || []); + if (options?.skipSessionResume) { + setSession(null); + setMessages([]); + return; + } + const activeSession = detail.sessions?.find((s: CoachingSession) => s.status === 'active'); if (activeSession) { await _unlockAudioForTts(); @@ -176,6 +206,7 @@ export function useCommcoach(): CommcoachHookReturn { const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => { if (!instanceId) return; + setActionLoading('creating'); try { const created = await createContextApi(request, instanceId, { title, description, category, goals }); if (isMountedRef.current) { @@ -190,11 +221,14 @@ export function useCommcoach(): CommcoachHookReturn { } } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Erstellen des Kontexts'); + } finally { + if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, refreshContexts]); const archiveContext = useCallback(async (contextId: string) => { if (!instanceId) return; + setActionLoading('archiving'); try { const { archiveContextApi } = await import('../api/commcoachApi'); await archiveContextApi(request, instanceId, contextId); @@ -207,11 +241,14 @@ export function useCommcoach(): CommcoachHookReturn { } } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Archivieren'); + } finally { + if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, selectedContextId, refreshContexts]); - const startSessionCb = useCallback(async () => { + const startSessionCb = useCallback(async (personaId?: string) => { if (!instanceId || !selectedContextId) return; + setActionLoading('starting'); await _unlockAudioForTts(); setError(null); setIsMuted(false); @@ -237,7 +274,10 @@ export function useCommcoach(): CommcoachHookReturn { if (eventData.resumed && Array.isArray(eventData.messages)) { setMessages(eventData.messages); } + } else if (eventType === 'messageChunk' && eventData) { + setStreamingMessage(eventData.accumulated || ''); } else if (eventType === 'message' && eventData) { + setStreamingMessage(null); const msg: CoachingMessage = { id: eventData.id || `msg-${Date.now()}`, sessionId: eventData.sessionId || '', @@ -255,6 +295,10 @@ export function useCommcoach(): CommcoachHookReturn { _playTtsAudio(eventData.audio); } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); + } else if (eventType === 'taskCreated' && eventData) { + setTasks(prev => [eventData, ...prev]); + } else if (eventType === 'documentCreated' && eventData) { + onDocumentCreatedRef.current?.(eventData); } else if (eventType === 'error' && eventData) { setError(eventData.message || 'Stream-Fehler'); } @@ -263,25 +307,32 @@ export function useCommcoach(): CommcoachHookReturn { if (isMountedRef.current) { setError(err.message); setIsStreaming(false); + setStreamingMessage(null); } }, () => { if (isMountedRef.current) { setIsStreaming(false); setStreamingStatus(null); + setStreamingMessage(null); } }, + personaId, ); } catch (err: any) { if (isMountedRef.current) { setError(err.message || 'Fehler beim Starten der Session'); setIsStreaming(false); } + } finally { + if (isMountedRef.current) setActionLoading(null); } }, [instanceId, selectedContextId, _playTtsAudio]); const sendMessage = useCallback(async (content: string) => { - if (!content.trim() || isStreaming || !instanceId || !session) return; + const normalizedContent = content.trim(); + if (!normalizedContent || !instanceId || !session) return; + stopTts(); await _unlockAudioForTts(); setError(null); setIsStreaming(true); @@ -292,7 +343,7 @@ export function useCommcoach(): CommcoachHookReturn { sessionId: session.id, contextId: session.contextId, role: 'user', - content: content.trim(), + content: normalizedContent, contentType: 'text', createdAt: new Date().toISOString(), }; @@ -303,13 +354,16 @@ export function useCommcoach(): CommcoachHookReturn { await sendMessageStreamApi( instanceId, session.id, - content, + normalizedContent, (event: SSEEvent) => { if (!isMountedRef.current) return; const eventType = event.type; const eventData = event.data; - if (eventType === 'message' && eventData) { + if (eventType === 'messageChunk' && eventData) { + setStreamingMessage(eventData.accumulated || ''); + } else if (eventType === 'message' && eventData) { + setStreamingMessage(null); const msg: CoachingMessage = { id: eventData.id || `msg-${Date.now()}`, sessionId: session.id, @@ -333,6 +387,8 @@ export function useCommcoach(): CommcoachHookReturn { setStreamingStatus(eventData.label || null); } else if (eventType === 'taskCreated' && eventData) { setTasks(prev => [eventData, ...prev]); + } else if (eventType === 'documentCreated' && eventData) { + onDocumentCreatedRef.current?.(eventData); } else if (eventType === 'scoreUpdate') { // Will refresh on complete } else if (eventType === 'error' && eventData) { @@ -343,12 +399,14 @@ export function useCommcoach(): CommcoachHookReturn { if (isMountedRef.current) { setError(err.message); setIsStreaming(false); + setStreamingMessage(null); } }, () => { if (isMountedRef.current) { setIsStreaming(false); setStreamingStatus(null); + setStreamingMessage(null); } }, ); @@ -358,7 +416,7 @@ export function useCommcoach(): CommcoachHookReturn { setIsStreaming(false); } } - }, [isStreaming, instanceId, session, _playTtsAudio]); + }, [instanceId, session, _playTtsAudio, stopTts]); const sendAudio = useCallback(async (audioBlob: Blob) => { if (!instanceId || !session) return; @@ -397,6 +455,10 @@ export function useCommcoach(): CommcoachHookReturn { } else if (eventType === 'ttsAudio' && eventData?.audio) { setError(null); _playTtsAudio(eventData.audio); + } else if (eventType === 'taskCreated' && eventData) { + setTasks(prev => [eventData, ...prev]); + } else if (eventType === 'documentCreated' && eventData) { + onDocumentCreatedRef.current?.(eventData); } else if (eventType === 'error' && eventData) { setError(eventData.message || 'Audio-Fehler'); } @@ -424,6 +486,7 @@ export function useCommcoach(): CommcoachHookReturn { const completeSessionCb = useCallback(async () => { if (!instanceId || !session) return; + setActionLoading('completing'); try { const completed = await completeSessionApi(request, instanceId, session.id); if (isMountedRef.current) { @@ -432,11 +495,14 @@ export function useCommcoach(): CommcoachHookReturn { } } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Abschliessen'); + } finally { + if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, session, selectedContextId, selectContext]); const cancelSessionCb = useCallback(async () => { if (!instanceId || !session) return; + setActionLoading('cancelling'); try { await cancelSessionApi(request, instanceId, session.id); if (isMountedRef.current) { @@ -445,11 +511,14 @@ export function useCommcoach(): CommcoachHookReturn { } } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Abbrechen'); + } finally { + if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, session]); const toggleTaskStatus = useCallback(async (taskId: string, currentStatus: string) => { if (!instanceId) return; + setActionLoading('togglingTask'); const newStatus = currentStatus === 'done' ? 'open' : 'done'; try { const updated = await updateTaskStatusApi(request, instanceId, taskId, newStatus); @@ -458,16 +527,21 @@ export function useCommcoach(): CommcoachHookReturn { } } catch (err: any) { if (isMountedRef.current) setError(err.message); + } finally { + if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId]); const addTask = useCallback(async (title: string, description?: string) => { if (!instanceId || !selectedContextId) return; + setActionLoading('addingTask'); try { const created = await createTaskApi(request, instanceId, selectedContextId, { title, description }); if (isMountedRef.current) setTasks(prev => [created, ...prev]); } catch (err: any) { if (isMountedRef.current) setError(err.message); + } finally { + if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, selectedContextId]); @@ -485,14 +559,16 @@ export function useCommcoach(): CommcoachHookReturn { return { contexts, selectedContextId, selectedContext, loadingContexts, - session, messages, isStreaming, streamingStatus, + session, messages, isStreaming, streamingStatus, streamingMessage, tasks, scores, sessions, error, inputValue, setInputValue, selectContext, createContext, archiveContext, startSession: startSessionCb, sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb, - isMuted, setMuted: setIsMuted, stopTts, isTtsPlayingRef, + isMuted, setMuted: setIsMuted, stopTts, resumeTts, wasInterrupted, isTtsPlayingRef, + actionLoading, toggleTaskStatus, addTask, removeTask, + onDocumentCreatedRef, refreshContexts, }; } diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index c3251dd..b11384f 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -45,7 +45,7 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView'; import { NeutralizationView } from './views/neutralization'; // CommCoach Views -import { CommcoachDashboardView, CommcoachCoachingView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach'; +import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach'; import styles from './FeatureView.module.css'; @@ -148,7 +148,7 @@ const VIEW_COMPONENTS: Record> = { }, commcoach: { dashboard: CommcoachDashboardView, - coaching: CommcoachCoachingView, + coaching: CommcoachDossierView, dossier: CommcoachDossierView, settings: CommcoachSettingsView, }, diff --git a/src/pages/InvitePage.tsx b/src/pages/InvitePage.tsx index c0bdfbd..9193e17 100644 --- a/src/pages/InvitePage.tsx +++ b/src/pages/InvitePage.tsx @@ -25,6 +25,7 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useInvitations, type InvitationValidation } from '../hooks/useInvitations'; import api from '../api'; +import { getUserDataCache } from '../utils/userCache'; import { FaCheckCircle, FaTimesCircle, FaSpinner, FaSignInAlt, FaUserPlus } from 'react-icons/fa'; import styles from './InvitePage.module.css'; @@ -45,6 +46,7 @@ export const InvitePage: React.FC = () => { const [accepting, setAccepting] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(null); + const [userMismatch, setUserMismatch] = useState(false); const [userExists, setUserExists] = useState(null); // Validate token on mount @@ -84,6 +86,14 @@ export const InvitePage: React.FC = () => { } } + if (result.valid && isAuthenticated && result.targetUsername) { + const cachedUser = getUserDataCache(); + if (cachedUser?.username && cachedUser.username.toLowerCase() !== result.targetUsername.toLowerCase()) { + localStorage.removeItem(PENDING_INVITATION_KEY); + setUserMismatch(true); + } + } + setValidating(false); }; @@ -190,6 +200,29 @@ export const InvitePage: React.FC = () => { ); } + // Authenticated but invitation is for a different user + if (userMismatch && validation?.valid) { + const cachedUser = getUserDataCache(); + return ( +
+
+
+ +

Falsche Anmeldung

+

+ Diese Einladung ist für {validation.targetUsername} bestimmt. + Sie sind als {cachedUser?.username || 'anderer Benutzer'} angemeldet. +

+

Bitte melden Sie sich ab und mit dem richtigen Konto wieder an.

+ + Zum Dashboard + +
+
+
+ ); + } + // Already authenticated - show accept button const isFeatureInvite = !!validation.featureInstanceId; const introText = isFeatureInvite diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx index 38bd1a3..ff07215 100644 --- a/src/pages/views/commcoach/CommcoachCoachingView.tsx +++ b/src/pages/views/commcoach/CommcoachCoachingView.tsx @@ -9,6 +9,9 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useCommcoach } from '../../../hooks/useCommcoach'; +import { useApiRequest } from '../../../hooks/useApi'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { getPersonasApi, type CoachingPersona } from '../../../api/commcoachApi'; import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -17,18 +20,25 @@ import styles from './CommcoachCoachingView.module.css'; export const CommcoachCoachingView: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const coach = useCommcoach(); + const { request } = useApiRequest(); + const instanceId = useInstanceId(); const [showNewContext, setShowNewContext] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newDescription, setNewDescription] = useState(''); const [newCategory, setNewCategory] = useState('custom'); const inputRef = useRef(null); + const [personas, setPersonas] = useState([]); + const [selectedPersonaId, setSelectedPersonaId] = useState(undefined); const streamRef = useRef(null); const speechRecognitionRef = useRef(null); const transcriptPartsRef = useRef([]); + const processedResultIndexRef = useRef(0); + const silenceTimerRef = useRef | null>(null); const [isListening, setIsListening] = useState(false); const [isUserSpeaking, setIsUserSpeaking] = useState(false); const [liveTranscript, setLiveTranscript] = useState(''); + const [isTtsPlaying, setIsTtsPlaying] = useState(false); const handleSend = useCallback(async () => { if (!coach.inputValue.trim() || coach.isStreaming) return; @@ -71,6 +81,21 @@ export const CommcoachCoachingView: React.FC = () => { } }, [coach.session]); + useEffect(() => { + if (!instanceId) return; + getPersonasApi(request, instanceId) + .then(p => setPersonas(p)) + .catch(() => {}); + }, [instanceId, request]); + + useEffect(() => { + if (!coach.session) return; + const interval = setInterval(() => { + setIsTtsPlaying(coach.isTtsPlayingRef.current); + }, 200); + return () => clearInterval(interval); + }, [coach.session, coach.isTtsPlayingRef]); + useEffect(() => { if (!coach.session || coach.isMuted) { if (speechRecognitionRef.current) { @@ -121,49 +146,83 @@ export const CommcoachCoachingView: React.FC = () => { if (cancelled) return; }; - recognition.onspeechstart = () => { - if (cancelled) return; - setIsUserSpeaking(true); - transcriptPartsRef.current = []; - setLiveTranscript(''); - }; + const SILENCE_TIMEOUT_MS = 5000; - recognition.onresult = (event: SpeechRecognitionEvent) => { - if (cancelled) return; - const finalized: string[] = []; - let currentInterim = ''; - for (let i = 0; i < event.results.length; i++) { - const r = event.results[i]; - if (r.isFinal) { - finalized.push(r[0].transcript.trim()); - } else { - currentInterim = r[0].transcript.trim(); - } - } - transcriptPartsRef.current = finalized.filter(Boolean); - const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim(); - setLiveTranscript(preview); - const totalWords = preview.split(/\s+/).filter(Boolean).length; - if (totalWords >= MIN_WORDS_TO_INTERRUPT) coach.stopTts(); - }; - - recognition.onspeechend = () => { - if (cancelled) return; + const _sendAndClearTranscript = () => { const fullTranscript = transcriptPartsRef.current.join(' ').trim(); if (fullTranscript) { const wordCount = fullTranscript.split(/\s+/).filter(Boolean).length; if (wordCount >= MIN_WORDS_TO_INTERRUPT) coach.sendMessage(fullTranscript); } transcriptPartsRef.current = []; + processedResultIndexRef.current = 0; setLiveTranscript(''); setIsUserSpeaking(false); }; + const _resetSilenceTimer = () => { + if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current); + silenceTimerRef.current = setTimeout(() => { + if (cancelled) return; + _sendAndClearTranscript(); + }, SILENCE_TIMEOUT_MS); + }; + + recognition.onspeechstart = () => { + if (cancelled || coach.isTtsPlayingRef.current) return; + setIsUserSpeaking(true); + transcriptPartsRef.current = []; + processedResultIndexRef.current = 0; + setLiveTranscript(''); + _resetSilenceTimer(); + }; + + recognition.onresult = (event: SpeechRecognitionEvent) => { + if (cancelled || coach.isTtsPlayingRef.current) return; + const interimParts: string[] = []; + for (let i = processedResultIndexRef.current; i < event.results.length; i++) { + const r = event.results[i]; + if (r.isFinal) { + const text = r[0].transcript.trim(); + if (text) transcriptPartsRef.current.push(text); + processedResultIndexRef.current = i + 1; + } else { + const text = r[0].transcript.trim(); + if (text) interimParts.push(text); + } + } + const currentInterim = interimParts.join(' '); + const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim(); + setLiveTranscript(preview); + if (preview) _resetSilenceTimer(); + const totalWords = preview.split(/\s+/).filter(Boolean).length; + if (totalWords >= MIN_WORDS_TO_INTERRUPT) coach.stopTts(); + }; + + recognition.onspeechend = () => { + if (cancelled) return; + if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current); + if (coach.isTtsPlayingRef.current) { + transcriptPartsRef.current = []; + processedResultIndexRef.current = 0; + setLiveTranscript(''); + setIsUserSpeaking(false); + return; + } + _sendAndClearTranscript(); + }; + recognition.onend = () => { if (cancelled) return; + setIsUserSpeaking(false); + transcriptPartsRef.current = []; + setLiveTranscript(''); if (speechRecognitionRef.current === recognition) { - speechRecognitionRef.current = null; - setIsUserSpeaking(false); + try { + recognition.start(); + } catch { + speechRecognitionRef.current = null; + } } }; @@ -182,6 +241,8 @@ export const CommcoachCoachingView: React.FC = () => { init(); return () => { cancelled = true; + coach.stopTts(); + if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current); if (speechRecognitionRef.current) { try { speechRecognitionRef.current.stop(); @@ -246,17 +307,17 @@ export const CommcoachCoachingView: React.FC = () => { onChange={e => setNewCategory(e.target.value)} > - + - +
- @@ -284,8 +345,38 @@ export const CommcoachCoachingView: React.FC = () => {

{coach.selectedContext?.title}

{coach.selectedContext?.description || 'Starte eine neue Coaching-Session zu diesem Thema.'}

- + ))} +
+
+ )} + + )} @@ -298,6 +389,16 @@ export const CommcoachCoachingView: React.FC = () => { Session aktiv - {coach.selectedContext?.title}
+ {isTtsPlaying && ( + + )} + {coach.wasInterrupted && !isTtsPlaying && ( + + )} - -
@@ -341,10 +450,16 @@ export const CommcoachCoachingView: React.FC = () => { {coach.isStreaming && (
-
- {coach.streamingStatus || 'Coach denkt nach'} - ... -
+ {coach.streamingMessage ? ( + + {coach.streamingMessage} + + ) : ( +
+ {coach.streamingStatus || 'Coach denkt nach'} + ... +
+ )}
)} diff --git a/src/pages/views/commcoach/CommcoachDashboardView.module.css b/src/pages/views/commcoach/CommcoachDashboardView.module.css index bfea6ac..54899b2 100644 --- a/src/pages/views/commcoach/CommcoachDashboardView.module.css +++ b/src/pages/views/commcoach/CommcoachDashboardView.module.css @@ -114,6 +114,32 @@ border-radius: 10px; } +.badgeGrid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.badgeCard { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.9rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 20px; + font-size: 0.85rem; +} + +.badgeIcon { + font-size: 1.1rem; +} + +.badgeLabel { + font-weight: 500; + color: var(--text-primary, #333); +} + .tipCard { background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e0e0e0); diff --git a/src/pages/views/commcoach/CommcoachDashboardView.tsx b/src/pages/views/commcoach/CommcoachDashboardView.tsx index 02a2b12..94e8ead 100644 --- a/src/pages/views/commcoach/CommcoachDashboardView.tsx +++ b/src/pages/views/commcoach/CommcoachDashboardView.tsx @@ -30,7 +30,7 @@ export const CommcoachDashboardView: React.FC = () => { } if (!dashboard) { - return
Keine Daten verfuegbar.
; + return
Keine Daten verfügbar.
; } return ( @@ -55,9 +55,11 @@ export const CommcoachDashboardView: React.FC = () => {
Durchschnitt
-
{dashboard.openTasks}
-
Offene Aufgaben
-
{dashboard.completedTasks} erledigt
+
+ {dashboard.goalProgress != null ? `${dashboard.goalProgress}%` : '--'} +
+
Zielfortschritt
+
{dashboard.openTasks} offene Aufgaben
@@ -84,6 +86,7 @@ export const CommcoachDashboardView: React.FC = () => {
{_categoryLabel(ctx.category)} {ctx.sessionCount} Sessions + {ctx.goalProgress != null && Ziele: {ctx.goalProgress}%}
{ctx.lastSessionAt && (
@@ -96,11 +99,32 @@ export const CommcoachDashboardView: React.FC = () => { )}
+ {/* Level + Badges */} + {(dashboard.level || (dashboard.badges && dashboard.badges.length > 0)) && ( +
+

+ {dashboard.level + ? `Level ${dashboard.level.number}: ${dashboard.level.label}` + : 'Auszeichnungen'} +

+ {dashboard.badges && dashboard.badges.length > 0 && ( +
+ {dashboard.badges.map(b => ( +
+
{_badgeIcon(b.icon)}
+
{b.label || b.badgeKey}
+
+ ))} +
+ )} +
+ )} + {/* Quick Start */}

Tipp des Tages

-

Konsistenz schlaegt Intensitaet. Auch 10 Minuten taegliches Coaching-Gespraech +

Konsistenz schlägt Intensität. Auch 10 Minuten tägliches Coaching-Gespräch bringt messbare Fortschritte in deiner Kommunikationskompetenz.

@@ -110,10 +134,10 @@ export const CommcoachDashboardView: React.FC = () => { function _categoryLabel(category: string): string { const labels: Record = { - leadership: 'Fuehrung', + leadership: 'Führung', conflict: 'Konflikt', negotiation: 'Verhandlung', - presentation: 'Praesentation', + presentation: 'Präsentation', feedback: 'Feedback', delegation: 'Delegation', changeManagement: 'Change Mgmt', @@ -122,6 +146,15 @@ function _categoryLabel(category: string): string { return labels[category] || category; } +function _badgeIcon(icon?: string): string { + const icons: Record = { + star: '\u2605', fire: '\u{1F525}', trophy: '\u{1F3C6}', + medal: '\u{1F3C5}', layers: '\u{1F4DA}', theater: '\u{1F3AD}', + compass: '\u{1F9ED}', 'check-circle': '\u2714', + }; + return icons[icon || 'star'] || '\u2605'; +} + function _formatDate(isoStr: string): string { try { const d = new Date(isoStr); diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css index cc79280..d078335 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.module.css +++ b/src/pages/views/commcoach/CommcoachDossierView.module.css @@ -1,16 +1,25 @@ .dossier { - padding: 1rem; - max-width: 900px; + display: flex; + flex-direction: column; + height: calc(100vh - 140px); + overflow: hidden; } +/* Context Selector */ .contextSelector { display: flex; flex-wrap: wrap; gap: 0.5rem; - margin-bottom: 1.25rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + flex-shrink: 0; + align-items: center; } .contextChip { + display: flex; + align-items: center; + gap: 0.35rem; padding: 0.4rem 0.9rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #ddd); @@ -19,6 +28,7 @@ font-size: 0.85rem; color: var(--text-primary, #333); transition: all 0.15s; + white-space: nowrap; } .contextChip:hover { @@ -32,21 +42,79 @@ border-color: var(--primary-color, #F25843); } -.contextChipActive:hover { - color: #fff; +.contextChipActive:hover { color: #fff; } + +.contextChipIcon { + font-weight: 700; + font-size: 0.75rem; +} + +.contextChipNew { + width: 32px; + height: 32px; + border: 1px dashed var(--border-color, #ccc); + border-radius: 50%; + background: transparent; + cursor: pointer; + font-size: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary, #888); + flex-shrink: 0; +} + +.contextChipNew:hover { + background: var(--bg-hover, #f5f5f5); + color: var(--primary-color, #F25843); +} + +/* New Context Form */ +.newContextForm { + padding: 1rem; + background: var(--bg-card, #fff); + border-bottom: 1px solid var(--border-color, #e0e0e0); + display: flex; + flex-direction: column; + gap: 0.5rem; + flex-shrink: 0; +} + +.newContextInput { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.9rem; + background: var(--bg-input, #fff); + color: var(--text-primary, #333); +} + +.newContextActions { + display: flex; + gap: 0.5rem; } .empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; padding: 3rem; text-align: center; color: var(--text-secondary, #666); } +.empty h3 { color: var(--text-primary, #333); margin-bottom: 0.5rem; } +.empty p { margin-bottom: 1rem; } + +/* Header */ .header { display: flex; justify-content: space-between; align-items: flex-start; - margin-bottom: 1.5rem; + padding: 0.75rem 1rem; + flex-shrink: 0; } .title { @@ -62,6 +130,53 @@ margin: 0; } +.headerActions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Buttons */ +.btnPrimary { + padding: 0.5rem 1.25rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; +} + +.btnPrimary:hover:not(:disabled) { filter: brightness(1.08); } +.btnPrimary:disabled { background: var(--color-medium-gray, #ccc); color: var(--text-secondary, #888); cursor: not-allowed; opacity: 0.8; } + +.btnSecondary { + padding: 0.5rem 1.25rem; + background: transparent; + color: var(--text-primary, #333); + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} + +.btnSecondary:hover:not(:disabled) { background: var(--hover-bg, #f5f5f5); border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); } + +.btnExport { + padding: 0.4rem 0.75rem; + background: transparent; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + color: var(--text-primary, #333); + text-decoration: none; + display: inline-block; +} + +.btnExport:hover { border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); } + .btnArchive { padding: 0.4rem 0.75rem; background: transparent; @@ -72,17 +187,41 @@ color: var(--text-primary, #333); } -.btnArchive:hover:not(:disabled) { - color: var(--error-color, #dc2626); - border-color: var(--error-color, #dc2626); +.btnArchive:hover:not(:disabled) { color: var(--error-color, #dc2626); border-color: var(--error-color, #dc2626); } + +.btnSmall { + padding: 0.3rem 0.75rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; } +.btnSmall:hover:not(:disabled) { filter: brightness(1.08); } + +.btnSmallDanger { + padding: 0.3rem 0.75rem; + background: transparent; + color: var(--error-color, #dc2626); + border: 1px solid var(--error-color, #dc2626); + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; +} + +.btnSmallDanger:hover:not(:disabled) { background: var(--error-color, #dc2626); color: #fff; } + +.mutedActive { background: var(--color-medium-gray, #999); color: #fff; border-color: var(--color-medium-gray, #999); } + /* Tabs */ .tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border-color, #e0e0e0); - margin-bottom: 1rem; + flex-shrink: 0; + padding: 0 1rem; } .tab { @@ -104,22 +243,177 @@ border-bottom-color: var(--primary-color, #F25843); } -.tabContent { min-height: 200px; } -.emptyTab { text-align: center; padding: 2rem; color: var(--text-secondary, #888); } - -/* Tasks */ -.addTaskRow { - display: flex; - gap: 0.5rem; - margin-bottom: 1rem; +.tabContent { + flex: 1; + overflow-y: auto; + padding: 1rem; } -.addTaskInput { +.emptyTab { text-align: center; padding: 2rem; color: var(--text-secondary, #888); } + +/* ============================================================ */ +/* COACHING TAB */ +/* ============================================================ */ +.coachingTab { flex: 1; - padding: 0.5rem 0.75rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sessionStart { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + padding: 2rem; +} + +.sessionStart p { color: var(--text-secondary, #666); margin-bottom: 1rem; } + +.personaSelector { margin-bottom: 1rem; } +.personaLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); display: block; margin-bottom: 0.5rem; } +.personaGrid { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; } +.personaChip { + display: flex; align-items: center; gap: 0.3rem; + padding: 0.4rem 0.8rem; border: 1px solid var(--border-color, #ddd); - border-radius: 6px; + border-radius: 20px; background: var(--bg-card, #fff); + cursor: pointer; font-size: 0.8rem; + color: var(--text-primary, #333); transition: all 0.15s; +} +.personaChip:hover { border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); } +.personaChipActive { background: var(--primary-color, #F25843); color: #fff; border-color: var(--primary-color, #F25843); } +.personaGender { font-size: 1rem; } + +.sessionHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background: var(--bg-card, #fff); + border-bottom: 1px solid var(--border-color, #e0e0e0); + flex-shrink: 0; +} + +.sessionLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); } +.sessionActions { display: flex; gap: 0.5rem; } + +/* Messages */ +.messages { + flex: 1; + padding: 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.message { max-width: 80%; } +.messageUser { align-self: flex-end; } +.messageAssistant { align-self: flex-start; } + +.messageBubble { + padding: 0.75rem 1rem; + border-radius: 12px; font-size: 0.9rem; + line-height: 1.5; +} + +.messageUser .messageBubble { + background: var(--primary-color, #F25843); + color: #fff; + border-bottom-right-radius: 4px; +} + +.messageLive { + opacity: 0.7; + font-style: italic; + border: 1px dashed rgba(255, 255, 255, 0.4); +} + +.messageAssistant .messageBubble { + background: var(--bg-card, #f5f5f5); + color: var(--text-primary, #333); + border: 1px solid var(--border-color, #e0e0e0); + border-bottom-left-radius: 4px; +} + +.messageBubble p { margin: 0 0 0.5rem; } +.messageBubble p:last-child { margin-bottom: 0; } + +.messageTime { + font-size: 0.7rem; + color: var(--text-secondary, #999); + margin-top: 0.2rem; + padding: 0 0.25rem; +} + +.messageUser .messageTime { text-align: right; } + +.typing { color: var(--text-secondary, #888); font-style: italic; } +.typingDots { animation: blink 1.4s infinite both; } +@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } } + +/* Input Area */ +.inputArea { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-card, #fff); + flex-shrink: 0; +} + +.textInputRow { display: flex; gap: 0.5rem; align-items: flex-end; } + +.textInput { + flex: 1; min-width: 0; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 8px; resize: none; + font-size: 0.9rem; font-family: inherit; + min-height: 40px; max-height: 120px; + background: var(--bg-input, #fff); + color: var(--text-primary, #333); +} + +.sendBtn { + padding: 0.6rem 1.25rem; + background: var(--primary-color, #F25843); + color: #fff; border: none; border-radius: 8px; + cursor: pointer; font-size: 0.85rem; font-weight: 500; + align-self: flex-end; +} + +.sendBtn:hover:not(:disabled) { filter: brightness(1.08); } +.sendBtn:disabled { background: var(--color-medium-gray, #ccc); color: var(--text-secondary, #888); cursor: not-allowed; opacity: 0.8; } + +.voiceStatus { display: flex; align-items: center; padding: 0.25rem 0; min-height: 1.5rem; } +.voiceIndicator { font-size: 0.9rem; color: var(--text-secondary, #888); } +.voiceIndicator.voiceActive { color: var(--primary-color, #F25843); font-weight: 500; } + +.errorBanner { + padding: 0.5rem 1rem; + background: #fde8e8; + color: var(--color-error, #d32f2f); + font-size: 0.85rem; + text-align: center; + flex-shrink: 0; +} + +/* ============================================================ */ +/* TASKS */ +/* ============================================================ */ +.addTaskRow { display: flex; gap: 0.5rem; margin-bottom: 1rem; } + +.addTaskInput { + flex: 1; padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; font-size: 0.9rem; background: var(--bg-input, #fff); color: var(--text-primary, #333); } @@ -127,27 +421,17 @@ .addTaskBtn { padding: 0.5rem 1rem; background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.85rem; + color: #fff; border: none; border-radius: 6px; + cursor: pointer; font-size: 0.85rem; } .addTaskBtn:hover:not(:disabled) { filter: brightness(1.08); } -.addTaskBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} +.addTaskBtn:disabled { background: var(--color-medium-gray, #ccc); color: var(--text-secondary, #888); cursor: not-allowed; opacity: 0.8; } .taskList { display: flex; flex-direction: column; gap: 0.5rem; } .taskItem { - display: flex; - align-items: flex-start; - gap: 0.75rem; + display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.75rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e0e0e0); @@ -158,72 +442,44 @@ .taskDone .taskTitle { text-decoration: line-through; } .taskCheck { - width: 28px; - height: 28px; + width: 28px; height: 28px; border: 2px solid var(--border-color, #ccc); - border-radius: 50%; - background: transparent; - cursor: pointer; - font-size: 1rem; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--primary-color, #F25843); + border-radius: 50%; background: transparent; + cursor: pointer; font-size: 1rem; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; color: var(--primary-color, #F25843); } .taskContent { flex: 1; } .taskTitle { font-size: 0.9rem; font-weight: 500; color: var(--text-primary, #333); } .taskDesc { font-size: 0.8rem; color: var(--text-secondary, #666); margin-top: 0.2rem; } - .taskMeta { font-size: 0.75rem; } -.taskPriority { - padding: 0.15rem 0.4rem; - border-radius: 4px; - font-size: 0.7rem; - text-transform: uppercase; -} - +.taskPriority { padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.7rem; text-transform: uppercase; } .priority_high { background: #fde8e8; color: #c62828; } .priority_medium { background: #fff3e0; color: #e65100; } .priority_low { background: #e8f5e9; color: #2e7d32; } .taskDelete { - background: transparent; - border: none; - cursor: pointer; - color: var(--text-secondary, #aaa); - font-size: 0.9rem; - padding: 0.2rem; + background: transparent; border: none; cursor: pointer; + color: var(--text-secondary, #aaa); font-size: 0.9rem; padding: 0.2rem; } - .taskDelete:hover { color: var(--error-color, #dc2626); } -/* Sessions */ +/* ============================================================ */ +/* SESSIONS */ +/* ============================================================ */ .sessionTimeline { display: flex; flex-direction: column; gap: 1rem; } .sessionItem { background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - padding: 1rem; + border-radius: 8px; padding: 1rem; } -.sessionItemHeader { - display: flex; - gap: 0.75rem; - align-items: center; - margin-bottom: 0.5rem; -} - -.sessionStatus { - padding: 0.15rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 500; -} +.sessionItemHeader { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.5rem; } +.sessionStatus { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 500; } .status_completed { background: #e8f5e9; color: #2e7d32; } .status_active { background: #e3f2fd; color: #1565c0; } .status_cancelled { background: #fde8e8; color: #c62828; } @@ -231,34 +487,26 @@ .sessionDate { font-size: 0.8rem; color: var(--text-secondary, #666); } .sessionScore { font-size: 0.8rem; font-weight: 600; color: var(--primary-color, #F25843); } -.sessionSummary { - font-size: 0.85rem; - line-height: 1.5; - color: var(--text-primary, #333); - margin-bottom: 0.5rem; -} - +.sessionSummary { font-size: 0.85rem; line-height: 1.5; color: var(--text-primary, #333); margin-bottom: 0.5rem; } .sessionSummary p { margin: 0 0 0.4rem; } .sessionMeta { font-size: 0.75rem; color: var(--text-secondary, #888); } -/* Scores */ +.sessionExport { margin-left: 0.5rem; font-size: 0.75rem; color: var(--primary-color, #F25843); text-decoration: none; } +.sessionExport:hover { text-decoration: underline; } + +/* ============================================================ */ +/* SCORES */ +/* ============================================================ */ .scoreList { display: flex; flex-direction: column; gap: 1rem; } .scoreGroup { background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - padding: 1rem; -} - -.scoreDimension { - display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 0.5rem; + border-radius: 8px; padding: 1rem; } +.scoreDimension { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } .scoreDimensionLabel { font-weight: 600; font-size: 0.9rem; flex: 1; } .scoreLatest { font-weight: 700; font-size: 1rem; color: var(--primary-color, #F25843); } @@ -267,23 +515,41 @@ .trend_stable { color: #e65100; } .trend_declining { color: #c62828; } -.scoreBar { - height: 6px; - background: var(--bg-hover, #eee); - border-radius: 3px; - overflow: hidden; - margin-bottom: 0.5rem; -} +.scoreBar { height: 6px; background: var(--bg-hover, #eee); border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; } +.scoreBarFill { height: 100%; background: var(--primary-color, #F25843); border-radius: 3px; transition: width 0.3s; } -.scoreBarFill { - height: 100%; +.scoreEvidence { font-size: 0.8rem; color: var(--text-secondary, #666); line-height: 1.4; } + +.scoreHistory { margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; } +.scoreHistoryLabel { font-size: 0.75rem; color: var(--text-secondary, #888); } +.scoreHistoryPoints { display: flex; gap: 0.4rem; flex-wrap: wrap; } +.scoreHistoryPoint { padding: 0.15rem 0.4rem; background: var(--bg-hover, #f0f0f0); border-radius: 4px; font-size: 0.7rem; color: var(--text-secondary, #666); } + +/* ============================================================ */ +/* DOCUMENTS */ +/* ============================================================ */ +.uploadLabel { + padding: 0.5rem 1rem; background: var(--primary-color, #F25843); - border-radius: 3px; - transition: width 0.3s; + color: #fff; border-radius: 6px; + cursor: pointer; font-size: 0.85rem; + display: inline-block; } -.scoreEvidence { - font-size: 0.8rem; - color: var(--text-secondary, #666); - line-height: 1.4; +.uploadLabel:hover { filter: brightness(1.08); } + +.documentList { display: flex; flex-direction: column; gap: 0.5rem; } + +.documentItem { + display: flex; align-items: flex-start; gap: 0.75rem; + padding: 0.75rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; } + +.documentInfo { flex: 1; } +.documentName { font-size: 0.9rem; font-weight: 500; color: var(--text-primary, #333); } +.documentMeta { font-size: 0.75rem; color: var(--text-secondary, #888); margin-top: 0.2rem; } +.documentSummary { font-size: 0.8rem; color: var(--text-secondary, #666); margin-top: 0.4rem; line-height: 1.4; } +.documentActions { display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0; } diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx index d0ac345..57ea656 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.tsx +++ b/src/pages/views/commcoach/CommcoachDossierView.tsx @@ -1,25 +1,316 @@ /** - * CommCoach Dossier View - * - * Shows context detail: sessions timeline, tasks checklist, scores, insights. + * CommCoach Dossier View (Main View) + * + * Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents. + * Voice first, always with text fallback. */ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useCommcoach } from '../../../hooks/useCommcoach'; +import { useApiRequest } from '../../../hooks/useApi'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import api from '../../../api'; +import { + getDossierExportUrl, getSessionExportUrl, + getDocumentsApi, uploadDocumentApi, deleteDocumentApi, + getScoreHistoryApi, getPersonasApi, + type CoachingDocument, type CoachingPersona, +} from '../../../api/commcoachApi'; +import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll'; import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import styles from './CommcoachDossierView.module.css'; +type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents'; + export const CommcoachDossierView: React.FC = () => { const coach = useCommcoach(); - const [newTaskTitle, setNewTaskTitle] = useState(''); - const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores'>('tasks'); + const { request } = useApiRequest(); + const instanceId = useInstanceId(); + const [activeTab, setActiveTab] = useState('coaching'); + const [showNewContext, setShowNewContext] = useState(false); + const [newTitle, setNewTitle] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const [newCategory, setNewCategory] = useState('custom'); + + const [newTaskTitle, setNewTaskTitle] = useState(''); + const [documents, setDocuments] = useState([]); + const [uploading, setUploading] = useState(false); + const [scoreHistory, setScoreHistory] = useState>>({}); + const [personas, setPersonas] = useState([]); + const [selectedPersonaId, setSelectedPersonaId] = useState(undefined); + + const inputRef = useRef(null); + const streamRef = useRef(null); + const speechRecognitionRef = useRef(null); + const transcriptPartsRef = useRef([]); + const processedResultIndexRef = useRef(0); + const silenceTimerRef = useRef | null>(null); + const [isListening, setIsListening] = useState(false); + const [isUserSpeaking, setIsUserSpeaking] = useState(false); + const [liveTranscript, setLiveTranscript] = useState(''); + const [isTtsPlaying, setIsTtsPlaying] = useState(false); + + // Auto-select first context useEffect(() => { if (!coach.selectedContextId && coach.contexts.length > 0) { - coach.selectContext(coach.contexts[0].id); + coach.selectContext(coach.contexts[0].id, { skipSessionResume: true }); } }, [coach.contexts, coach.selectedContextId, coach.selectContext]); + // Load documents, scores, personas when context changes + useEffect(() => { + if (!instanceId || !coach.selectedContextId) return; + getDocumentsApi(request, instanceId, coach.selectedContextId) + .then(d => setDocuments(d)) + .catch(() => {}); + getScoreHistoryApi(request, instanceId, coach.selectedContextId) + .then(h => setScoreHistory(h)) + .catch(() => {}); + }, [instanceId, request, coach.selectedContextId]); + + useEffect(() => { + coach.onDocumentCreatedRef.current = (doc) => { + setDocuments(prev => { + if (prev.some(d => d.id === doc.id)) return prev; + return [doc, ...prev]; + }); + }; + return () => { coach.onDocumentCreatedRef.current = null; }; + }, [coach.onDocumentCreatedRef]); + + useEffect(() => { + if (!instanceId) return; + getPersonasApi(request, instanceId) + .then(p => setPersonas(p)) + .catch(() => {}); + }, [instanceId, request]); + + // TTS playing state sync + useEffect(() => { + if (!coach.session) return; + const interval = setInterval(() => { + setIsTtsPlaying(coach.isTtsPlayingRef.current); + }, 200); + return () => clearInterval(interval); + }, [coach.session, coach.isTtsPlayingRef]); + + // Speech Recognition (only when coaching tab active + session running + not muted) + useEffect(() => { + if (activeTab !== 'coaching' || !coach.session || coach.isMuted) { + if (speechRecognitionRef.current) { + try { speechRecognitionRef.current.stop(); } catch { /* ignore */ } + speechRecognitionRef.current = null; + } + if (streamRef.current) { + streamRef.current.getTracks().forEach(t => t.stop()); + streamRef.current = null; + } + setIsListening(false); + setIsUserSpeaking(false); + return; + } + + const SpeechRecognitionApi = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognitionApi) return; + + let cancelled = false; + const MIN_WORDS_TO_INTERRUPT = 4; + + const init = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true }, + }); + if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; } + streamRef.current = stream; + setIsListening(true); + + const recognition = new SpeechRecognitionApi(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = 'de-DE'; + + const SILENCE_TIMEOUT_MS = 1500; + + const _sendAndClearTranscript = () => { + const fullTranscript = transcriptPartsRef.current.join(' ').trim(); + if (fullTranscript) { + const wordCount = fullTranscript.split(/\s+/).filter(Boolean).length; + if (wordCount >= MIN_WORDS_TO_INTERRUPT) coach.sendMessage(fullTranscript); + } + transcriptPartsRef.current = []; + processedResultIndexRef.current = 0; + setLiveTranscript(''); + setIsUserSpeaking(false); + }; + + const _resetSilenceTimer = () => { + if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current); + silenceTimerRef.current = setTimeout(() => { + if (cancelled) return; + _sendAndClearTranscript(); + }, SILENCE_TIMEOUT_MS); + }; + + recognition.onspeechstart = () => { + if (cancelled || coach.isTtsPlayingRef.current) return; + setIsUserSpeaking(true); + transcriptPartsRef.current = []; + processedResultIndexRef.current = 0; + setLiveTranscript(''); + _resetSilenceTimer(); + }; + + recognition.onresult = (event: SpeechRecognitionEvent) => { + if (cancelled) return; + const interimParts: string[] = []; + for (let i = processedResultIndexRef.current; i < event.results.length; i++) { + const r = event.results[i]; + if (r.isFinal) { + const text = r[0].transcript.trim(); + if (text) transcriptPartsRef.current.push(text); + processedResultIndexRef.current = i + 1; + } else { + if (coach.isTtsPlayingRef.current) continue; + const text = r[0].transcript.trim(); + if (text) interimParts.push(text); + } + } + const currentInterim = interimParts.join(' '); + const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim(); + setLiveTranscript(preview); + if (preview) _resetSilenceTimer(); + const finalizedWords = transcriptPartsRef.current.join(' ').split(/\s+/).filter(Boolean).length; + if (coach.isTtsPlayingRef.current && finalizedWords >= MIN_WORDS_TO_INTERRUPT) { + coach.stopTts(); + } + }; + + recognition.onspeechend = () => { + if (cancelled) return; + if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current); + if (coach.isTtsPlayingRef.current) { + transcriptPartsRef.current = []; + processedResultIndexRef.current = 0; + setLiveTranscript(''); + setIsUserSpeaking(false); + return; + } + _sendAndClearTranscript(); + }; + + recognition.onend = () => { + if (cancelled) return; + setIsUserSpeaking(false); + transcriptPartsRef.current = []; + setLiveTranscript(''); + if (speechRecognitionRef.current === recognition) { + try { recognition.start(); } catch { speechRecognitionRef.current = null; } + } + }; + + recognition.onerror = (event: any) => { + if (event.error === 'no-speech' || event.error === 'aborted') return; + console.warn('SpeechRecognition error:', event.error); + }; + + speechRecognitionRef.current = recognition; + recognition.start(); + } catch (err) { + console.warn('Mic access failed:', err); + } + }; + + init(); + return () => { + cancelled = true; + coach.stopTts(); + if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current); + if (speechRecognitionRef.current) { + try { speechRecognitionRef.current.stop(); } catch { /* ignore */ } + speechRecognitionRef.current = null; + } + if (streamRef.current) { + streamRef.current.getTracks().forEach(t => t.stop()); + streamRef.current = null; + } + }; + }, [activeTab, coach.session?.id, coach.isMuted]); + + // Reset mute when session ends + useEffect(() => { + if (!coach.session) coach.setMuted(false); + }, [coach.session]); + + // Focus input on session start + useEffect(() => { + if (coach.session && inputRef.current) inputRef.current.focus(); + }, [coach.session]); + + const handleSend = useCallback(async () => { + if (!coach.inputValue.trim() || coach.isStreaming) return; + await coach.sendMessage(coach.inputValue); + }, [coach]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } + }, [handleSend]); + + const handleCreateContext = useCallback(async () => { + if (!newTitle.trim()) return; + await coach.createContext(newTitle, newDescription || undefined, newCategory); + setNewTitle(''); + setNewDescription(''); + setNewCategory('custom'); + setShowNewContext(false); + }, [newTitle, newDescription, newCategory, coach]); + + const handleSelectContext = useCallback((contextId: string) => { + coach.selectContext(contextId, { skipSessionResume: true }); + }, [coach]); + + const handleUpload = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !instanceId || !coach.selectedContextId) return; + setUploading(true); + try { + const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file); + setDocuments(prev => [doc, ...prev]); + } catch { /* upload failed */ } finally { + setUploading(false); + e.target.value = ''; + } + }, [instanceId, coach.selectedContextId]); + + const handleDeleteDocument = useCallback(async (docId: string) => { + if (!instanceId) return; + try { + await deleteDocumentApi(request, instanceId, docId); + setDocuments(prev => prev.filter(d => d.id !== docId)); + } catch { /* delete failed */ } + }, [instanceId, request]); + + const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => { + if (!doc.fileRef) return; + try { + const response = await api.get(`/api/files/${doc.fileRef}/download`, { + responseType: 'blob', + }); + const url = window.URL.createObjectURL(response.data); + const a = document.createElement('a'); + a.href = url; + a.download = doc.fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error('Download failed:', err); + } + }, []); + const handleAddTask = useCallback(async () => { if (!newTaskTitle.trim()) return; await coach.addTask(newTaskTitle); @@ -30,14 +321,6 @@ export const CommcoachDossierView: React.FC = () => { return

Lade...

; } - if (coach.contexts.length === 0) { - return ( -
-

Noch keine Coaching-Themen vorhanden. Erstelle zuerst eines im Coaching-Tab.

-
- ); - } - return (
{/* Context Selector */} @@ -46,167 +329,403 @@ export const CommcoachDossierView: React.FC = () => { ))} +
- {!coach.selectedContextId ? ( -

Waehle ein Coaching-Thema.

- ) : (<> - {/* Context Header */} -
-
-

{coach.selectedContext?.title}

- {coach.selectedContext?.description && ( -

{coach.selectedContext.description}

- )} + {/* New Context Form */} + {showNewContext && ( +
+ setNewTitle(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreateContext()} + autoFocus + /> + setNewDescription(e.target.value)} + /> + +
+ + +
-
- + )} + + {/* No context selected */} + {!coach.selectedContextId && !showNewContext && coach.contexts.length === 0 && ( +
+

Willkommen beim Kommunikations-Coach

+

Erstelle ein Thema, um zu beginnen.

+
-
+ )} - {/* Tab Navigation */} -
- - - -
- - {/* Tasks Tab */} - {activeTab === 'tasks' && ( -
-
- setNewTaskTitle(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAddTask()} - /> -
- {coach.tasks.length === 0 ? ( -
Noch keine Aufgaben. Der Coach schlaegt waehrend Sessions Aufgaben vor.
- ) : ( -
- {coach.tasks.map(task => ( -
- -
-
{task.title}
- {task.description &&
{task.description}
} -
-
- - {task.priority} - -
- -
- ))} -
- )}
- )} - {/* Sessions Tab */} - {activeTab === 'sessions' && ( -
- {coach.sessions.length === 0 ? ( -
Noch keine abgeschlossenen Sessions.
- ) : ( -
- {coach.sessions.map(s => ( -
-
- - {s.status === 'completed' ? 'Abgeschlossen' : s.status === 'active' ? 'Aktiv' : 'Abgebrochen'} - - - {s.startedAt ? new Date(s.startedAt).toLocaleDateString('de-CH') : ''} - - {s.competenceScore != null && ( - Score: {Math.round(s.competenceScore)} + {/* Tab Navigation */} +
+ {(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => ( + + ))} +
+ + {/* ============================================================ */} + {/* COACHING TAB */} + {/* ============================================================ */} + {activeTab === 'coaching' && ( +
+ {!coach.session ? ( +
+

Starte eine neue Coaching-Session zu diesem Thema.

+ {personas.length > 0 && ( +
+ +
+ {personas.map(p => ( + + ))} +
+
+ )} + +
+ ) : ( + <> + {/* Session Header */} +
+ Session aktiv +
+ {isTtsPlaying && ( + + )} + {coach.wasInterrupted && !isTtsPlaying && ( + + )} + + + +
+
+ + {/* Messages */} + +
+ {coach.messages.map(msg => ( +
+
+ {msg.content} +
+
+ {msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''} +
+
+ ))} + {liveTranscript && ( +
+
{liveTranscript}
+
+ )} + {coach.isStreaming && ( +
+
+ {coach.streamingMessage ? ( + {coach.streamingMessage} + ) : ( +
{coach.streamingStatus || 'Coach denkt nach'}...
+ )} +
+
)}
- {s.summary && ( -
- {s.summary} -
- )} -
- {s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min. -
-
- ))} -
- )} -
- )} + - {/* Scores Tab */} - {activeTab === 'scores' && ( -
- {coach.scores.length === 0 ? ( -
Noch keine Bewertungen. Schliesse eine Session ab, um Scores zu erhalten.
- ) : ( -
- {_groupScoresByDimension(coach.scores).map(group => ( -
-
- {_dimensionLabel(group.dimension)} - {Math.round(group.latest.score)}/100 - - {group.latest.trend === 'improving' ? 'steigend' : group.latest.trend === 'declining' ? 'sinkend' : 'stabil'} + {/* Input Area */} +
+
+ + {coach.isMuted + ? 'Stumm – Mikrofon aus' + : coach.isStreaming + ? (coach.streamingStatus || 'Coach antwortet...') + : isUserSpeaking + ? 'Spricht...' + : isListening + ? 'Mikrofon an – bitte sprechen' + : 'Mikrofon wird gestartet...'}
-
-
+
+