com feature mvp
This commit is contained in:
parent
543b94523a
commit
7077f02104
16 changed files with 3044 additions and 2 deletions
|
|
@ -175,6 +175,10 @@ function App() {
|
||||||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||||
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
||||||
|
|
||||||
|
{/* CommCoach Feature Views */}
|
||||||
|
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
||||||
|
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
||||||
|
|
||||||
{/* Catch-all für unbekannte Sub-Pfade */}
|
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
475
src/api/commcoachApi.ts
Normal file
475
src/api/commcoachApi.ts
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
import api from '../api';
|
||||||
|
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CoachingContext {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
mandateId: string;
|
||||||
|
instanceId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
goals?: string;
|
||||||
|
insights?: string;
|
||||||
|
sessionCount: number;
|
||||||
|
taskCount: number;
|
||||||
|
lastSessionAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachingSession {
|
||||||
|
id: string;
|
||||||
|
contextId: string;
|
||||||
|
userId: string;
|
||||||
|
status: string;
|
||||||
|
summary?: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
messageCount: number;
|
||||||
|
competenceScore?: number;
|
||||||
|
emailSent: boolean;
|
||||||
|
startedAt?: string;
|
||||||
|
endedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachingMessage {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
contextId: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
contentType: string;
|
||||||
|
audioRef?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachingTask {
|
||||||
|
id: string;
|
||||||
|
contextId: string;
|
||||||
|
sessionId?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
dueDate?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachingScore {
|
||||||
|
id: string;
|
||||||
|
contextId: string;
|
||||||
|
sessionId: string;
|
||||||
|
dimension: string;
|
||||||
|
score: number;
|
||||||
|
trend: string;
|
||||||
|
evidence?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachingUserProfile {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
preferredLanguage: string;
|
||||||
|
preferredVoice?: string;
|
||||||
|
dailyReminderTime?: string;
|
||||||
|
dailyReminderEnabled: boolean;
|
||||||
|
emailSummaryEnabled: boolean;
|
||||||
|
streakDays: number;
|
||||||
|
longestStreak: number;
|
||||||
|
totalSessions: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
lastSessionAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
totalContexts: number;
|
||||||
|
activeContexts: number;
|
||||||
|
totalSessions: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
streakDays: number;
|
||||||
|
longestStreak: number;
|
||||||
|
averageScore?: number;
|
||||||
|
recentScores: CoachingScore[];
|
||||||
|
openTasks: number;
|
||||||
|
completedTasks: number;
|
||||||
|
contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEEvent {
|
||||||
|
type: string;
|
||||||
|
data?: any;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'get' });
|
||||||
|
return data.contexts || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||||
|
title: string; description?: string; category?: string; goals?: string[];
|
||||||
|
}): Promise<CoachingContext> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'post', data: body });
|
||||||
|
return data.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
||||||
|
context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[];
|
||||||
|
}> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/commcoach/${instanceId}/contexts/${contextId}`,
|
||||||
|
method: 'get',
|
||||||
|
params: { _t: Date.now() },
|
||||||
|
});
|
||||||
|
const ctx = data?.context ?? data;
|
||||||
|
return {
|
||||||
|
context: ctx,
|
||||||
|
tasks: data?.tasks ?? [],
|
||||||
|
scores: data?.scores ?? [],
|
||||||
|
sessions: data?.sessions ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise<CoachingContext> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'put', data: body });
|
||||||
|
return data.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> {
|
||||||
|
await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/archive`, method: 'post' });
|
||||||
|
return data.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/activate`, method: 'post' });
|
||||||
|
return data.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
||||||
|
session: CoachingSession; messages: CoachingMessage[]; resumed: boolean;
|
||||||
|
}> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`, method: 'post' });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSessionStreamApi(
|
||||||
|
instanceId: string,
|
||||||
|
contextId: string,
|
||||||
|
onEvent: (event: SSEEvent) => void,
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
const authToken = localStorage.getItem('authToken');
|
||||||
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
||||||
|
addCSRFTokenToHeaders(headers);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) throw new Error('Response body is null');
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const jsonStr = line.slice(6);
|
||||||
|
if (jsonStr.trim()) {
|
||||||
|
const event: SSEEvent = JSON.parse(jsonStr);
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onComplete?.();
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
else throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionApi(request: ApiRequestFunction, instanceId: string, sessionId: string): Promise<{
|
||||||
|
session: CoachingSession; messages: CoachingMessage[];
|
||||||
|
}> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/sessions/${sessionId}`, method: 'get' });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeSessionApi(request: ApiRequestFunction, instanceId: string, sessionId: string): Promise<CoachingSession> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/sessions/${sessionId}/complete`, method: 'post' });
|
||||||
|
return data.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelSessionApi(request: ApiRequestFunction, instanceId: string, sessionId: string): Promise<void> {
|
||||||
|
await request({ url: `/api/commcoach/${instanceId}/sessions/${sessionId}/cancel`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Streaming Chat API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function sendMessageStreamApi(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
content: string,
|
||||||
|
onEvent: (event: SSEEvent) => void,
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
const url = `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/message/stream`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
const authToken = localStorage.getItem('authToken');
|
||||||
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
||||||
|
addCSRFTokenToHeaders(headers);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) throw new Error('Response body is null');
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const jsonStr = line.slice(6);
|
||||||
|
if (jsonStr.trim()) {
|
||||||
|
const event: SSEEvent = JSON.parse(jsonStr);
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onComplete?.();
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
else throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAudioStreamApi(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
audioBlob: Blob,
|
||||||
|
onEvent: (event: SSEEvent) => void,
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
const url = `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/audio/stream`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/octet-stream' };
|
||||||
|
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 audioBuffer = await audioBlob.arrayBuffer();
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: audioBuffer,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) throw new Error('Response body is null');
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const jsonStr = line.slice(6);
|
||||||
|
if (jsonStr.trim()) onEvent(JSON.parse(jsonStr));
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onComplete?.();
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
else throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Task API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingTask[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'get' });
|
||||||
|
return data.tasks || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: {
|
||||||
|
title: string; description?: string; priority?: string; dueDate?: string;
|
||||||
|
}): Promise<CoachingTask> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'post', data: body });
|
||||||
|
return data.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTaskApi(request: ApiRequestFunction, instanceId: string, taskId: string, body: any): Promise<CoachingTask> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/tasks/${taskId}`, method: 'put', data: body });
|
||||||
|
return data.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTaskStatusApi(request: ApiRequestFunction, instanceId: string, taskId: string, status: string): Promise<CoachingTask> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/tasks/${taskId}/status`, method: 'put', data: { status } });
|
||||||
|
return data.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTaskApi(request: ApiRequestFunction, instanceId: string, taskId: string): Promise<void> {
|
||||||
|
await request({ url: `/api/commcoach/${instanceId}/tasks/${taskId}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dashboard API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getDashboardApi(request: ApiRequestFunction, instanceId: string): Promise<DashboardData> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/dashboard`, method: 'get' });
|
||||||
|
return data.dashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getProfileApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingUserProfile> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/profile`, method: 'get' });
|
||||||
|
return data.profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfileApi(request: ApiRequestFunction, instanceId: string, body: any): Promise<CoachingUserProfile> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/profile`, method: 'put', data: body });
|
||||||
|
return data.profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Voice API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/voice/languages`, method: 'get' });
|
||||||
|
return data.languages || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVoiceVoicesApi(request: ApiRequestFunction, instanceId: string, language: string = 'de-DE'): Promise<any[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } });
|
||||||
|
return data.voices || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||||
|
text?: string; language?: string; voiceId?: string;
|
||||||
|
}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
||||||
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||||
FaHeadset, FaVideo, FaHatWizard, FaStore,
|
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -98,7 +98,14 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.feature.neutralization.config': <FaCog />,
|
'page.feature.neutralization.config': <FaCog />,
|
||||||
'page.feature.neutralization.attributes': <FaDatabase />,
|
'page.feature.neutralization.attributes': <FaDatabase />,
|
||||||
|
|
||||||
|
// Feature pages - CommCoach
|
||||||
|
'page.feature.commcoach.dashboard': <FaChartLine />,
|
||||||
|
'page.feature.commcoach.coaching': <FaComments />,
|
||||||
|
'page.feature.commcoach.dossier': <FaClipboardList />,
|
||||||
|
'page.feature.commcoach.settings': <FaCog />,
|
||||||
|
|
||||||
// Feature icons (for feature grouping in navigation)
|
// Feature icons (for feature grouping in navigation)
|
||||||
|
'feature.commcoach': <FaUserTie />,
|
||||||
'feature.neutralization': <FaShieldAlt />,
|
'feature.neutralization': <FaShieldAlt />,
|
||||||
'feature.trustee': <FaBriefcase />,
|
'feature.trustee': <FaBriefcase />,
|
||||||
'feature.realestate': <FaBuilding />,
|
'feature.realestate': <FaBuilding />,
|
||||||
|
|
|
||||||
559
src/hooks/useCommcoach.ts
Normal file
559
src/hooks/useCommcoach.ts
Normal file
|
|
@ -0,0 +1,559 @@
|
||||||
|
/**
|
||||||
|
* useCommcoach Hook
|
||||||
|
*
|
||||||
|
* State management for CommCoach coaching sessions, contexts, and chat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef, type MutableRefObject } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { useInstanceId } from './useCurrentInstance';
|
||||||
|
import {
|
||||||
|
getContextsApi, createContextApi, getContextDetailApi,
|
||||||
|
startSessionStreamApi, getSessionApi, completeSessionApi, cancelSessionApi,
|
||||||
|
sendMessageStreamApi, sendAudioStreamApi,
|
||||||
|
getTasksApi, createTaskApi, updateTaskStatusApi, deleteTaskApi,
|
||||||
|
getProfileApi, testVoiceApi,
|
||||||
|
type CoachingContext, type CoachingSession, type CoachingMessage,
|
||||||
|
type CoachingTask, type CoachingScore, type SSEEvent,
|
||||||
|
} from '../api/commcoachApi';
|
||||||
|
|
||||||
|
export interface CommcoachHookReturn {
|
||||||
|
contexts: CoachingContext[];
|
||||||
|
selectedContextId: string | null;
|
||||||
|
selectedContext: CoachingContext | null;
|
||||||
|
loadingContexts: boolean;
|
||||||
|
|
||||||
|
session: CoachingSession | null;
|
||||||
|
messages: CoachingMessage[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
streamingStatus: string | null;
|
||||||
|
|
||||||
|
tasks: CoachingTask[];
|
||||||
|
scores: CoachingScore[];
|
||||||
|
sessions: CoachingSession[];
|
||||||
|
|
||||||
|
error: string | null;
|
||||||
|
inputValue: string;
|
||||||
|
setInputValue: (v: string) => void;
|
||||||
|
|
||||||
|
selectContext: (contextId: string) => Promise<void>;
|
||||||
|
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
|
||||||
|
archiveContext: (contextId: string) => Promise<void>;
|
||||||
|
|
||||||
|
startSession: () => Promise<void>;
|
||||||
|
sendMessage: (content: string) => Promise<void>;
|
||||||
|
sendAudio: (audioBlob: Blob) => Promise<void>;
|
||||||
|
completeSession: () => Promise<void>;
|
||||||
|
cancelSession: () => Promise<void>;
|
||||||
|
|
||||||
|
isMuted: boolean;
|
||||||
|
setMuted: (muted: boolean) => void;
|
||||||
|
stopTts: () => void;
|
||||||
|
isTtsPlayingRef: MutableRefObject<boolean>;
|
||||||
|
|
||||||
|
toggleTaskStatus: (taskId: string, currentStatus: string) => Promise<void>;
|
||||||
|
addTask: (title: string, description?: string) => Promise<void>;
|
||||||
|
removeTask: (taskId: string) => Promise<void>;
|
||||||
|
|
||||||
|
refreshContexts: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommcoach(): CommcoachHookReturn {
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
|
||||||
|
const [contexts, setContexts] = useState<CoachingContext[]>([]);
|
||||||
|
const [selectedContextId, setSelectedContextId] = useState<string | null>(null);
|
||||||
|
const [selectedContext, setSelectedContext] = useState<CoachingContext | null>(null);
|
||||||
|
const [loadingContexts, setLoadingContexts] = useState(false);
|
||||||
|
|
||||||
|
const [session, setSession] = useState<CoachingSession | null>(null);
|
||||||
|
const [messages, setMessages] = useState<CoachingMessage[]>([]);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [streamingStatus, setStreamingStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<CoachingTask[]>([]);
|
||||||
|
const [scores, setScores] = useState<CoachingScore[]>([]);
|
||||||
|
const [sessions, setSessions] = useState<CoachingSession[]>([]);
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const isTtsPlayingRef = useRef(false);
|
||||||
|
const profileRef = useRef<{ preferredLanguage?: string; preferredVoice?: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []);
|
||||||
|
|
||||||
|
const _speakText = useCallback(async (text: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
const plain = _stripMarkdownForTts(text);
|
||||||
|
if (!plain.trim()) return;
|
||||||
|
if (currentAudioRef.current) {
|
||||||
|
currentAudioRef.current.pause();
|
||||||
|
currentAudioRef.current = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let profile = profileRef.current;
|
||||||
|
if (!profile) {
|
||||||
|
const p = await getProfileApi(request, instanceId);
|
||||||
|
profile = { preferredLanguage: p?.preferredLanguage || 'de-DE', preferredVoice: p?.preferredVoice };
|
||||||
|
profileRef.current = profile;
|
||||||
|
}
|
||||||
|
const lang = profile?.preferredLanguage || 'de-DE';
|
||||||
|
const voiceId = profile?.preferredVoice || undefined;
|
||||||
|
const result = await testVoiceApi(request, instanceId, { text: plain, language: lang, voiceId });
|
||||||
|
if (result?.success && result?.audio && isMountedRef.current) {
|
||||||
|
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
|
||||||
|
currentAudioRef.current = audio;
|
||||||
|
audio.onended = () => { currentAudioRef.current = null; };
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
} catch (playErr: any) {
|
||||||
|
if (playErr?.name === 'NotAllowedError') {
|
||||||
|
console.warn('CommCoach TTS: Browser blocked audio. Click "Session starten" or "Senden" first.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// TTS failed silently, text is still visible
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const refreshContexts = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoadingContexts(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getContextsApi(request, instanceId);
|
||||||
|
if (isMountedRef.current) setContexts(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) setError(err.message || 'Fehler beim Laden der Kontexte');
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) setLoadingContexts(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const _playTtsAudio = useCallback((audioB64: string) => {
|
||||||
|
if (!audioB64 || !isMountedRef.current) return;
|
||||||
|
if (currentAudioRef.current) {
|
||||||
|
currentAudioRef.current.pause();
|
||||||
|
currentAudioRef.current = null;
|
||||||
|
}
|
||||||
|
isTtsPlayingRef.current = true;
|
||||||
|
try {
|
||||||
|
const audio = new Audio(`data:audio/mp3;base64,${audioB64}`);
|
||||||
|
currentAudioRef.current = audio;
|
||||||
|
audio.onended = () => {
|
||||||
|
currentAudioRef.current = null;
|
||||||
|
isTtsPlayingRef.current = false;
|
||||||
|
};
|
||||||
|
audio.play().catch(() => { isTtsPlayingRef.current = false; });
|
||||||
|
} catch {
|
||||||
|
isTtsPlayingRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopTts = useCallback(() => {
|
||||||
|
if (currentAudioRef.current) {
|
||||||
|
currentAudioRef.current.pause();
|
||||||
|
currentAudioRef.current = null;
|
||||||
|
}
|
||||||
|
isTtsPlayingRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectContext = useCallback(async (contextId: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setSelectedContextId(contextId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const detail = await getContextDetailApi(request, instanceId, contextId);
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
setSelectedContext(detail.context);
|
||||||
|
setTasks(detail.tasks || []);
|
||||||
|
setScores(detail.scores || []);
|
||||||
|
setSessions(detail.sessions || []);
|
||||||
|
|
||||||
|
const activeSession = detail.sessions?.find((s: CoachingSession) => s.status === 'active');
|
||||||
|
if (activeSession) {
|
||||||
|
await _unlockAudioForTts();
|
||||||
|
setIsStreaming(true);
|
||||||
|
await startSessionStreamApi(
|
||||||
|
instanceId,
|
||||||
|
contextId,
|
||||||
|
(event: SSEEvent) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
const eventType = event.type;
|
||||||
|
const eventData = event.data;
|
||||||
|
if (eventType === 'sessionState' && eventData) {
|
||||||
|
const sess = eventData.session;
|
||||||
|
if (sess) setSession(sess);
|
||||||
|
if (eventData.resumed && Array.isArray(eventData.messages)) {
|
||||||
|
setMessages(eventData.messages);
|
||||||
|
}
|
||||||
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
|
_playTtsAudio(eventData.audio);
|
||||||
|
}
|
||||||
|
if (eventType === 'complete') setIsStreaming(false);
|
||||||
|
},
|
||||||
|
(err) => { if (isMountedRef.current) { setError(err.message); setIsStreaming(false); } },
|
||||||
|
() => { if (isMountedRef.current) setIsStreaming(false); },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSession(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Kontexts');
|
||||||
|
}
|
||||||
|
}, [request, instanceId, _playTtsAudio]);
|
||||||
|
|
||||||
|
const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const created = await createContextApi(request, instanceId, { title, description, category, goals });
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
await refreshContexts();
|
||||||
|
setSelectedContextId(created.id);
|
||||||
|
setSelectedContext(created);
|
||||||
|
setTasks([]);
|
||||||
|
setScores([]);
|
||||||
|
setSessions([]);
|
||||||
|
setSession(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) setError(err.message || 'Fehler beim Erstellen des Kontexts');
|
||||||
|
}
|
||||||
|
}, [request, instanceId, refreshContexts]);
|
||||||
|
|
||||||
|
const archiveContext = useCallback(async (contextId: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const { archiveContextApi } = await import('../api/commcoachApi');
|
||||||
|
await archiveContextApi(request, instanceId, contextId);
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
await refreshContexts();
|
||||||
|
if (selectedContextId === contextId) {
|
||||||
|
setSelectedContextId(null);
|
||||||
|
setSelectedContext(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) setError(err.message || 'Fehler beim Archivieren');
|
||||||
|
}
|
||||||
|
}, [request, instanceId, selectedContextId, refreshContexts]);
|
||||||
|
|
||||||
|
const startSessionCb = useCallback(async () => {
|
||||||
|
if (!instanceId || !selectedContextId) return;
|
||||||
|
await _unlockAudioForTts();
|
||||||
|
setError(null);
|
||||||
|
setIsMuted(false);
|
||||||
|
setIsStreaming(true);
|
||||||
|
setStreamingStatus(null);
|
||||||
|
setMessages([]);
|
||||||
|
setSession(null);
|
||||||
|
try {
|
||||||
|
await startSessionStreamApi(
|
||||||
|
instanceId,
|
||||||
|
selectedContextId,
|
||||||
|
(event: SSEEvent) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
const eventType = event.type;
|
||||||
|
const eventData = event.data;
|
||||||
|
|
||||||
|
if (eventType === 'sessionState' && eventData) {
|
||||||
|
const sess = eventData.session;
|
||||||
|
if (sess) {
|
||||||
|
setSession(sess);
|
||||||
|
setIsMuted(false);
|
||||||
|
}
|
||||||
|
if (eventData.resumed && Array.isArray(eventData.messages)) {
|
||||||
|
setMessages(eventData.messages);
|
||||||
|
}
|
||||||
|
} else if (eventType === 'message' && eventData) {
|
||||||
|
const msg: CoachingMessage = {
|
||||||
|
id: eventData.id || `msg-${Date.now()}`,
|
||||||
|
sessionId: eventData.sessionId || '',
|
||||||
|
contextId: eventData.contextId || '',
|
||||||
|
role: eventData.role,
|
||||||
|
content: eventData.content,
|
||||||
|
contentType: eventData.contentType || 'text',
|
||||||
|
createdAt: eventData.createdAt,
|
||||||
|
};
|
||||||
|
setMessages(prev => {
|
||||||
|
if (prev.some(m => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
|
_playTtsAudio(eventData.audio);
|
||||||
|
} else if (eventType === 'status' && eventData) {
|
||||||
|
setStreamingStatus(eventData.label || null);
|
||||||
|
} else if (eventType === 'error' && eventData) {
|
||||||
|
setError(eventData.message || 'Stream-Fehler');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setError(err.message);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsStreaming(false);
|
||||||
|
setStreamingStatus(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setError(err.message || 'Fehler beim Starten der Session');
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [instanceId, selectedContextId, _playTtsAudio]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async (content: string) => {
|
||||||
|
if (!content.trim() || isStreaming || !instanceId || !session) return;
|
||||||
|
await _unlockAudioForTts();
|
||||||
|
setError(null);
|
||||||
|
setIsStreaming(true);
|
||||||
|
setStreamingStatus(null);
|
||||||
|
|
||||||
|
const tempMsg: CoachingMessage = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
sessionId: session.id,
|
||||||
|
contextId: session.contextId,
|
||||||
|
role: 'user',
|
||||||
|
content: content.trim(),
|
||||||
|
contentType: 'text',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, tempMsg]);
|
||||||
|
setInputValue('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMessageStreamApi(
|
||||||
|
instanceId,
|
||||||
|
session.id,
|
||||||
|
content,
|
||||||
|
(event: SSEEvent) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
const eventType = event.type;
|
||||||
|
const eventData = event.data;
|
||||||
|
|
||||||
|
if (eventType === 'message' && eventData) {
|
||||||
|
const msg: CoachingMessage = {
|
||||||
|
id: eventData.id || `msg-${Date.now()}`,
|
||||||
|
sessionId: session.id,
|
||||||
|
contextId: session.contextId,
|
||||||
|
role: eventData.role,
|
||||||
|
content: eventData.content,
|
||||||
|
contentType: 'text',
|
||||||
|
createdAt: eventData.createdAt,
|
||||||
|
};
|
||||||
|
setMessages(prev => {
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
return prev.map(m => m.id === tempMsg.id ? msg : m);
|
||||||
|
}
|
||||||
|
if (prev.some(m => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
|
setError(null);
|
||||||
|
_playTtsAudio(eventData.audio);
|
||||||
|
} else if (eventType === 'status' && eventData) {
|
||||||
|
setStreamingStatus(eventData.label || null);
|
||||||
|
} else if (eventType === 'taskCreated' && eventData) {
|
||||||
|
setTasks(prev => [eventData, ...prev]);
|
||||||
|
} else if (eventType === 'scoreUpdate') {
|
||||||
|
// Will refresh on complete
|
||||||
|
} else if (eventType === 'error' && eventData) {
|
||||||
|
setError(eventData.message || 'Stream-Fehler');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setError(err.message);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsStreaming(false);
|
||||||
|
setStreamingStatus(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setError(err.message);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isStreaming, instanceId, session, _playTtsAudio]);
|
||||||
|
|
||||||
|
const sendAudio = useCallback(async (audioBlob: Blob) => {
|
||||||
|
if (!instanceId || !session) return;
|
||||||
|
stopTts();
|
||||||
|
await _unlockAudioForTts();
|
||||||
|
setError(null);
|
||||||
|
setIsStreaming(true);
|
||||||
|
setStreamingStatus(null);
|
||||||
|
try {
|
||||||
|
await sendAudioStreamApi(
|
||||||
|
instanceId,
|
||||||
|
session.id,
|
||||||
|
audioBlob,
|
||||||
|
(event: SSEEvent) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
const eventType = event.type;
|
||||||
|
const eventData = event.data;
|
||||||
|
|
||||||
|
if (eventType === 'status' && eventData) {
|
||||||
|
setStreamingStatus(eventData.label || null);
|
||||||
|
} else if (eventType === 'message' && eventData) {
|
||||||
|
if (eventData.role === 'assistant') setError(null);
|
||||||
|
const msg: CoachingMessage = {
|
||||||
|
id: eventData.id || `msg-${Date.now()}`,
|
||||||
|
sessionId: session.id,
|
||||||
|
contextId: session.contextId,
|
||||||
|
role: eventData.role,
|
||||||
|
content: eventData.content,
|
||||||
|
contentType: eventData.contentType || 'text',
|
||||||
|
createdAt: eventData.createdAt,
|
||||||
|
};
|
||||||
|
setMessages(prev => {
|
||||||
|
if (prev.some(m => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
|
setError(null);
|
||||||
|
_playTtsAudio(eventData.audio);
|
||||||
|
} else if (eventType === 'error' && eventData) {
|
||||||
|
setError(eventData.message || 'Audio-Fehler');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setError(err.message);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsStreaming(false);
|
||||||
|
setStreamingStatus(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setError(err.message);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [instanceId, session, stopTts]);
|
||||||
|
|
||||||
|
const completeSessionCb = useCallback(async () => {
|
||||||
|
if (!instanceId || !session) return;
|
||||||
|
try {
|
||||||
|
const completed = await completeSessionApi(request, instanceId, session.id);
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setSession(completed);
|
||||||
|
if (selectedContextId) await selectContext(selectedContextId);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) setError(err.message || 'Fehler beim Abschliessen');
|
||||||
|
}
|
||||||
|
}, [request, instanceId, session, selectedContextId, selectContext]);
|
||||||
|
|
||||||
|
const cancelSessionCb = useCallback(async () => {
|
||||||
|
if (!instanceId || !session) return;
|
||||||
|
try {
|
||||||
|
await cancelSessionApi(request, instanceId, session.id);
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setSession(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) setError(err.message || 'Fehler beim Abbrechen');
|
||||||
|
}
|
||||||
|
}, [request, instanceId, session]);
|
||||||
|
|
||||||
|
const toggleTaskStatus = useCallback(async (taskId: string, currentStatus: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
const newStatus = currentStatus === 'done' ? 'open' : 'done';
|
||||||
|
try {
|
||||||
|
const updated = await updateTaskStatusApi(request, instanceId, taskId, newStatus);
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setTasks(prev => prev.map(t => t.id === taskId ? updated : t));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) setError(err.message);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const addTask = useCallback(async (title: string, description?: string) => {
|
||||||
|
if (!instanceId || !selectedContextId) return;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, selectedContextId]);
|
||||||
|
|
||||||
|
const removeTask = useCallback(async (taskId: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
await deleteTaskApi(request, instanceId, taskId);
|
||||||
|
if (isMountedRef.current) setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) setError(err.message);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { if (instanceId) refreshContexts(); }, [instanceId, refreshContexts]);
|
||||||
|
|
||||||
|
useEffect(() => { profileRef.current = null; }, [instanceId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contexts, selectedContextId, selectedContext, loadingContexts,
|
||||||
|
session, messages, isStreaming, streamingStatus,
|
||||||
|
tasks, scores, sessions,
|
||||||
|
error, inputValue, setInputValue,
|
||||||
|
selectContext, createContext, archiveContext,
|
||||||
|
startSession: startSessionCb,
|
||||||
|
sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb,
|
||||||
|
isMuted, setMuted: setIsMuted, stopTts, isTtsPlayingRef,
|
||||||
|
toggleTaskStatus, addTask, removeTask,
|
||||||
|
refreshContexts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stripMarkdownForTts(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||||
|
.replace(/\*(.+?)\*/g, '$1')
|
||||||
|
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
|
||||||
|
.replace(/^#+\s*/gm, '')
|
||||||
|
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _unlockAudioForTts(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
if (ctx.state === 'suspended') await ctx.resume();
|
||||||
|
const silent = new Audio('data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=');
|
||||||
|
silent.volume = 0;
|
||||||
|
await silent.play();
|
||||||
|
} catch {
|
||||||
|
// Ignore if audio unlock fails
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/hooks/useCommcoachDashboard.ts
Normal file
55
src/hooks/useCommcoachDashboard.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* useCommcoachDashboard Hook
|
||||||
|
*
|
||||||
|
* Loads and manages dashboard data for the CommCoach feature.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { useInstanceId } from './useCurrentInstance';
|
||||||
|
import { getDashboardApi, getProfileApi, type DashboardData, type CoachingUserProfile } from '../api/commcoachApi';
|
||||||
|
|
||||||
|
export interface CommcoachDashboardReturn {
|
||||||
|
dashboard: DashboardData | null;
|
||||||
|
profile: CoachingUserProfile | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommcoachDashboard(): CommcoachDashboardReturn {
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
|
||||||
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
||||||
|
const [profile, setProfile] = useState<CoachingUserProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [dashData, profileData] = await Promise.all([
|
||||||
|
getDashboardApi(request, instanceId),
|
||||||
|
getProfileApi(request, instanceId),
|
||||||
|
]);
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setDashboard(dashData);
|
||||||
|
setProfile(profileData);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Dashboards');
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { if (instanceId) refresh(); }, [instanceId, refresh]);
|
||||||
|
|
||||||
|
return { dashboard, profile, loading, error, refresh };
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||||
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
|
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
|
||||||
|
|
||||||
// Trustee Views
|
// Trustee Views
|
||||||
|
|
@ -43,6 +44,9 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
||||||
// Neutralization Views
|
// Neutralization Views
|
||||||
import { NeutralizationView } from './views/neutralization';
|
import { NeutralizationView } from './views/neutralization';
|
||||||
|
|
||||||
|
// CommCoach Views
|
||||||
|
import { CommcoachDashboardView, CommcoachCoachingView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
|
||||||
|
|
||||||
import styles from './FeatureView.module.css';
|
import styles from './FeatureView.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -142,6 +146,12 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
dashboard: NeutralizationView,
|
dashboard: NeutralizationView,
|
||||||
playground: NeutralizationView,
|
playground: NeutralizationView,
|
||||||
},
|
},
|
||||||
|
commcoach: {
|
||||||
|
dashboard: CommcoachDashboardView,
|
||||||
|
coaching: CommcoachCoachingView,
|
||||||
|
dossier: CommcoachDossierView,
|
||||||
|
settings: CommcoachSettingsView,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -154,6 +164,7 @@ interface FeatureViewPageProps {
|
||||||
|
|
||||||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
const { instance, featureCode, isValid } = useCurrentInstance();
|
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||||
|
const { currentLanguage } = useLanguage();
|
||||||
|
|
||||||
// Berechtigungs-Check
|
// Berechtigungs-Check
|
||||||
const viewCode = `${featureCode}-${view}`;
|
const viewCode = `${featureCode}-${view}`;
|
||||||
|
|
@ -202,7 +213,8 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
// View-Info aus Registry
|
// View-Info aus Registry
|
||||||
const featureConfig = FEATURE_REGISTRY[featureCode];
|
const featureConfig = FEATURE_REGISTRY[featureCode];
|
||||||
const viewConfig = featureConfig?.views?.find(v => v.code === view);
|
const viewConfig = featureConfig?.views?.find(v => v.code === view);
|
||||||
const viewLabel = viewConfig ? getLabel(viewConfig.label) : view;
|
const lang = (currentLanguage?.slice(0, 2) || 'de') as 'de' | 'en' | 'fr';
|
||||||
|
const viewLabel = viewConfig ? getLabel(viewConfig.label, lang) : view;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.featureView}>
|
<div className={styles.featureView}>
|
||||||
|
|
|
||||||
396
src/pages/views/commcoach/CommcoachCoachingView.module.css
Normal file
396
src/pages/views/commcoach/CommcoachCoachingView.module.css
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
.coaching {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 140px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Context Tabs */
|
||||||
|
.contextBar {
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextTabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextTab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextTab:hover {
|
||||||
|
background: var(--bg-hover, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextTabActive {
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextTabIcon {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextTabLabel {
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextTabNew {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextTabNew: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newContextInput,
|
||||||
|
.newContextSelect {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No context */
|
||||||
|
.noContext {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noContext h3 {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noContext p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat Area */
|
||||||
|
.chatArea {
|
||||||
|
flex: 1;
|
||||||
|
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 h3 {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionStart p {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin-bottom: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiceActive {
|
||||||
|
border: 2px solid #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mutedActive {
|
||||||
|
background: var(--color-medium-gray, #999);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-medium-gray, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBanner {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #fde8e8;
|
||||||
|
color: var(--color-error, #d32f2f);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
392
src/pages/views/commcoach/CommcoachCoachingView.tsx
Normal file
392
src/pages/views/commcoach/CommcoachCoachingView.tsx
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
/**
|
||||||
|
* CommCoach Coaching View
|
||||||
|
*
|
||||||
|
* Voice first, always with text fallback (CONCEPT.md).
|
||||||
|
* Chat und Voice parallel: Mikrofon und Texteingabe gleichzeitig nutzbar.
|
||||||
|
* Mute: nur Mikrofon stummschalten, kein Moduswechsel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||||
|
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import styles from './CommcoachCoachingView.module.css';
|
||||||
|
|
||||||
|
export const CommcoachCoachingView: React.FC = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const coach = useCommcoach();
|
||||||
|
const [showNewContext, setShowNewContext] = useState(false);
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [newDescription, setNewDescription] = useState('');
|
||||||
|
const [newCategory, setNewCategory] = useState('custom');
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const speechRecognitionRef = useRef<SpeechRecognition | null>(null);
|
||||||
|
const transcriptPartsRef = useRef<string[]>([]);
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const contextId = searchParams.get('context');
|
||||||
|
if (contextId && coach.contexts.some(c => c.id === contextId)) {
|
||||||
|
coach.selectContext(contextId);
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
}
|
||||||
|
}, [searchParams, coach.contexts, coach.selectContext, setSearchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (coach.session && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [coach.session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!coach.session) {
|
||||||
|
coach.setMuted(false);
|
||||||
|
}
|
||||||
|
}, [coach.session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!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) {
|
||||||
|
console.warn('SpeechRecognition not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const MIN_WORDS_TO_INTERRUPT = 2;
|
||||||
|
const lang = 'de-DE';
|
||||||
|
|
||||||
|
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 = lang;
|
||||||
|
|
||||||
|
recognition.onstart = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onspeechstart = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIsUserSpeaking(true);
|
||||||
|
transcriptPartsRef.current = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const result = event.results[event.resultIndex];
|
||||||
|
if (!result.isFinal) return;
|
||||||
|
const transcript = result[0].transcript.trim();
|
||||||
|
if (transcript) transcriptPartsRef.current.push(transcript);
|
||||||
|
const wordCount = transcript.split(/\s+/).filter(Boolean).length;
|
||||||
|
if (wordCount >= MIN_WORDS_TO_INTERRUPT) coach.stopTts();
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onspeechend = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
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 = [];
|
||||||
|
setIsUserSpeaking(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (speechRecognitionRef.current === recognition) {
|
||||||
|
speechRecognitionRef.current = null;
|
||||||
|
setIsUserSpeaking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [coach.session, coach.isMuted, coach.stopTts, coach.sendMessage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.coaching}>
|
||||||
|
{/* Context Tabs */}
|
||||||
|
<div className={styles.contextBar}>
|
||||||
|
<div className={styles.contextTabs}>
|
||||||
|
{coach.contexts.map(ctx => (
|
||||||
|
<button
|
||||||
|
key={ctx.id}
|
||||||
|
className={`${styles.contextTab} ${coach.selectedContextId === ctx.id ? styles.contextTabActive : ''}`}
|
||||||
|
onClick={() => coach.selectContext(ctx.id)}
|
||||||
|
title={ctx.title}
|
||||||
|
>
|
||||||
|
<span className={styles.contextTabIcon}>{_categoryIcon(ctx.category)}</span>
|
||||||
|
<span className={styles.contextTabLabel}>{ctx.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className={styles.contextTabNew}
|
||||||
|
onClick={() => setShowNewContext(!showNewContext)}
|
||||||
|
title="Neues Thema"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Context Form */}
|
||||||
|
{showNewContext && (
|
||||||
|
<div className={styles.newContextForm}>
|
||||||
|
<input
|
||||||
|
className={styles.newContextInput}
|
||||||
|
placeholder="Thema / Titel..."
|
||||||
|
value={newTitle}
|
||||||
|
onChange={e => setNewTitle(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleCreateContext()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={styles.newContextInput}
|
||||||
|
placeholder="Beschreibung (optional)"
|
||||||
|
value={newDescription}
|
||||||
|
onChange={e => setNewDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className={styles.newContextSelect}
|
||||||
|
value={newCategory}
|
||||||
|
onChange={e => setNewCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="custom">Individuell</option>
|
||||||
|
<option value="leadership">Fuehrung</option>
|
||||||
|
<option value="conflict">Konflikt</option>
|
||||||
|
<option value="negotiation">Verhandlung</option>
|
||||||
|
<option value="presentation">Praesentation</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>
|
||||||
|
<button className={styles.btnSecondary} onClick={() => setShowNewContext(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Context Selected */}
|
||||||
|
{!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>
|
||||||
|
<button className={styles.btnPrimary} onClick={() => setShowNewContext(true)}>
|
||||||
|
Neues Thema erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chat Area */}
|
||||||
|
{coach.selectedContextId && (
|
||||||
|
<div className={styles.chatArea}>
|
||||||
|
{/* Session controls */}
|
||||||
|
{!coach.session && (
|
||||||
|
<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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{coach.session && (
|
||||||
|
<>
|
||||||
|
<div className={styles.sessionHeader}>
|
||||||
|
<span className={styles.sessionLabel}>
|
||||||
|
Session aktiv - {coach.selectedContext?.title}
|
||||||
|
</span>
|
||||||
|
<div className={styles.sessionActions}>
|
||||||
|
<button
|
||||||
|
className={`${styles.btnSmall} ${coach.isMuted ? styles.mutedActive : ''}`}
|
||||||
|
onClick={() => coach.setMuted(!coach.isMuted)}
|
||||||
|
title={coach.isMuted ? 'Stummschaltung aufheben' : 'Stummschalten'}
|
||||||
|
>
|
||||||
|
{coach.isMuted ? '\u{1F507}' : '\u{1F3A4}'} {coach.isMuted ? 'Stumm' : 'Ton an'}
|
||||||
|
</button>
|
||||||
|
<button className={styles.btnSmall} onClick={coach.completeSession}>
|
||||||
|
Abschliessen
|
||||||
|
</button>
|
||||||
|
<button className={styles.btnSmallDanger} onClick={coach.cancelSession}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AutoScroll scrollDependency={coach.messages.length + (coach.isStreaming ? 1 : 0)}>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AutoScroll>
|
||||||
|
|
||||||
|
{/* Input: Chat und Voice parallel (CONCEPT: Voice first, always with text fallback) */}
|
||||||
|
<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
|
||||||
|
? 'Aufnahme...'
|
||||||
|
: isListening
|
||||||
|
? 'Mikrofon an – bitte sprechen'
|
||||||
|
: 'Mikrofon wird gestartet...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.textInputRow}>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
className={styles.textInput}
|
||||||
|
placeholder="Nachricht eingeben..."
|
||||||
|
value={coach.inputValue}
|
||||||
|
onChange={e => coach.setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={1}
|
||||||
|
disabled={coach.isStreaming}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.sendBtn}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!coach.inputValue.trim() || coach.isStreaming}
|
||||||
|
>
|
||||||
|
Senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{coach.error && (
|
||||||
|
<div className={styles.errorBanner}>{coach.error}</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] || '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommcoachCoachingView;
|
||||||
125
src/pages/views/commcoach/CommcoachDashboardView.module.css
Normal file
125
src/pages/views/commcoach/CommcoachDashboardView.module.css
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
.dashboard {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error, .empty {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error-color, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiValue {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiLabel {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiSub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextCard {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextCard:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextMeta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextCategory {
|
||||||
|
background: var(--bg-tag, #e3f2fd);
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextLast {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px dashed var(--border-color, #ccc);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipCard {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
132
src/pages/views/commcoach/CommcoachDashboardView.tsx
Normal file
132
src/pages/views/commcoach/CommcoachDashboardView.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
/**
|
||||||
|
* CommCoach Dashboard View
|
||||||
|
*
|
||||||
|
* Shows KPIs, streak, active contexts, and quick-start coaching entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useCommcoachDashboard } from '../../../hooks/useCommcoachDashboard';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import styles from './CommcoachDashboardView.module.css';
|
||||||
|
|
||||||
|
export const CommcoachDashboardView: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { mandateId, instanceId } = useCurrentInstance();
|
||||||
|
const { dashboard, profile, loading, error, refresh } = useCommcoachDashboard();
|
||||||
|
|
||||||
|
const handleContextClick = (contextId: string) => {
|
||||||
|
if (mandateId && instanceId) {
|
||||||
|
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/coaching?context=${contextId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !dashboard) {
|
||||||
|
return <div className={styles.loading}>Dashboard wird geladen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className={styles.error}>{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
return <div className={styles.empty}>Keine Daten verfuegbar.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboard}>
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className={styles.kpiGrid}>
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<div className={styles.kpiValue}>{dashboard.streakDays}</div>
|
||||||
|
<div className={styles.kpiLabel}>Tage Streak</div>
|
||||||
|
<div className={styles.kpiSub}>Rekord: {dashboard.longestStreak}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<div className={styles.kpiValue}>{dashboard.totalSessions}</div>
|
||||||
|
<div className={styles.kpiLabel}>Sessions</div>
|
||||||
|
<div className={styles.kpiSub}>{dashboard.totalMinutes} Min. gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<div className={styles.kpiValue}>
|
||||||
|
{dashboard.averageScore != null ? Math.round(dashboard.averageScore) : '--'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiLabel}>Kompetenz-Score</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Contexts */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Aktive Coaching-Themen</h3>
|
||||||
|
{dashboard.contexts.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<p>Noch keine Coaching-Themen erstellt.</p>
|
||||||
|
<p>Wechsle zum Coaching-Tab, um dein erstes Thema anzulegen.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.contextGrid}>
|
||||||
|
{dashboard.contexts.map(ctx => (
|
||||||
|
<div
|
||||||
|
key={ctx.id}
|
||||||
|
className={styles.contextCard}
|
||||||
|
onClick={() => handleContextClick(ctx.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleContextClick(ctx.id)}
|
||||||
|
>
|
||||||
|
<div className={styles.contextTitle}>{ctx.title}</div>
|
||||||
|
<div className={styles.contextMeta}>
|
||||||
|
<span className={styles.contextCategory}>{_categoryLabel(ctx.category)}</span>
|
||||||
|
<span>{ctx.sessionCount} Sessions</span>
|
||||||
|
</div>
|
||||||
|
{ctx.lastSessionAt && (
|
||||||
|
<div className={styles.contextLast}>
|
||||||
|
Letzte Session: {_formatDate(ctx.lastSessionAt)}
|
||||||
|
</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
|
||||||
|
bringt messbare Fortschritte in deiner Kommunikationskompetenz.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function _categoryLabel(category: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
leadership: 'Fuehrung',
|
||||||
|
conflict: 'Konflikt',
|
||||||
|
negotiation: 'Verhandlung',
|
||||||
|
presentation: 'Praesentation',
|
||||||
|
feedback: 'Feedback',
|
||||||
|
delegation: 'Delegation',
|
||||||
|
changeManagement: 'Change Mgmt',
|
||||||
|
custom: 'Individuell',
|
||||||
|
};
|
||||||
|
return labels[category] || category;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatDate(isoStr: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(isoStr);
|
||||||
|
return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
} catch { return isoStr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommcoachDashboardView;
|
||||||
256
src/pages/views/commcoach/CommcoachDossierView.module.css
Normal file
256
src/pages/views/commcoach/CommcoachDossierView.module.css
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
.dossier {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnArchive {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnArchive:hover:not(:disabled) {
|
||||||
|
color: var(--error-color, #dc2626);
|
||||||
|
border-color: var(--error-color, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { color: var(--text-primary, #333); }
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addTaskBtn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskList { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.taskItem {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskDone { opacity: 0.6; }
|
||||||
|
.taskDone .taskTitle { text-decoration: line-through; }
|
||||||
|
|
||||||
|
.taskCheck {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskDelete:hover { color: var(--error-color, #dc2626); }
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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 p { margin: 0 0 0.4rem; }
|
||||||
|
|
||||||
|
.sessionMeta { font-size: 0.75rem; color: var(--text-secondary, #888); }
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreDimensionLabel { font-weight: 600; font-size: 0.9rem; flex: 1; }
|
||||||
|
.scoreLatest { font-weight: 700; font-size: 1rem; color: var(--primary-color, #F25843); }
|
||||||
|
|
||||||
|
.scoreTrend { font-size: 0.75rem; }
|
||||||
|
.trend_improving { color: #2e7d32; }
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreBarFill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreEvidence {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
215
src/pages/views/commcoach/CommcoachDossierView.tsx
Normal file
215
src/pages/views/commcoach/CommcoachDossierView.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
/**
|
||||||
|
* CommCoach Dossier View
|
||||||
|
*
|
||||||
|
* Shows context detail: sessions timeline, tasks checklist, scores, insights.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import styles from './CommcoachDossierView.module.css';
|
||||||
|
|
||||||
|
export const CommcoachDossierView: React.FC = () => {
|
||||||
|
const coach = useCommcoach();
|
||||||
|
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores'>('tasks');
|
||||||
|
|
||||||
|
const handleAddTask = useCallback(async () => {
|
||||||
|
if (!newTaskTitle.trim()) return;
|
||||||
|
await coach.addTask(newTaskTitle);
|
||||||
|
setNewTaskTitle('');
|
||||||
|
}, [newTaskTitle, coach]);
|
||||||
|
|
||||||
|
if (!coach.selectedContextId) {
|
||||||
|
return (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<p>Waehle ein Coaching-Thema im Coaching-Tab, um das Dossier zu sehen.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dossier}>
|
||||||
|
{/* 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}>
|
||||||
|
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)}>
|
||||||
|
Archivieren
|
||||||
|
</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
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ScoreGroup {
|
||||||
|
dimension: string;
|
||||||
|
latest: { score: number; trend: string; evidence?: string };
|
||||||
|
history: Array<{ score: number; createdAt?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
|
||||||
|
const groups: Record<string, ScoreGroup> = {};
|
||||||
|
for (const s of scores) {
|
||||||
|
const dim = s.dimension;
|
||||||
|
if (!groups[dim]) {
|
||||||
|
groups[dim] = { dimension: dim, latest: s, history: [] };
|
||||||
|
}
|
||||||
|
groups[dim].history.push({ score: s.score, createdAt: s.createdAt });
|
||||||
|
if (s.createdAt > (groups[dim].latest.createdAt || '')) {
|
||||||
|
groups[dim].latest = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.values(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dimensionLabel(dim: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
empathy: 'Einfuehlungsvermoegen',
|
||||||
|
clarity: 'Klarheit',
|
||||||
|
assertiveness: 'Durchsetzung',
|
||||||
|
listening: 'Zuhoeren',
|
||||||
|
selfReflection: 'Selbstreflexion',
|
||||||
|
};
|
||||||
|
return labels[dim] || dim;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommcoachDossierView;
|
||||||
156
src/pages/views/commcoach/CommcoachSettingsView.module.css
Normal file
156
src/pages/views/commcoach/CommcoachSettingsView.module.css
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
.settings {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #fde8e8;
|
||||||
|
color: var(--color-error, #d32f2f);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select, .input {
|
||||||
|
width: 100%;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiceRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiceRow .select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testBtn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testBtn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
.testBtn:disabled {
|
||||||
|
background: var(--color-medium-gray, #ccc);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
.saveBtn:disabled {
|
||||||
|
background: var(--color-medium-gray, #ccc);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
243
src/pages/views/commcoach/CommcoachSettingsView.tsx
Normal file
243
src/pages/views/commcoach/CommcoachSettingsView.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
/**
|
||||||
|
* CommCoach Settings View
|
||||||
|
*
|
||||||
|
* User profile settings: voice preferences, reminders, email notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import {
|
||||||
|
getProfileApi, updateProfileApi,
|
||||||
|
getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi,
|
||||||
|
type CoachingUserProfile,
|
||||||
|
} from '../../../api/commcoachApi';
|
||||||
|
import styles from './CommcoachSettingsView.module.css';
|
||||||
|
|
||||||
|
export const CommcoachSettingsView: React.FC = () => {
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<CoachingUserProfile | null>(null);
|
||||||
|
const [languages, setLanguages] = useState<any[]>([]);
|
||||||
|
const [voices, setVoices] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [language, setLanguage] = useState('de-DE');
|
||||||
|
const [voiceId, setVoiceId] = useState('');
|
||||||
|
const [reminderEnabled, setReminderEnabled] = useState(false);
|
||||||
|
const [reminderTime, setReminderTime] = useState('09:00');
|
||||||
|
const [emailEnabled, setEmailEnabled] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [profileData, languagesData] = await Promise.all([
|
||||||
|
getProfileApi(request, instanceId),
|
||||||
|
getVoiceLanguagesApi(request, instanceId),
|
||||||
|
]);
|
||||||
|
setProfile(profileData);
|
||||||
|
setLanguages(languagesData || []);
|
||||||
|
|
||||||
|
if (profileData) {
|
||||||
|
setLanguage(profileData.preferredLanguage || 'de-DE');
|
||||||
|
setVoiceId(profileData.preferredVoice || '');
|
||||||
|
setReminderEnabled(profileData.dailyReminderEnabled || false);
|
||||||
|
setReminderTime(profileData.dailyReminderTime || '09:00');
|
||||||
|
setEmailEnabled(profileData.emailSummaryEnabled !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const voicesData = await getVoiceVoicesApi(request, instanceId, profileData?.preferredLanguage || 'de-DE');
|
||||||
|
setVoices(voicesData || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Laden');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const handleLanguageChange = useCallback(async (newLang: string) => {
|
||||||
|
setLanguage(newLang);
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const voicesData = await getVoiceVoicesApi(request, instanceId, newLang);
|
||||||
|
setVoices(voicesData || []);
|
||||||
|
setVoiceId('');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const updated = await updateProfileApi(request, instanceId, {
|
||||||
|
preferredLanguage: language,
|
||||||
|
preferredVoice: voiceId || null,
|
||||||
|
dailyReminderEnabled: reminderEnabled,
|
||||||
|
dailyReminderTime: reminderTime,
|
||||||
|
emailSummaryEnabled: emailEnabled,
|
||||||
|
});
|
||||||
|
setProfile(updated);
|
||||||
|
setSuccess('Einstellungen gespeichert');
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Speichern');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, language, voiceId, reminderEnabled, reminderTime, emailEnabled]);
|
||||||
|
|
||||||
|
const handleTestVoice = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const result = await testVoiceApi(request, instanceId, {
|
||||||
|
language,
|
||||||
|
voiceId: voiceId || undefined,
|
||||||
|
});
|
||||||
|
if (result.success && result.audio) {
|
||||||
|
const audioData = `data:audio/mp3;base64,${result.audio}`;
|
||||||
|
const audio = new Audio(audioData);
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError('Sprachtest fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, language, voiceId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={styles.loading}>Einstellungen werden geladen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.settings}>
|
||||||
|
<h2 className={styles.heading}>Coaching-Einstellungen</h2>
|
||||||
|
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
{success && <div className={styles.success}>{success}</div>}
|
||||||
|
|
||||||
|
{/* Voice Settings */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Sprache und Stimme</h3>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>Sprache</label>
|
||||||
|
<select className={styles.select} value={language} onChange={e => handleLanguageChange(e.target.value)}>
|
||||||
|
{languages.length > 0 ? (
|
||||||
|
languages.map((lang: any) => (
|
||||||
|
<option key={lang.code || lang} value={lang.code || lang}>
|
||||||
|
{lang.name || lang.code || lang}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="de-DE">Deutsch</option>
|
||||||
|
<option value="en-US">English (US)</option>
|
||||||
|
<option value="fr-FR">Francais</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>Stimme</label>
|
||||||
|
<div className={styles.voiceRow}>
|
||||||
|
<select className={styles.select} value={voiceId} onChange={e => setVoiceId(e.target.value)}>
|
||||||
|
<option value="">Standard</option>
|
||||||
|
{voices.map((v: any) => (
|
||||||
|
<option key={v.name || v} value={v.name || v}>
|
||||||
|
{v.displayName || v.name || v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className={styles.testBtn} onClick={handleTestVoice} disabled={testing}>
|
||||||
|
{testing ? 'Teste...' : 'Testen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reminder Settings */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Erinnerungen</h3>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.checkboxLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={reminderEnabled}
|
||||||
|
onChange={e => setReminderEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Taegliche Coaching-Erinnerung per E-Mail
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reminderEnabled && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>Uhrzeit</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className={styles.input}
|
||||||
|
value={reminderTime}
|
||||||
|
onChange={e => setReminderTime(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.checkboxLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailEnabled}
|
||||||
|
onChange={e => setEmailEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Session-Zusammenfassung per E-Mail senden
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{profile && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Statistik</h3>
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statValue}>{profile.totalSessions}</span>
|
||||||
|
<span className={styles.statLabel}>Sessions gesamt</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statValue}>{profile.totalMinutes}</span>
|
||||||
|
<span className={styles.statLabel}>Minuten gesamt</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statValue}>{profile.streakDays}</span>
|
||||||
|
<span className={styles.statLabel}>Aktueller Streak</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statValue}>{profile.longestStreak}</span>
|
||||||
|
<span className={styles.statLabel}>Laengster Streak</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className={styles.saveBtn} onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommcoachSettingsView;
|
||||||
4
src/pages/views/commcoach/index.ts
Normal file
4
src/pages/views/commcoach/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { CommcoachDashboardView } from './CommcoachDashboardView';
|
||||||
|
export { CommcoachCoachingView } from './CommcoachCoachingView';
|
||||||
|
export { CommcoachDossierView } from './CommcoachDossierView';
|
||||||
|
export { CommcoachSettingsView } from './CommcoachSettingsView';
|
||||||
|
|
@ -290,6 +290,17 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
{ code: 'attributes', label: { de: 'Attribute', en: 'Attributes', fr: 'Attributs' }, path: 'attributes' },
|
{ code: 'attributes', label: { de: 'Attribute', en: 'Attributes', fr: 'Attributs' }, path: 'attributes' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
commcoach: {
|
||||||
|
code: 'commcoach',
|
||||||
|
label: { de: 'Kommunikations-Coach', en: 'Communication Coach', fr: 'Coach Communication' },
|
||||||
|
icon: 'account_voice',
|
||||||
|
views: [
|
||||||
|
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
|
||||||
|
{ code: 'coaching', label: { de: 'Coaching', en: 'Coaching', fr: 'Coaching' }, path: 'coaching' },
|
||||||
|
{ code: 'dossier', label: { de: 'Dossier', en: 'Dossier', fr: 'Dossier' }, path: 'dossier' },
|
||||||
|
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Paramètres' }, path: 'settings' },
|
||||||
|
]
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue