Merge pull request #76 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
a96295490f
29 changed files with 3133 additions and 239 deletions
|
|
@ -179,12 +179,15 @@ function App() {
|
|||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
||||
|
||||
{/* Shared: assistant + modules routes (ComCoach + TeamsBot) */}
|
||||
<Route path="assistant" element={<FeatureViewPage view="assistant" />} />
|
||||
<Route path="modules" element={<FeatureViewPage view="modules" />} />
|
||||
|
||||
{/* Neutralization Feature Views */}
|
||||
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
||||
|
||||
{/* CommCoach Feature Views */}
|
||||
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
||||
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
||||
<Route path="session" element={<FeatureViewPage view="session" />} />
|
||||
|
||||
{/* Redmine Feature Views */}
|
||||
<Route path="stats" element={<FeatureViewPage view="stats" />} />
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ export interface CoachingUserProfile {
|
|||
}
|
||||
|
||||
export interface DashboardData {
|
||||
totalContexts: number;
|
||||
activeContexts: number;
|
||||
totalModules: number;
|
||||
activeModules: number;
|
||||
totalSessions: number;
|
||||
totalMinutes: number;
|
||||
streakDays: number;
|
||||
|
|
@ -122,7 +122,11 @@ export interface DashboardData {
|
|||
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 }>;
|
||||
modules: Array<{ id: string; title: string; moduleType: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||
/** @deprecated Use totalModules/activeModules/modules instead */
|
||||
totalContexts?: number;
|
||||
activeContexts?: number;
|
||||
contexts?: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
|
|
@ -133,31 +137,73 @@ export interface SSEEvent {
|
|||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
export function getApiRequest(): ApiRequestFunction {
|
||||
return async (options: ApiRequestOptions<any>) => {
|
||||
const response = await api(options);
|
||||
return response.data;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context API
|
||||
// Module API (TrainingModule — replaces Context API)
|
||||
// ============================================================================
|
||||
|
||||
export async function listModulesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||
return data.modules || [];
|
||||
}
|
||||
|
||||
export async function createModuleApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||
title: string; moduleType?: string; goals?: string; personaId?: string; kpiTargets?: string;
|
||||
}): Promise<any> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function getModuleDetailApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'get' });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string, body: any): Promise<any> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'put', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function deleteModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
export async function listSessionsApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/sessions`, method: 'get' });
|
||||
return data.sessions || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context / Module API (uses /modules/ endpoints)
|
||||
// ============================================================================
|
||||
|
||||
export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'get' });
|
||||
return data.contexts || [];
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||
return data.modules || [];
|
||||
}
|
||||
|
||||
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;
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
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}`,
|
||||
url: `/api/commcoach/${instanceId}/modules/${contextId}`,
|
||||
method: 'get',
|
||||
params: { _t: Date.now() },
|
||||
});
|
||||
const ctx = data?.context ?? data;
|
||||
const ctx = data?.module ?? data;
|
||||
return {
|
||||
context: ctx,
|
||||
tasks: data?.tasks ?? [],
|
||||
|
|
@ -167,22 +213,22 @@ export async function getContextDetailApi(request: ApiRequestFunction, instanceI
|
|||
}
|
||||
|
||||
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;
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'put', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'delete' });
|
||||
await request({ url: `/api/commcoach/${instanceId}/modules/${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;
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/archive`, method: 'post' });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
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;
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/activate`, method: 'post' });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -192,7 +238,7 @@ export async function activateContextApi(request: ApiRequestFunction, instanceId
|
|||
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' });
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/sessions/start`, method: 'post' });
|
||||
return data;
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +253,7 @@ export async function startSessionStreamApi(
|
|||
try {
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
|
||||
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`;
|
||||
const url = `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/sessions/start${personaParam}`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
|
|
@ -243,14 +289,11 @@ export async function startSessionStreamApi(
|
|||
|
||||
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
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
let event: SSEEvent;
|
||||
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -348,14 +391,11 @@ export async function sendMessageStreamApi(
|
|||
|
||||
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
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
let event: SSEEvent;
|
||||
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -424,10 +464,12 @@ export async function sendAudioStreamApi(
|
|||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) onEvent(JSON.parse(jsonStr));
|
||||
} catch { /* skip */ }
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
let event: SSEEvent;
|
||||
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -446,14 +488,14 @@ export async function sendAudioStreamApi(
|
|||
// ============================================================================
|
||||
|
||||
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' });
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${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 });
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'post', data: body });
|
||||
return data.task;
|
||||
}
|
||||
|
||||
|
|
@ -500,7 +542,14 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId:
|
|||
|
||||
export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
|
||||
return data.personas || [];
|
||||
return data.items || data.personas || [];
|
||||
}
|
||||
|
||||
export async function fetchPersonasPaginated(request: ApiRequestFunction, instanceId: string, params?: any): Promise<any> {
|
||||
const queryParams: Record<string, string> = {};
|
||||
if (params) queryParams.pagination = JSON.stringify(params);
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get', params: queryParams });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||
|
|
@ -510,10 +559,31 @@ export async function createPersonaApi(request: ApiRequestFunction, instanceId:
|
|||
return data.persona;
|
||||
}
|
||||
|
||||
export async function updatePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string, body: {
|
||||
label?: string; description?: string; gender?: string; systemPromptOverride?: string; isActive?: boolean;
|
||||
}): Promise<CoachingPersona> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'put', 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' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module-Persona Mapping API
|
||||
// ============================================================================
|
||||
|
||||
export async function getModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<string[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'get' });
|
||||
return data.personaIds || [];
|
||||
}
|
||||
|
||||
export async function setModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string, personaIds: string[]): Promise<string[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'put', data: { personaIds } });
|
||||
return data.personaIds || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Badge API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
|
@ -529,7 +599,7 @@ export async function getBadgesApi(request: ApiRequestFunction, instanceId: stri
|
|||
|
||||
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}`;
|
||||
return `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/export?format=${format}`;
|
||||
}
|
||||
|
||||
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
|
||||
|
|
@ -544,6 +614,6 @@ export function getSessionExportUrl(instanceId: string, sessionId: string, forma
|
|||
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' });
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/scores/history`, method: 'get' });
|
||||
return data.history || {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface TeamsbotSession {
|
|||
id: string;
|
||||
instanceId: string;
|
||||
mandateId: string;
|
||||
moduleId?: string;
|
||||
meetingLink: string;
|
||||
botName: string;
|
||||
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
|
||||
|
|
@ -574,3 +575,48 @@ export async function deleteDirectorPrompt(
|
|||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Meeting Module API
|
||||
// ============================================================================
|
||||
|
||||
export interface MeetingModule {
|
||||
id: string;
|
||||
instanceId: string;
|
||||
mandateId: string;
|
||||
ownerUserId: string;
|
||||
title: string;
|
||||
seriesType: string;
|
||||
defaultBotId?: string;
|
||||
defaultDirectorPrompts?: string;
|
||||
goals?: string;
|
||||
kpiTargets?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export async function listModules(instanceId: string): Promise<MeetingModule[]> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/modules`);
|
||||
return response.data?.modules || [];
|
||||
}
|
||||
|
||||
export async function createModule(instanceId: string, body: {
|
||||
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
|
||||
}): Promise<MeetingModule> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
|
||||
return response.data?.module;
|
||||
}
|
||||
|
||||
export async function getModuleDetail(instanceId: string, moduleId: string): Promise<{ module: MeetingModule; sessions: TeamsbotSession[] }> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateModule(instanceId: string, moduleId: string, body: Partial<MeetingModule>): Promise<MeetingModule> {
|
||||
const response = await api.put(`/api/teamsbot/${instanceId}/modules/${moduleId}`, body);
|
||||
return response.data?.module;
|
||||
}
|
||||
|
||||
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
|
||||
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,10 +352,18 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* File size + hover actions group (overlapping layout to save width) */
|
||||
.nodeSizeGroup {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* File size column */
|
||||
.nodeSize {
|
||||
width: 52px;
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
text-align: right;
|
||||
|
|
@ -388,20 +396,29 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Hover action icons (download, delete) -- only visible on hover, left of persistent */
|
||||
/* Hover action icons -- overlay on top of file size to save width */
|
||||
.nodeActionsHover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nodeRow:hover .nodeActionsHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nodeRow:hover .nodeSize {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Persistent action icons (scope, neutralize) -- always visible, right-aligned */
|
||||
.nodeActionsPersistent {
|
||||
display: flex;
|
||||
|
|
@ -626,6 +643,10 @@
|
|||
.nodeActionsHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nodeSize {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import type {
|
|||
ScopeValue,
|
||||
TreeBatchAction,
|
||||
} from './types';
|
||||
import { useConfirm } from '../../../hooks/useConfirm';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './FormGeneratorTree.module.css';
|
||||
|
||||
const INDENT_PX = 24;
|
||||
|
|
@ -301,43 +303,45 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
</span>
|
||||
)}
|
||||
|
||||
<span className={styles.nodeSize}>
|
||||
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
|
||||
</span>
|
||||
<div className={styles.nodeSizeGroup}>
|
||||
<span className={styles.nodeSize}>
|
||||
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
|
||||
</span>
|
||||
|
||||
<div className={styles.nodeActionsHover}>
|
||||
{canRename && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onStartRename(node.id); }}
|
||||
title="Umbenennen"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{'\u270F\uFE0F'}
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.nodeActionsHover}>
|
||||
{canRename && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onStartRename(node.id); }}
|
||||
title="Umbenennen"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{'\u270F\uFE0F'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{node.type !== 'folder' && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
|
||||
title="Datei herunterladen"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{'\u{1F4E5}'}
|
||||
</button>
|
||||
)}
|
||||
{node.type !== 'folder' && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
|
||||
title="Datei herunterladen"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{'\u{1F4E5}'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(node.id); }}
|
||||
title="Loeschen"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{'\u{1F5D1}\uFE0F'}
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(node.id); }}
|
||||
title="Loeschen"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{'\u{1F5D1}\uFE0F'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.nodeActionsPersistent}>
|
||||
|
|
@ -401,10 +405,10 @@ export function FormGeneratorTree<T = any>({
|
|||
onSelectionChange,
|
||||
onRefresh,
|
||||
onSendToChat,
|
||||
allowCreateFolder = true,
|
||||
className,
|
||||
}: FormGeneratorTreeProps<T>) {
|
||||
const { prompt, PromptDialog } = usePrompt();
|
||||
const { t } = useLanguage();
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
|
@ -620,7 +624,11 @@ export function FormGeneratorTree<T = any>({
|
|||
async (id: string) => {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
const label = node?.name ?? id;
|
||||
if (!window.confirm(`"${label}" wirklich loeschen?`)) return;
|
||||
const ok = await confirm(
|
||||
t('"{label}" wirklich loeschen?', { label }),
|
||||
{ confirmLabel: t('Loeschen'), variant: 'danger' },
|
||||
);
|
||||
if (!ok) return;
|
||||
await provider.deleteNodes?.([id]);
|
||||
setNodes((prev) => {
|
||||
const toRemove = new Set([id, ..._collectDescendantIds(id, prev)]);
|
||||
|
|
@ -935,7 +943,11 @@ export function FormGeneratorTree<T = any>({
|
|||
className={`${styles.batchButton} ${action.danger ? styles.batchButtonDanger : ''}`}
|
||||
onClick={async () => {
|
||||
if (action.danger) {
|
||||
if (!window.confirm(`${ids.length} ${action.label} wirklich loeschen?`)) return;
|
||||
const ok = await confirm(
|
||||
t('{count} {label} wirklich loeschen?', { count: String(ids.length), label: action.label }),
|
||||
{ confirmLabel: t('Loeschen'), variant: 'danger' },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
await action.onClick(ids);
|
||||
await _handleRefresh();
|
||||
|
|
|
|||
|
|
@ -137,9 +137,7 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
|||
|
||||
async createChild(parentId, name) {
|
||||
const res = await api.post('/api/files/folders', { name, parentId });
|
||||
const node = _mapFolderToNode(res.data, 'own');
|
||||
typeMap.set(node.id, 'folder');
|
||||
return node;
|
||||
return _mapFolderToNode(res.data, 'own');
|
||||
},
|
||||
|
||||
async renameNode(id, newName) {
|
||||
|
|
@ -161,9 +159,7 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
|||
await Promise.all(
|
||||
ids.map((id) => {
|
||||
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
|
||||
return api.post(`/api/files/folders/${id}/move`, {
|
||||
parentId: targetParentId,
|
||||
});
|
||||
return api.post(`/api/files/folders/${id}/move`, { targetParentId });
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useRef, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react';
|
||||
import type { UdbContext } from './UnifiedDataBar';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
|
|
@ -45,6 +45,12 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
setSharedTreeKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const _onFileUploaded = () => _handleRefresh();
|
||||
window.addEventListener('fileUploaded', _onFileUploaded);
|
||||
return () => window.removeEventListener('fileUploaded', _onFileUploaded);
|
||||
}, [_handleRefresh]);
|
||||
|
||||
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||
if (!context.instanceId || uploading) return;
|
||||
setUploading(true);
|
||||
|
|
@ -76,7 +82,9 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
const _handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const _handleDrop = useCallback((e: React.DragEvent) => {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ interface UnifiedDataBarProps {
|
|||
|
||||
function _tabLabel(tab: UdbTab, t: (k: string) => string): string {
|
||||
switch (tab) {
|
||||
case 'chats': return t('Chatverläufe');
|
||||
case 'chats': return t('Dossiers');
|
||||
case 'files': return t('Dateien');
|
||||
case 'sources': return t('Quellen');
|
||||
default: return tab;
|
||||
|
|
|
|||
|
|
@ -289,6 +289,16 @@ export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
|
|||
setTasks(prev => [eventData, ...prev]);
|
||||
} else if (eventType === 'documentCreated' && eventData) {
|
||||
onDocumentCreatedRef.current?.(eventData);
|
||||
const docMsg: CoachingMessage = {
|
||||
id: `doc-${Date.now()}`,
|
||||
sessionId: session?.id ?? '',
|
||||
contextId: session?.contextId ?? '',
|
||||
role: 'assistant',
|
||||
content: `📄 **Dokument erstellt:** ${eventData.fileName || 'Dokument'}\n\n_Das Dokument ist in der Seitenleiste unter "Dateien" verfuegbar._`,
|
||||
contentType: 'systemNote',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages(prev => [...prev, docMsg]);
|
||||
} else if (eventType === 'error' && eventData) {
|
||||
setError(eventData.message || 'Stream-Fehler');
|
||||
}
|
||||
|
|
@ -397,6 +407,16 @@ export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
|
|||
setTasks(prev => [eventData, ...prev]);
|
||||
} else if (eventType === 'documentCreated' && eventData) {
|
||||
onDocumentCreatedRef.current?.(eventData);
|
||||
const docMsg: CoachingMessage = {
|
||||
id: `doc-${Date.now()}`,
|
||||
sessionId: session.id,
|
||||
contextId: session.contextId,
|
||||
role: 'assistant',
|
||||
content: `📄 **Dokument erstellt:** ${eventData.fileName || 'Dokument'}\n\n_Das Dokument ist in der Seitenleiste unter "Dateien" verfuegbar._`,
|
||||
contentType: 'systemNote',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages(prev => [...prev, docMsg]);
|
||||
} else if (eventType === 'scoreUpdate') {
|
||||
// Will refresh on complete
|
||||
} else if (eventType === 'error' && eventData) {
|
||||
|
|
@ -474,6 +494,16 @@ export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
|
|||
setTasks(prev => [eventData, ...prev]);
|
||||
} else if (eventType === 'documentCreated' && eventData) {
|
||||
onDocumentCreatedRef.current?.(eventData);
|
||||
const docMsg: CoachingMessage = {
|
||||
id: `doc-${Date.now()}`,
|
||||
sessionId: session.id,
|
||||
contextId: session.contextId,
|
||||
role: 'assistant',
|
||||
content: `📄 **Dokument erstellt:** ${eventData.fileName || 'Dokument'}\n\n_Das Dokument ist in der Seitenleiste unter "Dateien" verfuegbar._`,
|
||||
contentType: 'systemNote',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages(prev => [...prev, docMsg]);
|
||||
} else if (eventType === 'error' && eventData) {
|
||||
setError(eventData.message || 'Audio-Fehler');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,11 @@ export function useTtsPlayback(callbacks?: TtsPlaybackCallbacks): TtsPlaybackApi
|
|||
|
||||
const stop = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
const audio = audioRef.current;
|
||||
audio.onpause = null;
|
||||
audio.onended = null;
|
||||
audioRef.current = null;
|
||||
audio.pause();
|
||||
}
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import styles from './MainLayout.module.css';
|
|||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
|
||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
||||
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
|
||||
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/;
|
||||
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
|
||||
const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/;
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsights
|
|||
|
||||
// Teamsbot Views
|
||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||
import { TeamsbotAssistantView } from './views/teamsbot/TeamsbotAssistantView';
|
||||
import { TeamsbotModulesView } from './views/teamsbot/TeamsbotModulesView';
|
||||
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
||||
import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
||||
|
||||
|
|
@ -46,7 +48,7 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
|||
import { NeutralizationView } from './views/neutralization';
|
||||
|
||||
// CommCoach Views
|
||||
import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
|
||||
import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, CommcoachSessionView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
|
||||
|
||||
// Redmine Views
|
||||
import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
|
||||
|
|
@ -158,6 +160,8 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
},
|
||||
teamsbot: {
|
||||
dashboard: TeamsbotDashboardView,
|
||||
assistant: TeamsbotAssistantView,
|
||||
modules: TeamsbotModulesView,
|
||||
sessions: TeamsbotSessionView,
|
||||
settings: TeamsbotSettingsView,
|
||||
},
|
||||
|
|
@ -167,7 +171,9 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
},
|
||||
commcoach: {
|
||||
dashboard: CommcoachDashboardView,
|
||||
coaching: CommcoachDossierView,
|
||||
assistant: CommcoachAssistantView,
|
||||
modules: CommcoachModulesView,
|
||||
session: CommcoachSessionView,
|
||||
dossier: CommcoachDossierView,
|
||||
settings: CommcoachSettingsView,
|
||||
},
|
||||
|
|
@ -228,8 +234,8 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
// CommCoach coaching/dossier is rendered persistently by CommcoachKeepAlive at MainLayout level.
|
||||
if (featureCode === 'commcoach' && (view === 'coaching' || view === 'dossier')) {
|
||||
// CommCoach session is rendered persistently by CommcoachKeepAlive at MainLayout level.
|
||||
if (featureCode === 'commcoach' && view === 'session') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
349
src/pages/views/commcoach/Commcoach.module.css
Normal file
349
src/pages/views/commcoach/Commcoach.module.css
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
/* CommCoach Shared Styles — Assistant, Modules, Session views */
|
||||
|
||||
.assistantContainer,
|
||||
.modulesContainer {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wizardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stepIndicator {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color, #ccc);
|
||||
}
|
||||
|
||||
.stepActive {
|
||||
background: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.wizardContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wizardStep {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.typeGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.typeCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--border-color, #ddd);
|
||||
background: var(--bg-card, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.typeCard:hover {
|
||||
border-color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.typeCardActive {
|
||||
border-color: var(--primary-color, #F25843);
|
||||
background: rgba(242, 88, 67, 0.06);
|
||||
}
|
||||
|
||||
.typeIcon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.wizardInput {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: var(--bg-input, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wizardTextarea {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: var(--bg-input, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.wizardActions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.wizardHint {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.confirmSummary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface-color, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
background: rgba(241, 76, 76, 0.1);
|
||||
color: #f14c4c;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modulesHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modulesFilters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modulesFilters select {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: var(--bg-input, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modulesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.moduleCard {
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.moduleExpanded {
|
||||
border-color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.moduleHighlighted {
|
||||
box-shadow: 0 0 0 2px var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.moduleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.moduleRow:hover {
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
}
|
||||
|
||||
.moduleType {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(242, 88, 67, 0.1);
|
||||
color: var(--primary-color, #F25843);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.moduleTitle {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.moduleStatus {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.moduleSessions {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.moduleActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sessionList {
|
||||
padding: 0.5rem 0 0 1rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.sessionRow {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sessionStatus {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.sessionDate {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.noSessions {
|
||||
color: var(--text-secondary, #666);
|
||||
font-style: italic;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.confirmOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.confirmDialog,
|
||||
.editDialog {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--primary-color, #F25843);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: transparent;
|
||||
color: var(--text-primary, #333);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btnSecondary:hover:not(:disabled) { background: var(--hover-bg, #f5f5f5); border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); }
|
||||
|
||||
.btnDanger {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #f14c4c;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btnDanger:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
|
||||
.btnSmall {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: transparent;
|
||||
color: var(--text-primary, #333);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btnSmall:hover:not(:disabled) { border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); }
|
||||
|
||||
.btnSmall.btnSmallActive {
|
||||
background: var(--primary-color, #F25843);
|
||||
border-color: var(--primary-color, #F25843);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btnSmall.btnSmallActive:hover:not(:disabled) { filter: brightness(1.08); color: #fff; }
|
||||
|
||||
.btnSmallDanger {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f14c4c;
|
||||
background: transparent;
|
||||
color: #f14c4c;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btnSmallDanger:hover:not(:disabled) { background: var(--error-color, #dc2626); color: #fff; }
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
174
src/pages/views/commcoach/CommcoachAssistantView.tsx
Normal file
174
src/pages/views/commcoach/CommcoachAssistantView.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* CommCoach Assistant View
|
||||
*
|
||||
* Wizard flow: Module type → Topic → Persona → KPIs → "Start first session"
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as commcoachApi from '../../../api/commcoachApi';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './Commcoach.module.css';
|
||||
|
||||
type WizardStep = 'type' | 'topic' | 'persona' | 'kpis' | 'confirm';
|
||||
|
||||
const STEPS: WizardStep[] = ['type', 'topic', 'persona', 'kpis', 'confirm'];
|
||||
|
||||
const MODULE_TYPES = [
|
||||
{ value: 'coaching', label: 'Coaching', icon: '🎯' },
|
||||
{ value: 'training', label: 'Training', icon: '📚' },
|
||||
{ value: 'exam', label: 'Prüfung', icon: '✍️' },
|
||||
{ value: 'elearning', label: 'E-Learning', icon: '💻' },
|
||||
];
|
||||
|
||||
export const CommcoachAssistantView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { instance, mandateId } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [step, setStep] = useState<WizardStep>('type');
|
||||
const [moduleType, setModuleType] = useState('coaching');
|
||||
const [title, setTitle] = useState('');
|
||||
const [goals, setGoals] = useState('');
|
||||
const [personaId, setPersonaId] = useState<string | null>(null);
|
||||
const [_personas, _setPersonas] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const stepIdx = STEPS.indexOf(step);
|
||||
|
||||
const _handleNext = () => {
|
||||
const nextIdx = stepIdx + 1;
|
||||
if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
|
||||
};
|
||||
|
||||
const _handleBack = () => {
|
||||
const prevIdx = stepIdx - 1;
|
||||
if (prevIdx >= 0) setStep(STEPS[prevIdx]);
|
||||
};
|
||||
|
||||
const _handleCreate = async () => {
|
||||
if (!title.trim()) {
|
||||
setError(t('Bitte einen Titel eingeben'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const apiRequest = commcoachApi.getApiRequest();
|
||||
const module = await commcoachApi.createModuleApi(apiRequest, instanceId, {
|
||||
title: title.trim(),
|
||||
moduleType,
|
||||
goals: goals.trim() || undefined,
|
||||
personaId: personaId || undefined,
|
||||
});
|
||||
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/session?moduleId=${module.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('Fehler beim Erstellen'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.assistantContainer}>
|
||||
<div className={styles.wizardHeader}>
|
||||
<h2>{t('Neues Modul erstellen')}</h2>
|
||||
<div className={styles.stepIndicator}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<div className={styles.wizardContent}>
|
||||
{step === 'type' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Modul-Typ wählen')}</h3>
|
||||
<div className={styles.typeGrid}>
|
||||
{MODULE_TYPES.map(mt => (
|
||||
<button
|
||||
key={mt.value}
|
||||
className={`${styles.typeCard} ${moduleType === mt.value ? styles.typeCardActive : ''}`}
|
||||
onClick={() => setModuleType(mt.value)}
|
||||
>
|
||||
<span className={styles.typeIcon}>{mt.icon}</span>
|
||||
<span>{t(mt.label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'topic' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Thema & Titel')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
placeholder={t('z.B. Konfliktgespräche, Sales Training...')}
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<textarea
|
||||
className={styles.wizardTextarea}
|
||||
placeholder={t('Ziele beschreiben (optional)')}
|
||||
value={goals}
|
||||
onChange={e => setGoals(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'persona' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Persona wählen (optional)')}</h3>
|
||||
<p className={styles.wizardHint}>{t('Eine Persona bestimmt den Coaching-Stil. Du kannst dies später ändern.')}</p>
|
||||
<button
|
||||
className={`${styles.typeCard} ${!personaId ? styles.typeCardActive : ''}`}
|
||||
onClick={() => setPersonaId(null)}
|
||||
>
|
||||
{t('Standard (kein Persona-Override)')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'kpis' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('KPIs (optional)')}</h3>
|
||||
<p className={styles.wizardHint}>{t('KPI-Ziele können später in den Modul-Einstellungen definiert werden.')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Zusammenfassung')}</h3>
|
||||
<div className={styles.confirmSummary}>
|
||||
<div><strong>{t('Typ')}:</strong> {MODULE_TYPES.find(m => m.value === moduleType)?.label}</div>
|
||||
<div><strong>{t('Titel')}:</strong> {title || t('(kein Titel)')}</div>
|
||||
{goals && <div><strong>{t('Ziele')}:</strong> {goals}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.wizardActions}>
|
||||
{stepIdx > 0 && (
|
||||
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{step !== 'confirm' ? (
|
||||
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
|
||||
) : (
|
||||
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
|
||||
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -19,15 +19,21 @@ export const CommcoachDashboardView: React.FC = () => {
|
|||
const { mandateId, instanceId } = useCurrentInstance();
|
||||
const { dashboard, loading, error } = useCommcoachDashboard();
|
||||
|
||||
const handleContextClick = (contextId: string) => {
|
||||
const handleModuleClick = (moduleId: string) => {
|
||||
if (mandateId && instanceId) {
|
||||
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/coaching?context=${contextId}`);
|
||||
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/modules?moduleId=${moduleId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleNewTopic = useCallback(() => {
|
||||
if (mandateId && instanceId) {
|
||||
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/coaching?newContext=true`);
|
||||
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/assistant`);
|
||||
}
|
||||
}, [mandateId, instanceId, navigate]);
|
||||
|
||||
const _handleOpenDossier = useCallback(() => {
|
||||
if (mandateId && instanceId) {
|
||||
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/dossier`);
|
||||
}
|
||||
}, [mandateId, instanceId, navigate]);
|
||||
|
||||
|
|
@ -96,29 +102,34 @@ export const CommcoachDashboardView: React.FC = () => {
|
|||
<div className={styles.section}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h3 className={styles.sectionTitle} style={{ margin: 0 }}>{t('Aktive Coaching-Themen')}</h3>
|
||||
<button className={styles.newTopicBtn} onClick={_handleNewTopic}>
|
||||
+ {t('Neues Thema')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className={styles.newTopicBtn} onClick={_handleOpenDossier}>
|
||||
{t('Dossier')}
|
||||
</button>
|
||||
<button className={styles.newTopicBtn} onClick={_handleNewTopic}>
|
||||
+ {t('Neues Thema')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{dashboard.contexts.length === 0 ? (
|
||||
{(dashboard.modules || dashboard.contexts || []).length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<p>{t('Noch keine Coaching-Themen angelegt.')}</p>
|
||||
<p>{t('Klicken Sie auf "Neues Thema" um zu starten.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.contextGrid}>
|
||||
{dashboard.contexts.map(ctx => (
|
||||
{(dashboard.modules || dashboard.contexts || []).map(ctx => (
|
||||
<div
|
||||
key={ctx.id}
|
||||
className={styles.contextCard}
|
||||
onClick={() => handleContextClick(ctx.id)}
|
||||
onClick={() => handleModuleClick(ctx.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && handleContextClick(ctx.id)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleModuleClick(ctx.id)}
|
||||
>
|
||||
<div className={styles.contextTitle}>{ctx.title}</div>
|
||||
<div className={styles.contextMeta}>
|
||||
<span className={styles.contextCategory}>{_categoryLabel(ctx.category)}</span>
|
||||
<span className={styles.contextCategory}>{_categoryLabel(ctx.moduleType)}</span>
|
||||
<span>
|
||||
{ctx.sessionCount} {t('Sessions')}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
|
||||
.udbSidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||
min-width: 180px;
|
||||
border-right: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card, #fff);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: width 0.2s, min-width 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.udbSidebarCollapsed {
|
||||
|
|
@ -22,6 +22,20 @@
|
|||
min-width: 36px;
|
||||
}
|
||||
|
||||
.udbResizeHandle {
|
||||
width: 5px;
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.udbResizeHandle:hover,
|
||||
.udbResizeHandle:active {
|
||||
background: var(--accent-color, #4a90d9);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dossierLayout {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -58,10 +58,12 @@ type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
|
|||
interface CommcoachDossierViewProps {
|
||||
persistentInstanceId?: string;
|
||||
persistentMandateId?: string;
|
||||
initialModuleId?: string;
|
||||
}
|
||||
|
||||
export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ persistentInstanceId,
|
||||
persistentMandateId,
|
||||
initialModuleId: _initialModuleId,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const _toolPayloadForDisplay = (payload: Record<string, unknown>): string => {
|
||||
|
|
@ -83,6 +85,8 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
|
|||
const [newCategory, setNewCategory] = useState('custom');
|
||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||
const [udbWidth, setUdbWidth] = useState(280);
|
||||
const udbResizing = useRef(false);
|
||||
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||||
|
|
@ -271,7 +275,10 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
|
|||
<div className={styles.dossierLayout}>
|
||||
{/* UDB Sidebar */}
|
||||
{_udbContext && (
|
||||
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
||||
<div
|
||||
className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}
|
||||
style={udbCollapsed ? undefined : { width: udbWidth, minWidth: 180 }}
|
||||
>
|
||||
<button
|
||||
className={styles.udbToggle}
|
||||
onClick={() => setUdbCollapsed(v => !v)}
|
||||
|
|
@ -280,12 +287,35 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
|
|||
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||
</button>
|
||||
{!udbCollapsed && (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
/>
|
||||
<>
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
/>
|
||||
<div
|
||||
className={styles.udbResizeHandle}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
udbResizing.current = true;
|
||||
const startX = e.clientX;
|
||||
const startW = udbWidth;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!udbResizing.current) return;
|
||||
const newW = Math.max(180, Math.min(600, startW + (ev.clientX - startX)));
|
||||
setUdbWidth(newW);
|
||||
};
|
||||
const onUp = () => {
|
||||
udbResizing.current = false;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
/**
|
||||
* CommcoachKeepAlive
|
||||
*
|
||||
* Keeps the CommCoach dossier/coaching page mounted across route changes.
|
||||
* Visibility is toggled via CSS so session state, messages, and input state
|
||||
* stay alive when the user leaves and later returns.
|
||||
* Keeps the CommCoach session page mounted across route changes.
|
||||
* The voice session must persist when the user navigates to other tabs.
|
||||
* Only the "session" tab is kept alive; modules/dashboard can unmount freely.
|
||||
*
|
||||
* Persistence is scoped per `(mandateId, instanceId)` — switching to a
|
||||
* different mandate or instance via the navigator unmounts the previous
|
||||
* view and mounts a fresh one.
|
||||
* different mandate or instance unmounts the previous view.
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { CommcoachDossierView } from './CommcoachDossierView';
|
||||
import { CommcoachSessionView } from './CommcoachSessionView';
|
||||
|
||||
const _COMMCOACH_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/(?:coaching|dossier)/;
|
||||
const _COMMCOACH_SESSION_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/;
|
||||
|
||||
interface CommcoachKeepAliveProps {
|
||||
isVisible: boolean;
|
||||
|
|
@ -25,7 +24,7 @@ export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisibl
|
|||
const cachedMandateIdRef = useRef<string>('');
|
||||
const cachedInstanceIdRef = useRef<string>('');
|
||||
|
||||
const match = location.pathname.match(_COMMCOACH_ROUTE_RE);
|
||||
const match = location.pathname.match(_COMMCOACH_SESSION_ROUTE_RE);
|
||||
if (match?.[1] && match?.[2]) {
|
||||
cachedMandateIdRef.current = match[1];
|
||||
cachedInstanceIdRef.current = match[2];
|
||||
|
|
@ -48,11 +47,7 @@ export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisibl
|
|||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<CommcoachDossierView
|
||||
key={scopeKey}
|
||||
persistentInstanceId={instanceId}
|
||||
persistentMandateId={mandateId}
|
||||
/>
|
||||
<CommcoachSessionView key={scopeKey} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
293
src/pages/views/commcoach/CommcoachModulesView.tsx
Normal file
293
src/pages/views/commcoach/CommcoachModulesView.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* CommCoach Modules View
|
||||
*
|
||||
* CRUD list of all TrainingModules, filterable by status/type.
|
||||
* Each module row expands to show its sessions.
|
||||
* Edit dialog includes persona multi-select for module-persona mapping.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as commcoachApi from '../../../api/commcoachApi';
|
||||
import type { CoachingPersona } from '../../../api/commcoachApi';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './Commcoach.module.css';
|
||||
|
||||
const MODULE_TYPE_LABELS: Record<string, string> = {
|
||||
coaching: 'Coaching',
|
||||
training: 'Training',
|
||||
exam: 'Prüfung',
|
||||
elearning: 'E-Learning',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Aktiv',
|
||||
paused: 'Pausiert',
|
||||
archived: 'Archiviert',
|
||||
completed: 'Abgeschlossen',
|
||||
};
|
||||
|
||||
export const CommcoachModulesView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { instance, mandateId } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const highlightModuleId = searchParams.get('moduleId');
|
||||
|
||||
const [modules, setModules] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(highlightModuleId);
|
||||
const [sessions, setSessions] = useState<Record<string, any[]>>({});
|
||||
const [editingModule, setEditingModule] = useState<any | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [filterType, setFilterType] = useState<string>('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||
|
||||
const [allPersonas, setAllPersonas] = useState<CoachingPersona[]>([]);
|
||||
const [editPersonaIds, setEditPersonaIds] = useState<string[]>([]);
|
||||
const [personasLoaded, setPersonasLoaded] = useState(false);
|
||||
|
||||
const _loadModules = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const apiRequest = commcoachApi.getApiRequest();
|
||||
const result = await commcoachApi.listModulesApi(apiRequest, instanceId);
|
||||
setModules(result || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load modules:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { _loadModules(); }, [_loadModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || personasLoaded) return;
|
||||
const _loadAllPersonas = async () => {
|
||||
try {
|
||||
const apiRequest = commcoachApi.getApiRequest();
|
||||
const personas = await commcoachApi.getPersonasApi(apiRequest, instanceId);
|
||||
setAllPersonas(personas);
|
||||
setPersonasLoaded(true);
|
||||
} catch {}
|
||||
};
|
||||
_loadAllPersonas();
|
||||
}, [instanceId, personasLoaded]);
|
||||
|
||||
const _loadSessions = useCallback(async (moduleId: string) => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const apiRequest = commcoachApi.getApiRequest();
|
||||
const result = await commcoachApi.listSessionsApi(apiRequest, instanceId, moduleId);
|
||||
setSessions(prev => ({ ...prev, [moduleId]: result || [] }));
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions:', err);
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
const _toggleExpand = (moduleId: string) => {
|
||||
if (expandedId === moduleId) {
|
||||
setExpandedId(null);
|
||||
} else {
|
||||
setExpandedId(moduleId);
|
||||
if (!sessions[moduleId]) _loadSessions(moduleId);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleDelete = async (moduleId: string) => {
|
||||
try {
|
||||
const apiRequest = commcoachApi.getApiRequest();
|
||||
await commcoachApi.deleteModuleApi(apiRequest, instanceId, moduleId);
|
||||
setDeleteConfirm(null);
|
||||
_loadModules();
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleEdit = async (moduleId: string, updates: any) => {
|
||||
try {
|
||||
const apiRequest = commcoachApi.getApiRequest();
|
||||
await commcoachApi.updateModuleApi(apiRequest, instanceId, moduleId, updates);
|
||||
await commcoachApi.setModulePersonasApi(apiRequest, instanceId, moduleId, editPersonaIds);
|
||||
setEditingModule(null);
|
||||
_loadModules();
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const _openEditDialog = async (mod: any) => {
|
||||
setEditingModule(mod);
|
||||
try {
|
||||
const apiRequest = commcoachApi.getApiRequest();
|
||||
const ids = await commcoachApi.getModulePersonasApi(apiRequest, instanceId, mod.id);
|
||||
setEditPersonaIds(ids);
|
||||
} catch {
|
||||
setEditPersonaIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const _togglePersonaId = (personaId: string) => {
|
||||
setEditPersonaIds(prev =>
|
||||
prev.includes(personaId)
|
||||
? prev.filter(id => id !== personaId)
|
||||
: [...prev, personaId]
|
||||
);
|
||||
};
|
||||
|
||||
const filteredModules = modules.filter(m => {
|
||||
if (filterType && m.moduleType !== filterType) return false;
|
||||
if (filterStatus && m.status !== filterStatus) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.modulesContainer}>
|
||||
<div className={styles.modulesHeader}>
|
||||
<h2>{t('Module')}</h2>
|
||||
<div className={styles.modulesFilters}>
|
||||
<select value={filterType} onChange={e => setFilterType(e.target.value)}>
|
||||
<option value="">{t('Alle Typen')}</option>
|
||||
{Object.entries(MODULE_TYPE_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{t(v)}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">{t('Alle Status')}</option>
|
||||
{Object.entries(STATUS_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{t(v)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className={styles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/assistant`)}
|
||||
>
|
||||
{t('Neues Modul')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className={styles.loading}>{t('Laden...')}</div>}
|
||||
|
||||
<div className={styles.modulesList}>
|
||||
{filteredModules.map(mod => (
|
||||
<div
|
||||
key={mod.id}
|
||||
className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''} ${highlightModuleId === mod.id ? styles.moduleHighlighted : ''}`}
|
||||
>
|
||||
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
|
||||
<span className={styles.moduleType}>{t(MODULE_TYPE_LABELS[mod.moduleType] || mod.moduleType)}</span>
|
||||
<span className={styles.moduleTitle}>{mod.title}</span>
|
||||
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status)}</span>
|
||||
<span className={styles.moduleSessions}>{mod.sessionCount || 0} {t('Sessions')}</span>
|
||||
<div className={styles.moduleActions}>
|
||||
<button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => {
|
||||
e.stopPropagation();
|
||||
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/session?moduleId=${mod.id}`);
|
||||
}}>{t('Session starten')}</button>
|
||||
<button className={styles.btnSmall} onClick={e => { e.stopPropagation(); _openEditDialog(mod); }}>{t('Bearbeiten')}</button>
|
||||
<button className={styles.btnSmallDanger} onClick={e => { e.stopPropagation(); setDeleteConfirm(mod.id); }}>{t('Löschen')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedId === mod.id && (
|
||||
<div className={styles.moduleSessions}>
|
||||
{(sessions[mod.id] || []).length === 0 ? (
|
||||
<p className={styles.noSessions}>{t('Keine Sessions vorhanden')}</p>
|
||||
) : (
|
||||
<div className={styles.sessionList}>
|
||||
{(sessions[mod.id] || []).map((sess: any) => (
|
||||
<div key={sess.id} className={styles.sessionRow}>
|
||||
<span>{sess.summary || t('Session')}</span>
|
||||
<span className={styles.sessionStatus}>{sess.status}</span>
|
||||
<span className={styles.sessionDate}>
|
||||
{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{deleteConfirm && (
|
||||
<div className={styles.confirmOverlay}>
|
||||
<div className={styles.confirmDialog}>
|
||||
<p>{t('Modul und alle zugehörigen Sessions wirklich löschen?')}</p>
|
||||
<div className={styles.confirmActions}>
|
||||
<button className={styles.btnSecondary} onClick={() => setDeleteConfirm(null)}>{t('Abbrechen')}</button>
|
||||
<button className={styles.btnDanger} onClick={() => _handleDelete(deleteConfirm)}>{t('Löschen')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingModule && (
|
||||
<div className={styles.confirmOverlay}>
|
||||
<div className={styles.editDialog}>
|
||||
<h3>{t('Modul bearbeiten')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={editingModule.title}
|
||||
className={styles.wizardInput}
|
||||
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
defaultValue={editingModule.goals || ''}
|
||||
className={styles.wizardTextarea}
|
||||
placeholder={t('Ziele')}
|
||||
rows={3}
|
||||
onBlur={e => setEditingModule({ ...editingModule, goals: e.target.value })}
|
||||
/>
|
||||
|
||||
{/* Persona Multi-Select */}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<label className={styles.wizardLabel} style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>
|
||||
{t('Verfuegbare Gespraechspartner')}
|
||||
</label>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||
{t('Waehle, welche Gespraechspartner in Sessions dieses Moduls zur Verfuegung stehen. Ohne Auswahl sind alle verfuegbar.')}
|
||||
</p>
|
||||
<div style={{
|
||||
maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border-color, #ddd)',
|
||||
borderRadius: 6, padding: '0.5rem',
|
||||
}}>
|
||||
{allPersonas.filter(p => p.isActive).map(p => (
|
||||
<label key={p.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.3rem 0',
|
||||
cursor: 'pointer', fontSize: '0.85rem',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPersonaIds.includes(p.id)}
|
||||
onChange={() => _togglePersonaId(p.id)}
|
||||
/>
|
||||
<span>{p.label}</span>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)' }}>
|
||||
({p.category === 'builtin' ? t('System') : t('Eigene')})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.confirmActions}>
|
||||
<button className={styles.btnSecondary} onClick={() => setEditingModule(null)}>{t('Abbrechen')}</button>
|
||||
<button className={styles.btnPrimary} onClick={() => _handleEdit(editingModule.id, {
|
||||
title: editingModule.title,
|
||||
goals: editingModule.goals,
|
||||
})}>{t('Speichern')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
610
src/pages/views/commcoach/CommcoachSessionView.tsx
Normal file
610
src/pages/views/commcoach/CommcoachSessionView.tsx
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
/**
|
||||
* CommCoach Session View (Refactored)
|
||||
*
|
||||
* Shows ONLY the active coaching session: Chat, Voice, TTS, Agent activity.
|
||||
* Three states:
|
||||
* 1. No module selected -> hint with links to Assistant / Modules
|
||||
* 2. Module selected, no session -> persona picker + "Start session"
|
||||
* 3. Session active -> full chat/voice/TTS interface
|
||||
*
|
||||
* Reachable via Assistant wizard or Modules page ("Session starten" button).
|
||||
* KeepAlive-wrapped — voice sessions persist across tab switches.
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
||||
import {
|
||||
getPersonasApi, getModulePersonasApi,
|
||||
type CoachingPersona,
|
||||
type SendMessageOptions,
|
||||
} from '../../../api/commcoachApi';
|
||||
import api from '../../../api';
|
||||
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext, UdbTab, AddToChat_FileItem } from '../../../components/UnifiedDataBar';
|
||||
import { _defaultProviderSelection } from '../../../components/ProviderSelector';
|
||||
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||
import { getPageIcon } from '../../../config/pageRegistry';
|
||||
import styles from './CommcoachDossierView.module.css';
|
||||
import sessionStyles from './Commcoach.module.css';
|
||||
import { useVoiceController } from './useVoiceController';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface WorkspaceFileInfo { id: string; fileName: string; mimeType: string; fileSize: number; }
|
||||
interface DataSourceInfo { id: string; connectionId: string; sourceType: string; path: string; label: string; }
|
||||
interface FeatureDataSourceInfo { id: string; featureInstanceId: string; featureCode: string; tableName: string; label: string; }
|
||||
|
||||
function _formatToolPayload(payload: Record<string, unknown>): string {
|
||||
try {
|
||||
const s = JSON.stringify(payload, null, 0);
|
||||
return s.length > 120 ? s.substring(0, 120) + '...' : s;
|
||||
} catch { return '[unlesbar]'; }
|
||||
}
|
||||
|
||||
export const CommcoachSessionView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const instanceId = useInstanceId();
|
||||
const mandateId = useMandateId();
|
||||
const coach = useCommcoach(instanceId);
|
||||
const { request } = useApiRequest();
|
||||
const [searchParams] = useSearchParams();
|
||||
const moduleId = searchParams.get('moduleId');
|
||||
|
||||
const isSessionRoute = /\/commcoach\/[^/]+\/session/.test(location.pathname);
|
||||
|
||||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||
const [modulePersonaIds, setModulePersonaIds] = useState<string[] | null>(null);
|
||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||||
const [showAgentActivity, setShowAgentActivity] = useState(true);
|
||||
|
||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||
const [udbWidth, setUdbWidth] = useState(280);
|
||||
const udbResizing = useRef(false);
|
||||
|
||||
const [wsFiles, setWsFiles] = useState<WorkspaceFileInfo[]>([]);
|
||||
const [wsDataSources, setWsDataSources] = useState<DataSourceInfo[]>([]);
|
||||
const [wsFeatureDataSources, setWsFeatureDataSources] = useState<FeatureDataSourceInfo[]>([]);
|
||||
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
||||
const [attachedDsIds, setAttachedDsIds] = useState<string[]>([]);
|
||||
const [attachedFdsIds, setAttachedFdsIds] = useState<string[]>([]);
|
||||
const [providerSelection, _setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection);
|
||||
const [_showFilePicker, setShowFilePicker] = useState(false);
|
||||
const [_showSourcePicker, setShowSourcePicker] = useState(false);
|
||||
|
||||
const _udbContext: UdbContext | null = instanceId
|
||||
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
|
||||
: null;
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const sendMessageRef = useRef(coach.sendMessage);
|
||||
sendMessageRef.current = coach.sendMessage;
|
||||
const attachedFileIdsRef = useRef(attachedFileIds);
|
||||
attachedFileIdsRef.current = attachedFileIds;
|
||||
const attachedDsIdsRef = useRef(attachedDsIds);
|
||||
attachedDsIdsRef.current = attachedDsIds;
|
||||
const attachedFdsIdsRef = useRef(attachedFdsIds);
|
||||
attachedFdsIdsRef.current = attachedFdsIds;
|
||||
const providerSelRef = useRef(providerSelection);
|
||||
providerSelRef.current = providerSelection;
|
||||
|
||||
const voice = useVoiceController({
|
||||
onFinalText: (text) => {
|
||||
const opts: SendMessageOptions = {};
|
||||
if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
|
||||
if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
|
||||
if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
|
||||
const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
|
||||
if (allowed) opts.allowedProviders = allowed;
|
||||
sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
coach.onTtsEventRef.current = (event: TtsEvent) => {
|
||||
if (event === 'playing') voice.ttsPlaying();
|
||||
else if (event === 'ended') voice.ttsEnded();
|
||||
else if (event === 'paused') voice.ttsPaused();
|
||||
else if (event === 'error') voice.ttsEnded();
|
||||
};
|
||||
return () => { coach.onTtsEventRef.current = null; };
|
||||
}, [coach.onTtsEventRef, voice.ttsPlaying, voice.ttsEnded, voice.ttsPaused]);
|
||||
|
||||
// Auto-select module from URL param
|
||||
useEffect(() => {
|
||||
if (moduleId && coach.contexts.length > 0 && coach.selectedContextId !== moduleId) {
|
||||
const found = coach.contexts.find(c => c.id === moduleId);
|
||||
if (found) coach.selectContext(moduleId);
|
||||
} else if (!moduleId && !coach.selectedContextId && coach.contexts.length > 0) {
|
||||
coach.selectContext(coach.contexts[0].id, { skipSessionResume: true });
|
||||
}
|
||||
}, [moduleId, coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !isSessionRoute) return;
|
||||
getPersonasApi(request, instanceId).then(p => setPersonas(p)).catch(() => {});
|
||||
}, [instanceId, request, isSessionRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !coach.selectedContextId || !isSessionRoute) { setModulePersonaIds(null); return; }
|
||||
getModulePersonasApi(request, instanceId, coach.selectedContextId)
|
||||
.then(ids => setModulePersonaIds(ids.length > 0 ? ids : null))
|
||||
.catch(() => setModulePersonaIds(null));
|
||||
}, [instanceId, request, coach.selectedContextId, isSessionRoute]);
|
||||
|
||||
const _refreshWorkspaceAssets = useCallback(() => {
|
||||
if (!instanceId) return;
|
||||
api.get(`/api/workspace/${instanceId}/files`).then(r => setWsFiles(r.data.files || [])).catch(() => {});
|
||||
api.get(`/api/workspace/${instanceId}/datasources`).then(r => setWsDataSources(r.data.dataSources || [])).catch(() => {});
|
||||
api.get(`/api/workspace/${instanceId}/feature-datasources`).then(r => setWsFeatureDataSources(r.data.featureDataSources || [])).catch(() => {});
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { _refreshWorkspaceAssets(); }, [_refreshWorkspaceAssets]);
|
||||
useEffect(() => {
|
||||
const h = () => _refreshWorkspaceAssets();
|
||||
window.addEventListener('fileUploaded', h);
|
||||
return () => window.removeEventListener('fileUploaded', h);
|
||||
}, [_refreshWorkspaceAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coach.session) {
|
||||
voice.deactivate();
|
||||
} else if (voice.state === 'idle') {
|
||||
voice.activate();
|
||||
}
|
||||
}, [coach.session?.id, voice]);
|
||||
|
||||
useEffect(() => {
|
||||
coach.onDocumentCreatedRef.current = () => {
|
||||
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } }));
|
||||
};
|
||||
return () => { coach.onDocumentCreatedRef.current = null; };
|
||||
}, [coach, _refreshWorkspaceAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (coach.agentToolCalls.length > 0) setShowAgentActivity(true);
|
||||
}, [coach.agentToolCalls.length]);
|
||||
|
||||
const handleStopTts = useCallback(() => { coach.stopTts(); voice.ttsStopped(); }, [coach, voice]);
|
||||
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
||||
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
||||
const opts: SendMessageOptions = {};
|
||||
if (attachedFileIds.length) opts.fileIds = attachedFileIds;
|
||||
if (attachedDsIds.length) opts.dataSourceIds = attachedDsIds;
|
||||
if (attachedFdsIds.length) opts.featureDataSourceIds = attachedFdsIds;
|
||||
const allowed = providerSelection.include.length > 0 ? providerSelection.include : undefined;
|
||||
if (allowed) opts.allowedProviders = allowed;
|
||||
await coach.sendMessage(coach.inputValue, Object.keys(opts).length ? opts : undefined);
|
||||
setAttachedFileIds([]);
|
||||
setShowSourcePicker(false);
|
||||
setShowFilePicker(false);
|
||||
}, [coach, attachedFileIds, attachedDsIds, attachedFdsIds, providerSelection]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
||||
}, [handleSend]);
|
||||
|
||||
const _toggleFile = useCallback((fileId: string) => {
|
||||
setAttachedFileIds(prev => prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]);
|
||||
}, []);
|
||||
const _toggleDs = useCallback((dsId: string) => {
|
||||
setAttachedDsIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId]);
|
||||
}, []);
|
||||
const _toggleFds = useCallback((fdsId: string) => {
|
||||
setAttachedFdsIds(prev => prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId]);
|
||||
}, []);
|
||||
|
||||
const attachedFileNamesRef = useRef<Record<string, string>>({});
|
||||
|
||||
const _handleUdbFileSelect = useCallback((fileId: string, fileName?: string) => {
|
||||
if (fileName) attachedFileNamesRef.current[fileId] = fileName;
|
||||
setAttachedFileIds(prev => prev.includes(fileId) ? prev : [...prev, fileId]);
|
||||
}, []);
|
||||
|
||||
const _handleUdbSendToChat = useCallback((items: AddToChat_FileItem[]) => {
|
||||
for (const item of items) {
|
||||
if (item.name) attachedFileNamesRef.current[item.id] = item.name;
|
||||
}
|
||||
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
||||
setAttachedFileIds(prev => {
|
||||
const merged = [...prev];
|
||||
for (const fId of fileIds) {
|
||||
if (!merged.includes(fId)) merged.push(fId);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const _isTreeDrag = useCallback((e: React.DragEvent) => {
|
||||
return e.dataTransfer.types.includes('application/tree-items') ||
|
||||
e.dataTransfer.types.includes('application/file-id') ||
|
||||
e.dataTransfer.types.includes('application/file-ids');
|
||||
}, []);
|
||||
|
||||
const _handleInputDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (_isTreeDrag(e)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}, [_isTreeDrag]);
|
||||
|
||||
const _handleInputDrop = useCallback((e: React.DragEvent) => {
|
||||
const treeJson = e.dataTransfer.getData('application/tree-items');
|
||||
if (treeJson) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const items: AddToChat_FileItem[] = JSON.parse(treeJson);
|
||||
for (const item of items) {
|
||||
if (item.name) attachedFileNamesRef.current[item.id] = item.name;
|
||||
}
|
||||
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
||||
setAttachedFileIds(prev => {
|
||||
const merged = [...prev];
|
||||
for (const fId of fileIds) {
|
||||
if (!merged.includes(fId)) merged.push(fId);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
const fileId = e.dataTransfer.getData('application/file-id');
|
||||
if (fileId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const name = e.dataTransfer.getData('text/plain');
|
||||
if (name) attachedFileNamesRef.current[fileId] = name;
|
||||
setAttachedFileIds(prev => prev.includes(fileId) ? prev : [...prev, fileId]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const _toolPayloadForDisplay = (payload: Record<string, unknown>): string => {
|
||||
const formatted = _formatToolPayload(payload);
|
||||
return formatted === '[unlesbar]' ? t('[unlesbar]') : formatted;
|
||||
};
|
||||
|
||||
if (coach.loadingContexts) {
|
||||
return <div className={sessionStyles.loading}>{t('Laden...')}</div>;
|
||||
}
|
||||
|
||||
// ========== STATE 1: No module selected ==========
|
||||
if (!coach.selectedContextId && coach.contexts.length === 0) {
|
||||
return (
|
||||
<div className={sessionStyles.modulesContainer} style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||
<h3>{t('Keine aktive Session')}</h3>
|
||||
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
|
||||
{t('Erstelle zuerst ein Modul ueber den Assistenten oder starte eine Session ueber die Module-Seite.')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
<button className={sessionStyles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/assistant`)}
|
||||
>{t('Zum Assistenten')}</button>
|
||||
<button className={sessionStyles.btnSecondary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/modules`)}
|
||||
>{t('Zu den Modulen')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== STATE 2: Module selected, no active session ==========
|
||||
if (coach.selectedContextId && !coach.session) {
|
||||
return (
|
||||
<div className={sessionStyles.modulesContainer}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<h3>{coach.selectedContext?.title || t('Modul')}</h3>
|
||||
{coach.selectedContext?.description && (
|
||||
<p style={{ color: 'var(--text-secondary, #666)', marginTop: '0.25rem' }}>{coach.selectedContext.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{personas.length > 0 && (() => {
|
||||
const availablePersonas = modulePersonaIds
|
||||
? personas.filter(p => modulePersonaIds.includes(p.id))
|
||||
: personas;
|
||||
return availablePersonas.length > 0 ? (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.5rem' }}>{t('Gespraechspartner waehlen')}</label>
|
||||
{modulePersonaIds && (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||
{t('Fuer dieses Modul sind bestimmte Gespraechspartner konfiguriert.')}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{availablePersonas.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={`${sessionStyles.btnSmall} ${selectedPersonaId === p.id ? sessionStyles.btnSmallActive : ''}`}
|
||||
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
|
||||
title={p.description}
|
||||
>
|
||||
<span>{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}</span>
|
||||
{' '}{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<button
|
||||
className={sessionStyles.btnPrimary}
|
||||
onClick={() => coach.startSession(selectedPersonaId)}
|
||||
disabled={!!coach.actionLoading}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{coach.actionLoading === 'starting'
|
||||
? t('Wird gestartet...')
|
||||
: selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
|
||||
? `${t('Session starten mit')} ${personas.find(p => p.id === selectedPersonaId)!.label}`
|
||||
: t('Session starten')}
|
||||
</button>
|
||||
|
||||
{coach.error && <div className={sessionStyles.errorBanner}>{coach.error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== STATE 3: Active session ==========
|
||||
return (
|
||||
<div className={styles.dossierLayout}>
|
||||
{/* UDB Sidebar */}
|
||||
{_udbContext && (
|
||||
<div
|
||||
className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}
|
||||
style={udbCollapsed ? undefined : { width: udbWidth, minWidth: 180 }}
|
||||
>
|
||||
<button
|
||||
className={styles.udbToggle}
|
||||
onClick={() => setUdbCollapsed(v => !v)}
|
||||
title={udbCollapsed ? t('Seitenleiste einblenden') : t('Seitenleiste ausblenden')}
|
||||
>
|
||||
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||
</button>
|
||||
{!udbCollapsed && (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
onFileSelect={_handleUdbFileSelect}
|
||||
onSendToChat_Files={_handleUdbSendToChat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{_udbContext && !udbCollapsed && (
|
||||
<div
|
||||
className={styles.udbResizeHandle}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
udbResizing.current = true;
|
||||
const startX = e.clientX;
|
||||
const startW = udbWidth;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!udbResizing.current) return;
|
||||
const newW = Math.max(180, Math.min(600, startW + (ev.clientX - startX)));
|
||||
setUdbWidth(newW);
|
||||
};
|
||||
const onUp = () => {
|
||||
udbResizing.current = false;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Session Content */}
|
||||
<div className={styles.dossier}>
|
||||
{/* Session Header */}
|
||||
<div className={styles.sessionHeader}>
|
||||
<span className={styles.sessionLabel}>
|
||||
{coach.selectedContext?.title ? `${coach.selectedContext.title} — ` : ''}
|
||||
{t('Session aktiv')}
|
||||
</span>
|
||||
<div className={styles.sessionActions}>
|
||||
{voice.state === 'botSpeaking' && (
|
||||
<>
|
||||
<button className={styles.btnSmall} onClick={handlePauseTts}>Pause</button>
|
||||
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
|
||||
</>
|
||||
)}
|
||||
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
|
||||
<button className={styles.btnSmall} onClick={handleResumeTts}>{t('Weitersprechen')}</button>
|
||||
)}
|
||||
<button
|
||||
className={`${styles.btnSmall} ${voice.muted ? styles.mutedActive : ''}`}
|
||||
onClick={voice.toggleMute}
|
||||
title={voice.muted ? t('Stummschaltung aufheben') : t('stummschalten')}
|
||||
>
|
||||
{voice.muted ? t('Stumm') : t('Ton an')}
|
||||
</button>
|
||||
<button className={styles.btnSmall} onClick={coach.completeSession} disabled={!!coach.actionLoading}>
|
||||
{coach.actionLoading === 'completing' ? t('wird abgeschlossen') : t('abschliessen')}
|
||||
</button>
|
||||
<button className={styles.btnSmallDanger} onClick={coach.cancelSession} disabled={!!coach.actionLoading}>
|
||||
{coach.actionLoading === 'cancelling' ? t('wird abgebrochen') : t('Abbrechen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{coach.error && <div className={styles.errorBanner || sessionStyles.errorBanner}>{coach.error}</div>}
|
||||
|
||||
{/* Messages */}
|
||||
<AutoScroll scrollDependency={coach.messages.length + (coach.isStreaming ? 1 : 0) + voice.liveTranscript.length}>
|
||||
<div className={styles.messages}>
|
||||
{coach.messages.map(msg => (
|
||||
<div key={msg.id} className={`${styles.message} ${msg.role === 'user' ? styles.messageUser : styles.messageAssistant}`}>
|
||||
<div className={styles.messageBubble}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
<div className={styles.messageTime}>
|
||||
{msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{voice.liveTranscript && (
|
||||
<div className={`${styles.message} ${styles.messageUser}`}>
|
||||
<div className={`${styles.messageBubble} ${styles.messageLive}`}>{voice.liveTranscript}</div>
|
||||
</div>
|
||||
)}
|
||||
{coach.isStreaming && (
|
||||
<div className={`${styles.message} ${styles.messageAssistant}`}>
|
||||
<div className={styles.messageBubble}>
|
||||
{coach.streamingMessage ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{coach.streamingMessage}</ReactMarkdown>
|
||||
) : (
|
||||
<div className={styles.typing}>{coach.streamingStatus || t('Coach denkt nach')}<span className={styles.typingDots}>...</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AutoScroll>
|
||||
|
||||
{/* Agent Activity Panel */}
|
||||
{(coach.isStreaming || coach.agentToolCalls.length > 0) && (
|
||||
<div className={styles.agentActivityPanel}>
|
||||
<button
|
||||
className={styles.agentActivityHeader}
|
||||
onClick={() => setShowAgentActivity(prev => !prev)}
|
||||
type="button"
|
||||
>
|
||||
<span className={styles.agentActivityTitle}>
|
||||
{t('Agent-Aktivitaet')}
|
||||
{coach.agentToolCalls.length > 0 ? ` (${coach.agentToolCalls.length})` : ''}
|
||||
</span>
|
||||
<span className={styles.agentActivityStatus}>
|
||||
{coach.streamingStatus || (coach.agentToolCalls.length > 0 ? t('Toolaufrufe vorhanden') : t('Warte auf Agent'))}
|
||||
</span>
|
||||
<span className={styles.agentActivityChevron}>{showAgentActivity ? '\u25BE' : '\u25B8'}</span>
|
||||
</button>
|
||||
{showAgentActivity && (
|
||||
<div className={styles.agentActivityBody}>
|
||||
{coach.agentToolCalls.length === 0 ? (
|
||||
<div className={styles.agentActivityEmpty}>{t('Noch keine Tool-Aufrufe in dieser Antwort.')}</div>
|
||||
) : (
|
||||
coach.agentToolCalls.map((toolCall, idx) => (
|
||||
<div key={`${toolCall.toolName}-${idx}`} className={styles.agentActivityItem}>
|
||||
<div className={styles.agentActivityItemHeader}>
|
||||
<span className={styles.agentActivityToolName}>{toolCall.toolName}</span>
|
||||
<span className={`${styles.agentActivityBadge} ${
|
||||
toolCall.success === true ? styles.agentActivityBadgeSuccess
|
||||
: toolCall.success === false ? styles.agentActivityBadgeError
|
||||
: styles.agentActivityBadgeRunning
|
||||
}`}>
|
||||
{toolCall.success === true ? t('fertig') : toolCall.success === false ? t('fehler') : t('laeuft')}
|
||||
</span>
|
||||
</div>
|
||||
{toolCall.args && (
|
||||
<div className={styles.agentActivityMeta}>
|
||||
<strong>{t('Argumente:')}</strong> {_toolPayloadForDisplay(toolCall.args)}
|
||||
</div>
|
||||
)}
|
||||
{toolCall.result && (
|
||||
<div className={styles.agentActivityMeta}>
|
||||
<strong>{t('Ergebnis:')}</strong> {toolCall.result}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Area */}
|
||||
<div className={styles.inputArea} onDragOver={_handleInputDragOver} onDrop={_handleInputDrop}>
|
||||
<div className={styles.voiceStatus}>
|
||||
<span className={`${styles.voiceIndicator} ${voice.state === 'listening' ? styles.voiceActive : ''}`}>
|
||||
{voice.muted
|
||||
? t('Stumm - Mikrofon aus')
|
||||
: voice.state === 'botSpeaking'
|
||||
? (coach.streamingStatus || t('Coach spricht...'))
|
||||
: coach.isStreaming
|
||||
? (coach.streamingStatus || t('Coach denkt nach...'))
|
||||
: voice.state === 'interrupted'
|
||||
? t('Unterbrochen - Mikrofon an')
|
||||
: voice.state === 'listening'
|
||||
? (voice.liveTranscript ? t('Spricht...') : t('Mikrofon an - bitte sprechen'))
|
||||
: t('Mikrofon wird gestartet...')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Attachment Chips */}
|
||||
{(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', paddingBottom: 4 }}>
|
||||
{attachedFileIds.map(fId => {
|
||||
const file = wsFiles.find(f => f.id === fId);
|
||||
const displayName = file?.fileName || attachedFileNamesRef.current[fId] || fId;
|
||||
return (
|
||||
<span key={fId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#e3f2fd', color: '#1565c0', fontWeight: 500 }}>
|
||||
{displayName}
|
||||
<button onClick={() => _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>x</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{attachedDsIds.map(dsId => {
|
||||
const ds = wsDataSources.find(d => d.id === dsId);
|
||||
return (
|
||||
<span key={dsId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#e8f5e9', color: '#2e7d32', fontWeight: 500 }}>
|
||||
{ds?.label || ds?.path || dsId}
|
||||
<button onClick={() => _toggleDs(dsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1 }}>x</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{attachedFdsIds.map(fdsId => {
|
||||
const fds = wsFeatureDataSources.find(d => d.id === fdsId);
|
||||
return (
|
||||
<span key={fdsId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500 }}>
|
||||
<span style={{ fontSize: 12 }}>{fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'}</span>
|
||||
{fds?.label || fdsId}
|
||||
<button onClick={() => _toggleFds(fdsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1 }}>x</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.textInputRow}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles.textInput}
|
||||
placeholder={t('Nachricht eingeben')}
|
||||
value={coach.inputValue}
|
||||
onChange={e => coach.setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDragOver={_handleInputDragOver}
|
||||
onDrop={_handleInputDrop}
|
||||
rows={1}
|
||||
disabled={coach.isStreaming}
|
||||
/>
|
||||
<button
|
||||
className={styles.sendBtn}
|
||||
onClick={handleSend}
|
||||
disabled={coach.isStreaming || !coach.inputValue.trim()}
|
||||
>
|
||||
{t('Senden')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -169,10 +169,142 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.tabBar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--border-color, #ddd);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--primary-color, #F25843);
|
||||
border-bottom-color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
/* Personas Tab */
|
||||
.personasTab {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.personasHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
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);
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: 12px;
|
||||
width: 520px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.modalHeader h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modalClose {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #666);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modalClose:hover {
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.btnSecondary {
|
||||
padding: 0.4rem 1rem;
|
||||
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 {
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
border-color: var(--primary-color, #F25843);
|
||||
color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings { padding: 0.75rem; max-width: 100%; }
|
||||
.statsGrid { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||
.statItem { padding: 0.5rem; }
|
||||
.statValue { font-size: 1.2rem; }
|
||||
.voiceRow { flex-direction: column; }
|
||||
.modal { width: 95vw; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,38 @@
|
|||
/**
|
||||
* CommCoach Settings View
|
||||
*
|
||||
* Coaching-specific settings: reminders, email notifications, stats.
|
||||
* Voice/language settings are in user-level settings (/settings -> "Stimme & Sprache").
|
||||
* Two tabs:
|
||||
* 1. Allgemein – Reminders, email notifications, voice/language link
|
||||
* 2. Gespraechspartner – Persona CRUD with FormGeneratorTable (apiEndpoint-driven)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import {
|
||||
getProfileApi, updateProfileApi,
|
||||
type CoachingUserProfile,
|
||||
fetchPersonasPaginated, createPersonaApi, updatePersonaApi, deletePersonaApi,
|
||||
type CoachingUserProfile, type CoachingPersona,
|
||||
} from '../../../api/commcoachApi';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './CommcoachSettingsView.module.css';
|
||||
|
||||
import adminStyles from '../../admin/Admin.module.css';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
export const CommcoachSettingsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
type SettingsTab = 'general' | 'personas';
|
||||
|
||||
export const CommcoachSettingsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [profile, setProfile] = useState<CoachingUserProfile | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||||
|
||||
// =========================================================================
|
||||
// Tab: Allgemein
|
||||
// =========================================================================
|
||||
const [_profile, setProfile] = useState<CoachingUserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -35,7 +44,7 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
const loadData = async () => {
|
||||
const _loadProfile = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const profileData = await getProfileApi(request, instanceId);
|
||||
|
|
@ -51,7 +60,7 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
_loadProfile();
|
||||
}, [request, instanceId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
|
|
@ -73,64 +82,302 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, reminderEnabled, reminderTime, emailEnabled]);
|
||||
}, [request, instanceId, reminderEnabled, reminderTime, emailEnabled, t]);
|
||||
|
||||
if (loading) {
|
||||
// =========================================================================
|
||||
// Tab: Gespraechspartner (FormGeneratorTable with apiEndpoint)
|
||||
// =========================================================================
|
||||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||
const [personasLoading, setPersonasLoading] = useState(false);
|
||||
const [personaPagination, setPersonaPagination] = useState<{
|
||||
currentPage: number; pageSize: number; totalItems: number; totalPages: number;
|
||||
} | null>(null);
|
||||
const [showCreatePersona, setShowCreatePersona] = useState(false);
|
||||
const [editingPersona, setEditingPersona] = useState<CoachingPersona | null>(null);
|
||||
const [personaForm, setPersonaForm] = useState({ label: '', description: '', gender: '' });
|
||||
const [personaSaving, setPersonaSaving] = useState(false);
|
||||
const [personaError, setPersonaError] = useState<string | null>(null);
|
||||
|
||||
const personaApiEndpoint = instanceId ? `/api/commcoach/${instanceId}/personas` : undefined;
|
||||
|
||||
const _refetchPersonas = useCallback(async (params?: any) => {
|
||||
if (!instanceId) return;
|
||||
setPersonasLoading(true);
|
||||
try {
|
||||
const data = await fetchPersonasPaginated(request, instanceId, params);
|
||||
const items = data.items || data.personas || [];
|
||||
setPersonas(items);
|
||||
if (data.pagination) {
|
||||
setPersonaPagination(data.pagination);
|
||||
} else {
|
||||
setPersonaPagination(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPersonaError(err.message || 'Fehler beim Laden');
|
||||
setPersonas([]);
|
||||
} finally {
|
||||
setPersonasLoading(false);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'personas' && instanceId) {
|
||||
_refetchPersonas({ page: 1, pageSize: 50 });
|
||||
}
|
||||
}, [activeTab, instanceId, _refetchPersonas]);
|
||||
|
||||
const personaColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Name'), minWidth: 160, sortable: true, searchable: true },
|
||||
{ key: 'description', label: t('Beschreibung'), minWidth: 250 },
|
||||
{
|
||||
key: 'gender', label: t('Geschlecht'), width: 100, sortable: true,
|
||||
formatter: (v: string) => v === 'f' ? t('Weiblich') : v === 'm' ? t('Maennlich') : '-',
|
||||
},
|
||||
{
|
||||
key: 'category', label: t('Typ'), width: 100, sortable: true, filterable: true,
|
||||
formatter: (v: string) => v === 'builtin' ? t('System') : t('Eigene'),
|
||||
filterOptions: ['builtin', 'custom'],
|
||||
filterLabelResolver: (v: string) => v === 'builtin' ? t('System') : t('Eigene'),
|
||||
},
|
||||
{
|
||||
key: 'isActive', label: t('Aktiv'), width: 70, sortable: true, type: 'boolean' as any,
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const _handleCreatePersona = useCallback(async () => {
|
||||
if (!instanceId || !personaForm.label.trim()) return;
|
||||
setPersonaSaving(true);
|
||||
setPersonaError(null);
|
||||
try {
|
||||
await createPersonaApi(request, instanceId, {
|
||||
label: personaForm.label.trim(),
|
||||
description: personaForm.description.trim(),
|
||||
gender: personaForm.gender || undefined,
|
||||
});
|
||||
setPersonaForm({ label: '', description: '', gender: '' });
|
||||
setShowCreatePersona(false);
|
||||
await _refetchPersonas({ page: 1, pageSize: 50 });
|
||||
} catch (err: any) {
|
||||
setPersonaError(err.message || 'Fehler beim Erstellen');
|
||||
} finally {
|
||||
setPersonaSaving(false);
|
||||
}
|
||||
}, [request, instanceId, personaForm, _refetchPersonas]);
|
||||
|
||||
const _handleUpdatePersona = useCallback(async () => {
|
||||
if (!instanceId || !editingPersona) return;
|
||||
setPersonaSaving(true);
|
||||
setPersonaError(null);
|
||||
try {
|
||||
await updatePersonaApi(request, instanceId, editingPersona.id, {
|
||||
label: personaForm.label.trim() || undefined,
|
||||
description: personaForm.description.trim() || undefined,
|
||||
gender: personaForm.gender || undefined,
|
||||
});
|
||||
setEditingPersona(null);
|
||||
setPersonaForm({ label: '', description: '', gender: '' });
|
||||
await _refetchPersonas({ page: 1, pageSize: 50 });
|
||||
} catch (err: any) {
|
||||
setPersonaError(err.message || 'Fehler beim Speichern');
|
||||
} finally {
|
||||
setPersonaSaving(false);
|
||||
}
|
||||
}, [request, instanceId, editingPersona, personaForm, _refetchPersonas]);
|
||||
|
||||
const _handleDeletePersona = useCallback(async (itemId: string): Promise<boolean> => {
|
||||
if (!instanceId) return false;
|
||||
try {
|
||||
await deletePersonaApi(request, instanceId, itemId);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
setPersonaError(err.message || 'Fehler beim Loeschen');
|
||||
return false;
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
const _handleEditClick = useCallback((row: CoachingPersona) => {
|
||||
setEditingPersona(row);
|
||||
setPersonaForm({ label: row.label, description: row.description, gender: row.gender || '' });
|
||||
}, []);
|
||||
|
||||
const _handleInlineToggleActive = useCallback(async (row: CoachingPersona, _field: string, newValue: any) => {
|
||||
if (!instanceId || row.category === 'builtin') return;
|
||||
try {
|
||||
await updatePersonaApi(request, instanceId, row.id, { isActive: newValue });
|
||||
setPersonas(prev => prev.map(p => p.id === row.id ? { ...p, isActive: newValue } : p));
|
||||
} catch {}
|
||||
}, [request, instanceId]);
|
||||
|
||||
const personaHookData = useMemo(() => ({
|
||||
refetch: _refetchPersonas,
|
||||
pagination: personaPagination,
|
||||
handleDelete: _handleDeletePersona,
|
||||
}), [_refetchPersonas, personaPagination, _handleDeletePersona]);
|
||||
|
||||
// =========================================================================
|
||||
// Render
|
||||
// =========================================================================
|
||||
if (loading && activeTab === 'general') {
|
||||
return <div className={styles.loading}>{t('Einstellungen werden geladen')}</div>;
|
||||
}
|
||||
|
||||
const isPersonasTab = activeTab === 'personas';
|
||||
|
||||
return (
|
||||
<div className={styles.settings}>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{success && <div className={styles.success}>{success}</div>}
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t('Stimme/Sprache')}</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||
{t('Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.')}
|
||||
</p>
|
||||
<Link to="/settings" onClick={() => {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
|
||||
{t('Benutzereinstellungen öffnen (Tab "Stimme & Sprache")')}
|
||||
</Link>
|
||||
<div className={isPersonasTab ? `${adminStyles.adminPage} ${adminStyles.adminPageFill}` : styles.settings}>
|
||||
{/* Tab Bar */}
|
||||
<div className={styles.tabBar} style={{ flexShrink: 0 }}>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'general' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('general')}
|
||||
>{t('Allgemein')}</button>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'personas' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('personas')}
|
||||
>{t('Gespraechspartner')}</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t('Erinnerungen')}</h3>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input type="checkbox" checked={reminderEnabled} onChange={e => setReminderEnabled(e.target.checked)} />
|
||||
{t('Tägliche Coaching-Erinnerung per E-Mail')}
|
||||
</label>
|
||||
</div>
|
||||
{reminderEnabled && (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('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)} />
|
||||
{t('Session-Zusammenfassung per E-Mail senden')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tab: Allgemein */}
|
||||
{activeTab === 'general' && (
|
||||
<>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{success && <div className={styles.success}>{success}</div>}
|
||||
|
||||
{profile && (
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t('Statistik')}</h3>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalSessions}</span><span className={styles.statLabel}>{t('Sessions gesamt')}</span></div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalMinutes}</span><span className={styles.statLabel}>{t('Minuten gesamt')}</span></div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.streakDays}</span><span className={styles.statLabel}>{t('Aktueller Streak')}</span></div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.longestStreak}</span><span className={styles.statLabel}>{t('Längster Streak')}</span></div>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t('Stimme/Sprache')}</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||
{t('Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.')}
|
||||
</p>
|
||||
<Link to="/settings" style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
|
||||
{t('Benutzereinstellungen oeffnen (Tab "Stimme & Sprache")')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t('Erinnerungen')}</h3>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input type="checkbox" checked={reminderEnabled} onChange={e => setReminderEnabled(e.target.checked)} />
|
||||
{t('Taegliche Coaching-Erinnerung per E-Mail')}
|
||||
</label>
|
||||
</div>
|
||||
{reminderEnabled && (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('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)} />
|
||||
{t('Session-Zusammenfassung per E-Mail senden')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className={styles.saveBtn} onClick={handleSave} disabled={saving}>
|
||||
{saving ? t('speichern') : t('Einstellungen speichern')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className={styles.saveBtn} onClick={handleSave} disabled={saving}>
|
||||
{saving ? t('speichern') : t('Einstellungen speichern')}
|
||||
</button>
|
||||
{/* Tab: Gespraechspartner */}
|
||||
{activeTab === 'personas' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, width: '100%' }}>
|
||||
{personaError && <div className={styles.error} style={{ flexShrink: 0 }}>{personaError}</div>}
|
||||
|
||||
<div className={styles.personasHeader} style={{ flexShrink: 0 }}>
|
||||
<h3 className={styles.sectionTitle}>{t('Gespraechspartner verwalten')}</h3>
|
||||
<button className={styles.saveBtn} style={{ width: 'auto', padding: '0.4rem 1rem' }} onClick={() => {
|
||||
setShowCreatePersona(true);
|
||||
setPersonaForm({ label: '', description: '', gender: '' });
|
||||
}}>
|
||||
{t('+ Neuer Gespraechspartner')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 1rem', flexShrink: 0 }}>
|
||||
{t('System-Personas koennen nicht bearbeitet oder geloescht werden. Eigene Personas koennen pro Modul zugeordnet werden.')}
|
||||
</p>
|
||||
|
||||
<div className={adminStyles.tableContainer}>
|
||||
<FormGeneratorTable
|
||||
data={personas}
|
||||
columns={personaColumns}
|
||||
apiEndpoint={personaApiEndpoint}
|
||||
loading={personasLoading}
|
||||
pagination={true}
|
||||
pageSize={50}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
inlineEditable={true}
|
||||
onInlineUpdate={_handleInlineToggleActive}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
onAction: _handleEditClick,
|
||||
title: t('Bearbeiten'),
|
||||
visible: (row: CoachingPersona) => row.category !== 'builtin',
|
||||
},
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: t('Loeschen'),
|
||||
visible: (row: CoachingPersona) => row.category !== 'builtin',
|
||||
},
|
||||
]}
|
||||
hookData={personaHookData}
|
||||
emptyMessage={t('Keine Gespraechspartner vorhanden')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
{(showCreatePersona || editingPersona) && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h3>{editingPersona ? t('Gespraechspartner bearbeiten') : t('Neuer Gespraechspartner')}</h3>
|
||||
<button className={styles.modalClose} onClick={() => { setShowCreatePersona(false); setEditingPersona(null); }}>x</button>
|
||||
</div>
|
||||
<div className={styles.modalBody}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('Name')}</label>
|
||||
<input className={styles.input} value={personaForm.label}
|
||||
onChange={e => setPersonaForm(f => ({ ...f, label: e.target.value }))}
|
||||
placeholder={t('z.B. Kritischer Investor')} />
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('Beschreibung / Rollenbeschreibung')}</label>
|
||||
<textarea className={styles.textarea} rows={5} value={personaForm.description}
|
||||
onChange={e => setPersonaForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder={t('Detaillierte Beschreibung der Rolle und des Verhaltens...')} />
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('Geschlecht')}</label>
|
||||
<select className={styles.select} value={personaForm.gender}
|
||||
onChange={e => setPersonaForm(f => ({ ...f, gender: e.target.value }))}>
|
||||
<option value="">{t('Nicht angegeben')}</option>
|
||||
<option value="f">{t('Weiblich')}</option>
|
||||
<option value="m">{t('Maennlich')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button className={styles.btnSecondary} onClick={() => { setShowCreatePersona(false); setEditingPersona(null); }}>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
<button className={styles.saveBtn} style={{ width: 'auto', padding: '0.4rem 1rem' }}
|
||||
disabled={personaSaving || !personaForm.label.trim()}
|
||||
onClick={editingPersona ? _handleUpdatePersona : _handleCreatePersona}>
|
||||
{personaSaving ? t('Speichern...') : editingPersona ? t('Speichern') : t('Erstellen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
export { CommcoachDashboardView } from './CommcoachDashboardView';
|
||||
export { CommcoachAssistantView } from './CommcoachAssistantView';
|
||||
export { CommcoachModulesView } from './CommcoachModulesView';
|
||||
export { CommcoachSessionView } from './CommcoachSessionView';
|
||||
export { CommcoachDossierView } from './CommcoachDossierView';
|
||||
export { CommcoachSettingsView } from './CommcoachSettingsView';
|
||||
|
|
|
|||
|
|
@ -436,7 +436,7 @@
|
|||
|
||||
.udbSidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
min-width: 180px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
|
|
@ -444,7 +444,7 @@
|
|||
background: var(--bg-card, #fff);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: width 0.2s, min-width 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.udbSidebarCollapsed {
|
||||
|
|
@ -452,6 +452,20 @@
|
|||
min-width: 36px;
|
||||
}
|
||||
|
||||
.udbResizeHandle {
|
||||
width: 5px;
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.udbResizeHandle:hover,
|
||||
.udbResizeHandle:active {
|
||||
background: var(--accent-color, #4a90d9);
|
||||
}
|
||||
|
||||
.udbToggle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
|
@ -1303,3 +1317,340 @@
|
|||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Agent Status Bubble + Stats Cards + Module Views (Greenfield IA)
|
||||
============================================================================ */
|
||||
|
||||
.agentStatusBubble {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(74, 144, 217, 0.08);
|
||||
border-radius: 8px;
|
||||
margin: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
animation: agentPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes agentPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.agentStatusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #4A90D9);
|
||||
animation: agentPulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.statsCards {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--surface-color, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.statsValue {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.statsLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.assistantContainer,
|
||||
.modulesContainer {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wizardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stepIndicator {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color, #ccc);
|
||||
}
|
||||
|
||||
.stepActive {
|
||||
background: var(--primary-color, #4A90D9);
|
||||
}
|
||||
|
||||
.wizardContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wizardStep {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wizardInput,
|
||||
.wizardSelect {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: var(--bg-input, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wizardTextarea {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: var(--bg-input, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.wizardActions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.moduleChoice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modulesHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modulesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.moduleCard {
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.moduleExpanded {
|
||||
border-color: var(--primary-color, #4A90D9);
|
||||
}
|
||||
|
||||
.moduleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.moduleRow:hover {
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
}
|
||||
|
||||
.moduleType {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(74, 144, 217, 0.1);
|
||||
color: var(--primary-color, #4A90D9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.moduleTitle {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.moduleStatus {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.moduleActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.moduleSessionsList {
|
||||
padding: 0.5rem 1rem 1rem 2rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.sessionRow {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.4rem 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sessionRow:hover {
|
||||
color: var(--primary-color, #4A90D9);
|
||||
}
|
||||
|
||||
.sessionStatus {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.noSessions {
|
||||
color: var(--text-secondary, #666);
|
||||
font-style: italic;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.confirmOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.confirmDialog,
|
||||
.editDialog {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.confirmSummary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface-color, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.wizardHint {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
background: rgba(241, 76, 76, 0.1);
|
||||
color: #f14c4c;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--primary-color, #4A90D9);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btnPrimary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnSecondary {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: transparent;
|
||||
color: var(--text-primary, #333);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btnDanger {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #f14c4c;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btnSmall {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: transparent;
|
||||
color: var(--text-primary, #333);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btnSmallDanger {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f14c4c;
|
||||
background: transparent;
|
||||
color: #f14c4c;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
|
|
|||
190
src/pages/views/teamsbot/TeamsbotAssistantView.tsx
Normal file
190
src/pages/views/teamsbot/TeamsbotAssistantView.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* TeamsBot Assistant View
|
||||
*
|
||||
* Wizard: Select/create module → Meeting link → Bot selection → "Start bot"
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
type WizardStep = 'module' | 'meeting' | 'bot' | 'confirm';
|
||||
const STEPS: WizardStep[] = ['module', 'meeting', 'bot', 'confirm'];
|
||||
|
||||
export const TeamsbotAssistantView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { instance, mandateId } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const preselectedModuleId = searchParams.get('moduleId');
|
||||
|
||||
const [step, setStep] = useState<WizardStep>(preselectedModuleId ? 'meeting' : 'module');
|
||||
const [modules, setModules] = useState<any[]>([]);
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(preselectedModuleId);
|
||||
const [newModuleTitle, setNewModuleTitle] = useState('');
|
||||
const [createNewModule, setCreateNewModule] = useState(false);
|
||||
const [meetingLink, setMeetingLink] = useState('');
|
||||
const [botName, setBotName] = useState('AI Assistant');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const stepIdx = STEPS.indexOf(step);
|
||||
|
||||
const _loadModules = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const result = await teamsbotApi.listModules(instanceId);
|
||||
setModules(result || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load modules:', err);
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { _loadModules(); }, [_loadModules]);
|
||||
|
||||
const _handleNext = () => {
|
||||
const nextIdx = stepIdx + 1;
|
||||
if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
|
||||
};
|
||||
|
||||
const _handleBack = () => {
|
||||
const prevIdx = stepIdx - 1;
|
||||
if (prevIdx >= 0) setStep(STEPS[prevIdx]);
|
||||
};
|
||||
|
||||
const _handleStart = async () => {
|
||||
if (!meetingLink.trim()) {
|
||||
setError(t('Meeting-Link erforderlich'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let moduleId = selectedModuleId;
|
||||
if (createNewModule && newModuleTitle.trim()) {
|
||||
const mod = await teamsbotApi.createModule(instanceId, { title: newModuleTitle.trim() });
|
||||
moduleId = mod.id;
|
||||
}
|
||||
|
||||
const result = await teamsbotApi.startSession(instanceId, {
|
||||
meetingLink: meetingLink.trim(),
|
||||
botName,
|
||||
moduleId: moduleId || undefined,
|
||||
} as any);
|
||||
|
||||
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${result.session.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('Fehler beim Starten'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.assistantContainer}>
|
||||
<div className={styles.wizardHeader}>
|
||||
<h2>{t('Neues Meeting starten')}</h2>
|
||||
<div className={styles.stepIndicator}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<div className={styles.wizardContent}>
|
||||
{step === 'module' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Meeting-Modul wählen')}</h3>
|
||||
<div className={styles.moduleChoice}>
|
||||
<label>
|
||||
<input type="radio" checked={!createNewModule} onChange={() => setCreateNewModule(false)} />
|
||||
{t('Bestehendes Modul')}
|
||||
</label>
|
||||
{!createNewModule && (
|
||||
<select
|
||||
value={selectedModuleId || ''}
|
||||
onChange={e => setSelectedModuleId(e.target.value || null)}
|
||||
className={styles.wizardSelect}
|
||||
>
|
||||
<option value="">{t('Kein Modul (Adhoc)')}</option>
|
||||
{modules.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.title}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label>
|
||||
<input type="radio" checked={createNewModule} onChange={() => setCreateNewModule(true)} />
|
||||
{t('Neues Modul erstellen')}
|
||||
</label>
|
||||
{createNewModule && (
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
placeholder={t('z.B. Weekly Standup, Q3 Review...')}
|
||||
value={newModuleTitle}
|
||||
onChange={e => setNewModuleTitle(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'meeting' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Meeting-Link')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
placeholder="https://teams.microsoft.com/l/meetup-join/..."
|
||||
value={meetingLink}
|
||||
onChange={e => setMeetingLink(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'bot' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Bot-Name')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
value={botName}
|
||||
onChange={e => setBotName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Zusammenfassung')}</h3>
|
||||
<div className={styles.confirmSummary}>
|
||||
<div><strong>{t('Modul')}:</strong> {createNewModule ? newModuleTitle : (modules.find(m => m.id === selectedModuleId)?.title || t('Adhoc'))}</div>
|
||||
<div><strong>{t('Meeting')}:</strong> {meetingLink}</div>
|
||||
<div><strong>{t('Bot')}:</strong> {botName}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.wizardActions}>
|
||||
{stepIdx > 0 && (
|
||||
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{step !== 'confirm' ? (
|
||||
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
|
||||
) : (
|
||||
<button className={styles.btnPrimary} onClick={_handleStart} disabled={loading}>
|
||||
{loading ? t('Starte...') : t('Bot starten')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -73,14 +73,15 @@ export const TeamsbotDashboardView: React.FC = () => {
|
|||
}
|
||||
}, [joinMode, instanceId]);
|
||||
|
||||
// Auto-refresh: poll every 10s when there are active sessions
|
||||
// Adaptive polling: 3s with active sessions, 30s otherwise
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
const hasActiveSessions = sessions.some(s => ['pending', 'joining', 'active'].includes(s.status));
|
||||
if (hasActiveSessions && instanceId) {
|
||||
const interval = hasActiveSessions ? 3000 : 30000;
|
||||
if (instanceId) {
|
||||
pollRef.current = setInterval(() => {
|
||||
teamsbotApi.listSessions(instanceId).then(r => setSessions(r.sessions || [])).catch(() => {});
|
||||
}, 10000);
|
||||
}, interval);
|
||||
}
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, [sessions, instanceId]);
|
||||
|
|
|
|||
189
src/pages/views/teamsbot/TeamsbotModulesView.tsx
Normal file
189
src/pages/views/teamsbot/TeamsbotModulesView.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* TeamsBot Modules View
|
||||
*
|
||||
* CRUD list of MeetingModules with expandable session lists per module.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
const SERIES_TYPE_LABELS: Record<string, string> = {
|
||||
weekly: 'Wöchentlich',
|
||||
biweekly: 'Zweiwöchentlich',
|
||||
monthly: 'Monatlich',
|
||||
adhoc: 'Adhoc',
|
||||
project: 'Projekt',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Aktiv',
|
||||
archived: 'Archiviert',
|
||||
completed: 'Abgeschlossen',
|
||||
};
|
||||
|
||||
export const TeamsbotModulesView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { instance, mandateId } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [modules, setModules] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [moduleSessions, setModuleSessions] = useState<Record<string, any[]>>({});
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [editingModule, setEditingModule] = useState<any | null>(null);
|
||||
|
||||
const _loadModules = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await teamsbotApi.listModules(instanceId);
|
||||
setModules(result || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load modules:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { _loadModules(); }, [_loadModules]);
|
||||
|
||||
const _loadModuleSessions = useCallback(async (moduleId: string) => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const detail = await teamsbotApi.getModuleDetail(instanceId, moduleId);
|
||||
setModuleSessions(prev => ({ ...prev, [moduleId]: detail?.sessions || [] }));
|
||||
} catch (err) {
|
||||
console.error('Failed to load module sessions:', err);
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
const _toggleExpand = (moduleId: string) => {
|
||||
if (expandedId === moduleId) {
|
||||
setExpandedId(null);
|
||||
} else {
|
||||
setExpandedId(moduleId);
|
||||
if (!moduleSessions[moduleId]) _loadModuleSessions(moduleId);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleDelete = async (moduleId: string) => {
|
||||
try {
|
||||
await teamsbotApi.deleteModule(instanceId, moduleId);
|
||||
setDeleteConfirm(null);
|
||||
_loadModules();
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleUpdate = async (moduleId: string, updates: any) => {
|
||||
try {
|
||||
await teamsbotApi.updateModule(instanceId, moduleId, updates);
|
||||
setEditingModule(null);
|
||||
_loadModules();
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.modulesContainer}>
|
||||
<div className={styles.modulesHeader}>
|
||||
<h2>{t('Meeting-Module')}</h2>
|
||||
<button
|
||||
className={styles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||
>
|
||||
{t('Neues Modul')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className={styles.loading}>{t('Laden...')}</div>}
|
||||
|
||||
<div className={styles.modulesList}>
|
||||
{modules.map(mod => (
|
||||
<div key={mod.id} className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''}`}>
|
||||
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
|
||||
<span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span>
|
||||
<span className={styles.moduleTitle}>{mod.title}</span>
|
||||
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status)}</span>
|
||||
<div className={styles.moduleActions}>
|
||||
<button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => {
|
||||
e.stopPropagation();
|
||||
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant?moduleId=${mod.id}`);
|
||||
}}>{t('Meeting starten')}</button>
|
||||
<button className={styles.btnSmall} onClick={e => { e.stopPropagation(); setEditingModule(mod); }}>{t('Bearbeiten')}</button>
|
||||
<button className={styles.btnSmallDanger} onClick={e => { e.stopPropagation(); setDeleteConfirm(mod.id); }}>{t('Löschen')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedId === mod.id && (
|
||||
<div className={styles.moduleSessionsList}>
|
||||
{(moduleSessions[mod.id] || []).length === 0 ? (
|
||||
<p className={styles.noSessions}>{t('Keine Sitzungen')}</p>
|
||||
) : (
|
||||
(moduleSessions[mod.id] || []).map((sess: any) => (
|
||||
<div
|
||||
key={sess.id}
|
||||
className={styles.sessionRow}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${sess.id}`)}
|
||||
>
|
||||
<span>{sess.botName || 'Bot'}</span>
|
||||
<span className={styles.sessionStatus}>{sess.status}</span>
|
||||
<span>{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{deleteConfirm && (
|
||||
<div className={styles.confirmOverlay}>
|
||||
<div className={styles.confirmDialog}>
|
||||
<p>{t('Modul wirklich löschen? Sessions werden dem Modul entkoppelt.')}</p>
|
||||
<div className={styles.confirmActions}>
|
||||
<button className={styles.btnSecondary} onClick={() => setDeleteConfirm(null)}>{t('Abbrechen')}</button>
|
||||
<button className={styles.btnDanger} onClick={() => _handleDelete(deleteConfirm)}>{t('Löschen')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingModule && (
|
||||
<div className={styles.confirmOverlay}>
|
||||
<div className={styles.editDialog}>
|
||||
<h3>{t('Modul bearbeiten')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={editingModule.title}
|
||||
className={styles.wizardInput}
|
||||
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
defaultValue={editingModule.goals || ''}
|
||||
className={styles.wizardTextarea}
|
||||
placeholder={t('Ziele')}
|
||||
rows={3}
|
||||
onBlur={e => setEditingModule({ ...editingModule, goals: e.target.value })}
|
||||
/>
|
||||
<div className={styles.confirmActions}>
|
||||
<button className={styles.btnSecondary} onClick={() => setEditingModule(null)}>{t('Abbrechen')}</button>
|
||||
<button className={styles.btnPrimary} onClick={() => _handleUpdate(editingModule.id, {
|
||||
title: editingModule.title,
|
||||
goals: editingModule.goals,
|
||||
})}>{t('Speichern')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type {
|
||||
|
|
@ -28,8 +28,9 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
|||
*/
|
||||
export const TeamsbotSessionView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { instance } = useCurrentInstance();
|
||||
const { instance, mandateId } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const sessionId = searchParams.get('sessionId') || '';
|
||||
|
|
@ -56,6 +57,12 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
timestamp: string;
|
||||
}>>([]);
|
||||
|
||||
const [agentStatus, setAgentStatus] = useState<{ toolName?: string; status?: string; reason?: string } | null>(null);
|
||||
const agentStatusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [sessionStats, setSessionStats] = useState<any>(null);
|
||||
const [reconnectTick, setReconnectTick] = useState(0);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Director Prompt panel state
|
||||
const [directorPrompts, setDirectorPrompts] = useState<DirectorPrompt[]>([]);
|
||||
const [directorText, setDirectorText] = useState('');
|
||||
|
|
@ -76,6 +83,8 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
// UDB Sidebar state
|
||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||
const [udbWidth, setUdbWidth] = useState(280);
|
||||
const udbResizing = useRef(false);
|
||||
const _udbContext: UdbContext | null = instanceId
|
||||
? { instanceId, featureInstanceId: instanceId }
|
||||
: null;
|
||||
|
|
@ -156,22 +165,21 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
};
|
||||
}, [instanceId, sessionId]);
|
||||
|
||||
// SSE Live Stream - connect once per session, don't re-create on status changes.
|
||||
// We deliberately depend ONLY on (instanceId, sessionId), not on session.status,
|
||||
// so transient status transitions (pending -> joining -> active) don't tear down
|
||||
// and rebuild the EventSource (which used to flicker botConnected and spawn
|
||||
// multiple parallel /stream connections to the gateway).
|
||||
// SSE Live Stream with reconnect support.
|
||||
// Depends on (instanceId, sessionId, reconnectTick) -- reconnectTick is bumped
|
||||
// to force reconnect after connection loss without changing sessionId.
|
||||
const sseSessionRef = useRef<string | null>(null);
|
||||
const sessionStatusRef = useRef<string | undefined>(session?.status);
|
||||
sessionStatusRef.current = session?.status;
|
||||
useEffect(() => {
|
||||
if (!instanceId || !sessionId) return;
|
||||
// Avoid reconnecting if already streaming this session
|
||||
if (sseSessionRef.current === sessionId && eventSourceRef.current) return;
|
||||
// Avoid reconnecting if already streaming this session (unless reconnectTick changed)
|
||||
if (sseSessionRef.current === sessionId && eventSourceRef.current && eventSourceRef.current.readyState !== EventSource.CLOSED) return;
|
||||
// Don't open a stream for sessions that are known to already be terminal.
|
||||
const initialStatus = sessionStatusRef.current;
|
||||
if (initialStatus && !['active', 'joining', 'pending'].includes(initialStatus)) return;
|
||||
|
||||
if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; }
|
||||
eventSourceRef.current?.close();
|
||||
sseSessionRef.current = sessionId;
|
||||
|
||||
|
|
@ -189,7 +197,10 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
switch (evType) {
|
||||
case 'sessionState':
|
||||
if (sseEvent.data) setSession(prev => prev ? { ...prev, ...sseEvent.data } : sseEvent.data);
|
||||
if (sseEvent.data) {
|
||||
setSession(prev => prev ? { ...prev, ...sseEvent.data } : sseEvent.data);
|
||||
if (sseEvent.data.stats) setSessionStats(sseEvent.data.stats);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'transcript': {
|
||||
|
|
@ -289,7 +300,15 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
case 'agentRun': {
|
||||
const data = sseEvent.data || {};
|
||||
_dlog('AGENT', `${data.status || ''} ${data.reason || ''}`.trim());
|
||||
_dlog('AGENT', `${data.status || ''} ${data.toolName || ''} ${data.reason || ''}`.trim());
|
||||
if (data.status === 'started' || data.status === 'running') {
|
||||
setAgentStatus({ toolName: data.toolName, status: data.status, reason: data.reason });
|
||||
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
|
||||
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 15000);
|
||||
} else {
|
||||
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
|
||||
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 2000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -315,6 +334,17 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
eventSource.onerror = () => {
|
||||
setIsLive(false);
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
_dlog('SSE', 'connection closed, scheduling reconnect');
|
||||
eventSourceRef.current = null;
|
||||
sseSessionRef.current = null;
|
||||
const status = sessionStatusRef.current;
|
||||
if (status && ['active', 'joining', 'pending'].includes(status)) {
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
setReconnectTick(v => v + 1);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
|
|
@ -323,27 +353,36 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
sseSessionRef.current = null;
|
||||
setIsLive(false);
|
||||
setBotConnected(false);
|
||||
if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; }
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instanceId, sessionId]);
|
||||
}, [instanceId, sessionId, reconnectTick]);
|
||||
|
||||
// Polling fallback: refresh session data every 5s when SSE is not connected
|
||||
// Polling fallback: refresh session data every 5s when SSE is not connected.
|
||||
// Uses isActive (boolean) instead of session object to prevent interval resets.
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session]);
|
||||
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session?.status]);
|
||||
const isLiveRef = useRef(isLive);
|
||||
isLiveRef.current = isLive;
|
||||
useEffect(() => {
|
||||
if (instanceId && sessionId && (isActive || !session)) {
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (isLive) return;
|
||||
try {
|
||||
const result = await teamsbotApi.getSession(instanceId, sessionId);
|
||||
setSession(result.session);
|
||||
if (result.transcripts) setTranscripts(result.transcripts);
|
||||
if (result.botResponses) setBotResponses(result.botResponses);
|
||||
} catch {}
|
||||
}, 5000);
|
||||
}
|
||||
if (!instanceId || !sessionId) return;
|
||||
if (!isActive) return;
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (isLiveRef.current) return;
|
||||
try {
|
||||
const result = await teamsbotApi.getSession(instanceId, sessionId);
|
||||
setSession(result.session);
|
||||
if (result.transcripts) setTranscripts(result.transcripts);
|
||||
if (result.botResponses) setBotResponses(result.botResponses);
|
||||
// If session became active and SSE is dead, trigger reconnect
|
||||
const newStatus = result.session?.status;
|
||||
if (newStatus && ['active', 'joining', 'pending'].includes(newStatus) && !eventSourceRef.current) {
|
||||
setReconnectTick(v => v + 1);
|
||||
}
|
||||
} catch {}
|
||||
}, 5000);
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, [isActive, instanceId, sessionId, isLive, session]);
|
||||
}, [isActive, instanceId, sessionId]);
|
||||
|
||||
// Auto-scroll transcript
|
||||
useEffect(() => {
|
||||
|
|
@ -588,9 +627,19 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>;
|
||||
if (noSessions) return (
|
||||
<div className={styles.emptyState || styles.loading}>
|
||||
<p>{t('Keine Sitzungen vorhanden')}</p>
|
||||
<p>{t('Starte eine neue Sitzung im Dashboard.')}</p>
|
||||
<div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}>
|
||||
<h3>{t('Keine aktive Sitzung')}</h3>
|
||||
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
|
||||
{t('Starte ein neues Meeting ueber den Assistenten oder die Module-Seite.')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button className={styles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||
>{t('Zum Assistenten')}</button>
|
||||
<button className={styles.btnSecondary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/modules`)}
|
||||
>{t('Zu den Modulen')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!session) return <div className={styles.errorBanner}>{t('Sitzung nicht gefunden')}</div>;
|
||||
|
|
@ -626,6 +675,38 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Status Bubble (F-fix-2) */}
|
||||
{agentStatus && (
|
||||
<div className={styles.agentStatusBubble}>
|
||||
<span className={styles.agentStatusDot} />
|
||||
<span>{t('Agent denkt nach')}{agentStatus.toolName ? `: ${agentStatus.toolName}` : '...'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards (F-fix-3) */}
|
||||
{sessionStats && (
|
||||
<div className={styles.statsCards}>
|
||||
{sessionStats.talkingMinutes != null && (
|
||||
<div className={styles.statsCard}>
|
||||
<span className={styles.statsValue}>{Math.round(sessionStats.talkingMinutes)}</span>
|
||||
<span className={styles.statsLabel}>{t('Sprechminuten')}</span>
|
||||
</div>
|
||||
)}
|
||||
{sessionStats.botResponseCount != null && (
|
||||
<div className={styles.statsCard}>
|
||||
<span className={styles.statsValue}>{sessionStats.botResponseCount}</span>
|
||||
<span className={styles.statsLabel}>{t('Bot-Antworten')}</span>
|
||||
</div>
|
||||
)}
|
||||
{sessionStats.avgLatencyMs != null && (
|
||||
<div className={styles.statsCard}>
|
||||
<span className={styles.statsValue}>{Math.round(sessionStats.avgLatencyMs)}ms</span>
|
||||
<span className={styles.statsLabel}>{t('Ø Latenz')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Header */}
|
||||
<div className={styles.sessionViewHeader}>
|
||||
<div className={styles.sessionInfo}>
|
||||
|
|
@ -648,7 +729,10 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
<div className={styles.sessionLayout}>
|
||||
{/* UDB Sidebar (Files / Sources) */}
|
||||
{_udbContext && (
|
||||
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
||||
<div
|
||||
className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}
|
||||
style={udbCollapsed ? undefined : { width: udbWidth, minWidth: 180 }}
|
||||
>
|
||||
<button
|
||||
className={styles.udbToggle}
|
||||
onClick={() => setUdbCollapsed((v) => !v)}
|
||||
|
|
@ -657,16 +741,39 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||
</button>
|
||||
{!udbCollapsed && (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
onFileSelect={_handleUdbFileSelect}
|
||||
/>
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
onFileSelect={_handleUdbFileSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{_udbContext && !udbCollapsed && (
|
||||
<div
|
||||
className={styles.udbResizeHandle}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
udbResizing.current = true;
|
||||
const startX = e.clientX;
|
||||
const startW = udbWidth;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!udbResizing.current) return;
|
||||
const newW = Math.max(180, Math.min(600, startW + (ev.clientX - startX)));
|
||||
setUdbWidth(newW);
|
||||
};
|
||||
const onUp = () => {
|
||||
udbResizing.current = false;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main column */}
|
||||
<div className={styles.sessionMain}>
|
||||
|
|
|
|||
|
|
@ -247,7 +247,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
icon: 'headset_mic',
|
||||
views: [
|
||||
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
|
||||
{ code: 'sessions', label: 'Sitzungen', path: 'sessions' },
|
||||
{ code: 'assistant', label: 'Assistent', path: 'assistant' },
|
||||
{ code: 'modules', label: 'Module', path: 'modules' },
|
||||
{ code: 'sessions', label: 'Live-Session', path: 'sessions' },
|
||||
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
|
||||
]
|
||||
},
|
||||
|
|
@ -280,8 +282,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
icon: 'account_voice',
|
||||
views: [
|
||||
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
|
||||
{ code: 'coaching', label: 'Coaching', path: 'coaching' },
|
||||
{ code: 'dossier', label: 'Dossier', path: 'dossier' },
|
||||
{ code: 'assistant', label: 'Assistent', path: 'assistant' },
|
||||
{ code: 'modules', label: 'Module', path: 'modules' },
|
||||
{ code: 'session', label: 'Session', path: 'session' },
|
||||
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue