commit
44f1e1a498
10 changed files with 1533 additions and 337 deletions
|
|
@ -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<void> {
|
||||
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<string, string> = { '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<CoachingPersona[]> {
|
||||
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<CoachingPersona> {
|
||||
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<void> {
|
||||
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<CoachingDocument[]> {
|
||||
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<CoachingDocument> {
|
||||
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<string, string> = {};
|
||||
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<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Badge API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
||||
export async function getBadgesApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingBadge[]> {
|
||||
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<Record<string, Array<{
|
||||
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
|
||||
}>>> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' });
|
||||
return data.history || {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
|
||||
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
|
||||
archiveContext: (contextId: string) => Promise<void>;
|
||||
|
||||
startSession: () => Promise<void>;
|
||||
startSession: (personaId?: string) => Promise<void>;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
sendAudio: (audioBlob: Blob) => Promise<void>;
|
||||
completeSession: () => Promise<void>;
|
||||
|
|
@ -48,12 +49,18 @@ export interface CommcoachHookReturn {
|
|||
isMuted: boolean;
|
||||
setMuted: (muted: boolean) => void;
|
||||
stopTts: () => void;
|
||||
resumeTts: () => void;
|
||||
wasInterrupted: boolean;
|
||||
isTtsPlayingRef: MutableRefObject<boolean>;
|
||||
|
||||
actionLoading: string | null;
|
||||
|
||||
toggleTaskStatus: (taskId: string, currentStatus: string) => Promise<void>;
|
||||
addTask: (title: string, description?: string) => Promise<void>;
|
||||
removeTask: (taskId: string) => Promise<void>;
|
||||
|
||||
onDocumentCreatedRef: MutableRefObject<((doc: any) => void) | null>;
|
||||
|
||||
refreshContexts: () => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +77,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
|||
const [messages, setMessages] = useState<CoachingMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingStatus, setStreamingStatus] = useState<string | null>(null);
|
||||
const [streamingMessage, setStreamingMessage] = useState<string | null>(null);
|
||||
|
||||
const [tasks, setTasks] = useState<CoachingTask[]>([]);
|
||||
const [scores, setScores] = useState<CoachingScore[]>([]);
|
||||
|
|
@ -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<string | null>(null);
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isTtsPlayingRef = useRef(false);
|
||||
const lastTtsAudioRef = useRef<string | null>(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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, ViewComponent>> = {
|
|||
},
|
||||
commcoach: {
|
||||
dashboard: CommcoachDashboardView,
|
||||
coaching: CommcoachCoachingView,
|
||||
coaching: CommcoachDossierView,
|
||||
dossier: CommcoachDossierView,
|
||||
settings: CommcoachSettingsView,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [userMismatch, setUserMismatch] = useState(false);
|
||||
const [userExists, setUserExists] = useState<boolean | null>(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 (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.errorState}>
|
||||
<FaTimesCircle className={styles.errorIcon} />
|
||||
<h1>Falsche Anmeldung</h1>
|
||||
<p>
|
||||
Diese Einladung ist für <strong>{validation.targetUsername}</strong> bestimmt.
|
||||
Sie sind als <strong>{cachedUser?.username || 'anderer Benutzer'}</strong> angemeldet.
|
||||
</p>
|
||||
<p>Bitte melden Sie sich ab und mit dem richtigen Konto wieder an.</p>
|
||||
<Link to="/" className={styles.primaryButton}>
|
||||
Zum Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Already authenticated - show accept button
|
||||
const isFeatureInvite = !!validation.featureInstanceId;
|
||||
const introText = isFeatureInvite
|
||||
|
|
|
|||
|
|
@ -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<HTMLTextAreaElement>(null);
|
||||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||||
|
||||
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);
|
||||
|
||||
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)}
|
||||
>
|
||||
<option value="custom">Individuell</option>
|
||||
<option value="leadership">Fuehrung</option>
|
||||
<option value="leadership">Führung</option>
|
||||
<option value="conflict">Konflikt</option>
|
||||
<option value="negotiation">Verhandlung</option>
|
||||
<option value="presentation">Praesentation</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()}>
|
||||
Erstellen
|
||||
<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
|
||||
|
|
@ -269,7 +330,7 @@ export const CommcoachCoachingView: React.FC = () => {
|
|||
{!coach.selectedContextId && !showNewContext && (
|
||||
<div className={styles.noContext}>
|
||||
<h3>Willkommen beim Kommunikations-Coach</h3>
|
||||
<p>Waehle ein bestehendes Thema oder erstelle ein neues, um zu beginnen.</p>
|
||||
<p>Wähle ein bestehendes Thema oder erstelle ein neues, um zu beginnen.</p>
|
||||
<button className={styles.btnPrimary} onClick={() => setShowNewContext(true)}>
|
||||
Neues Thema erstellen
|
||||
</button>
|
||||
|
|
@ -284,8 +345,38 @@ export const CommcoachCoachingView: React.FC = () => {
|
|||
<div className={styles.sessionStart}>
|
||||
<h3>{coach.selectedContext?.title}</h3>
|
||||
<p>{coach.selectedContext?.description || 'Starte eine neue Coaching-Session zu diesem Thema.'}</p>
|
||||
<button className={styles.btnPrimary} onClick={coach.startSession}>
|
||||
Session starten
|
||||
|
||||
{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 className={styles.personaName}>{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>
|
||||
)}
|
||||
|
|
@ -298,6 +389,16 @@ export const CommcoachCoachingView: React.FC = () => {
|
|||
Session aktiv - {coach.selectedContext?.title}
|
||||
</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)}
|
||||
|
|
@ -305,11 +406,19 @@ export const CommcoachCoachingView: React.FC = () => {
|
|||
>
|
||||
{coach.isMuted ? '\u{1F507}' : '\u{1F3A4}'} {coach.isMuted ? 'Stumm' : 'Ton an'}
|
||||
</button>
|
||||
<button className={styles.btnSmall} onClick={coach.completeSession}>
|
||||
Abschliessen
|
||||
<button
|
||||
className={styles.btnSmall}
|
||||
onClick={coach.completeSession}
|
||||
disabled={!!coach.actionLoading}
|
||||
>
|
||||
{coach.actionLoading === 'completing' ? 'Wird abgeschlossen...' : 'Abschliessen'}
|
||||
</button>
|
||||
<button className={styles.btnSmallDanger} onClick={coach.cancelSession}>
|
||||
Abbrechen
|
||||
<button
|
||||
className={styles.btnSmallDanger}
|
||||
onClick={coach.cancelSession}
|
||||
disabled={!!coach.actionLoading}
|
||||
>
|
||||
{coach.actionLoading === 'cancelling' ? 'Wird abgebrochen...' : 'Abbrechen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -341,10 +450,16 @@ export const CommcoachCoachingView: React.FC = () => {
|
|||
{coach.isStreaming && (
|
||||
<div className={`${styles.message} ${styles.messageAssistant}`}>
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.typing}>
|
||||
{coach.streamingStatus || 'Coach denkt nach'}
|
||||
<span className={styles.typingDots}>...</span>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const CommcoachDashboardView: React.FC = () => {
|
|||
}
|
||||
|
||||
if (!dashboard) {
|
||||
return <div className={styles.empty}>Keine Daten verfuegbar.</div>;
|
||||
return <div className={styles.empty}>Keine Daten verfügbar.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -55,9 +55,11 @@ export const CommcoachDashboardView: React.FC = () => {
|
|||
<div className={styles.kpiSub}>Durchschnitt</div>
|
||||
</div>
|
||||
<div className={styles.kpiCard}>
|
||||
<div className={styles.kpiValue}>{dashboard.openTasks}</div>
|
||||
<div className={styles.kpiLabel}>Offene Aufgaben</div>
|
||||
<div className={styles.kpiSub}>{dashboard.completedTasks} erledigt</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{dashboard.goalProgress != null ? `${dashboard.goalProgress}%` : '--'}
|
||||
</div>
|
||||
<div className={styles.kpiLabel}>Zielfortschritt</div>
|
||||
<div className={styles.kpiSub}>{dashboard.openTasks} offene Aufgaben</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -84,6 +86,7 @@ export const CommcoachDashboardView: React.FC = () => {
|
|||
<div className={styles.contextMeta}>
|
||||
<span className={styles.contextCategory}>{_categoryLabel(ctx.category)}</span>
|
||||
<span>{ctx.sessionCount} Sessions</span>
|
||||
{ctx.goalProgress != null && <span>Ziele: {ctx.goalProgress}%</span>}
|
||||
</div>
|
||||
{ctx.lastSessionAt && (
|
||||
<div className={styles.contextLast}>
|
||||
|
|
@ -96,11 +99,32 @@ export const CommcoachDashboardView: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Level + Badges */}
|
||||
{(dashboard.level || (dashboard.badges && dashboard.badges.length > 0)) && (
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>
|
||||
{dashboard.level
|
||||
? `Level ${dashboard.level.number}: ${dashboard.level.label}`
|
||||
: 'Auszeichnungen'}
|
||||
</h3>
|
||||
{dashboard.badges && dashboard.badges.length > 0 && (
|
||||
<div className={styles.badgeGrid}>
|
||||
{dashboard.badges.map(b => (
|
||||
<div key={b.id} className={styles.badgeCard} title={b.description || b.badgeKey}>
|
||||
<div className={styles.badgeIcon}>{_badgeIcon(b.icon)}</div>
|
||||
<div className={styles.badgeLabel}>{b.label || b.badgeKey}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Start */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Tipp des Tages</h3>
|
||||
<div className={styles.tipCard}>
|
||||
<p>Konsistenz schlaegt Intensitaet. Auch 10 Minuten taegliches Coaching-Gespraech
|
||||
<p>Konsistenz schlägt Intensität. Auch 10 Minuten tägliches Coaching-Gespräch
|
||||
bringt messbare Fortschritte in deiner Kommunikationskompetenz.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -110,10 +134,10 @@ export const CommcoachDashboardView: React.FC = () => {
|
|||
|
||||
function _categoryLabel(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
|
||||
// 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<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);
|
||||
|
|
@ -30,14 +321,6 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
return <div className={styles.empty}><p>Lade...</p></div>;
|
||||
}
|
||||
|
||||
if (coach.contexts.length === 0) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<p>Noch keine Coaching-Themen vorhanden. Erstelle zuerst eines im Coaching-Tab.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dossier}>
|
||||
{/* Context Selector */}
|
||||
|
|
@ -46,167 +329,403 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
<button
|
||||
key={ctx.id}
|
||||
className={`${styles.contextChip} ${ctx.id === coach.selectedContextId ? styles.contextChipActive : ''}`}
|
||||
onClick={() => coach.selectContext(ctx.id)}
|
||||
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>
|
||||
|
||||
{!coach.selectedContextId ? (
|
||||
<div className={styles.empty}><p>Waehle ein Coaching-Thema.</p></div>
|
||||
) : (<>
|
||||
{/* 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>
|
||||
)}
|
||||
{/* 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>
|
||||
<div className={styles.headerActions}>
|
||||
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)}>
|
||||
Archivieren
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className={styles.tabs}>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'tasks' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('tasks')}
|
||||
>
|
||||
Aufgaben ({coach.tasks.length})
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'sessions' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('sessions')}
|
||||
>
|
||||
Sessions ({coach.sessions.length})
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'scores' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('scores')}
|
||||
>
|
||||
Bewertungen ({coach.scores.length})
|
||||
</button>
|
||||
</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()}>
|
||||
Hinzufuegen
|
||||
{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>
|
||||
{coach.tasks.length === 0 ? (
|
||||
<div className={styles.emptyTab}>Noch keine Aufgaben. Der Coach schlaegt waehrend 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>
|
||||
{/* 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>
|
||||
{s.summary && (
|
||||
<div className={styles.sessionSummary}>
|
||||
<ReactMarkdown>{s.summary}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.sessionMeta}>
|
||||
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AutoScroll>
|
||||
|
||||
{/* 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'}
|
||||
{/* 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.scoreBar}>
|
||||
<div className={styles.scoreBarFill} style={{ width: `${group.latest.score}%` }} />
|
||||
<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>
|
||||
{group.latest.evidence && (
|
||||
<div className={styles.scoreEvidence}>{group.latest.evidence}</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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</>)}
|
||||
</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 };
|
||||
|
|
@ -217,23 +736,23 @@ 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: [] };
|
||||
}
|
||||
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;
|
||||
}
|
||||
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: 'Einfuehlungsvermoegen',
|
||||
clarity: 'Klarheit',
|
||||
assertiveness: 'Durchsetzung',
|
||||
listening: 'Zuhoeren',
|
||||
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
||||
assertiveness: 'Durchsetzung', listening: 'Zuhören',
|
||||
selfReflection: 'Selbstreflexion',
|
||||
};
|
||||
return labels[dim] || dim;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
export { CommcoachDashboardView } from './CommcoachDashboardView';
|
||||
export { CommcoachCoachingView } from './CommcoachCoachingView';
|
||||
export { CommcoachDossierView } from './CommcoachDossierView';
|
||||
export { CommcoachSettingsView } from './CommcoachSettingsView';
|
||||
|
|
|
|||
Loading…
Reference in a new issue