From 5186e58e007f2884a137f570a6832f90bf58c081 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 3 Mar 2026 23:02:49 +0100 Subject: [PATCH] iteration 2 done --- src/api/commcoachApi.ts | 124 ++++++++++++++- src/hooks/useCommcoach.ts | 5 +- .../views/commcoach/CommcoachCoachingView.tsx | 44 +++++- .../CommcoachDashboardView.module.css | 26 ++++ .../commcoach/CommcoachDashboardView.tsx | 30 ++++ .../commcoach/CommcoachDossierView.module.css | 113 ++++++++++++++ .../views/commcoach/CommcoachDossierView.tsx | 146 +++++++++++++++++- 7 files changed, 483 insertions(+), 5 deletions(-) diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts index 69c8e40..2462668 100644 --- a/src/api/commcoachApi.ts +++ b/src/api/commcoachApi.ts @@ -29,6 +29,7 @@ export interface CoachingSession { contextId: string; userId: string; status: string; + personaId?: string; summary?: string; durationSeconds: number; messageCount: number; @@ -38,6 +39,38 @@ export interface CoachingSession { endedAt?: string; } +export interface CoachingPersona { + id: string; + userId: string; + key: string; + label: string; + description: string; + gender?: string; + category: string; + isActive: boolean; +} + +export interface CoachingDocument { + id: string; + contextId: string; + fileName: string; + mimeType: string; + fileSize: number; + extractedText?: string; + summary?: string; + createdAt?: string; +} + +export interface CoachingBadge { + id: string; + userId: string; + badgeKey: string; + label?: string; + description?: string; + icon?: string; + awardedAt?: string; +} + export interface CoachingMessage { id: string; sessionId: string; @@ -100,6 +133,8 @@ export interface DashboardData { openTasks: number; completedTasks: number; goalProgress?: number; + badges?: CoachingBadge[]; + level?: { number: number; label: string; totalSessions: number }; contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>; } @@ -180,10 +215,12 @@ export async function startSessionStreamApi( onEvent: (event: SSEEvent) => void, onError?: (error: Error) => void, onComplete?: () => void, + personaId?: string, ): Promise { try { const baseURL = api.defaults.baseURL || ''; - const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`; + const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : ''; + const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`; const headers: Record = { 'Content-Type': 'application/json' }; const authToken = localStorage.getItem('authToken'); @@ -474,3 +511,88 @@ export async function testVoiceApi(request: ApiRequestFunction, instanceId: stri const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body }); return data; } + +// ============================================================================ +// Persona API (Iteration 2) +// ============================================================================ + +export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' }); + return data.personas || []; +} + +export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: { + label: string; description: string; gender?: string; systemPromptOverride?: string; +}): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'post', data: body }); + return data.persona; +} + +export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise { + await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' }); +} + +// ============================================================================ +// Document API (Iteration 2) +// ============================================================================ + +export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' }); + return data.documents || []; +} + +export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise { + const baseURL = api.defaults.baseURL || ''; + const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`; + const formData = new FormData(); + formData.append('file', file); + + const headers: Record = {}; + const authToken = localStorage.getItem('authToken'); + if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + if (!getCSRFToken()) generateAndStoreCSRFToken(); + addCSRFTokenToHeaders(headers); + + const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' }); + if (!response.ok) throw new Error(`Upload failed: ${response.status}`); + const data = await response.json(); + return data.document; +} + +export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise { + await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' }); +} + +// ============================================================================ +// Badge API (Iteration 2) +// ============================================================================ + +export async function getBadgesApi(request: ApiRequestFunction, instanceId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/badges`, method: 'get' }); + return data.badges || []; +} + +// ============================================================================ +// Export API (Iteration 2) +// ============================================================================ + +export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string { + const baseURL = api.defaults.baseURL || ''; + return `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/export?format=${format}`; +} + +export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string { + const baseURL = api.defaults.baseURL || ''; + return `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/export?format=${format}`; +} + +// ============================================================================ +// Score History API (Iteration 2) +// ============================================================================ + +export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise>> { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' }); + return data.history || {}; +} diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts index f2bdbb6..6dc65cc 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -40,7 +40,7 @@ export interface CommcoachHookReturn { createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise; archiveContext: (contextId: string) => Promise; - startSession: () => Promise; + startSession: (personaId?: string) => Promise; sendMessage: (content: string) => Promise; sendAudio: (audioBlob: Blob) => Promise; completeSession: () => Promise; @@ -212,7 +212,7 @@ export function useCommcoach(): CommcoachHookReturn { } }, [request, instanceId, selectedContextId, refreshContexts]); - const startSessionCb = useCallback(async () => { + const startSessionCb = useCallback(async (personaId?: string) => { if (!instanceId || !selectedContextId) return; await _unlockAudioForTts(); setError(null); @@ -278,6 +278,7 @@ export function useCommcoach(): CommcoachHookReturn { setStreamingMessage(null); } }, + personaId, ); } catch (err: any) { if (isMountedRef.current) { diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx index be4597a..d658658 100644 --- a/src/pages/views/commcoach/CommcoachCoachingView.tsx +++ b/src/pages/views/commcoach/CommcoachCoachingView.tsx @@ -9,6 +9,9 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useCommcoach } from '../../../hooks/useCommcoach'; +import { useApiRequest } from '../../../hooks/useApi'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { getPersonasApi, type CoachingPersona } from '../../../api/commcoachApi'; import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -17,11 +20,15 @@ import styles from './CommcoachCoachingView.module.css'; export const CommcoachCoachingView: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const coach = useCommcoach(); + const { request } = useApiRequest(); + const instanceId = useInstanceId(); const [showNewContext, setShowNewContext] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newDescription, setNewDescription] = useState(''); const [newCategory, setNewCategory] = useState('custom'); const inputRef = useRef(null); + const [personas, setPersonas] = useState([]); + const [selectedPersonaId, setSelectedPersonaId] = useState(undefined); const streamRef = useRef(null); const speechRecognitionRef = useRef(null); @@ -71,6 +78,13 @@ export const CommcoachCoachingView: React.FC = () => { } }, [coach.session]); + useEffect(() => { + if (!instanceId) return; + getPersonasApi(request, instanceId) + .then(p => setPersonas(p)) + .catch(() => {}); + }, [instanceId, request]); + useEffect(() => { if (!coach.session || coach.isMuted) { if (speechRecognitionRef.current) { @@ -284,8 +298,36 @@ export const CommcoachCoachingView: React.FC = () => {

{coach.selectedContext?.title}

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

- + ))} +
+ + )} + + )} diff --git a/src/pages/views/commcoach/CommcoachDashboardView.module.css b/src/pages/views/commcoach/CommcoachDashboardView.module.css index bfea6ac..54899b2 100644 --- a/src/pages/views/commcoach/CommcoachDashboardView.module.css +++ b/src/pages/views/commcoach/CommcoachDashboardView.module.css @@ -114,6 +114,32 @@ border-radius: 10px; } +.badgeGrid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.badgeCard { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.9rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 20px; + font-size: 0.85rem; +} + +.badgeIcon { + font-size: 1.1rem; +} + +.badgeLabel { + font-weight: 500; + color: var(--text-primary, #333); +} + .tipCard { background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e0e0e0); diff --git a/src/pages/views/commcoach/CommcoachDashboardView.tsx b/src/pages/views/commcoach/CommcoachDashboardView.tsx index 403c764..d30b8f3 100644 --- a/src/pages/views/commcoach/CommcoachDashboardView.tsx +++ b/src/pages/views/commcoach/CommcoachDashboardView.tsx @@ -99,6 +99,27 @@ export const CommcoachDashboardView: React.FC = () => { )} + {/* Level + Badges */} + {(dashboard.level || (dashboard.badges && dashboard.badges.length > 0)) && ( +
+

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

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

Tipp des Tages

@@ -125,6 +146,15 @@ function _categoryLabel(category: string): string { return labels[category] || category; } +function _badgeIcon(icon?: string): string { + const icons: Record = { + star: '\u2605', fire: '\u{1F525}', trophy: '\u{1F3C6}', + medal: '\u{1F3C5}', layers: '\u{1F4DA}', theater: '\u{1F3AD}', + compass: '\u{1F9ED}', 'check-circle': '\u2714', + }; + return icons[icon || 'star'] || '\u2605'; +} + function _formatDate(isoStr: string): string { try { const d = new Date(isoStr); diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css index cc79280..8372ef0 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.module.css +++ b/src/pages/views/commcoach/CommcoachDossierView.module.css @@ -287,3 +287,116 @@ color: var(--text-secondary, #666); line-height: 1.4; } + +/* Score History */ +.scoreHistory { + margin-top: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.scoreHistoryLabel { + font-size: 0.75rem; + color: var(--text-secondary, #888); +} + +.scoreHistoryPoints { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.scoreHistoryPoint { + padding: 0.15rem 0.4rem; + background: var(--bg-hover, #f0f0f0); + border-radius: 4px; + font-size: 0.7rem; + color: var(--text-secondary, #666); +} + +/* Export Button */ +.btnExport { + padding: 0.4rem 0.75rem; + background: transparent; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + color: var(--text-primary, #333); + text-decoration: none; + display: inline-block; +} + +.btnExport:hover { + border-color: var(--primary-color, #F25843); + color: var(--primary-color, #F25843); +} + +.headerActions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Session Export */ +.sessionExport { + margin-left: 0.5rem; + font-size: 0.75rem; + color: var(--primary-color, #F25843); + text-decoration: none; +} + +.sessionExport:hover { + text-decoration: underline; +} + +/* Documents */ +.uploadLabel { + padding: 0.5rem 1rem; + background: var(--primary-color, #F25843); + color: #fff; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + display: inline-block; +} + +.uploadLabel:hover { filter: brightness(1.08); } + +.documentList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.documentItem { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; +} + +.documentInfo { flex: 1; } + +.documentName { + font-size: 0.9rem; + font-weight: 500; + color: var(--text-primary, #333); +} + +.documentMeta { + font-size: 0.75rem; + color: var(--text-secondary, #888); + margin-top: 0.2rem; +} + +.documentSummary { + font-size: 0.8rem; + color: var(--text-secondary, #666); + margin-top: 0.4rem; + line-height: 1.4; +} diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx index d0ac345..1737590 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.tsx +++ b/src/pages/views/commcoach/CommcoachDossierView.tsx @@ -6,13 +6,26 @@ import React, { useState, useCallback, useEffect } from 'react'; import { useCommcoach } from '../../../hooks/useCommcoach'; +import { useApiRequest } from '../../../hooks/useApi'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { + getDossierExportUrl, getSessionExportUrl, + getDocumentsApi, uploadDocumentApi, deleteDocumentApi, + getScoreHistoryApi, + type CoachingDocument, +} from '../../../api/commcoachApi'; import ReactMarkdown from 'react-markdown'; import styles from './CommcoachDossierView.module.css'; export const CommcoachDossierView: React.FC = () => { const coach = useCommcoach(); + const { request } = useApiRequest(); + const instanceId = useInstanceId(); const [newTaskTitle, setNewTaskTitle] = useState(''); - const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores'>('tasks'); + const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores' | 'documents'>('tasks'); + const [documents, setDocuments] = useState([]); + const [uploading, setUploading] = useState(false); + const [scoreHistory, setScoreHistory] = useState>>({}); useEffect(() => { if (!coach.selectedContextId && coach.contexts.length > 0) { @@ -20,6 +33,41 @@ export const CommcoachDossierView: React.FC = () => { } }, [coach.contexts, coach.selectedContextId, coach.selectContext]); + useEffect(() => { + if (!instanceId || !coach.selectedContextId) return; + getDocumentsApi(request, instanceId, coach.selectedContextId) + .then(d => setDocuments(d)) + .catch(() => {}); + getScoreHistoryApi(request, instanceId, coach.selectedContextId) + .then(h => setScoreHistory(h)) + .catch(() => {}); + }, [instanceId, request, coach.selectedContextId]); + + const handleUpload = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !instanceId || !coach.selectedContextId) return; + setUploading(true); + try { + const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file); + setDocuments(prev => [doc, ...prev]); + } catch { + // upload failed + } finally { + setUploading(false); + e.target.value = ''; + } + }, [instanceId, coach.selectedContextId]); + + const handleDeleteDocument = useCallback(async (docId: string) => { + if (!instanceId) return; + try { + await deleteDocumentApi(request, instanceId, docId); + setDocuments(prev => prev.filter(d => d.id !== docId)); + } catch { + // delete failed + } + }, [instanceId, request]); + const handleAddTask = useCallback(async () => { if (!newTaskTitle.trim()) return; await coach.addTask(newTaskTitle); @@ -65,6 +113,26 @@ export const CommcoachDossierView: React.FC = () => { )}
+ {instanceId && coach.selectedContextId && ( + <> + + Export MD + + + Export PDF + + + )} @@ -91,6 +159,12 @@ export const CommcoachDossierView: React.FC = () => { > Bewertungen ({coach.scores.length}) +
{/* Tasks Tab */} @@ -166,6 +240,18 @@ export const CommcoachDossierView: React.FC = () => { )}
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min. + {s.personaId && | Persona} + {instanceId && s.status === 'completed' && ( + e.stopPropagation()} + > + Export + + )}
))} @@ -196,6 +282,58 @@ export const CommcoachDossierView: React.FC = () => { {group.latest.evidence && (
{group.latest.evidence}
)} + {scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && ( +
+
Verlauf:
+
+ {scoreHistory[group.dimension].map((entry, i) => ( + + {Math.round(entry.score)} + + ))} +
+
+ )} + + ))} + + )} + + )} + + {/* Documents Tab */} + {activeTab === 'documents' && ( +
+
+ +
+ {documents.length === 0 ? ( +
Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknuepfen.
+ ) : ( +
+ {documents.map(doc => ( +
+
+
{doc.fileName}
+
+ {_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''} +
+ {doc.summary && ( +
{doc.summary}
+ )} +
+
))}
@@ -228,6 +366,12 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] { return Object.values(groups); } +function _formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + function _dimensionLabel(dim: string): string { const labels: Record = { empathy: 'Einfuehlungsvermoegen',