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;
|
language?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
privilege?: string;
|
privilege?: string;
|
||||||
|
registrationType?: 'personal' | 'company';
|
||||||
|
companyName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
|
|
@ -40,6 +42,8 @@ export interface RegisterRequest {
|
||||||
authenticationAuthority: string;
|
authenticationAuthority: string;
|
||||||
};
|
};
|
||||||
frontendUrl: string;
|
frontendUrl: string;
|
||||||
|
registrationType?: string;
|
||||||
|
companyName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordResetRequestResponse {
|
export interface PasswordResetRequestResponse {
|
||||||
|
|
@ -172,7 +176,9 @@ export async function registerApi(registerData: RegisterData): Promise<RegisterR
|
||||||
privilege: registerData.privilege || 'user',
|
privilege: registerData.privilege || 'user',
|
||||||
authenticationAuthority: 'local'
|
authenticationAuthority: 'local'
|
||||||
},
|
},
|
||||||
frontendUrl: window.location.origin
|
frontendUrl: window.location.origin,
|
||||||
|
registrationType: registerData.registrationType,
|
||||||
|
companyName: registerData.companyName,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prepare headers with CSRF token if available
|
// Prepare headers with CSRF token if available
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,21 @@
|
||||||
|
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
|
export interface StoreFeatureInstance {
|
||||||
|
instanceId: string;
|
||||||
|
mandateId: string;
|
||||||
|
mandateName: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreFeature {
|
export interface StoreFeature {
|
||||||
featureCode: string;
|
featureCode: string;
|
||||||
label: Record<string, string>;
|
label: Record<string, string>;
|
||||||
icon: string;
|
icon: string;
|
||||||
description: Record<string, string>;
|
description: Record<string, string>;
|
||||||
isActive: boolean;
|
instances: StoreFeatureInstance[];
|
||||||
canActivate: boolean;
|
canActivate: boolean;
|
||||||
instanceId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoreActivateResponse {
|
export interface StoreActivateResponse {
|
||||||
|
|
@ -31,17 +38,44 @@ export interface StoreDeactivateResponse {
|
||||||
deactivated: boolean;
|
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[]> {
|
export async function fetchStoreFeatures(): Promise<StoreFeature[]> {
|
||||||
const response = await api.get<StoreFeature[]>('/api/store/features');
|
const response = await api.get<StoreFeature[]>('/api/store/features');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function activateStoreFeature(featureCode: string): Promise<StoreActivateResponse> {
|
export async function fetchUserMandates(): Promise<UserMandate[]> {
|
||||||
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode });
|
const response = await api.get<UserMandate[]>('/api/store/mandates');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivateStoreFeature(featureCode: string): Promise<StoreDeactivateResponse> {
|
export async function fetchSubscriptionInfo(mandateId?: string): Promise<SubscriptionInfo> {
|
||||||
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode });
|
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;
|
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;
|
taskNumber: number;
|
||||||
actionNumber: number;
|
actionNumber: number;
|
||||||
actionId: string;
|
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 {
|
interface GoogleAuthResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
tokenType: string;
|
tokenType: string;
|
||||||
|
isNewUser?: boolean;
|
||||||
user: {
|
user: {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
|
||||||
|
|
@ -11,40 +11,64 @@ import {
|
||||||
fetchStoreFeatures,
|
fetchStoreFeatures,
|
||||||
activateStoreFeature,
|
activateStoreFeature,
|
||||||
deactivateStoreFeature,
|
deactivateStoreFeature,
|
||||||
|
fetchUserMandates,
|
||||||
|
fetchSubscriptionInfo,
|
||||||
type StoreFeature,
|
type StoreFeature,
|
||||||
|
type UserMandate,
|
||||||
|
type SubscriptionInfo,
|
||||||
} from '../api/storeApi';
|
} from '../api/storeApi';
|
||||||
import { useFeatureStore } from '../stores/featureStore';
|
import { useFeatureStore } from '../stores/featureStore';
|
||||||
|
|
||||||
interface UseStoreReturn {
|
interface UseStoreReturn {
|
||||||
features: StoreFeature[];
|
features: StoreFeature[];
|
||||||
|
mandates: UserMandate[];
|
||||||
|
subscriptionInfo: SubscriptionInfo | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
actionLoading: string | null;
|
actionLoading: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loadStore: () => Promise<void>;
|
loadStore: () => Promise<void>;
|
||||||
activate: (featureCode: string) => Promise<void>;
|
loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
|
||||||
deactivate: (featureCode: string) => Promise<void>;
|
activate: (featureCode: string, mandateId?: string) => Promise<void>;
|
||||||
|
deactivate: (featureCode: string, mandateId: string, instanceId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStore(): UseStoreReturn {
|
export function useStore(): UseStoreReturn {
|
||||||
const [features, setFeatures] = useState<StoreFeature[]>([]);
|
const [features, setFeatures] = useState<StoreFeature[]>([]);
|
||||||
|
const [mandates, setMandates] = useState<UserMandate[]>([]);
|
||||||
|
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const featureStore = useFeatureStore();
|
const featureStore = useFeatureStore();
|
||||||
|
|
||||||
|
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
|
||||||
|
try {
|
||||||
|
const info = await fetchSubscriptionInfo(mandateId);
|
||||||
|
setSubscriptionInfo(info);
|
||||||
|
} catch {
|
||||||
|
// non-critical
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadStore = useCallback(async () => {
|
const loadStore = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await fetchStoreFeatures();
|
const [data, userMandates] = await Promise.all([
|
||||||
|
fetchStoreFeatures(),
|
||||||
|
fetchUserMandates(),
|
||||||
|
]);
|
||||||
setFeatures(data);
|
setFeatures(data);
|
||||||
|
setMandates(userMandates);
|
||||||
|
const firstMandateId = userMandates.length > 0 ? userMandates[0].id : undefined;
|
||||||
|
await loadSubscriptionInfo(firstMandateId);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to load store';
|
const msg = err instanceof Error ? err.message : 'Failed to load store';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [loadSubscriptionInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStore();
|
loadStore();
|
||||||
|
|
@ -56,11 +80,11 @@ export function useStore(): UseStoreReturn {
|
||||||
await loadStore();
|
await loadStore();
|
||||||
}, [featureStore, loadStore]);
|
}, [featureStore, loadStore]);
|
||||||
|
|
||||||
const activate = useCallback(async (featureCode: string) => {
|
const activate = useCallback(async (featureCode: string, mandateId?: string) => {
|
||||||
setActionLoading(featureCode);
|
setActionLoading(featureCode);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await activateStoreFeature(featureCode);
|
await activateStoreFeature(featureCode, mandateId);
|
||||||
await _refreshAfterAction();
|
await _refreshAfterAction();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Activation failed';
|
const msg = err instanceof Error ? err.message : 'Activation failed';
|
||||||
|
|
@ -70,11 +94,11 @@ export function useStore(): UseStoreReturn {
|
||||||
}
|
}
|
||||||
}, [_refreshAfterAction]);
|
}, [_refreshAfterAction]);
|
||||||
|
|
||||||
const deactivate = useCallback(async (featureCode: string) => {
|
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
|
||||||
setActionLoading(featureCode);
|
setActionLoading(featureCode);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await deactivateStoreFeature(featureCode);
|
await deactivateStoreFeature(featureCode, mandateId, instanceId);
|
||||||
await _refreshAfterAction();
|
await _refreshAfterAction();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
||||||
|
|
@ -84,7 +108,7 @@ export function useStore(): UseStoreReturn {
|
||||||
}
|
}
|
||||||
}, [_refreshAfterAction]);
|
}, [_refreshAfterAction]);
|
||||||
|
|
||||||
return { features, loading, actionLoading, error, loadStore, activate, deactivate };
|
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useStore;
|
export default useStore;
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,50 @@
|
||||||
text-decoration: underline;
|
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 {
|
button:disabled {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa';
|
||||||
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
|
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
|
||||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
import { PENDING_INVITATION_KEY } from './InvitePage';
|
import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||||
|
import OnboardingWizard from '../components/OnboardingWizard';
|
||||||
|
|
||||||
import styles from './Login.module.css';
|
import styles from './Login.module.css';
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ function Login() {
|
||||||
const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
|
const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
|
||||||
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
||||||
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
||||||
|
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
|
||||||
|
|
||||||
// Check for pending invitation
|
// Check for pending invitation
|
||||||
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||||
|
|
@ -84,6 +86,10 @@ function Login() {
|
||||||
console.log("Attempting Google login...");
|
console.log("Attempting Google login...");
|
||||||
const response = await loginWithGoogle();
|
const response = await loginWithGoogle();
|
||||||
console.log("Google login successful:", response);
|
console.log("Google login successful:", response);
|
||||||
|
if (response?.isNewUser) {
|
||||||
|
setShowOnboardingWizard(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleSuccessfulLogin();
|
handleSuccessfulLogin();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Google login failed:", 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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
|
|
@ -213,12 +234,22 @@ function Login() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={styles.registerLink}>
|
<div className={styles.registerLink}>
|
||||||
<span>Du hast noch keinen Konto?</span>
|
<span>Du hast noch kein Konto?</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.ctaSection}>
|
||||||
<button
|
<button
|
||||||
className={styles.textButton}
|
type="button"
|
||||||
onClick={() => navigate("/register", { state: location.state })}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react';
|
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 { FaEnvelopeOpenText } from 'react-icons/fa';
|
||||||
|
|
||||||
import styles from './Register.module.css';
|
import styles from './Register.module.css';
|
||||||
|
|
@ -27,6 +27,10 @@ function Register() {
|
||||||
email: invitationEmail,
|
email: invitationEmail,
|
||||||
fullName: ''
|
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 [validationError, setValidationError] = useState<string | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [usernameFocused, setUsernameFocused] = useState(false);
|
const [usernameFocused, setUsernameFocused] = useState(false);
|
||||||
|
|
@ -40,11 +44,13 @@ function Register() {
|
||||||
|
|
||||||
// Set page title and generate CSRF token
|
// Set page title and generate CSRF token
|
||||||
useEffect(() => {
|
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
|
// Generate CSRF token for new security implementation
|
||||||
generateAndStoreCSRFToken();
|
generateAndStoreCSRFToken();
|
||||||
}, []);
|
}, [registrationType]);
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
@ -70,6 +76,11 @@ function Register() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (registrationType === 'company' && !companyName.trim()) {
|
||||||
|
setValidationError('Bitte geben Sie einen Firmennamen ein.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -97,7 +108,7 @@ function Register() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Username is available, proceed with registration (no password - magic link flow)
|
// 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
|
// 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.';
|
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>
|
<label className={emailFocused || formData.email ? styles.focusedLabel : styles.label}>E-Mail</label>
|
||||||
</div>
|
</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}>
|
<div className={styles.floatingLabelInput}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -221,7 +248,7 @@ function Register() {
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isLoading || isChecking}
|
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>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,52 @@
|
||||||
font-size: 0.9375rem;
|
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 */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -120,6 +166,49 @@
|
||||||
background: currentColor;
|
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 */
|
/* Actions */
|
||||||
.cardActions {
|
.cardActions {
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
|
|
@ -243,17 +332,35 @@
|
||||||
border-top-color: var(--border-dark, #333);
|
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);
|
border-color: var(--border-dark, #444);
|
||||||
color: var(--text-secondary-dark, #aaa);
|
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);
|
border-color: var(--error-color-dark, #f87171);
|
||||||
color: var(--error-color-dark, #f87171);
|
color: var(--error-color-dark, #f87171);
|
||||||
background: rgba(248, 113, 113, 0.1);
|
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 {
|
:global(.dark-theme) .error {
|
||||||
background: var(--error-bg-dark, #450a0a);
|
background: var(--error-bg-dark, #450a0a);
|
||||||
border-color: var(--error-border-dark, #991b1b);
|
border-color: var(--error-border-dark, #991b1b);
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@
|
||||||
* and users get their own FeatureAccess + user-role upon activation.
|
* 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 { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { useStore } from '../hooks/useStore';
|
||||||
import type { StoreFeature } from '../api/storeApi';
|
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
||||||
import styles from './Store.module.css';
|
import styles from './Store.module.css';
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
|
@ -62,23 +62,39 @@ function _getDescription(featureCode: string, lang: string): string {
|
||||||
interface FeatureCardProps {
|
interface FeatureCardProps {
|
||||||
feature: StoreFeature;
|
feature: StoreFeature;
|
||||||
language: string;
|
language: string;
|
||||||
|
mandates: UserMandate[];
|
||||||
actionLoading: string | null;
|
actionLoading: string | null;
|
||||||
onActivate: (code: string) => void;
|
onActivate: (code: string, mandateId?: string) => void;
|
||||||
onDeactivate: (code: string) => void;
|
onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeatureCard: React.FC<FeatureCardProps> = ({
|
const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
feature,
|
feature,
|
||||||
language,
|
language,
|
||||||
|
mandates,
|
||||||
actionLoading,
|
actionLoading,
|
||||||
onActivate,
|
onActivate,
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||||
const isProcessing = actionLoading === feature.featureCode;
|
const isProcessing = actionLoading === feature.featureCode;
|
||||||
const icon = FEATURE_ICONS[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 (
|
return (
|
||||||
<div className={`${styles.card} ${feature.isActive ? styles.cardActive : ''}`}>
|
<div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}>
|
||||||
<div className={styles.cardHeader}>
|
<div className={styles.cardHeader}>
|
||||||
{icon && <span className={styles.cardIcon}>{icon}</span>}
|
{icon && <span className={styles.cardIcon}>{icon}</span>}
|
||||||
<h3 className={styles.cardTitle}>
|
<h3 className={styles.cardTitle}>
|
||||||
|
|
@ -92,36 +108,76 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{activeInstances.length > 0 && (
|
||||||
<span className={`${styles.statusBadge} ${feature.isActive ? styles.statusActive : styles.statusInactive}`}>
|
<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} />
|
<span className={styles.statusDot} />
|
||||||
{feature.isActive
|
{inst.mandateName || inst.label}
|
||||||
? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active')
|
|
||||||
: (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.cardActions}>
|
|
||||||
{feature.isActive ? (
|
|
||||||
<button
|
<button
|
||||||
className={styles.deactivateButton}
|
className={styles.deactivateButtonSmall}
|
||||||
onClick={() => onDeactivate(feature.featureCode)}
|
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isProcessing
|
{isProcessing
|
||||||
? (language === 'de' ? 'Wird deaktiviert...' : 'Deactivating...')
|
? '...'
|
||||||
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
|
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
|
||||||
</button>
|
</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.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
|
<button
|
||||||
className={styles.activateButton}
|
className={styles.activateButton}
|
||||||
onClick={() => onActivate(feature.featureCode)}
|
onClick={_handleActivate}
|
||||||
disabled={isProcessing || !feature.canActivate}
|
disabled={isProcessing || (needsMandateSelection && !selectedMandateId)}
|
||||||
>
|
>
|
||||||
{isProcessing
|
{isProcessing
|
||||||
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
|
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
|
||||||
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
|
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,7 +186,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
|
|
||||||
const StorePage: React.FC = () => {
|
const StorePage: React.FC = () => {
|
||||||
const { currentLanguage } = useLanguage();
|
const { currentLanguage } = useLanguage();
|
||||||
const { features, loading, actionLoading, error, activate, deactivate } = useStore();
|
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.store}>
|
<div className={styles.store}>
|
||||||
|
|
@ -145,6 +201,27 @@ const StorePage: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -164,6 +241,7 @@ const StorePage: React.FC = () => {
|
||||||
key={feature.featureCode}
|
key={feature.featureCode}
|
||||||
feature={feature}
|
feature={feature}
|
||||||
language={currentLanguage}
|
language={currentLanguage}
|
||||||
|
mandates={mandates}
|
||||||
actionLoading={actionLoading}
|
actionLoading={actionLoading}
|
||||||
onActivate={activate}
|
onActivate={activate}
|
||||||
onDeactivate={deactivate}
|
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 {
|
.dossier {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 140px);
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,53 @@
|
||||||
/**
|
/**
|
||||||
* CommCoach Dossier View (Main View)
|
* 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.
|
* Voice first, always with text fallback.
|
||||||
|
* Files & Sources are provided via the shared UnifiedDataBar sidebar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||||
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
||||||
import api from '../../../api';
|
|
||||||
import {
|
import {
|
||||||
getDossierExportUrl, getSessionExportUrl,
|
getDossierExportUrl, getSessionExportUrl,
|
||||||
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
|
|
||||||
getScoreHistoryApi, getPersonasApi,
|
getScoreHistoryApi, getPersonasApi,
|
||||||
type CoachingDocument, type CoachingPersona,
|
type CoachingPersona,
|
||||||
} from '../../../api/commcoachApi';
|
} from '../../../api/commcoachApi';
|
||||||
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { UnifiedDataBar, FilesTab, SourcesTab } from '../../../components/UnifiedDataBar';
|
||||||
|
import type { UdbContext } from '../../../components/UnifiedDataBar';
|
||||||
import styles from './CommcoachDossierView.module.css';
|
import styles from './CommcoachDossierView.module.css';
|
||||||
import { useVoiceController } from './useVoiceController';
|
import { useVoiceController } from './useVoiceController';
|
||||||
|
|
||||||
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents';
|
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
|
||||||
|
|
||||||
export const CommcoachDossierView: React.FC = () => {
|
export const CommcoachDossierView: React.FC = () => {
|
||||||
const coach = useCommcoach();
|
const coach = useCommcoach();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
|
const mandateId = useMandateId();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
||||||
const [showNewContext, setShowNewContext] = useState(false);
|
const [showNewContext, setShowNewContext] = useState(false);
|
||||||
const [newTitle, setNewTitle] = useState('');
|
const [newTitle, setNewTitle] = useState('');
|
||||||
const [newDescription, setNewDescription] = useState('');
|
const [newDescription, setNewDescription] = useState('');
|
||||||
const [newCategory, setNewCategory] = useState('custom');
|
const [newCategory, setNewCategory] = useState('custom');
|
||||||
|
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||||
|
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
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 [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||||||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
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 inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const sendMessageRef = useRef(coach.sendMessage);
|
const sendMessageRef = useRef(coach.sendMessage);
|
||||||
sendMessageRef.current = coach.sendMessage;
|
sendMessageRef.current = coach.sendMessage;
|
||||||
|
|
@ -82,27 +87,14 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||||||
|
|
||||||
// Load documents, scores, personas when context changes
|
// Load scores, personas when context changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !coach.selectedContextId) return;
|
if (!instanceId || !coach.selectedContextId) return;
|
||||||
getDocumentsApi(request, instanceId, coach.selectedContextId)
|
|
||||||
.then(d => setDocuments(d))
|
|
||||||
.catch(() => {});
|
|
||||||
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
||||||
.then(h => setScoreHistory(h))
|
.then(h => setScoreHistory(h))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [instanceId, request, coach.selectedContextId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
getPersonasApi(request, instanceId)
|
getPersonasApi(request, instanceId)
|
||||||
|
|
@ -144,46 +136,6 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
coach.selectContext(contextId, { skipSessionResume: true });
|
coach.selectContext(contextId, { skipSessionResume: true });
|
||||||
}, [coach]);
|
}, [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 () => {
|
const handleAddTask = useCallback(async () => {
|
||||||
if (!newTaskTitle.trim()) return;
|
if (!newTaskTitle.trim()) return;
|
||||||
await coach.addTask(newTaskTitle);
|
await coach.addTask(newTaskTitle);
|
||||||
|
|
@ -195,6 +147,30 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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}>
|
<div className={styles.dossier}>
|
||||||
{/* Context Selector */}
|
{/* Context Selector */}
|
||||||
<div className={styles.contextSelector}>
|
<div className={styles.contextSelector}>
|
||||||
|
|
@ -286,13 +262,13 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
|
{(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
>
|
>
|
||||||
{_tabLabel(tab, coach, documents)}
|
{_tabLabel(tab, coach)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -546,40 +522,6 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{/* DOCUMENTS TAB */}
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{activeTab === 'documents' && (
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
<div className={styles.addTaskRow}>
|
|
||||||
<label className={styles.uploadLabel}>
|
|
||||||
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
|
|
||||||
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{documents.length === 0 ? (
|
|
||||||
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch 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 */}
|
{/* #region agent log */}
|
||||||
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
|
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
|
||||||
|
|
@ -595,6 +537,7 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
{/* #endregion */}
|
{/* #endregion */}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -607,13 +550,12 @@ function _categoryIcon(category: string): string {
|
||||||
return icons[category] || '*';
|
return icons[category] || '*';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string {
|
function _tabLabel(tab: TabKey, coach: any): string {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
|
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
|
||||||
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
|
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
|
||||||
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
||||||
case 'scores': return `Bewertungen (${coach.scores.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);
|
return Object.values(groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _formatFileSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _dimensionLabel(dim: string): string {
|
function _dimensionLabel(dim: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import api from '../../../api';
|
||||||
import {
|
import {
|
||||||
getProfileApi, updateProfileApi,
|
getProfileApi, updateProfileApi,
|
||||||
getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi,
|
getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi,
|
||||||
|
|
@ -14,6 +15,18 @@ import {
|
||||||
} from '../../../api/commcoachApi';
|
} from '../../../api/commcoachApi';
|
||||||
import styles from './CommcoachSettingsView.module.css';
|
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 = () => {
|
export const CommcoachSettingsView: React.FC = () => {
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
|
|
@ -88,6 +101,9 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
emailSummaryEnabled: emailEnabled,
|
emailSummaryEnabled: emailEnabled,
|
||||||
});
|
});
|
||||||
setProfile(updated);
|
setProfile(updated);
|
||||||
|
|
||||||
|
_syncSharedVoicePreferences(language, voiceId || undefined);
|
||||||
|
|
||||||
setSuccess('Einstellungen gespeichert');
|
setSuccess('Einstellungen gespeichert');
|
||||||
setTimeout(() => setSuccess(null), 3000);
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,32 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
||||||
charCount={(msg as any)._audioCharCount}
|
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>
|
||||||
)}
|
)}
|
||||||
</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]);
|
}, [onPasteAsFile]);
|
||||||
|
|
||||||
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
|
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.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
setTreeDropOver(true);
|
setTreeDropOver(true);
|
||||||
|
|
@ -273,11 +276,22 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
|
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
|
||||||
|
|
||||||
const _handlePromptDrop = useCallback((e: React.DragEvent) => {
|
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');
|
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||||
if (treeItemsJson && onTreeItemsDrop) {
|
if (treeItemsJson && onTreeItemsDrop) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setTreeDropOver(false);
|
|
||||||
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
|
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
|
||||||
onTreeItemsDrop(items);
|
onTreeItemsDrop(items);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ import { FileBrowser } from './FileBrowser';
|
||||||
import { DataSourcePanel } from './DataSourcePanel';
|
import { DataSourcePanel } from './DataSourcePanel';
|
||||||
import { FilePreview } from './FilePreview';
|
import { FilePreview } from './FilePreview';
|
||||||
import { ToolActivityLog } from './ToolActivityLog';
|
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) {
|
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
|
||||||
const [width, setWidth] = useState(initialWidth);
|
const [width, setWidth] = useState(initialWidth);
|
||||||
|
|
@ -52,7 +55,6 @@ function _useResizable(initialWidth: number, minWidth: number, maxWidth: number)
|
||||||
|
|
||||||
return { width, onMouseDown: _onMouseDown };
|
return { width, onMouseDown: _onMouseDown };
|
||||||
}
|
}
|
||||||
type LeftTab = 'conversations' | 'files' | 'datasources';
|
|
||||||
type RightTab = 'activity' | 'preview';
|
type RightTab = 'activity' | 'preview';
|
||||||
|
|
||||||
interface PendingFile {
|
interface PendingFile {
|
||||||
|
|
@ -78,7 +80,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
const _leftResize = _useResizable(280, 200, 450);
|
const _leftResize = _useResizable(280, 200, 450);
|
||||||
const _rightResize = _useResizable(320, 200, 500);
|
const _rightResize = _useResizable(320, 200, 500);
|
||||||
const [leftTab, setLeftTab] = useState<LeftTab>('conversations');
|
|
||||||
const [rightTab, setRightTab] = useState<RightTab>('activity');
|
const [rightTab, setRightTab] = useState<RightTab>('activity');
|
||||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||||
|
|
@ -210,43 +211,42 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
textTransform: 'uppercase' as const,
|
textTransform: 'uppercase' as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
const _leftPanelBody = (
|
const _udbContext: UdbContext = {
|
||||||
<>
|
instanceId: instanceId,
|
||||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
mandateId: mandateId,
|
||||||
<button style={tabButtonStyle(leftTab === 'conversations')} onClick={() => setLeftTab('conversations')}>Chats</button>
|
featureInstanceId: instanceId,
|
||||||
<button style={tabButtonStyle(leftTab === 'files')} onClick={() => setLeftTab('files')}>Files</button>
|
};
|
||||||
<button style={tabButtonStyle(leftTab === 'datasources')} onClick={() => setLeftTab('datasources')}>Sources</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
const _leftPanelBody = (
|
||||||
{leftTab === 'conversations' && (
|
<UnifiedDataBar
|
||||||
|
context={_udbContext}
|
||||||
|
renderChats={(ctx) => (
|
||||||
<ConversationList
|
<ConversationList
|
||||||
instanceId={instanceId}
|
instanceId={ctx.instanceId}
|
||||||
activeWorkflowId={workspace.workflowId}
|
activeWorkflowId={workspace.workflowId}
|
||||||
onSelect={_handleConversationSelect}
|
onSelect={_handleConversationSelect}
|
||||||
onCreateNew={workspace.resetToNew}
|
onCreateNew={workspace.resetToNew}
|
||||||
refreshTrigger={workspace.workflowVersion}
|
refreshTrigger={workspace.workflowVersion}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{leftTab === 'files' && (
|
renderFiles={(ctx) => (
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
instanceId={instanceId}
|
instanceId={ctx.instanceId}
|
||||||
files={workspace.files}
|
files={workspace.files}
|
||||||
onRefresh={workspace.refreshFiles}
|
onRefresh={workspace.refreshFiles}
|
||||||
onFileSelect={_handleFileSelect}
|
onFileSelect={_handleFileSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{leftTab === 'datasources' && (
|
renderSources={(ctx) => (
|
||||||
<DataSourcePanel
|
<DataSourcePanel
|
||||||
instanceId={instanceId}
|
instanceId={ctx.instanceId}
|
||||||
dataSources={workspace.dataSources}
|
dataSources={workspace.dataSources}
|
||||||
featureDataSources={workspace.featureDataSources}
|
featureDataSources={workspace.featureDataSources}
|
||||||
onRefresh={workspace.refreshDataSources}
|
onRefresh={workspace.refreshDataSources}
|
||||||
onRefreshFeatureDataSources={workspace.refreshFeatureDataSources}
|
onRefreshFeatureDataSources={workspace.refreshFeatureDataSources}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const _rightPanelBody = (
|
const _rightPanelBody = (
|
||||||
|
|
@ -386,6 +386,11 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
Dateien hier ablegen
|
Dateien hier ablegen
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<OnboardingAssistant
|
||||||
|
instanceId={instanceId}
|
||||||
|
mandateId={mandateId}
|
||||||
|
featureCode={featureCode}
|
||||||
|
/>
|
||||||
<ChatStream
|
<ChatStream
|
||||||
messages={workspace.messages}
|
messages={workspace.messages}
|
||||||
agentProgress={workspace.agentProgress}
|
agentProgress={workspace.agentProgress}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ import React, { useState } from 'react';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { WorkspaceSettings } from './WorkspaceSettings';
|
import { WorkspaceSettings } from './WorkspaceSettings';
|
||||||
import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings';
|
import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings';
|
||||||
|
import NeutralizationPanel from './NeutralizationPanel';
|
||||||
|
|
||||||
type SettingsTab = 'general' | 'voice';
|
type SettingsTab = 'general' | 'voice' | 'neutralization';
|
||||||
|
|
||||||
const _TABS: { key: SettingsTab; label: string }[] = [
|
const _TABS: { key: SettingsTab; label: string }[] = [
|
||||||
{ key: 'general', label: 'Generelle Einstellungen' },
|
{ key: 'general', label: 'Generelle Einstellungen' },
|
||||||
{ key: 'voice', label: 'Sprache & Stimme' },
|
{ key: 'voice', label: 'Sprache & Stimme' },
|
||||||
|
{ key: 'neutralization', label: 'Neutralisierung' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const WorkspaceSettingsPage: React.FC = () => {
|
export const WorkspaceSettingsPage: React.FC = () => {
|
||||||
|
|
@ -69,6 +71,9 @@ export const WorkspaceSettingsPage: React.FC = () => {
|
||||||
{activeTab === 'voice' && (
|
{activeTab === 'voice' && (
|
||||||
<WorkspaceSettings instanceId={instanceId} />
|
<WorkspaceSettings instanceId={instanceId} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'neutralization' && (
|
||||||
|
<NeutralizationPanel instanceId={instanceId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue