frontend_nyla/src/pages/views/commcoach/CommcoachDossierView.tsx
2026-03-06 14:32:59 +01:00

822 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, 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 { request } = useApiRequest();
const instanceId = useInstanceId();
const [activeTab, setActiveTab] = useState<TabKey>('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<CoachingDocument[]>([]);
const [uploading, setUploading] = useState(false);
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
const inputRef = useRef<HTMLTextAreaElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const speechRecognitionRef = useRef<SpeechRecognition | null>(null);
const transcriptPartsRef = useRef<string[]>([]);
const processedResultIndexRef = useRef(0);
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isListening, setIsListening] = useState(false);
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
const [isTtsPlaying, setIsTtsPlaying] = useState(false);
// #region agent log
const debugLogsRef = useRef<string[]>([]);
const [debugVisible, setDebugVisible] = useState(false);
const [debugSnapshot, setDebugSnapshot] = useState<string[]>([]);
const _dlog = useCallback((tag: string, info?: string) => {
const t = new Date();
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2,'0')}.${String(t.getMilliseconds()).padStart(3,'0')}`;
const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`;
debugLogsRef.current.push(entry);
if (debugLogsRef.current.length > 80) debugLogsRef.current.shift();
}, []);
useEffect(() => { (window as any).__dlog = _dlog; return () => { delete (window as any).__dlog; }; }, [_dlog]);
// #endregion
// Auto-select first context
useEffect(() => {
if (!coach.selectedContextId && coach.contexts.length > 0) {
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();
// #region agent log
const wc = fullTranscript.split(/\s+/).filter(Boolean).length;
_dlog('SEND', `words=${wc} send=${wc>=MIN_WORDS_TO_INTERRUPT} "${fullTranscript.substring(0,60)}"`);
// #endregion
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 = () => {
// #region agent log
_dlog('SPCH-START', `tts=${coach.isTtsPlayingRef.current}`);
// #endregion
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 = () => {
// #region agent log
_dlog('SPCH-END', `tts=${coach.isTtsPlayingRef.current} parts=${transcriptPartsRef.current.length}`);
// #endregion
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 = () => {
// #region agent log
_dlog('REC-END', `cancelled=${cancelled} sameRef=${speechRecognitionRef.current===recognition} tts=${coach.isTtsPlayingRef.current}`);
// #endregion
if (cancelled) return;
if (coach.isTtsPlayingRef.current) return;
if (speechRecognitionRef.current === recognition) {
try { recognition.start(); } catch { speechRecognitionRef.current = null; }
}
};
recognition.onerror = (event: any) => {
// #region agent log
_dlog('REC-ERR', event.error);
// #endregion
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 () => {
// #region agent log
_dlog('CLEANUP', `tab=${activeTab} sess=${coach.session?.id} muted=${coach.isMuted}`);
// #endregion
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]);
// On mobile, SpeechRecognition and Audio output conflict for the audio session.
// Pause recognition while TTS plays, resume when it stops.
useEffect(() => {
if (!speechRecognitionRef.current) return;
if (isTtsPlaying) {
// #region agent log
_dlog('REC-SUSPEND', 'tts started, stopping recognition');
// #endregion
try { speechRecognitionRef.current.stop(); } catch { /* ignore */ }
} else {
// #region agent log
_dlog('REC-RESUME', 'tts ended, restarting recognition');
// #endregion
try { speechRecognitionRef.current.start(); } catch { /* ignore */ }
}
}, [isTtsPlaying, _dlog]);
// 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<HTMLInputElement>) => {
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);
setNewTaskTitle('');
}, [newTaskTitle, coach]);
if (coach.loadingContexts) {
return <div className={styles.empty}><p>Lade...</p></div>;
}
return (
<div className={styles.dossier}>
{/* Context Selector */}
<div className={styles.contextSelector}>
{coach.contexts.map(ctx => (
<button
key={ctx.id}
className={`${styles.contextChip} ${ctx.id === coach.selectedContextId ? styles.contextChipActive : ''}`}
onClick={() => handleSelectContext(ctx.id)}
>
<span className={styles.contextChipIcon}>{_categoryIcon(ctx.category)}</span>
{ctx.title}
</button>
))}
<button
className={styles.contextChipNew}
onClick={() => setShowNewContext(!showNewContext)}
title="Neues Thema"
>
+
</button>
</div>
{/* New Context Form */}
{showNewContext && (
<div className={styles.newContextForm}>
<input
className={styles.newContextInput}
placeholder="Thema / Titel..."
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateContext()}
autoFocus
/>
<input
className={styles.newContextInput}
placeholder="Beschreibung (optional)"
value={newDescription}
onChange={e => setNewDescription(e.target.value)}
/>
<select className={styles.newContextInput} value={newCategory} onChange={e => setNewCategory(e.target.value)}>
<option value="custom">Individuell</option>
<option value="leadership">Führung</option>
<option value="conflict">Konflikt</option>
<option value="negotiation">Verhandlung</option>
<option value="presentation">Präsentation</option>
<option value="feedback">Feedback</option>
<option value="delegation">Delegation</option>
<option value="changeManagement">Change Management</option>
</select>
<div className={styles.newContextActions}>
<button className={styles.btnPrimary} onClick={handleCreateContext} disabled={!newTitle.trim() || !!coach.actionLoading}>
{coach.actionLoading === 'creating' ? 'Wird erstellt...' : 'Erstellen'}
</button>
<button className={styles.btnSecondary} onClick={() => setShowNewContext(false)}>Abbrechen</button>
</div>
</div>
)}
{/* No context selected */}
{!coach.selectedContextId && !showNewContext && coach.contexts.length === 0 && (
<div className={styles.empty}>
<h3>Willkommen beim Kommunikations-Coach</h3>
<p>Erstelle ein Thema, um zu beginnen.</p>
<button className={styles.btnPrimary} onClick={() => setShowNewContext(true)}>Neues Thema erstellen</button>
</div>
)}
{coach.selectedContextId && (<>
{/* Context Header */}
<div className={styles.header}>
<div>
<h2 className={styles.title}>{coach.selectedContext?.title}</h2>
{coach.selectedContext?.description && (
<p className={styles.description}>{coach.selectedContext.description}</p>
)}
</div>
<div className={styles.headerActions}>
{instanceId && (
<>
<a className={styles.btnExport} href={getDossierExportUrl(instanceId, coach.selectedContextId, 'md')} target="_blank" rel="noopener noreferrer">Export MD</a>
<a className={styles.btnExport} href={getDossierExportUrl(instanceId, coach.selectedContextId, 'pdf')} target="_blank" rel="noopener noreferrer">Export PDF</a>
</>
)}
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'archiving' ? 'Wird archiviert...' : 'Archivieren'}
</button>
</div>
</div>
{/* Tab Navigation */}
<div className={styles.tabs}>
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
<button
key={tab}
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab)}
>
{_tabLabel(tab, coach, documents)}
</button>
))}
</div>
{/* ============================================================ */}
{/* COACHING TAB */}
{/* ============================================================ */}
{activeTab === 'coaching' && (
<div className={styles.coachingTab}>
{!coach.session ? (
<div className={styles.sessionStart}>
<p>Starte eine neue Coaching-Session zu diesem Thema.</p>
{personas.length > 0 && (
<div className={styles.personaSelector}>
<label className={styles.personaLabel}>Gesprächspartner wählen:</label>
<div className={styles.personaGrid}>
{personas.map(p => (
<button
key={p.id}
className={`${styles.personaChip} ${selectedPersonaId === p.id ? styles.personaChipActive : ''}`}
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
title={p.description}
>
<span className={styles.personaGender}>{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}</span>
<span>{p.label}</span>
</button>
))}
</div>
</div>
)}
<button className={styles.btnPrimary} onClick={() => coach.startSession(selectedPersonaId)} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'starting'
? 'Wird gestartet...'
: selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
? `Session starten mit ${personas.find(p => p.id === selectedPersonaId)!.label}`
: 'Session starten'}
</button>
</div>
) : (
<>
{/* Session Header */}
<div className={styles.sessionHeader}>
<span className={styles.sessionLabel}>Session aktiv</span>
<div className={styles.sessionActions}>
{isTtsPlaying && (
<button className={styles.btnSmallDanger} onClick={coach.stopTts}>Stop</button>
)}
{coach.wasInterrupted && !isTtsPlaying && (
<button className={styles.btnSmall} onClick={coach.resumeTts}>Weitersprechen</button>
)}
<button
className={`${styles.btnSmall} ${coach.isMuted ? styles.mutedActive : ''}`}
onClick={() => coach.setMuted(!coach.isMuted)}
title={coach.isMuted ? 'Stummschaltung aufheben' : 'Stummschalten'}
>
{coach.isMuted ? '\u{1F507} Stumm' : '\u{1F3A4} Ton an'}
</button>
<button className={styles.btnSmall} onClick={coach.completeSession} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'completing' ? 'Wird abgeschlossen...' : 'Abschliessen'}
</button>
<button className={styles.btnSmallDanger} onClick={coach.cancelSession} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'cancelling' ? 'Wird abgebrochen...' : 'Abbrechen'}
</button>
</div>
</div>
{/* Messages */}
<AutoScroll scrollDependency={coach.messages.length + (coach.isStreaming ? 1 : 0) + liveTranscript.length}>
<div className={styles.messages}>
{coach.messages.map(msg => (
<div key={msg.id} className={`${styles.message} ${msg.role === 'user' ? styles.messageUser : styles.messageAssistant}`}>
<div className={styles.messageBubble}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
<div className={styles.messageTime}>
{msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''}
</div>
</div>
))}
{liveTranscript && (
<div className={`${styles.message} ${styles.messageUser}`}>
<div className={`${styles.messageBubble} ${styles.messageLive}`}>{liveTranscript}</div>
</div>
)}
{coach.isStreaming && (
<div className={`${styles.message} ${styles.messageAssistant}`}>
<div className={styles.messageBubble}>
{coach.streamingMessage ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{coach.streamingMessage}</ReactMarkdown>
) : (
<div className={styles.typing}>{coach.streamingStatus || 'Coach denkt nach'}<span className={styles.typingDots}>...</span></div>
)}
</div>
</div>
)}
</div>
</AutoScroll>
{/* Input Area */}
<div className={styles.inputArea}>
<div className={styles.voiceStatus}>
<span className={`${styles.voiceIndicator} ${isListening && !coach.isMuted ? styles.voiceActive : ''}`}>
{coach.isMuted
? 'Stumm Mikrofon aus'
: coach.isStreaming
? (coach.streamingStatus || 'Coach antwortet...')
: isUserSpeaking
? 'Spricht...'
: isListening
? 'Mikrofon an bitte sprechen'
: 'Mikrofon wird gestartet...'}
</span>
</div>
<div className={styles.textInputRow}>
<textarea
ref={inputRef}
className={styles.textInput}
placeholder="Nachricht eingeben..."
value={coach.inputValue}
onChange={e => coach.setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
disabled={coach.isStreaming}
/>
<button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
Senden
</button>
</div>
</div>
</>
)}
{coach.error && <div className={styles.errorBanner}>{coach.error}</div>}
</div>
)}
{/* ============================================================ */}
{/* TASKS TAB */}
{/* ============================================================ */}
{activeTab === 'tasks' && (
<div className={styles.tabContent}>
<div className={styles.addTaskRow}>
<input
className={styles.addTaskInput}
placeholder="Neue Aufgabe..."
value={newTaskTitle}
onChange={e => setNewTaskTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddTask()}
/>
<button className={styles.addTaskBtn} onClick={handleAddTask} disabled={!newTaskTitle.trim() || !!coach.actionLoading}>
{coach.actionLoading === 'addingTask' ? 'Wird hinzugefügt...' : 'Hinzufügen'}
</button>
</div>
{coach.tasks.length === 0 ? (
<div className={styles.emptyTab}>Noch keine Aufgaben. Der Coach schlägt während Sessions Aufgaben vor.</div>
) : (
<div className={styles.taskList}>
{coach.tasks.map(task => (
<div key={task.id} className={`${styles.taskItem} ${task.status === 'done' ? styles.taskDone : ''}`}>
<button className={styles.taskCheck} onClick={() => coach.toggleTaskStatus(task.id, task.status)}>
{task.status === 'done' ? '\u2713' : '\u25CB'}
</button>
<div className={styles.taskContent}>
<div className={styles.taskTitle}>{task.title}</div>
{task.description && <div className={styles.taskDesc}>{task.description}</div>}
</div>
<div className={styles.taskMeta}>
<span className={`${styles.taskPriority} ${styles[`priority_${task.priority}`]}`}>{task.priority}</span>
</div>
<button className={styles.taskDelete} onClick={() => coach.removeTask(task.id)}>x</button>
</div>
))}
</div>
)}
</div>
)}
{/* ============================================================ */}
{/* SESSIONS TAB */}
{/* ============================================================ */}
{activeTab === 'sessions' && (
<div className={styles.tabContent}>
{coach.sessions.length === 0 ? (
<div className={styles.emptyTab}>Noch keine abgeschlossenen Sessions.</div>
) : (
<div className={styles.sessionTimeline}>
{coach.sessions.map(s => (
<div key={s.id} className={styles.sessionItem}>
<div className={styles.sessionItemHeader}>
<span className={`${styles.sessionStatus} ${styles[`status_${s.status}`]}`}>
{s.status === 'completed' ? 'Abgeschlossen' : s.status === 'active' ? 'Aktiv' : 'Abgebrochen'}
</span>
<span className={styles.sessionDate}>{s.startedAt ? new Date(s.startedAt).toLocaleDateString('de-CH') : ''}</span>
{s.competenceScore != null && <span className={styles.sessionScore}>Score: {Math.round(s.competenceScore)}</span>}
</div>
{s.summary && (
<div className={styles.sessionSummary}><ReactMarkdown>{s.summary}</ReactMarkdown></div>
)}
<div className={styles.sessionMeta}>
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
{s.personaId && <span> | Persona</span>}
{instanceId && s.status === 'completed' && (
<a className={styles.sessionExport} href={getSessionExportUrl(instanceId, s.id, 'md')} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>Export</a>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ============================================================ */}
{/* SCORES TAB */}
{/* ============================================================ */}
{activeTab === 'scores' && (
<div className={styles.tabContent}>
{coach.scores.length === 0 ? (
<div className={styles.emptyTab}>Noch keine Bewertungen. Schliesse eine Session ab, um Scores zu erhalten.</div>
) : (
<div className={styles.scoreList}>
{_groupScoresByDimension(coach.scores).map(group => (
<div key={group.dimension} className={styles.scoreGroup}>
<div className={styles.scoreDimension}>
<span className={styles.scoreDimensionLabel}>{_dimensionLabel(group.dimension)}</span>
<span className={styles.scoreLatest}>{Math.round(group.latest.score)}/100</span>
<span className={`${styles.scoreTrend} ${styles[`trend_${group.latest.trend}`]}`}>
{group.latest.trend === 'improving' ? 'steigend' : group.latest.trend === 'declining' ? 'sinkend' : 'stabil'}
</span>
</div>
<div className={styles.scoreBar}><div className={styles.scoreBarFill} style={{ width: `${group.latest.score}%` }} /></div>
{group.latest.evidence && <div className={styles.scoreEvidence}>{group.latest.evidence}</div>}
{scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && (
<div className={styles.scoreHistory}>
<div className={styles.scoreHistoryLabel}>Verlauf:</div>
<div className={styles.scoreHistoryPoints}>
{scoreHistory[group.dimension].map((entry, i) => (
<span key={i} className={styles.scoreHistoryPoint} title={entry.createdAt || ''}>{Math.round(entry.score)}</span>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* ============================================================ */}
{/* DOCUMENTS TAB */}
{/* ============================================================ */}
{activeTab === 'documents' && (
<div className={styles.tabContent}>
<div className={styles.addTaskRow}>
<label className={styles.uploadLabel}>
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
</label>
</div>
{documents.length === 0 ? (
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.</div>
) : (
<div className={styles.documentList}>
{documents.map(doc => (
<div key={doc.id} className={styles.documentItem}>
<div className={styles.documentInfo}>
<div className={styles.documentName}>{doc.fileName}</div>
<div className={styles.documentMeta}>
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
</div>
{doc.summary && <div className={styles.documentSummary}>{doc.summary}</div>}
</div>
<div className={styles.documentActions}>
<button className={styles.btnExport} onClick={() => handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download</button>
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>x</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</>)}
{/* #region agent log */}
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
<button
onClick={() => { setDebugSnapshot([...debugLogsRef.current]); setDebugVisible(v => !v); }}
style={{background:'#333',color:'#0f0',border:'none',padding:'4px 8px',fontSize:'10px',borderRadius:'4px 0 0 0'}}
>DBG ({debugLogsRef.current.length})</button>
{debugVisible && (
<div style={{background:'rgba(0,0,0,0.9)',color:'#0f0',fontSize:'9px',maxHeight:'40vh',overflow:'auto',padding:'4px',fontFamily:'monospace',whiteSpace:'pre-wrap',width:'100vw'}}>
{debugSnapshot.map((l,i) => <div key={i}>{l}</div>)}
</div>
)}
</div>
{/* #endregion */}
</div>
);
};
function _categoryIcon(category: string): string {
const icons: Record<string, string> = {
leadership: 'L', conflict: 'K', negotiation: 'V',
presentation: 'P', feedback: 'F', delegation: 'D',
changeManagement: 'C', custom: '*',
};
return icons[category] || '*';
}
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string {
switch (tab) {
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
case 'sessions': return `Sessions (${coach.sessions.length})`;
case 'scores': return `Bewertungen (${coach.scores.length})`;
case 'documents': return `Dokumente (${documents.length})`;
}
}
interface ScoreGroup {
dimension: string;
latest: { score: number; trend: string; evidence?: string; createdAt?: string };
history: Array<{ score: number; createdAt?: string }>;
}
function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
const groups: Record<string, ScoreGroup> = {};
for (const s of scores) {
const dim = s.dimension;
if (!groups[dim]) groups[dim] = { dimension: dim, latest: s, history: [] };
groups[dim].history.push({ score: s.score, createdAt: s.createdAt });
if (s.createdAt > (groups[dim].latest.createdAt || '')) groups[dim].latest = s;
}
return Object.values(groups);
}
function _formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function _dimensionLabel(dim: string): string {
const labels: Record<string, string> = {
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
assertiveness: 'Durchsetzung', listening: 'Zuhören',
selfReflection: 'Selbstreflexion',
};
return labels[dim] || dim;
}
export default CommcoachDossierView;