unified data - step 1
This commit is contained in:
parent
cc8a699e58
commit
bc091c399c
30 changed files with 1876 additions and 210 deletions
|
|
@ -27,6 +27,8 @@ export interface RegisterData {
|
|||
language?: string;
|
||||
enabled?: boolean;
|
||||
privilege?: string;
|
||||
registrationType?: 'personal' | 'company';
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
|
|
@ -40,6 +42,8 @@ export interface RegisterRequest {
|
|||
authenticationAuthority: string;
|
||||
};
|
||||
frontendUrl: string;
|
||||
registrationType?: string;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestResponse {
|
||||
|
|
@ -172,7 +176,9 @@ export async function registerApi(registerData: RegisterData): Promise<RegisterR
|
|||
privilege: registerData.privilege || 'user',
|
||||
authenticationAuthority: 'local'
|
||||
},
|
||||
frontendUrl: window.location.origin
|
||||
frontendUrl: window.location.origin,
|
||||
registrationType: registerData.registrationType,
|
||||
companyName: registerData.companyName,
|
||||
};
|
||||
|
||||
// Prepare headers with CSRF token if available
|
||||
|
|
|
|||
|
|
@ -7,14 +7,21 @@
|
|||
|
||||
import api from '../api';
|
||||
|
||||
export interface StoreFeatureInstance {
|
||||
instanceId: string;
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface StoreFeature {
|
||||
featureCode: string;
|
||||
label: Record<string, string>;
|
||||
icon: string;
|
||||
description: Record<string, string>;
|
||||
isActive: boolean;
|
||||
instances: StoreFeatureInstance[];
|
||||
canActivate: boolean;
|
||||
instanceId: string | null;
|
||||
}
|
||||
|
||||
export interface StoreActivateResponse {
|
||||
|
|
@ -31,17 +38,44 @@ export interface StoreDeactivateResponse {
|
|||
deactivated: boolean;
|
||||
}
|
||||
|
||||
export interface UserMandate {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
mandateType: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan: string | null;
|
||||
status: string | null;
|
||||
maxDataVolumeMB: number | null;
|
||||
maxFeatureInstances: number | null;
|
||||
currentFeatureInstances: number;
|
||||
trialEndsAt: string | null;
|
||||
}
|
||||
|
||||
export async function fetchStoreFeatures(): Promise<StoreFeature[]> {
|
||||
const response = await api.get<StoreFeature[]>('/api/store/features');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function activateStoreFeature(featureCode: string): Promise<StoreActivateResponse> {
|
||||
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode });
|
||||
export async function fetchUserMandates(): Promise<UserMandate[]> {
|
||||
const response = await api.get<UserMandate[]>('/api/store/mandates');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deactivateStoreFeature(featureCode: string): Promise<StoreDeactivateResponse> {
|
||||
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode });
|
||||
export async function fetchSubscriptionInfo(mandateId?: string): Promise<SubscriptionInfo> {
|
||||
const params = mandateId ? { mandateId } : {};
|
||||
const response = await api.get<SubscriptionInfo>('/api/store/subscription-info', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function activateStoreFeature(featureCode: string, mandateId?: string): Promise<StoreActivateResponse> {
|
||||
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode, mandateId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deactivateStoreFeature(featureCode: string, mandateId: string, instanceId: string): Promise<StoreDeactivateResponse> {
|
||||
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode, mandateId, instanceId });
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
206
src/components/OnboardingAssistant.tsx
Normal file
206
src/components/OnboardingAssistant.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
|
||||
interface OnboardingStep {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
interface OnboardingAssistantProps {
|
||||
instanceId?: string;
|
||||
mandateId?: string;
|
||||
featureCode?: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const _DISMISS_KEY = 'onboarding_dismissed';
|
||||
const _DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({
|
||||
instanceId,
|
||||
mandateId,
|
||||
featureCode,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [steps, setSteps] = useState<OnboardingStep[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const dismissedAt = localStorage.getItem(_DISMISS_KEY);
|
||||
if (dismissedAt && Date.now() - parseInt(dismissedAt) < _DISMISS_COOLDOWN_MS) {
|
||||
setDismissed(true);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
_checkOnboardingState();
|
||||
}, [instanceId, mandateId]);
|
||||
|
||||
const _checkOnboardingState = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const onboardingSteps: OnboardingStep[] = [];
|
||||
|
||||
let hasMandate = !!mandateId;
|
||||
if (!hasMandate) {
|
||||
try {
|
||||
const mandatesRes = await api.get('/api/store/mandates');
|
||||
hasMandate = (mandatesRes.data || []).length > 0;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
onboardingSteps.push({
|
||||
id: 'mandate',
|
||||
label: 'Mandant einrichten',
|
||||
description: hasMandate ? 'Dein Mandant ist eingerichtet.' : 'Richte deinen Mandanten ein, um loszulegen.',
|
||||
completed: hasMandate,
|
||||
action: hasMandate ? undefined : () => navigate('/store'),
|
||||
});
|
||||
|
||||
let hasInstances = !!instanceId;
|
||||
if (!hasInstances) {
|
||||
try {
|
||||
const storeRes = await api.get('/api/store/features');
|
||||
const features = storeRes.data || [];
|
||||
hasInstances = features.some((f: any) => f.instances && f.instances.length > 0);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
onboardingSteps.push({
|
||||
id: 'feature',
|
||||
label: 'Erstes Feature aktivieren',
|
||||
description: hasInstances ? 'Du hast aktive Features.' : 'Aktiviere dein erstes Feature im Store.',
|
||||
completed: hasInstances,
|
||||
action: hasInstances ? undefined : () => navigate('/store'),
|
||||
});
|
||||
|
||||
let hasData = false;
|
||||
if (instanceId) {
|
||||
try {
|
||||
const filesRes = await api.get(`/api/workspace/${instanceId}/files`);
|
||||
const files = filesRes.data?.data || filesRes.data || [];
|
||||
hasData = files.length > 0;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
onboardingSteps.push({
|
||||
id: 'data',
|
||||
label: 'Erste Datenquelle einbinden',
|
||||
description: hasData ? 'Du hast Daten im Workspace.' : 'Lade eine Datei hoch oder verbinde eine Datenquelle.',
|
||||
completed: hasData,
|
||||
});
|
||||
|
||||
let hasChats = false;
|
||||
if (instanceId) {
|
||||
try {
|
||||
const chatsRes = await api.get(`/api/workspace/${instanceId}/workflows`);
|
||||
const chats = chatsRes.data?.data || chatsRes.data || [];
|
||||
hasChats = chats.length > 0;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
onboardingSteps.push({
|
||||
id: 'chat',
|
||||
label: 'Ersten AI-Chat starten',
|
||||
description: hasChats ? 'Du hast bereits Chats.' : 'Starte deinen ersten Chat mit dem AI-Assistenten.',
|
||||
completed: hasChats,
|
||||
});
|
||||
|
||||
setSteps(onboardingSteps);
|
||||
|
||||
if (onboardingSteps.every(s => s.completed)) {
|
||||
setDismissed(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Onboarding check failed:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleDismiss = () => {
|
||||
setDismissed(true);
|
||||
onDismiss?.();
|
||||
try {
|
||||
localStorage.setItem(_DISMISS_KEY, Date.now().toString());
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
if (dismissed || loading) return null;
|
||||
|
||||
const completedCount = steps.filter(s => s.completed).length;
|
||||
if (completedCount === steps.length) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: 16, margin: 16, borderRadius: 12,
|
||||
border: '1px solid var(--border-color, #e5e7eb)',
|
||||
background: 'linear-gradient(135deg, var(--bg-primary, #fff) 0%, #eef2ff 100%)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Willkommen bei PowerOn</h3>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{completedCount} von {steps.length} Schritten abgeschlossen
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={_handleDismiss}
|
||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '1.1rem', color: '#9ca3af', padding: '2px 6px' }}
|
||||
>
|
||||
{'\u00D7'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16, height: 4 }}>
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
style={{
|
||||
flex: 1, borderRadius: 2, height: 4,
|
||||
background: step.completed ? 'var(--accent, #4f46e5)' : 'var(--border-color, #e5e7eb)',
|
||||
transition: 'background 0.3s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
|
||||
borderRadius: 8, background: step.completed ? 'transparent' : 'var(--bg-primary, #fff)',
|
||||
border: step.completed ? 'none' : '1px solid var(--border-color, #e5e7eb)',
|
||||
opacity: step.completed ? 0.6 : 1,
|
||||
cursor: step.action ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={step.action}
|
||||
>
|
||||
<span style={{ fontSize: '1.1rem', flexShrink: 0, width: 24, textAlign: 'center' }}>
|
||||
{step.completed ? '\u2713' : '\u25CB'}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.85rem', textDecoration: step.completed ? 'line-through' : 'none' }}>
|
||||
{step.label}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
{step.action && !step.completed && (
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--accent, #4f46e5)', fontWeight: 500 }}>{'\u2192'}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingAssistant;
|
||||
119
src/components/OnboardingWizard.tsx
Normal file
119
src/components/OnboardingWizard.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useState } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
onComplete: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismiss }) => {
|
||||
const [mandateType, setMandateType] = useState<'personal' | 'company'>('personal');
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const _handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post('/api/local/onboarding', {
|
||||
mandateType,
|
||||
companyName: mandateType === 'company' ? companyName : undefined,
|
||||
});
|
||||
onComplete();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || 'Fehler bei der Einrichtung');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-primary, #fff)', borderRadius: '12px', padding: '32px',
|
||||
maxWidth: '480px', width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Willkommen bei PowerOn</h2>
|
||||
<p style={{ color: 'var(--text-secondary, #666)', margin: '0 0 24px' }}>
|
||||
Wie möchtest du PowerOn nutzen?
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
|
||||
border: mandateType === 'personal' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
|
||||
borderRadius: '8px', cursor: 'pointer',
|
||||
}}>
|
||||
<input type="radio" name="type" checked={mandateType === 'personal'}
|
||||
onChange={() => setMandateType('personal')} />
|
||||
<div>
|
||||
<strong>Persönlich</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
|
||||
7 Tage kostenlos testen, danach flexibel upgraden
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style={{
|
||||
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
|
||||
border: mandateType === 'company' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
|
||||
borderRadius: '8px', cursor: 'pointer',
|
||||
}}>
|
||||
<input type="radio" name="type" checked={mandateType === 'company'}
|
||||
onChange={() => setMandateType('company')} />
|
||||
<div>
|
||||
<strong>Unternehmen</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
|
||||
Team-Workspace mit Mandanten-Verwaltung
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mandateType === 'company' && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500 }}>
|
||||
Firmenname
|
||||
</label>
|
||||
<input
|
||||
type="text" value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Name des Unternehmens"
|
||||
style={{
|
||||
width: '100%', padding: '10px 12px', borderRadius: '6px',
|
||||
border: '1px solid var(--border, #d1d5db)', fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p style={{ color: '#ef4444', margin: '0 0 16px' }}>{error}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={onDismiss} style={{
|
||||
padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)',
|
||||
background: 'transparent', cursor: 'pointer',
|
||||
}}>
|
||||
Später
|
||||
</button>
|
||||
<button onClick={_handleSubmit} disabled={loading || (mandateType === 'company' && !companyName.trim())}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: '6px', border: 'none',
|
||||
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}>
|
||||
{loading ? 'Wird eingerichtet...' : 'Loslegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingWizard;
|
||||
|
|
@ -15,6 +15,12 @@ export interface MessageDocument {
|
|||
taskNumber: number;
|
||||
actionNumber: number;
|
||||
actionId: string;
|
||||
documentName?: string;
|
||||
validationMetadata?: {
|
||||
neutralized?: boolean;
|
||||
skipped?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
134
src/components/UnifiedDataBar/ChatsTab.module.css
Normal file
134
src/components/UnifiedDataBar/ChatsTab.module.css
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
.chatsTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color, #d1d5db);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--bg-input, #fff);
|
||||
color: var(--text-primary, #111);
|
||||
}
|
||||
|
||||
.modeToggle {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color, #d1d5db);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modeActive {
|
||||
background: var(--bg-active, #eef2ff);
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.flatList,
|
||||
.tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chatItem {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chatItem:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.chatLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chatDate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.treeGroup {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.treeGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.treeGroupHeader:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.treeGroupCurrent {
|
||||
color: var(--accent, #4f46e5);
|
||||
}
|
||||
|
||||
.treeArrow {
|
||||
font-size: 0.7rem;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.treeGroupLabel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.treeGroupCount {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
background: var(--bg-badge, #f3f4f6);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.treeChildren {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.search {
|
||||
background: var(--bg-input-dark, #1f2937);
|
||||
border-color: var(--border-dark, #374151);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.chatItem:hover,
|
||||
.treeGroupHeader:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.treeGroupCount {
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
186
src/components/UnifiedDataBar/ChatsTab.tsx
Normal file
186
src/components/UnifiedDataBar/ChatsTab.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import type { UdbContext } from './UnifiedDataBar';
|
||||
import api from '../../api';
|
||||
import styles from './ChatsTab.module.css';
|
||||
|
||||
interface ChatItem {
|
||||
id: string;
|
||||
label: string;
|
||||
updatedAt?: string;
|
||||
featureInstanceId?: string;
|
||||
featureCode?: string;
|
||||
}
|
||||
|
||||
interface ChatGroup {
|
||||
featureInstanceId: string;
|
||||
featureLabel: string;
|
||||
featureCode: string;
|
||||
chats: ChatItem[];
|
||||
}
|
||||
|
||||
interface ChatsTabProps {
|
||||
context: UdbContext;
|
||||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
||||
onDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart }) => {
|
||||
const [groups, setGroups] = useState<ChatGroup[]>([]);
|
||||
const [flatMode, setFlatMode] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const _loadChats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/api/workspace/${context.instanceId}/workflows`);
|
||||
const workflows = response.data?.data || response.data || [];
|
||||
|
||||
const groupMap = new Map<string, ChatGroup>();
|
||||
for (const wf of workflows) {
|
||||
const fiId = wf.featureInstanceId || context.instanceId;
|
||||
if (!groupMap.has(fiId)) {
|
||||
groupMap.set(fiId, {
|
||||
featureInstanceId: fiId,
|
||||
featureLabel: wf.featureLabel || wf.featureCode || fiId.slice(0, 8),
|
||||
featureCode: wf.featureCode || 'workspace',
|
||||
chats: [],
|
||||
});
|
||||
}
|
||||
groupMap.get(fiId)!.chats.push({
|
||||
id: wf.id,
|
||||
label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`,
|
||||
updatedAt: wf.updatedAt || wf.createdAt,
|
||||
featureInstanceId: fiId,
|
||||
featureCode: wf.featureCode,
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = Array.from(groupMap.values());
|
||||
sorted.forEach(g =>
|
||||
g.chats.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')),
|
||||
);
|
||||
setGroups(sorted);
|
||||
|
||||
if (expandedGroups.size === 0 && sorted.length > 0) {
|
||||
setExpandedGroups(new Set([context.instanceId]));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load chats:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [context.instanceId]);
|
||||
|
||||
useEffect(() => { _loadChats(); }, [_loadChats]);
|
||||
|
||||
const _toggleGroup = (id: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const _filteredGroups = groups
|
||||
.map(g => ({
|
||||
...g,
|
||||
chats: search
|
||||
? g.chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase()))
|
||||
: g.chats,
|
||||
}))
|
||||
.filter(g => g.chats.length > 0);
|
||||
|
||||
const _allChats = _filteredGroups
|
||||
.flatMap(g => g.chats)
|
||||
.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
||||
|
||||
if (loading) return <div className={styles.loading}>Lade Chats...</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.chatsTab}>
|
||||
<div className={styles.toolbar}>
|
||||
<input
|
||||
className={styles.search}
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
|
||||
onClick={() => setFlatMode(!flatMode)}
|
||||
title={flatMode ? 'Baumansicht' : 'Listenansicht'}
|
||||
>
|
||||
{flatMode ? '\uD83C\uDF33' : '\uD83D\uDCCB'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{flatMode ? (
|
||||
<div className={styles.flatList}>
|
||||
{_allChats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={styles.chatItem}
|
||||
onClick={() => onSelectChat?.(chat.id, chat.featureInstanceId || context.instanceId)}
|
||||
draggable={!!onDragStart}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/chat-id', chat.id);
|
||||
e.dataTransfer.setData('text/plain', chat.label);
|
||||
onDragStart?.(chat.id, e);
|
||||
}}
|
||||
>
|
||||
<span className={styles.chatLabel}>{chat.label}</span>
|
||||
{chat.updatedAt && (
|
||||
<span className={styles.chatDate}>
|
||||
{new Date(chat.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tree}>
|
||||
{_filteredGroups.map((group) => (
|
||||
<div key={group.featureInstanceId} className={styles.treeGroup}>
|
||||
<div
|
||||
className={`${styles.treeGroupHeader} ${
|
||||
group.featureInstanceId === context.instanceId ? styles.treeGroupCurrent : ''
|
||||
}`}
|
||||
onClick={() => _toggleGroup(group.featureInstanceId)}
|
||||
>
|
||||
<span className={styles.treeArrow}>
|
||||
{expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'}
|
||||
</span>
|
||||
<span className={styles.treeGroupLabel}>{group.featureLabel}</span>
|
||||
<span className={styles.treeGroupCount}>{group.chats.length}</span>
|
||||
</div>
|
||||
{expandedGroups.has(group.featureInstanceId) && (
|
||||
<div className={styles.treeChildren}>
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={styles.chatItem}
|
||||
onClick={() => onSelectChat?.(chat.id, group.featureInstanceId)}
|
||||
draggable={!!onDragStart}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/chat-id', chat.id);
|
||||
e.dataTransfer.setData('text/plain', chat.label);
|
||||
onDragStart?.(chat.id, e);
|
||||
}}
|
||||
>
|
||||
<span className={styles.chatLabel}>{chat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatsTab;
|
||||
94
src/components/UnifiedDataBar/FilesTab.module.css
Normal file
94
src/components/UnifiedDataBar/FilesTab.module.css
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
.filesTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.fileList {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fileRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.fileRow:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.fileName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fileIcons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scopeIcon,
|
||||
.neutralizeIcon {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.scopeIcon:hover,
|
||||
.neutralizeIcon:hover {
|
||||
opacity: 1;
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
.neutralizeActive {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border-color, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fileRow:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.scopeIcon:hover,
|
||||
.neutralizeIcon:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.legend {
|
||||
border-top-color: var(--border-dark, #374151);
|
||||
}
|
||||
}
|
||||
134
src/components/UnifiedDataBar/FilesTab.tsx
Normal file
134
src/components/UnifiedDataBar/FilesTab.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import type { UdbContext } from './UnifiedDataBar';
|
||||
import api from '../../api';
|
||||
import styles from './FilesTab.module.css';
|
||||
|
||||
interface FileEntry {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
scope: string;
|
||||
neutralize: boolean;
|
||||
fileSize?: number;
|
||||
}
|
||||
|
||||
const _SCOPE_ICONS: Record<string, string> = {
|
||||
personal: '\uD83D\uDC64',
|
||||
featureInstance: '\uD83D\uDC65',
|
||||
mandate: '\uD83C\uDFE2',
|
||||
global: '\uD83C\uDF10',
|
||||
};
|
||||
|
||||
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
|
||||
|
||||
interface FilesTabProps {
|
||||
context: UdbContext;
|
||||
onFileSelect?: (fileId: string) => void;
|
||||
}
|
||||
|
||||
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const _loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/api/workspace/${context.instanceId}/files`);
|
||||
const data = response.data?.data || response.data || [];
|
||||
setFiles(
|
||||
data.map((f: any) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName || f.name || 'unknown',
|
||||
mimeType: f.mimeType,
|
||||
scope: f.scope || 'personal',
|
||||
neutralize: f.neutralize || false,
|
||||
fileSize: f.fileSize,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to load files:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [context.instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
_loadFiles();
|
||||
}, [_loadFiles]);
|
||||
|
||||
const _cycleScope = async (file: FileEntry) => {
|
||||
const currentIdx = _SCOPE_CYCLE.indexOf(file.scope);
|
||||
const nextScope = _SCOPE_CYCLE[(currentIdx + 1) % _SCOPE_CYCLE.length];
|
||||
try {
|
||||
await api.patch(`/api/files/${file.id}/scope`, { scope: nextScope });
|
||||
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, scope: nextScope } : f)));
|
||||
} catch (err) {
|
||||
console.error('Failed to update scope:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const _toggleNeutralize = async (file: FileEntry) => {
|
||||
try {
|
||||
await api.patch(`/api/files/${file.id}/neutralize`, { neutralize: !file.neutralize });
|
||||
setFiles(prev =>
|
||||
prev.map(f => (f.id === file.id ? { ...f, neutralize: !f.neutralize } : f)),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle neutralize:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className={styles.loading}>Lade Dateien...</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.filesTab}>
|
||||
{files.length === 0 ? (
|
||||
<div className={styles.empty}>Keine Dateien vorhanden</div>
|
||||
) : (
|
||||
<div className={styles.fileList}>
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={styles.fileRow}
|
||||
onClick={() => onFileSelect?.(file.id)}
|
||||
>
|
||||
<span className={styles.fileName}>{file.fileName}</span>
|
||||
<div className={styles.fileIcons}>
|
||||
<button
|
||||
className={styles.scopeIcon}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
_cycleScope(file);
|
||||
}}
|
||||
title={`Scope: ${file.scope} (klicken zum Wechseln)`}
|
||||
>
|
||||
{_SCOPE_ICONS[file.scope] || '\uD83D\uDC64'}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.neutralizeIcon} ${
|
||||
file.neutralize ? styles.neutralizeActive : ''
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
_toggleNeutralize(file);
|
||||
}}
|
||||
title={file.neutralize ? 'Neutralisierung aktiv' : 'Neutralisierung aus'}
|
||||
>
|
||||
\uD83D\uDD12
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.legend}>
|
||||
<span>{'\uD83D\uDC64'} Pers\u00F6nlich</span>
|
||||
<span>{'\uD83D\uDC65'} Instanz</span>
|
||||
<span>{'\uD83C\uDFE2'} Mandant</span>
|
||||
<span>{'\uD83D\uDD12'} Neutralisiert</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilesTab;
|
||||
11
src/components/UnifiedDataBar/SourcesTab.module.css
Normal file
11
src/components/UnifiedDataBar/SourcesTab.module.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.sourcesTab {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
24
src/components/UnifiedDataBar/SourcesTab.tsx
Normal file
24
src/components/UnifiedDataBar/SourcesTab.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import type { UdbContext } from './UnifiedDataBar';
|
||||
import styles from './SourcesTab.module.css';
|
||||
|
||||
interface SourcesTabProps {
|
||||
context: UdbContext;
|
||||
renderDataSourcePanel?: (instanceId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
const SourcesTab: React.FC<SourcesTabProps> = ({ context, renderDataSourcePanel }) => {
|
||||
if (renderDataSourcePanel) {
|
||||
return <div className={styles.sourcesTab}>{renderDataSourcePanel(context.instanceId)}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.sourcesTab}>
|
||||
<div className={styles.placeholder}>
|
||||
Datenquellen werden \u00FCber den Workspace verwaltet.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourcesTab;
|
||||
60
src/components/UnifiedDataBar/UnifiedDataBar.module.css
Normal file
60
src/components/UnifiedDataBar/UnifiedDataBar.module.css
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
.udb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 8px 8px 0;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--accent, #4f46e5);
|
||||
border-bottom-color: var(--accent, #4f46e5);
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tabBar {
|
||||
border-bottom-color: var(--border-color-dark, #374151);
|
||||
}
|
||||
.tab {
|
||||
color: var(--text-secondary-dark, #9ca3af);
|
||||
}
|
||||
.tab:hover {
|
||||
color: var(--text-primary-dark, #f3f4f6);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.tabActive {
|
||||
color: var(--accent, #818cf8);
|
||||
border-bottom-color: var(--accent, #818cf8);
|
||||
}
|
||||
}
|
||||
69
src/components/UnifiedDataBar/UnifiedDataBar.tsx
Normal file
69
src/components/UnifiedDataBar/UnifiedDataBar.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useState } from 'react';
|
||||
import styles from './UnifiedDataBar.module.css';
|
||||
|
||||
export type UdbTab = 'chats' | 'files' | 'sources';
|
||||
|
||||
export interface UdbContext {
|
||||
instanceId: string;
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface UnifiedDataBarProps {
|
||||
context: UdbContext;
|
||||
activeTab?: UdbTab;
|
||||
onTabChange?: (tab: UdbTab) => void;
|
||||
renderChats?: (context: UdbContext) => React.ReactNode;
|
||||
renderFiles?: (context: UdbContext) => React.ReactNode;
|
||||
renderSources?: (context: UdbContext) => React.ReactNode;
|
||||
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const _TAB_LABELS: Record<UdbTab, Record<string, string>> = {
|
||||
chats: { de: 'Chats', en: 'Chats', fr: 'Chats' },
|
||||
files: { de: 'Dateien', en: 'Files', fr: 'Fichiers' },
|
||||
sources: { de: 'Quellen', en: 'Sources', fr: 'Sources' },
|
||||
};
|
||||
|
||||
const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||
context,
|
||||
activeTab: controlledTab,
|
||||
onTabChange,
|
||||
renderChats,
|
||||
renderFiles,
|
||||
renderSources,
|
||||
className,
|
||||
}) => {
|
||||
const [internalTab, setInternalTab] = useState<UdbTab>('chats');
|
||||
const currentTab = controlledTab ?? internalTab;
|
||||
|
||||
const _handleTabChange = (tab: UdbTab) => {
|
||||
setInternalTab(tab);
|
||||
onTabChange?.(tab);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.udb} ${className || ''}`}>
|
||||
<div className={styles.tabBar}>
|
||||
{(['chats', 'files', 'sources'] as UdbTab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`${styles.tab} ${currentTab === tab ? styles.tabActive : ''}`}
|
||||
onClick={() => _handleTabChange(tab)}
|
||||
>
|
||||
{_TAB_LABELS[tab].de}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.tabContent}>
|
||||
{currentTab === 'chats' && renderChats?.(context)}
|
||||
{currentTab === 'files' && renderFiles?.(context)}
|
||||
{currentTab === 'sources' && renderSources?.(context)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedDataBar;
|
||||
6
src/components/UnifiedDataBar/index.ts
Normal file
6
src/components/UnifiedDataBar/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { default as UnifiedDataBar } from './UnifiedDataBar';
|
||||
export type { UdbContext, UdbTab } from './UnifiedDataBar';
|
||||
export { default as ChatsTab } from './ChatsTab';
|
||||
export { default as FilesTab } from './FilesTab';
|
||||
export { default as SourcesTab } from './SourcesTab';
|
||||
export { useUdlContext } from './useUdlContext';
|
||||
23
src/components/UnifiedDataBar/useUdlContext.ts
Normal file
23
src/components/UnifiedDataBar/useUdlContext.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { UdbContext } from './UnifiedDataBar';
|
||||
|
||||
/**
|
||||
* Build a UDL (Unified Data Layer) context from the current feature instance.
|
||||
* Features use this to query scope-based data from the UDL
|
||||
* instead of instance-scoped data silos.
|
||||
*
|
||||
* FeatureInstance -> UI-Scope (workflow surface)
|
||||
* UDL -> Data-Scope (actual data access boundary)
|
||||
*/
|
||||
export function useUdlContext(
|
||||
instanceId: string,
|
||||
mandateId?: string,
|
||||
userId?: string
|
||||
): UdbContext {
|
||||
return useMemo(() => ({
|
||||
instanceId,
|
||||
mandateId,
|
||||
featureInstanceId: instanceId,
|
||||
userId,
|
||||
}), [instanceId, mandateId, userId]);
|
||||
}
|
||||
|
|
@ -279,6 +279,7 @@ export function useRegister() {
|
|||
interface GoogleAuthResponse {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
isNewUser?: boolean;
|
||||
user: {
|
||||
username: string;
|
||||
email: string;
|
||||
|
|
|
|||
|
|
@ -11,40 +11,64 @@ import {
|
|||
fetchStoreFeatures,
|
||||
activateStoreFeature,
|
||||
deactivateStoreFeature,
|
||||
fetchUserMandates,
|
||||
fetchSubscriptionInfo,
|
||||
type StoreFeature,
|
||||
type UserMandate,
|
||||
type SubscriptionInfo,
|
||||
} from '../api/storeApi';
|
||||
import { useFeatureStore } from '../stores/featureStore';
|
||||
|
||||
interface UseStoreReturn {
|
||||
features: StoreFeature[];
|
||||
mandates: UserMandate[];
|
||||
subscriptionInfo: SubscriptionInfo | null;
|
||||
loading: boolean;
|
||||
actionLoading: string | null;
|
||||
error: string | null;
|
||||
loadStore: () => Promise<void>;
|
||||
activate: (featureCode: string) => Promise<void>;
|
||||
deactivate: (featureCode: string) => Promise<void>;
|
||||
loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
|
||||
activate: (featureCode: string, mandateId?: string) => Promise<void>;
|
||||
deactivate: (featureCode: string, mandateId: string, instanceId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useStore(): UseStoreReturn {
|
||||
const [features, setFeatures] = useState<StoreFeature[]>([]);
|
||||
const [mandates, setMandates] = useState<UserMandate[]>([]);
|
||||
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const featureStore = useFeatureStore();
|
||||
|
||||
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
|
||||
try {
|
||||
const info = await fetchSubscriptionInfo(mandateId);
|
||||
setSubscriptionInfo(info);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadStore = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchStoreFeatures();
|
||||
const [data, userMandates] = await Promise.all([
|
||||
fetchStoreFeatures(),
|
||||
fetchUserMandates(),
|
||||
]);
|
||||
setFeatures(data);
|
||||
setMandates(userMandates);
|
||||
const firstMandateId = userMandates.length > 0 ? userMandates[0].id : undefined;
|
||||
await loadSubscriptionInfo(firstMandateId);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load store';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [loadSubscriptionInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStore();
|
||||
|
|
@ -56,11 +80,11 @@ export function useStore(): UseStoreReturn {
|
|||
await loadStore();
|
||||
}, [featureStore, loadStore]);
|
||||
|
||||
const activate = useCallback(async (featureCode: string) => {
|
||||
const activate = useCallback(async (featureCode: string, mandateId?: string) => {
|
||||
setActionLoading(featureCode);
|
||||
setError(null);
|
||||
try {
|
||||
await activateStoreFeature(featureCode);
|
||||
await activateStoreFeature(featureCode, mandateId);
|
||||
await _refreshAfterAction();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Activation failed';
|
||||
|
|
@ -70,11 +94,11 @@ export function useStore(): UseStoreReturn {
|
|||
}
|
||||
}, [_refreshAfterAction]);
|
||||
|
||||
const deactivate = useCallback(async (featureCode: string) => {
|
||||
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
|
||||
setActionLoading(featureCode);
|
||||
setError(null);
|
||||
try {
|
||||
await deactivateStoreFeature(featureCode);
|
||||
await deactivateStoreFeature(featureCode, mandateId, instanceId);
|
||||
await _refreshAfterAction();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
||||
|
|
@ -84,7 +108,7 @@ export function useStore(): UseStoreReturn {
|
|||
}
|
||||
}, [_refreshAfterAction]);
|
||||
|
||||
return { features, loading, actionLoading, error, loadStore, activate, deactivate };
|
||||
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
|
||||
}
|
||||
|
||||
export default useStore;
|
||||
|
|
|
|||
|
|
@ -242,6 +242,50 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ctaSection {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctaPrimary {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 25px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.ctaPrimary:hover {
|
||||
background-color: var(--color-secondary-hover);
|
||||
}
|
||||
|
||||
.ctaSecondary {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 25px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-secondary);
|
||||
background-color: transparent;
|
||||
color: var(--color-secondary);
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.ctaSecondary:hover {
|
||||
background-color: color-mix(in srgb, var(--color-secondary) 10%, transparent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa';
|
|||
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
|
||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||
import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||
import OnboardingWizard from '../components/OnboardingWizard';
|
||||
|
||||
import styles from './Login.module.css';
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ function Login() {
|
|||
const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
|
||||
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
||||
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
||||
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
|
||||
|
||||
// Check for pending invitation
|
||||
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||
|
|
@ -84,6 +86,10 @@ function Login() {
|
|||
console.log("Attempting Google login...");
|
||||
const response = await loginWithGoogle();
|
||||
console.log("Google login successful:", response);
|
||||
if (response?.isNewUser) {
|
||||
setShowOnboardingWizard(true);
|
||||
return;
|
||||
}
|
||||
handleSuccessfulLogin();
|
||||
} catch (error) {
|
||||
console.error("Google login failed:", error);
|
||||
|
|
@ -104,6 +110,21 @@ function Login() {
|
|||
}
|
||||
};
|
||||
|
||||
if (showOnboardingWizard) {
|
||||
return (
|
||||
<OnboardingWizard
|
||||
onComplete={() => {
|
||||
setShowOnboardingWizard(false);
|
||||
handleSuccessfulLogin();
|
||||
}}
|
||||
onDismiss={() => {
|
||||
setShowOnboardingWizard(false);
|
||||
handleSuccessfulLogin();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.mainContent}>
|
||||
|
|
@ -213,12 +234,22 @@ function Login() {
|
|||
</button>
|
||||
|
||||
<div className={styles.registerLink}>
|
||||
<span>Du hast noch keinen Konto?</span>
|
||||
<span>Du hast noch kein Konto?</span>
|
||||
</div>
|
||||
<div className={styles.ctaSection}>
|
||||
<button
|
||||
className={styles.textButton}
|
||||
onClick={() => navigate("/register", { state: location.state })}
|
||||
type="button"
|
||||
className={styles.ctaPrimary}
|
||||
onClick={() => navigate('/register?type=personal', { state: location.state })}
|
||||
>
|
||||
Registrieren
|
||||
Kostenlos testen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctaSecondary}
|
||||
onClick={() => navigate('/register?type=company', { state: location.state })}
|
||||
>
|
||||
Für Unternehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { FaEnvelopeOpenText } from 'react-icons/fa';
|
||||
|
||||
import styles from './Register.module.css';
|
||||
|
|
@ -27,6 +27,10 @@ function Register() {
|
|||
email: invitationEmail,
|
||||
fullName: ''
|
||||
});
|
||||
const [searchParams] = useSearchParams();
|
||||
const registrationType = searchParams.get('type') === 'company' ? 'company' : 'personal';
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [companyNameFocused, setCompanyNameFocused] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [usernameFocused, setUsernameFocused] = useState(false);
|
||||
|
|
@ -40,11 +44,13 @@ function Register() {
|
|||
|
||||
// Set page title and generate CSRF token
|
||||
useEffect(() => {
|
||||
document.title = "PowerOn AI Platform - Registrieren";
|
||||
document.title = registrationType === 'company'
|
||||
? "PowerOn AI Platform - Unternehmenskonto erstellen"
|
||||
: "PowerOn AI Platform - Kostenlos testen";
|
||||
|
||||
// Generate CSRF token for new security implementation
|
||||
generateAndStoreCSRFToken();
|
||||
}, []);
|
||||
}, [registrationType]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
|
@ -70,6 +76,11 @@ function Register() {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (registrationType === 'company' && !companyName.trim()) {
|
||||
setValidationError('Bitte geben Sie einen Firmennamen ein.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -97,7 +108,7 @@ function Register() {
|
|||
}
|
||||
|
||||
// Username is available, proceed with registration (no password - magic link flow)
|
||||
await register(formData);
|
||||
await register({ ...formData, registrationType, companyName: registrationType === 'company' ? companyName : undefined });
|
||||
|
||||
// Build success message
|
||||
let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.';
|
||||
|
|
@ -192,6 +203,22 @@ function Register() {
|
|||
<label className={emailFocused || formData.email ? styles.focusedLabel : styles.label}>E-Mail</label>
|
||||
</div>
|
||||
|
||||
{registrationType === 'company' && (
|
||||
<div className={styles.floatingLabelInput}>
|
||||
<input
|
||||
type="text"
|
||||
name="companyName"
|
||||
placeholder=" "
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
onFocus={() => setCompanyNameFocused(true)}
|
||||
onBlur={() => setCompanyNameFocused(false)}
|
||||
className={`${styles.input} ${companyNameFocused || companyName ? styles.focused : ''}`}
|
||||
/>
|
||||
<label className={companyNameFocused || companyName ? styles.focusedLabel : styles.label}>Firmenname *</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.floatingLabelInput}>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -221,7 +248,7 @@ function Register() {
|
|||
onClick={handleSubmit}
|
||||
disabled={isLoading || isChecking}
|
||||
>
|
||||
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : "Registrieren"}
|
||||
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : registrationType === 'company' ? 'Unternehmenskonto erstellen' : 'Kostenlos testen'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,52 @@
|
|||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* Subscription Banner */
|
||||
.subscriptionBanner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
background: var(--info-bg, #eff6ff);
|
||||
border: 1px solid var(--info-border, #bfdbfe);
|
||||
color: var(--info-color, #1e40af);
|
||||
}
|
||||
|
||||
.bannerSeparator::before {
|
||||
content: '|';
|
||||
margin-right: 0.25rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Mandate Select */
|
||||
.mandateSelect {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
background: var(--surface-color, #ffffff);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.mandateSelect:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mandateHint {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
|
|
@ -120,6 +166,49 @@
|
|||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Instance List */
|
||||
.instanceList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.instanceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.instanceInfo {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deactivateButtonSmall {
|
||||
flex-shrink: 0;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.deactivateButtonSmall:hover:not(:disabled) {
|
||||
border-color: var(--error-color, #dc2626);
|
||||
color: var(--error-color, #dc2626);
|
||||
background: var(--error-bg, #fef2f2);
|
||||
}
|
||||
|
||||
.deactivateButtonSmall:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.cardActions {
|
||||
padding-top: 0.5rem;
|
||||
|
|
@ -243,17 +332,35 @@
|
|||
border-top-color: var(--border-dark, #333);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .deactivateButton {
|
||||
:global(.dark-theme) .deactivateButton,
|
||||
:global(.dark-theme) .deactivateButtonSmall {
|
||||
border-color: var(--border-dark, #444);
|
||||
color: var(--text-secondary-dark, #aaa);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .deactivateButton:hover:not(:disabled) {
|
||||
:global(.dark-theme) .deactivateButton:hover:not(:disabled),
|
||||
:global(.dark-theme) .deactivateButtonSmall:hover:not(:disabled) {
|
||||
border-color: var(--error-color-dark, #f87171);
|
||||
color: var(--error-color-dark, #f87171);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .subscriptionBanner {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
border-color: rgba(37, 99, 235, 0.25);
|
||||
color: var(--primary-light, #93bbfc);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .mandateSelect {
|
||||
background: var(--surface-dark, #1a1a1a);
|
||||
border-color: var(--border-dark, #444);
|
||||
color: var(--text-primary-dark, #ffffff);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .mandateHint {
|
||||
color: var(--text-secondary-dark, #aaa);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .error {
|
||||
background: var(--error-bg-dark, #450a0a);
|
||||
border-color: var(--error-border-dark, #991b1b);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
* and users get their own FeatureAccess + user-role upon activation.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useStore } from '../hooks/useStore';
|
||||
import type { StoreFeature } from '../api/storeApi';
|
||||
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
||||
import styles from './Store.module.css';
|
||||
|
||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||
|
|
@ -62,23 +62,39 @@ function _getDescription(featureCode: string, lang: string): string {
|
|||
interface FeatureCardProps {
|
||||
feature: StoreFeature;
|
||||
language: string;
|
||||
mandates: UserMandate[];
|
||||
actionLoading: string | null;
|
||||
onActivate: (code: string) => void;
|
||||
onDeactivate: (code: string) => void;
|
||||
onActivate: (code: string, mandateId?: string) => void;
|
||||
onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
|
||||
}
|
||||
|
||||
const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
feature,
|
||||
language,
|
||||
mandates,
|
||||
actionLoading,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
}) => {
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const isProcessing = actionLoading === feature.featureCode;
|
||||
const icon = FEATURE_ICONS[feature.featureCode];
|
||||
const activeInstances = feature.instances.filter(inst => inst.isActive);
|
||||
const hasActive = activeInstances.length > 0;
|
||||
const needsMandateSelection = mandates.length > 1;
|
||||
|
||||
const _handleActivate = () => {
|
||||
if (needsMandateSelection) {
|
||||
onActivate(feature.featureCode, selectedMandateId || undefined);
|
||||
} else if (mandates.length === 1) {
|
||||
onActivate(feature.featureCode, mandates[0].id);
|
||||
} else {
|
||||
onActivate(feature.featureCode);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.card} ${feature.isActive ? styles.cardActive : ''}`}>
|
||||
<div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}>
|
||||
<div className={styles.cardHeader}>
|
||||
{icon && <span className={styles.cardIcon}>{icon}</span>}
|
||||
<h3 className={styles.cardTitle}>
|
||||
|
|
@ -92,36 +108,76 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={`${styles.statusBadge} ${feature.isActive ? styles.statusActive : styles.statusInactive}`}>
|
||||
<span className={styles.statusDot} />
|
||||
{feature.isActive
|
||||
? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active')
|
||||
: (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')}
|
||||
</span>
|
||||
</div>
|
||||
{activeInstances.length > 0 && (
|
||||
<div className={styles.instanceList}>
|
||||
{activeInstances.map((inst) => (
|
||||
<div key={inst.instanceId} className={styles.instanceRow}>
|
||||
<div className={styles.instanceInfo}>
|
||||
<span className={`${styles.statusBadge} ${styles.statusActive}`}>
|
||||
<span className={styles.statusDot} />
|
||||
{inst.mandateName || inst.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className={styles.deactivateButtonSmall}
|
||||
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? '...'
|
||||
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeInstances.length === 0 && (
|
||||
<div>
|
||||
<span className={`${styles.statusBadge} ${styles.statusInactive}`}>
|
||||
<span className={styles.statusDot} />
|
||||
{language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
{feature.isActive ? (
|
||||
<button
|
||||
className={styles.deactivateButton}
|
||||
onClick={() => onDeactivate(feature.featureCode)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? (language === 'de' ? 'Wird deaktiviert...' : 'Deactivating...')
|
||||
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={styles.activateButton}
|
||||
onClick={() => onActivate(feature.featureCode)}
|
||||
disabled={isProcessing || !feature.canActivate}
|
||||
>
|
||||
{isProcessing
|
||||
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
|
||||
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
|
||||
</button>
|
||||
{feature.canActivate && (
|
||||
<>
|
||||
{mandates.length === 0 && (
|
||||
<p className={styles.mandateHint}>
|
||||
{language === 'de'
|
||||
? 'Ein persoenliches Konto wird automatisch erstellt.'
|
||||
: language === 'fr'
|
||||
? 'Un compte personnel sera cree automatiquement.'
|
||||
: 'A personal account will be created automatically.'}
|
||||
</p>
|
||||
)}
|
||||
{needsMandateSelection && (
|
||||
<select
|
||||
className={styles.mandateSelect}
|
||||
value={selectedMandateId}
|
||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<option value="">
|
||||
{language === 'de' ? '-- Mandant waehlen --' : language === 'fr' ? '-- Choisir mandat --' : '-- Select mandate --'}
|
||||
</option>
|
||||
{mandates.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.label || m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
className={styles.activateButton}
|
||||
onClick={_handleActivate}
|
||||
disabled={isProcessing || (needsMandateSelection && !selectedMandateId)}
|
||||
>
|
||||
{isProcessing
|
||||
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
|
||||
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -130,7 +186,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
|
||||
const StorePage: React.FC = () => {
|
||||
const { currentLanguage } = useLanguage();
|
||||
const { features, loading, actionLoading, error, activate, deactivate } = useStore();
|
||||
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
|
||||
|
||||
return (
|
||||
<div className={styles.store}>
|
||||
|
|
@ -145,6 +201,27 @@ const StorePage: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{subscriptionInfo && subscriptionInfo.plan && (
|
||||
<div className={styles.subscriptionBanner}>
|
||||
<span>Plan: <strong>{subscriptionInfo.plan}</strong></span>
|
||||
{subscriptionInfo.maxFeatureInstances != null && (
|
||||
<span className={styles.bannerSeparator}>
|
||||
{currentLanguage === 'de' ? 'Instanzen' : 'Instances'}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances}
|
||||
</span>
|
||||
)}
|
||||
{subscriptionInfo.maxDataVolumeMB != null && (
|
||||
<span className={styles.bannerSeparator}>
|
||||
{currentLanguage === 'de' ? 'Speicher' : 'Storage'}: {subscriptionInfo.maxDataVolumeMB} MB
|
||||
</span>
|
||||
)}
|
||||
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
|
||||
<span className={styles.bannerSeparator}>
|
||||
{currentLanguage === 'de' ? 'Trial endet' : 'Trial ends'}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -164,6 +241,7 @@ const StorePage: React.FC = () => {
|
|||
key={feature.featureCode}
|
||||
feature={feature}
|
||||
language={currentLanguage}
|
||||
mandates={mandates}
|
||||
actionLoading={actionLoading}
|
||||
onActivate={activate}
|
||||
onDeactivate={deactivate}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,56 @@
|
|||
/* Outer flex layout: UDB sidebar + main dossier */
|
||||
.dossierLayout {
|
||||
display: flex;
|
||||
height: calc(100vh - 140px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.udbSidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card, #fff);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: width 0.2s, min-width 0.2s;
|
||||
}
|
||||
|
||||
.udbSidebarCollapsed {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.udbToggle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary, #888);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.udbToggle:hover {
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.dossier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 140px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,48 +1,53 @@
|
|||
/**
|
||||
* CommCoach Dossier View (Main View)
|
||||
*
|
||||
* Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents.
|
||||
* Unified view per context: Coaching session, Tasks, Sessions history, Scores.
|
||||
* Voice first, always with text fallback.
|
||||
* Files & Sources are provided via the shared UnifiedDataBar sidebar.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import api from '../../../api';
|
||||
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
||||
import {
|
||||
getDossierExportUrl, getSessionExportUrl,
|
||||
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
|
||||
getScoreHistoryApi, getPersonasApi,
|
||||
type CoachingDocument, type CoachingPersona,
|
||||
type CoachingPersona,
|
||||
} from '../../../api/commcoachApi';
|
||||
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { UnifiedDataBar, FilesTab, SourcesTab } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext } from '../../../components/UnifiedDataBar';
|
||||
import styles from './CommcoachDossierView.module.css';
|
||||
import { useVoiceController } from './useVoiceController';
|
||||
|
||||
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents';
|
||||
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
|
||||
|
||||
export const CommcoachDossierView: React.FC = () => {
|
||||
const coach = useCommcoach();
|
||||
const { request } = useApiRequest();
|
||||
const instanceId = useInstanceId();
|
||||
const mandateId = useMandateId();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
||||
const [showNewContext, setShowNewContext] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [newCategory, setNewCategory] = useState('custom');
|
||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||
const [documents, setDocuments] = useState<CoachingDocument[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||||
|
||||
const _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;
|
||||
|
|
@ -82,27 +87,14 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
}
|
||||
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||||
|
||||
// Load documents, scores, personas when context changes
|
||||
// Load scores, personas when context changes
|
||||
useEffect(() => {
|
||||
if (!instanceId || !coach.selectedContextId) return;
|
||||
getDocumentsApi(request, instanceId, coach.selectedContextId)
|
||||
.then(d => setDocuments(d))
|
||||
.catch(() => {});
|
||||
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
||||
.then(h => setScoreHistory(h))
|
||||
.catch(() => {});
|
||||
}, [instanceId, request, coach.selectedContextId]);
|
||||
|
||||
useEffect(() => {
|
||||
coach.onDocumentCreatedRef.current = (doc) => {
|
||||
setDocuments(prev => {
|
||||
if (prev.some(d => d.id === doc.id)) return prev;
|
||||
return [doc, ...prev];
|
||||
});
|
||||
};
|
||||
return () => { coach.onDocumentCreatedRef.current = null; };
|
||||
}, [coach.onDocumentCreatedRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
getPersonasApi(request, instanceId)
|
||||
|
|
@ -144,46 +136,6 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
coach.selectContext(contextId, { skipSessionResume: true });
|
||||
}, [coach]);
|
||||
|
||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !instanceId || !coach.selectedContextId) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
|
||||
setDocuments(prev => [doc, ...prev]);
|
||||
} catch { /* upload failed */ } finally {
|
||||
setUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
}, [instanceId, coach.selectedContextId]);
|
||||
|
||||
const handleDeleteDocument = useCallback(async (docId: string) => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
await deleteDocumentApi(request, instanceId, docId);
|
||||
setDocuments(prev => prev.filter(d => d.id !== docId));
|
||||
} catch { /* delete failed */ }
|
||||
}, [instanceId, request]);
|
||||
|
||||
const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => {
|
||||
if (!doc.fileRef) return;
|
||||
try {
|
||||
const response = await api.get(`/api/files/${doc.fileRef}/download`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(response.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddTask = useCallback(async () => {
|
||||
if (!newTaskTitle.trim()) return;
|
||||
await coach.addTask(newTaskTitle);
|
||||
|
|
@ -195,7 +147,31 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dossier}>
|
||||
<div className={styles.dossierLayout}>
|
||||
{/* UDB Sidebar */}
|
||||
{_udbContext && (
|
||||
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
||||
<button
|
||||
className={styles.udbToggle}
|
||||
onClick={() => setUdbCollapsed(v => !v)}
|
||||
title={udbCollapsed ? 'Seitenleiste einblenden' : 'Seitenleiste ausblenden'}
|
||||
>
|
||||
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||
</button>
|
||||
{!udbCollapsed && (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
activeTab="files"
|
||||
renderChats={() => null}
|
||||
renderFiles={(ctx) => <FilesTab context={ctx} />}
|
||||
renderSources={(ctx) => <SourcesTab context={ctx} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={styles.dossier}>
|
||||
{/* Context Selector */}
|
||||
<div className={styles.contextSelector}>
|
||||
{coach.contexts.map(ctx => (
|
||||
|
|
@ -286,13 +262,13 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
|
||||
{/* Tab Navigation */}
|
||||
<div className={styles.tabs}>
|
||||
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
|
||||
{(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{_tabLabel(tab, coach, documents)}
|
||||
{_tabLabel(tab, coach)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -546,40 +522,6 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* DOCUMENTS TAB */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'documents' && (
|
||||
<div className={styles.tabContent}>
|
||||
<div className={styles.addTaskRow}>
|
||||
<label className={styles.uploadLabel}>
|
||||
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
|
||||
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
|
||||
</label>
|
||||
</div>
|
||||
{documents.length === 0 ? (
|
||||
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.</div>
|
||||
) : (
|
||||
<div className={styles.documentList}>
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} className={styles.documentItem}>
|
||||
<div className={styles.documentInfo}>
|
||||
<div className={styles.documentName}>{doc.fileName}</div>
|
||||
<div className={styles.documentMeta}>
|
||||
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
|
||||
</div>
|
||||
{doc.summary && <div className={styles.documentSummary}>{doc.summary}</div>}
|
||||
</div>
|
||||
<div className={styles.documentActions}>
|
||||
<button className={styles.btnExport} onClick={() => handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download</button>
|
||||
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>x</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
{/* #region agent log */}
|
||||
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
|
||||
|
|
@ -595,6 +537,7 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
</div>
|
||||
{/* #endregion */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -607,13 +550,12 @@ function _categoryIcon(category: string): string {
|
|||
return icons[category] || '*';
|
||||
}
|
||||
|
||||
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string {
|
||||
function _tabLabel(tab: TabKey, coach: any): string {
|
||||
switch (tab) {
|
||||
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
|
||||
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
|
||||
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
||||
case 'scores': return `Bewertungen (${coach.scores.length})`;
|
||||
case 'documents': return `Dokumente (${documents.length})`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -634,12 +576,6 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
|
|||
return Object.values(groups);
|
||||
}
|
||||
|
||||
function _formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function _dimensionLabel(dim: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import api from '../../../api';
|
||||
import {
|
||||
getProfileApi, updateProfileApi,
|
||||
getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi,
|
||||
|
|
@ -14,6 +15,18 @@ import {
|
|||
} from '../../../api/commcoachApi';
|
||||
import styles from './CommcoachSettingsView.module.css';
|
||||
|
||||
async function _syncSharedVoicePreferences(lang: string, voice?: string): Promise<void> {
|
||||
try {
|
||||
await api.put('/api/local/voice-preferences', {
|
||||
sttLanguage: lang,
|
||||
ttsLanguage: lang,
|
||||
ttsVoice: voice ?? null,
|
||||
});
|
||||
} catch {
|
||||
// Silent fallback — shared prefs sync is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export const CommcoachSettingsView: React.FC = () => {
|
||||
const { request } = useApiRequest();
|
||||
const instanceId = useInstanceId();
|
||||
|
|
@ -88,6 +101,9 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
emailSummaryEnabled: emailEnabled,
|
||||
});
|
||||
setProfile(updated);
|
||||
|
||||
_syncSharedVoicePreferences(language, voiceId || undefined);
|
||||
|
||||
setSuccess('Einstellungen gespeichert');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err: any) {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,32 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
|||
charCount={(msg as any)._audioCharCount}
|
||||
/>
|
||||
)}
|
||||
{msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && (
|
||||
<details className="sentDataDetails" style={{ marginTop: 8, fontSize: '0.8rem', borderTop: '1px solid var(--border-color, #e5e7eb)', paddingTop: 6 }}>
|
||||
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #6b7280)', fontWeight: 500, userSelect: 'none' }}>
|
||||
Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'})
|
||||
</summary>
|
||||
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{msg.documents.map((doc, idx) => (
|
||||
<div key={doc.id || idx} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-hover, rgba(0,0,0,0.02))', borderRadius: 4 }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{doc.documentName || doc.fileName || `Dokument ${idx + 1}`}
|
||||
</span>
|
||||
{doc.validationMetadata?.neutralized && (
|
||||
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#dcfce7', color: '#166534' }}>
|
||||
neutralisiert
|
||||
</span>
|
||||
)}
|
||||
{doc.validationMetadata?.skipped && (
|
||||
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#fef2f2', color: '#991b1b' }}>
|
||||
übersprungen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
191
src/pages/views/workspace/NeutralizationPanel.tsx
Normal file
191
src/pages/views/workspace/NeutralizationPanel.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../../../api';
|
||||
|
||||
interface NeutralizationMapping {
|
||||
id: string;
|
||||
originalText: string;
|
||||
placeholder: string;
|
||||
patternType: string;
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface NeutralizationSource {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
neutralizationStatus: string;
|
||||
mappingCount: number;
|
||||
}
|
||||
|
||||
interface NeutralizationPanelProps {
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId }) => {
|
||||
const [sources, setSources] = useState<NeutralizationSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const _loadSources = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/api/workspace/${instanceId}/files`);
|
||||
const files = response.data?.data || response.data || [];
|
||||
const neutralized = files
|
||||
.filter((f: any) => f.neutralize)
|
||||
.map((f: any) => ({
|
||||
fileId: f.id,
|
||||
fileName: f.fileName || f.name || 'unknown',
|
||||
neutralizationStatus: f.neutralizationStatus || f.status || 'unknown',
|
||||
mappingCount: 0,
|
||||
}));
|
||||
setSources(neutralized);
|
||||
} catch (err) {
|
||||
console.error('Failed to load neutralization sources:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
const _loadMappings = useCallback(async (fileId: string) => {
|
||||
try {
|
||||
const response = await api.get(`/api/neutralization/${instanceId}/attributes`, { params: { fileId } });
|
||||
const data = response.data?.data || response.data || [];
|
||||
setMappings(data.map((m: any) => ({
|
||||
id: m.id,
|
||||
originalText: m.originalText || '',
|
||||
placeholder: m.placeholder || m.id,
|
||||
patternType: m.patternType || 'unknown',
|
||||
fileId: m.fileId,
|
||||
fileName: m.fileName,
|
||||
createdAt: m.createdAt || m._createdAt,
|
||||
})));
|
||||
} catch (err) {
|
||||
console.error('Failed to load mappings:', err);
|
||||
setMappings([]);
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { _loadSources(); }, [_loadSources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSource) _loadMappings(selectedSource);
|
||||
}, [selectedSource, _loadMappings]);
|
||||
|
||||
const _handleDeleteMapping = async (mappingId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/neutralization/${instanceId}/attributes/single/${mappingId}`);
|
||||
setMappings(prev => prev.filter(m => m.id !== mappingId));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete mapping:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleRetrigger = async (fileId: string) => {
|
||||
try {
|
||||
await api.post(`/api/neutralization/${instanceId}/retrigger`, { fileId });
|
||||
_loadSources();
|
||||
} catch (err) {
|
||||
console.error('Failed to retrigger neutralization:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const _statusBadge = (status: string) => {
|
||||
const colors: Record<string, { bg: string; text: string }> = {
|
||||
completed: { bg: '#dcfce7', text: '#166534' },
|
||||
pending: { bg: '#fef3c7', text: '#92400e' },
|
||||
failed: { bg: '#fef2f2', text: '#991b1b' },
|
||||
not_required: { bg: '#f3f4f6', text: '#6b7280' },
|
||||
};
|
||||
const c = colors[status] || colors.not_required;
|
||||
return (
|
||||
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: 10, background: c.bg, color: c.text }}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: 16, textAlign: 'center', color: '#6b7280' }}>Lade Neutralisierungsdaten...</div>;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>Neutralisierung</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
|
||||
Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings.
|
||||
</p>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
|
||||
Keine Datenquellen mit aktiver Neutralisierung.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sources.map((src) => (
|
||||
<div
|
||||
key={src.fileId}
|
||||
style={{
|
||||
padding: '12px 16px', borderRadius: 8,
|
||||
border: selectedSource === src.fileId ? '2px solid var(--accent, #4f46e5)' : '1px solid var(--border-color, #e5e7eb)',
|
||||
cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}
|
||||
onClick={() => setSelectedSource(src.fileId === selectedSource ? null : src.fileId)}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>{src.fileName}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#6b7280', marginTop: 2 }}>
|
||||
{_statusBadge(src.neutralizationStatus)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); _handleRetrigger(src.fileId); }}
|
||||
style={{ fontSize: '0.8rem', padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-color, #d1d5db)', background: 'transparent', cursor: 'pointer' }}
|
||||
>
|
||||
Erneut neutralisieren
|
||||
</button>
|
||||
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
|
||||
{selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSource && mappings.length > 0 && (
|
||||
<div style={{ border: '1px solid var(--border-color, #e5e7eb)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '8px 16px', background: 'var(--bg-hover, #f9fafb)', fontSize: '0.85rem', fontWeight: 500 }}>
|
||||
Platzhalter-Mappings ({mappings.length})
|
||||
</div>
|
||||
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
||||
{mappings.map((m) => (
|
||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '8px 16px', borderTop: '1px solid var(--border-color, #f3f4f6)', fontSize: '0.8rem', gap: 12 }}>
|
||||
<span style={{ flex: 1, fontFamily: 'monospace', color: '#4f46e5' }}>{m.placeholder}</span>
|
||||
<span style={{ color: '#9ca3af' }}>{'\u2192'}</span>
|
||||
<span style={{ flex: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.originalText}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: '#9ca3af', flexShrink: 0 }}>{m.patternType}</span>
|
||||
<button
|
||||
onClick={() => _handleDeleteMapping(m.id)}
|
||||
style={{ color: '#ef4444', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.9rem', padding: '2px 6px' }}
|
||||
title="Mapping löschen"
|
||||
>
|
||||
{'\u00D7'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSource && mappings.length === 0 && (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
|
||||
Keine Mappings für diese Datenquelle.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NeutralizationPanel;
|
||||
|
|
@ -263,7 +263,10 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
}, [onPasteAsFile]);
|
||||
|
||||
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes('application/tree-items')) {
|
||||
if (
|
||||
e.dataTransfer.types.includes('application/tree-items') ||
|
||||
e.dataTransfer.types.includes('application/chat-id')
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setTreeDropOver(true);
|
||||
|
|
@ -273,11 +276,22 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
|
||||
|
||||
const _handlePromptDrop = useCallback((e: React.DragEvent) => {
|
||||
setTreeDropOver(false);
|
||||
|
||||
const chatId = e.dataTransfer.getData('application/chat-id');
|
||||
if (chatId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const chatLabel = e.dataTransfer.getData('text/plain');
|
||||
const ref = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
|
||||
setPrompt(prev => (prev ? `${prev} ${ref}` : ref));
|
||||
return;
|
||||
}
|
||||
|
||||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||
if (treeItemsJson && onTreeItemsDrop) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTreeDropOver(false);
|
||||
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
|
||||
onTreeItemsDrop(items);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import { FileBrowser } from './FileBrowser';
|
|||
import { DataSourcePanel } from './DataSourcePanel';
|
||||
import { FilePreview } from './FilePreview';
|
||||
import { ToolActivityLog } from './ToolActivityLog';
|
||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext } from '../../../components/UnifiedDataBar';
|
||||
import OnboardingAssistant from '../../../components/OnboardingAssistant';
|
||||
|
||||
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
|
||||
const [width, setWidth] = useState(initialWidth);
|
||||
|
|
@ -52,7 +55,6 @@ function _useResizable(initialWidth: number, minWidth: number, maxWidth: number)
|
|||
|
||||
return { width, onMouseDown: _onMouseDown };
|
||||
}
|
||||
type LeftTab = 'conversations' | 'files' | 'datasources';
|
||||
type RightTab = 'activity' | 'preview';
|
||||
|
||||
interface PendingFile {
|
||||
|
|
@ -78,7 +80,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const _leftResize = _useResizable(280, 200, 450);
|
||||
const _rightResize = _useResizable(320, 200, 500);
|
||||
const [leftTab, setLeftTab] = useState<LeftTab>('conversations');
|
||||
const [rightTab, setRightTab] = useState<RightTab>('activity');
|
||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||
|
|
@ -210,43 +211,42 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
textTransform: 'uppercase' as const,
|
||||
});
|
||||
|
||||
const _leftPanelBody = (
|
||||
<>
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||
<button style={tabButtonStyle(leftTab === 'conversations')} onClick={() => setLeftTab('conversations')}>Chats</button>
|
||||
<button style={tabButtonStyle(leftTab === 'files')} onClick={() => setLeftTab('files')}>Files</button>
|
||||
<button style={tabButtonStyle(leftTab === 'datasources')} onClick={() => setLeftTab('datasources')}>Sources</button>
|
||||
</div>
|
||||
const _udbContext: UdbContext = {
|
||||
instanceId: instanceId,
|
||||
mandateId: mandateId,
|
||||
featureInstanceId: instanceId,
|
||||
};
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{leftTab === 'conversations' && (
|
||||
<ConversationList
|
||||
instanceId={instanceId}
|
||||
activeWorkflowId={workspace.workflowId}
|
||||
onSelect={_handleConversationSelect}
|
||||
onCreateNew={workspace.resetToNew}
|
||||
refreshTrigger={workspace.workflowVersion}
|
||||
/>
|
||||
)}
|
||||
{leftTab === 'files' && (
|
||||
<FileBrowser
|
||||
instanceId={instanceId}
|
||||
files={workspace.files}
|
||||
onRefresh={workspace.refreshFiles}
|
||||
onFileSelect={_handleFileSelect}
|
||||
/>
|
||||
)}
|
||||
{leftTab === 'datasources' && (
|
||||
<DataSourcePanel
|
||||
instanceId={instanceId}
|
||||
dataSources={workspace.dataSources}
|
||||
featureDataSources={workspace.featureDataSources}
|
||||
onRefresh={workspace.refreshDataSources}
|
||||
onRefreshFeatureDataSources={workspace.refreshFeatureDataSources}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
const _leftPanelBody = (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
renderChats={(ctx) => (
|
||||
<ConversationList
|
||||
instanceId={ctx.instanceId}
|
||||
activeWorkflowId={workspace.workflowId}
|
||||
onSelect={_handleConversationSelect}
|
||||
onCreateNew={workspace.resetToNew}
|
||||
refreshTrigger={workspace.workflowVersion}
|
||||
/>
|
||||
)}
|
||||
renderFiles={(ctx) => (
|
||||
<FileBrowser
|
||||
instanceId={ctx.instanceId}
|
||||
files={workspace.files}
|
||||
onRefresh={workspace.refreshFiles}
|
||||
onFileSelect={_handleFileSelect}
|
||||
/>
|
||||
)}
|
||||
renderSources={(ctx) => (
|
||||
<DataSourcePanel
|
||||
instanceId={ctx.instanceId}
|
||||
dataSources={workspace.dataSources}
|
||||
featureDataSources={workspace.featureDataSources}
|
||||
onRefresh={workspace.refreshDataSources}
|
||||
onRefreshFeatureDataSources={workspace.refreshFeatureDataSources}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const _rightPanelBody = (
|
||||
|
|
@ -386,6 +386,11 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
Dateien hier ablegen
|
||||
</div>
|
||||
)}
|
||||
<OnboardingAssistant
|
||||
instanceId={instanceId}
|
||||
mandateId={mandateId}
|
||||
featureCode={featureCode}
|
||||
/>
|
||||
<ChatStream
|
||||
messages={workspace.messages}
|
||||
agentProgress={workspace.agentProgress}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ import React, { useState } from 'react';
|
|||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { WorkspaceSettings } from './WorkspaceSettings';
|
||||
import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings';
|
||||
import NeutralizationPanel from './NeutralizationPanel';
|
||||
|
||||
type SettingsTab = 'general' | 'voice';
|
||||
type SettingsTab = 'general' | 'voice' | 'neutralization';
|
||||
|
||||
const _TABS: { key: SettingsTab; label: string }[] = [
|
||||
{ key: 'general', label: 'Generelle Einstellungen' },
|
||||
{ key: 'voice', label: 'Sprache & Stimme' },
|
||||
{ key: 'neutralization', label: 'Neutralisierung' },
|
||||
];
|
||||
|
||||
export const WorkspaceSettingsPage: React.FC = () => {
|
||||
|
|
@ -69,6 +71,9 @@ export const WorkspaceSettingsPage: React.FC = () => {
|
|||
{activeTab === 'voice' && (
|
||||
<WorkspaceSettings instanceId={instanceId} />
|
||||
)}
|
||||
{activeTab === 'neutralization' && (
|
||||
<NeutralizationPanel instanceId={instanceId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue