iteration 2 done
This commit is contained in:
parent
cd14e8a6fb
commit
5186e58e00
7 changed files with 483 additions and 5 deletions
|
|
@ -29,6 +29,7 @@ export interface CoachingSession {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
personaId?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
|
|
@ -38,6 +39,38 @@ export interface CoachingSession {
|
||||||
endedAt?: string;
|
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 {
|
export interface CoachingMessage {
|
||||||
id: string;
|
id: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|
@ -100,6 +133,8 @@ export interface DashboardData {
|
||||||
openTasks: number;
|
openTasks: number;
|
||||||
completedTasks: number;
|
completedTasks: number;
|
||||||
goalProgress?: 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 }>;
|
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,
|
onEvent: (event: SSEEvent) => void,
|
||||||
onError?: (error: Error) => void,
|
onError?: (error: Error) => void,
|
||||||
onComplete?: () => void,
|
onComplete?: () => void,
|
||||||
|
personaId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const baseURL = api.defaults.baseURL || '';
|
const baseURL = api.defaults.baseURL || '';
|
||||||
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`;
|
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
|
||||||
|
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`;
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
const authToken = localStorage.getItem('authToken');
|
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 });
|
const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Persona API (Iteration 2)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
|
||||||
|
return data.personas || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||||
|
label: string; description: string; gender?: string; systemPromptOverride?: string;
|
||||||
|
}): Promise<CoachingPersona> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'post', data: body });
|
||||||
|
return data.persona;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise<void> {
|
||||||
|
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Document API (Iteration 2)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingDocument[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' });
|
||||||
|
return data.documents || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise<CoachingDocument> {
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const authToken = localStorage.getItem('authToken');
|
||||||
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
||||||
|
addCSRFTokenToHeaders(headers);
|
||||||
|
|
||||||
|
const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise<void> {
|
||||||
|
await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Badge API (Iteration 2)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getBadgesApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingBadge[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/badges`, method: 'get' });
|
||||||
|
return data.badges || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export API (Iteration 2)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string {
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
return `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/export?format=${format}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
return `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/export?format=${format}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Score History API (Iteration 2)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{
|
||||||
|
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
|
||||||
|
}>>> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' });
|
||||||
|
return data.history || {};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export interface CommcoachHookReturn {
|
||||||
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
|
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
|
||||||
archiveContext: (contextId: string) => Promise<void>;
|
archiveContext: (contextId: string) => Promise<void>;
|
||||||
|
|
||||||
startSession: () => Promise<void>;
|
startSession: (personaId?: string) => Promise<void>;
|
||||||
sendMessage: (content: string) => Promise<void>;
|
sendMessage: (content: string) => Promise<void>;
|
||||||
sendAudio: (audioBlob: Blob) => Promise<void>;
|
sendAudio: (audioBlob: Blob) => Promise<void>;
|
||||||
completeSession: () => Promise<void>;
|
completeSession: () => Promise<void>;
|
||||||
|
|
@ -212,7 +212,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
}
|
}
|
||||||
}, [request, instanceId, selectedContextId, refreshContexts]);
|
}, [request, instanceId, selectedContextId, refreshContexts]);
|
||||||
|
|
||||||
const startSessionCb = useCallback(async () => {
|
const startSessionCb = useCallback(async (personaId?: string) => {
|
||||||
if (!instanceId || !selectedContextId) return;
|
if (!instanceId || !selectedContextId) return;
|
||||||
await _unlockAudioForTts();
|
await _unlockAudioForTts();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -278,6 +278,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setStreamingMessage(null);
|
setStreamingMessage(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
personaId,
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
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 AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
@ -17,11 +20,15 @@ import styles from './CommcoachCoachingView.module.css';
|
||||||
export const CommcoachCoachingView: React.FC = () => {
|
export const CommcoachCoachingView: React.FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const coach = useCommcoach();
|
const coach = useCommcoach();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const instanceId = useInstanceId();
|
||||||
const [showNewContext, setShowNewContext] = useState(false);
|
const [showNewContext, setShowNewContext] = useState(false);
|
||||||
const [newTitle, setNewTitle] = useState('');
|
const [newTitle, setNewTitle] = useState('');
|
||||||
const [newDescription, setNewDescription] = useState('');
|
const [newDescription, setNewDescription] = useState('');
|
||||||
const [newCategory, setNewCategory] = useState('custom');
|
const [newCategory, setNewCategory] = useState('custom');
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||||
|
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const speechRecognitionRef = useRef<SpeechRecognition | null>(null);
|
const speechRecognitionRef = useRef<SpeechRecognition | null>(null);
|
||||||
|
|
@ -71,6 +78,13 @@ export const CommcoachCoachingView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [coach.session]);
|
}, [coach.session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
getPersonasApi(request, instanceId)
|
||||||
|
.then(p => setPersonas(p))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!coach.session || coach.isMuted) {
|
if (!coach.session || coach.isMuted) {
|
||||||
if (speechRecognitionRef.current) {
|
if (speechRecognitionRef.current) {
|
||||||
|
|
@ -284,8 +298,36 @@ export const CommcoachCoachingView: React.FC = () => {
|
||||||
<div className={styles.sessionStart}>
|
<div className={styles.sessionStart}>
|
||||||
<h3>{coach.selectedContext?.title}</h3>
|
<h3>{coach.selectedContext?.title}</h3>
|
||||||
<p>{coach.selectedContext?.description || 'Starte eine neue Coaching-Session zu diesem Thema.'}</p>
|
<p>{coach.selectedContext?.description || 'Starte eine neue Coaching-Session zu diesem Thema.'}</p>
|
||||||
<button className={styles.btnPrimary} onClick={coach.startSession}>
|
|
||||||
|
{personas.length > 0 && (
|
||||||
|
<div className={styles.personaSelector}>
|
||||||
|
<label className={styles.personaLabel}>Gespraechspartner waehlen:</label>
|
||||||
|
<div className={styles.personaGrid}>
|
||||||
|
{personas.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className={`${styles.personaChip} ${selectedPersonaId === p.id ? styles.personaChipActive : ''}`}
|
||||||
|
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
|
||||||
|
title={p.description}
|
||||||
|
>
|
||||||
|
<span className={styles.personaGender}>
|
||||||
|
{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.personaName}>{p.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
onClick={() => coach.startSession(selectedPersonaId)}
|
||||||
|
>
|
||||||
Session starten
|
Session starten
|
||||||
|
{selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
|
||||||
|
? ` mit ${personas.find(p => p.id === selectedPersonaId)!.label}`
|
||||||
|
: ''}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,32 @@
|
||||||
border-radius: 10px;
|
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 {
|
.tipCard {
|
||||||
background: var(--bg-card, #fff);
|
background: var(--bg-card, #fff);
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,27 @@ export const CommcoachDashboardView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Level + Badges */}
|
||||||
|
{(dashboard.level || (dashboard.badges && dashboard.badges.length > 0)) && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
{dashboard.level
|
||||||
|
? `Level ${dashboard.level.number}: ${dashboard.level.label}`
|
||||||
|
: 'Auszeichnungen'}
|
||||||
|
</h3>
|
||||||
|
{dashboard.badges && dashboard.badges.length > 0 && (
|
||||||
|
<div className={styles.badgeGrid}>
|
||||||
|
{dashboard.badges.map(b => (
|
||||||
|
<div key={b.id} className={styles.badgeCard} title={b.description || b.badgeKey}>
|
||||||
|
<div className={styles.badgeIcon}>{_badgeIcon(b.icon)}</div>
|
||||||
|
<div className={styles.badgeLabel}>{b.label || b.badgeKey}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Start */}
|
{/* Quick Start */}
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>Tipp des Tages</h3>
|
<h3 className={styles.sectionTitle}>Tipp des Tages</h3>
|
||||||
|
|
@ -125,6 +146,15 @@ function _categoryLabel(category: string): string {
|
||||||
return labels[category] || category;
|
return labels[category] || category;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _badgeIcon(icon?: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
star: '\u2605', fire: '\u{1F525}', trophy: '\u{1F3C6}',
|
||||||
|
medal: '\u{1F3C5}', layers: '\u{1F4DA}', theater: '\u{1F3AD}',
|
||||||
|
compass: '\u{1F9ED}', 'check-circle': '\u2714',
|
||||||
|
};
|
||||||
|
return icons[icon || 'star'] || '\u2605';
|
||||||
|
}
|
||||||
|
|
||||||
function _formatDate(isoStr: string): string {
|
function _formatDate(isoStr: string): string {
|
||||||
try {
|
try {
|
||||||
const d = new Date(isoStr);
|
const d = new Date(isoStr);
|
||||||
|
|
|
||||||
|
|
@ -287,3 +287,116 @@
|
||||||
color: var(--text-secondary, #666);
|
color: var(--text-secondary, #666);
|
||||||
line-height: 1.4;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,26 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
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 ReactMarkdown from 'react-markdown';
|
||||||
import styles from './CommcoachDossierView.module.css';
|
import styles from './CommcoachDossierView.module.css';
|
||||||
|
|
||||||
export const CommcoachDossierView: React.FC = () => {
|
export const CommcoachDossierView: React.FC = () => {
|
||||||
const coach = useCommcoach();
|
const coach = useCommcoach();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const instanceId = useInstanceId();
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
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<CoachingDocument[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!coach.selectedContextId && coach.contexts.length > 0) {
|
if (!coach.selectedContextId && coach.contexts.length > 0) {
|
||||||
|
|
@ -20,6 +33,41 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
}, [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<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !instanceId || !coach.selectedContextId) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
|
||||||
|
setDocuments(prev => [doc, ...prev]);
|
||||||
|
} catch {
|
||||||
|
// upload failed
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}, [instanceId, coach.selectedContextId]);
|
||||||
|
|
||||||
|
const handleDeleteDocument = useCallback(async (docId: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
await deleteDocumentApi(request, instanceId, docId);
|
||||||
|
setDocuments(prev => prev.filter(d => d.id !== docId));
|
||||||
|
} catch {
|
||||||
|
// delete failed
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
const handleAddTask = useCallback(async () => {
|
const handleAddTask = useCallback(async () => {
|
||||||
if (!newTaskTitle.trim()) return;
|
if (!newTaskTitle.trim()) return;
|
||||||
await coach.addTask(newTaskTitle);
|
await coach.addTask(newTaskTitle);
|
||||||
|
|
@ -65,6 +113,26 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
|
{instanceId && coach.selectedContextId && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
className={styles.btnExport}
|
||||||
|
href={getDossierExportUrl(instanceId, coach.selectedContextId, 'md')}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Export MD
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className={styles.btnExport}
|
||||||
|
href={getDossierExportUrl(instanceId, coach.selectedContextId, 'pdf')}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Export PDF
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)}>
|
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)}>
|
||||||
Archivieren
|
Archivieren
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -91,6 +159,12 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
>
|
>
|
||||||
Bewertungen ({coach.scores.length})
|
Bewertungen ({coach.scores.length})
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.tab} ${activeTab === 'documents' ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('documents')}
|
||||||
|
>
|
||||||
|
Dokumente ({documents.length})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks Tab */}
|
{/* Tasks Tab */}
|
||||||
|
|
@ -166,6 +240,18 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
<div className={styles.sessionMeta}>
|
<div className={styles.sessionMeta}>
|
||||||
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
|
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
|
||||||
|
{s.personaId && <span> | Persona</span>}
|
||||||
|
{instanceId && s.status === 'completed' && (
|
||||||
|
<a
|
||||||
|
className={styles.sessionExport}
|
||||||
|
href={getSessionExportUrl(instanceId, s.id, 'md')}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -196,6 +282,58 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
{group.latest.evidence && (
|
{group.latest.evidence && (
|
||||||
<div className={styles.scoreEvidence}>{group.latest.evidence}</div>
|
<div className={styles.scoreEvidence}>{group.latest.evidence}</div>
|
||||||
)}
|
)}
|
||||||
|
{scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && (
|
||||||
|
<div className={styles.scoreHistory}>
|
||||||
|
<div className={styles.scoreHistoryLabel}>Verlauf:</div>
|
||||||
|
<div className={styles.scoreHistoryPoints}>
|
||||||
|
{scoreHistory[group.dimension].map((entry, i) => (
|
||||||
|
<span key={i} className={styles.scoreHistoryPoint} title={entry.createdAt || ''}>
|
||||||
|
{Math.round(entry.score)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Documents Tab */}
|
||||||
|
{activeTab === 'documents' && (
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
<div className={styles.addTaskRow}>
|
||||||
|
<label className={styles.uploadLabel}>
|
||||||
|
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.md,.pdf,.doc,.docx"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknuepfen.</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.documentList}>
|
||||||
|
{documents.map(doc => (
|
||||||
|
<div key={doc.id} className={styles.documentItem}>
|
||||||
|
<div className={styles.documentInfo}>
|
||||||
|
<div className={styles.documentName}>{doc.fileName}</div>
|
||||||
|
<div className={styles.documentMeta}>
|
||||||
|
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
|
||||||
|
</div>
|
||||||
|
{doc.summary && (
|
||||||
|
<div className={styles.documentSummary}>{doc.summary}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -228,6 +366,12 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
|
||||||
return Object.values(groups);
|
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 {
|
function _dimensionLabel(dim: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
empathy: 'Einfuehlungsvermoegen',
|
empathy: 'Einfuehlungsvermoegen',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue