This commit is contained in:
ValueOn AG 2026-05-12 15:19:07 +02:00
parent 791d575b7d
commit a6b37ed684
29 changed files with 1319 additions and 1174 deletions

View file

@ -44,6 +44,7 @@ import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
import { RagInventoryPage } from './pages/RagInventoryPage';
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
function App() {
// Load saved theme preference and set app name on app mount
@ -127,6 +128,11 @@ function App() {
{/* ============================================== */}
<Route path="automations" element={<AutomationsDashboardPage />} />
{/* ============================================== */}
{/* RAG INVENTORY */}
{/* ============================================== */}
<Route path="rag-inventory" element={<RagInventoryPage />} />
{/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */}
<Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<Navigate to="/" replace />} />

View file

@ -6,17 +6,11 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface KnowledgePreferences {
schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number;
}
@ -292,3 +286,110 @@ export async function submitInfomaniakToken(
});
}
// ============================================================================
// RAG KNOWLEDGE CONSENT & CONTROL
// ============================================================================
export async function patchKnowledgeConsent(
request: ApiRequestFunction,
connectionId: string,
enabled: boolean
): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled }
});
}
export async function patchKnowledgePreferences(
request: ApiRequestFunction,
connectionId: string,
preferences: KnowledgePreferences
): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-preferences`,
method: 'patch',
data: { preferences }
});
}
export async function postKnowledgeStop(
request: ApiRequestFunction,
connectionId: string
): Promise<{ connectionId: string; cancelled: number }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-stop`,
method: 'post'
});
}
export async function patchDataSourceRagIndex(
request: ApiRequestFunction,
dataSourceId: string,
ragIndexEnabled: boolean
): Promise<{ sourceId: string; ragIndexEnabled: boolean; updated: boolean }> {
return await request({
url: `/api/datasources/${dataSourceId}/rag-index`,
method: 'patch',
data: { ragIndexEnabled }
});
}
// ============================================================================
// RAG INVENTORY
// ============================================================================
export interface RagDataSourceDto {
id: string;
label: string;
path: string;
sourceType: string;
ragIndexEnabled: boolean;
neutralize: boolean;
lastIndexed: number | null;
chunkCount: number;
}
export interface RagConnectionDto {
id: string;
authority: string;
externalEmail: string;
knowledgeIngestionEnabled: boolean;
preferences: KnowledgePreferences;
dataSources: RagDataSourceDto[];
totalChunks: number;
runningJobs: { jobId: string; progress: number; progressMessage: string }[];
lastError?: { jobId: string; errorMessage: string } | null;
}
export interface RagInventoryDto {
connections: RagConnectionDto[];
totals: { chunks: number; bytes?: number };
}
export interface RagActiveJobDto {
jobId: string;
connectionId: string;
connectionLabel?: string;
jobType: string;
progress: number | null;
progressMessage: string;
}
export async function getRagInventoryMe(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/me', method: 'get' });
}
export async function getRagInventoryMandate(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/mandate', method: 'get' });
}
export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise<any> {
return await request({ url: '/api/rag/inventory/platform', method: 'get' });
}
export async function getRagActiveJobs(request: ApiRequestFunction): Promise<RagActiveJobDto[]> {
return await request({ url: '/api/rag/inventory/jobs', method: 'get' });
}

View file

@ -73,13 +73,12 @@
/* Connector grid (Step 0) */
.connectorGrid {
display: flex;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
flex-wrap: wrap;
}
.connectorCard {
flex: 1 1 140px;
display: flex;
flex-direction: column;
align-items: center;
@ -447,6 +446,22 @@
cursor: not-allowed;
}
.patInput {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9rem;
font-family: monospace;
margin: 12px 0 16px;
}
.patInput:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* Dark theme */
:global(.dark-theme) .connectorCard {
background: var(--surface-color);

View file

@ -1,153 +1,52 @@
/**
* AddConnectionWizard
*
* Multi-step modal for adding a new connector with optional knowledge
* ingestion consent and per-connection preferences (§2.6).
*
* Steps:
* 0 Connector wählen
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
* Streamlined multi-step modal for adding a new connector.
* Steps are connector-type-aware:
* Base: Connector Consent Connect
* Microsoft: Connector Consent Admin Consent (optional) Connect
* Infomaniak: Connector Consent PAT Input (done)
*/
import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa';
import type { KnowledgePreferences } from '../../api/connectionApi';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import styles from './AddConnectionWizard.module.css';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup';
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
interface WizardState {
step: 0 | 1 | 2 | 3;
currentStep: StepId;
connector: ConnectorType | null;
knowledgeEnabled: boolean;
prefs: KnowledgePreferences;
infomaniakToken: string;
adminConsentDone: boolean;
}
const DEFAULT_PREFS: KnowledgePreferences = {
schemaVersion: 1,
neutralizeBeforeEmbed: false,
mailContentDepth: 'full',
mailIndexAttachments: false,
filesIndexBinaries: true,
clickupScope: 'title_description',
clickupIndexAttachments: false,
maxAgeDays: 90,
};
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google',
msft: 'Microsoft 365',
clickup: 'ClickUp',
infomaniak: 'Infomaniak',
};
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
};
// ---------------------------------------------------------------------------
// Cost estimate helper
// ---------------------------------------------------------------------------
/**
* Returns a cost estimate broken into two lines:
*
* 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) always tiny.
* 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call)
* this is the DOMINANT cost when enabled. One call per email/task for
* short content; several calls for long threads or files.
*
* Numbers are conservative ranges. Subsequent syncs are cheaper because
* unchanged content is deduplicated before any LLM/embedding call.
*/
function computeCostEstimate(
connector: ConnectorType | null,
prefs: KnowledgePreferences,
): {
embeddingLow: string;
embeddingHigh: string;
neutralizationLow: string | null;
neutralizationHigh: string | null;
note: string;
} | null {
if (!connector) return null;
// ---- Embedding (OpenAI, USD) ----
const EMBED_USD_PER_M = 0.02;
const tokensPerMail: Record<string, number> = { metadata: 30, snippet: 120, full: 500 };
const depth = prefs.mailContentDepth ?? 'full';
const maxAge = prefs.maxAgeDays ?? 90;
const mailCount = Math.min(500, Math.round((maxAge / 90) * 500));
const taskCount = Math.min(500, Math.round((maxAge / 90) * 300));
let embedLowTokens = 0;
let embedHighTokens = 0;
if (connector === 'google' || connector === 'msft') {
const mailTokens = mailCount * tokensPerMail[depth];
embedLowTokens += mailTokens * 0.6;
embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint
if (prefs.mailIndexAttachments) embedHighTokens += 200_000;
} else if (connector === 'clickup') {
const scope = prefs.clickupScope ?? 'title_description';
const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400;
embedLowTokens += taskCount * tpt * 0.6;
embedHighTokens += taskCount * tpt * 1.5;
}
const fmtUsd = (tokens: number) => {
const usd = (tokens / 1_000_000) * EMBED_USD_PER_M;
if (usd < 0.001) return '< 0.01 $';
if (usd < 0.10) return `~${usd.toFixed(3)} $`;
return `~${usd.toFixed(2)} $`;
};
// ---- Neutralization (Private LLM, CHF 0.01/call) ----
// Each item (email / task / file) = 1 LLM call for short content,
// 2-4 for long threads/documents.
const NEUT_CHF_PER_CALL = 0.01;
let neutLow: string | null = null;
let neutHigh: string | null = null;
if (prefs.neutralizeBeforeEmbed) {
let lowCalls = 0;
let highCalls = 0;
if (connector === 'google' || connector === 'msft') {
lowCalls += mailCount * 1; // 1 call / short email
highCalls += mailCount * 3; // up to 3 calls / long thread
lowCalls += 20; // Drive/SharePoint files (low)
highCalls += 200; // Drive/SharePoint files (high, large PDFs)
} else if (connector === 'clickup') {
lowCalls += taskCount * 1;
highCalls += taskCount * 2;
}
const fmtChf = (calls: number) => {
const chf = calls * NEUT_CHF_PER_CALL;
if (chf < 0.01) return '< 0.01 CHF';
return `~${chf.toFixed(2)} CHF`;
};
neutLow = fmtChf(lowCalls);
neutHigh = fmtChf(highCalls);
}
return {
embeddingLow: fmtUsd(embedLowTokens),
embeddingHigh: fmtUsd(embedHighTokens),
neutralizationLow: neutLow,
neutralizationHigh: neutHigh,
note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).',
};
function _getSteps(connector: ConnectorType | null): StepId[] {
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
return ['connector', 'consent', 'connect'];
}
// ---------------------------------------------------------------------------
@ -157,11 +56,9 @@ function computeCostEstimate(
interface AddConnectionWizardProps {
open: boolean;
onClose: () => void;
onConnect: (
type: ConnectorType,
knowledgeEnabled: boolean,
prefs: KnowledgePreferences | null,
) => Promise<void>;
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
onMsftAdminConsent?: () => void;
isConnecting?: boolean;
}
@ -173,84 +70,91 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open,
onClose,
onConnect,
onInfomaniakConnect,
onMsftAdminConsent,
isConnecting = false,
}) => {
const [state, setState] = useState<WizardState>({
step: 0,
currentStep: 'connector',
connector: null,
knowledgeEnabled: false,
prefs: { ...DEFAULT_PREFS },
infomaniakToken: '',
adminConsentDone: false,
});
const reset = () =>
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } });
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
const handleClose = () => {
reset();
onClose();
const handleClose = () => { reset(); onClose(); };
const steps = _getSteps(state.connector);
const stepIndex = steps.indexOf(state.currentStep);
const goNext = () => {
const nextIdx = stepIndex + 1;
if (nextIdx < steps.length) {
setState(s => ({ ...s, currentStep: steps[nextIdx] }));
}
};
const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step }));
const setConnector = (connector: ConnectorType) =>
setState(s => ({ ...s, connector, step: 1 }));
const setKnowledgeEnabled = (v: boolean) =>
setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 }));
const updatePref = <K extends keyof KnowledgePreferences>(key: K, value: KnowledgePreferences[K]) =>
setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } }));
const goBack = () => {
const prevIdx = stepIndex - 1;
if (prevIdx >= 0) {
setState(s => ({ ...s, currentStep: steps[prevIdx] }));
}
};
const handleConnect = async () => {
const selectConnector = (c: ConnectorType) => {
setState(s => ({ ...s, connector: c, currentStep: 'consent' }));
};
const setConsent = (enabled: boolean) => {
setState(s => ({ ...s, knowledgeEnabled: enabled }));
goNext();
};
const handleFinalConnect = async () => {
if (!state.connector) return;
await onConnect(
state.connector,
state.knowledgeEnabled,
state.knowledgeEnabled ? state.prefs : null,
);
if (state.connector === 'infomaniak' && onInfomaniakConnect) {
await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
} else {
await onConnect(state.connector, state.knowledgeEnabled);
}
reset();
onClose();
};
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return (
<Modal
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
<Modal open={open} onClose={handleClose} title="Verbindung hinzufügen" size="md" closeOnEscape>
{/* Stepper */}
<div className={styles.stepper}>
{[0, 1, 2, 3].map(i => (
{steps.map((s, i) => (
<div
key={i}
key={s}
className={[
styles.stepDot,
state.step === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
stepIndex === i ? styles.stepDotActive : '',
stepIndex > i ? styles.stepDotDone : '',
].join(' ')}
>
{state.step > i ? <FaCheck size={10} /> : i + 1}
{stepIndex > i ? <FaCheck size={10} /> : i + 1}
</div>
))}
</div>
<div className={styles.body}>
{/* ---- Step 0: Connector ---- */}
{state.step === 0 && (
{/* ---- Step: Connector ---- */}
{state.currentStep === 'connector' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
<button
key={type}
type="button"
className={styles.connectorCard}
onClick={() => setConnector(type)}
onClick={() => selectConnector(type)}
>
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
@ -260,151 +164,103 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
</div>
)}
{/* ---- Step 1: Consent ---- */}
{state.step === 1 && (
{/* ---- Step: Consent ---- */}
{state.currentStep === 'consent' && (
<div className={styles.stepContent}>
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '}
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
aus {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'} zurückgreifen kann?
</p>
<p className={styles.stepHint}>
Du kannst diese Einstellung später in den Verbindungsdetails ändern.
Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.
</p>
<div className={styles.consentButtons}>
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
<FaCheck /> Ja, aktivieren
</button>
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
Nein, überspringen
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
</div>
</div>
)}
{/* ---- Step: MSFT Admin Consent ---- */}
{state.currentStep === 'msftAdminConsent' && (
<div className={styles.stepContent}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
</div>
<h3 className={styles.stepTitle}>Organisations-Zustimmung (optional)</h3>
<p className={styles.stepBody}>
Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen.
So müssen andere Benutzer nicht einzeln bestätigen.
</p>
<p className={styles.stepHint}>
Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.
</p>
<div className={styles.consentButtons}>
<button
type="button"
className={styles.consentButtonYes}
onClick={() => setKnowledgeEnabled(true)}
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
>
<FaCheck /> Ja, aufnehmen
<FaShieldAlt /> Admin-Zustimmung erteilen
</button>
<button
type="button"
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
Überspringen
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
Zurück
</button>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
</div>
</div>
)}
{/* ---- Step 2: Preferences ---- */}
{state.step === 2 && (
{/* ---- Step: Infomaniak PAT ---- */}
{state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Einstellungen</h3>
<p className={styles.stepHint}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
<h3 className={styles.stepTitle}>Infomaniak Personal Access Token</h3>
<p className={styles.stepBody}>
Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.
</p>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
<FaShieldAlt className={styles.prefIcon} />
Anonymisierung vor dem Indexieren
<input
type="checkbox"
checked={!!state.prefs.neutralizeBeforeEmbed}
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
className={styles.prefCheck}
/>
</label>
<p className={styles.prefHint}>
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
</p>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<>
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
E-Mail-Inhalt
<select
value={state.prefs.mailContentDepth ?? 'full'}
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
className={styles.prefSelect}
>
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
<option value="full">Vollständiger Text</option>
</select>
</label>
</div>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
E-Mail-Anhänge indexieren
<input
type="checkbox"
checked={!!state.prefs.mailIndexAttachments}
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
className={styles.prefCheck}
/>
</label>
</div>
</>
)}
{state.connector === 'clickup' && (
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Aufgaben-Inhalt
<select
value={state.prefs.clickupScope ?? 'title_description'}
onChange={e => updatePref('clickupScope', e.target.value as any)}
className={styles.prefSelect}
>
<option value="titles">Nur Aufgabentitel</option>
<option value="title_description">Titel + Beschreibung</option>
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
</select>
</label>
</div>
)}
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Zeitfenster (Tage)
<input
type="number"
min={0}
max={3650}
value={state.prefs.maxAgeDays ?? 90}
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
className={styles.prefNumber}
/>
</label>
<p className={styles.prefHint}>0 = kein Limit</p>
</div>
<input
type="password"
placeholder="pat_..."
value={state.infomaniakToken}
onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
className={styles.patInput}
autoFocus
/>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
Zurück
</button>
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
Weiter <FaArrowRight size={12} />
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
<button
type="button"
className={styles.navConnect}
onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()}
>
{isConnecting ? 'Verbinden…' : 'Verbinden'}
{!isConnecting && <FaArrowRight size={12} />}
</button>
</div>
</div>
)}
{/* ---- Step 3: Summary ---- */}
{state.step === 3 && (
{/* ---- Step: Connect ---- */}
{state.currentStep === 'connect' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
<h3 className={styles.stepTitle}>Verbindung herstellen</h3>
<div className={styles.summary}>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryVal}>
{CONNECTOR_ICONS[state.connector!]}&nbsp;
{state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span>
</div>
@ -414,96 +270,13 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
</span>
</div>
{state.knowledgeEnabled && (
<>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anonymisierung</span>
<span className={styles.summaryVal}>
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
</span>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
<span className={styles.summaryVal}>
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
state.prefs.mailContentDepth ?? 'full'
] ?? state.prefs.mailContentDepth}
</span>
</div>
)}
{state.connector === 'clickup' && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
<span className={styles.summaryVal}>
{{
titles: 'Nur Titel',
title_description: 'Titel + Beschreibung',
with_comments: 'Titel + Beschreibung + Kommentare',
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Zeitfenster</span>
<span className={styles.summaryVal}>
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
</span>
</div>
</>
)}
</div>
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
{state.knowledgeEnabled && (() => {
const est = computeCostEstimate(state.connector, state.prefs);
if (!est) return null;
return (
<div className={styles.costHint}>
<FaInfoCircle className={styles.costHintIcon} />
<div>
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
<table className={styles.costTable}>
<tbody>
<tr>
<td className={styles.costLabel}>Embedding</td>
<td className={styles.costVal}>
{est.embeddingLow} {est.embeddingHigh}
</td>
</tr>
{est.neutralizationLow && (
<tr className={styles.costRowNeut}>
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
<td className={styles.costVal}>
{est.neutralizationLow} {est.neutralizationHigh}
</td>
</tr>
)}
</tbody>
</table>
{est.neutralizationLow && (
<span className={styles.costHintWarn}>
Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
</span>
)}
<span className={styles.costHintNote}>{est.note}</span>
</div>
</div>
);
})()}
<div className={styles.stepNav}>
<button
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
<button
type="button"
className={styles.navConnect}
onClick={handleConnect}
onClick={handleFinalConnect}
disabled={isConnecting}
>
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}

View file

@ -101,9 +101,8 @@
flex-direction: column;
gap: 0.5rem;
min-width: 0;
/* Share remaining viewport among expanded groups; scroll when many groups */
flex: 1 1 280px;
min-height: 0;
flex: 1 1 400px;
min-height: 350px;
}
.groupSectionCollapsed {

View file

@ -681,7 +681,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
resizable = true,
pagination = true,
pageSize = 10,
pageSizeOptions = [10, 25, 50, 100, 500],
pageSizeOptions = [10, 25, 50, 100, 500, 1000, 2000, 10000],
showPageSizeSelector = true,
onRowClick,
onRowSelect,
@ -740,13 +740,16 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [activeViewKey, setActiveViewKey] = useState<string | null>(null);
const [activeViewId, setActiveViewId] = useState<string | null>(null);
const [groupByLevels, setGroupByLevels] = useState<GroupByLevelSpec[]>([]);
const useSectionsGroupLayout =
tableGroupLayoutMode === 'sections' &&
const [groupLayoutMode, setGroupLayoutMode] = useState<'inline' | 'sections'>(tableGroupLayoutMode ?? 'inline');
const canUseSections =
!!tableContextKey &&
groupByLevels.length === 1 &&
groupByLevels.length > 0 &&
typeof hookDataProp?.fetchGroupSectionSummaries === 'function' &&
typeof hookDataProp?.refetchForSection === 'function';
const useSectionsGroupLayout = canUseSections && groupLayoutMode === 'sections';
const [sectionSummaries, setSectionSummaries] = useState<
Array<{ value: string | null; label: string; totalCount: number }>
>([]);
@ -1360,6 +1363,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
viewKey: activeViewKey,
groupField: spec.field,
groupDirection: spec.direction || 'asc',
groupByLevels: groupLevelsToApiPayload(groupByLevels),
});
if (!cancelled) setSectionSummaries(Array.isArray(list) ? list : []);
} catch (e) {
@ -2750,6 +2754,27 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onUpdateViewGrouping={(id, levels) => void handleUpdateViewGrouping(id, levels)}
onDeleteView={(id) => void handleDeleteView(id)}
onReloadViews={() => void reloadViews()}
canUseSections={canUseSections}
groupLayoutMode={groupLayoutMode}
onGroupLayoutModeChange={(mode) => {
setGroupLayoutMode(mode);
setCollapsedGroups(new Set());
setCollapsedSectionKeys(new Set());
setCurrentPage(1);
}}
hasGroupBands={!!effectiveGroupLayout && effectiveGroupLayout.bands.length > 0}
onCollapseAll={() => {
if (effectiveGroupLayout) {
setCollapsedGroups(new Set(effectiveGroupLayout.bands.map((b) => b.path.join('///'))));
}
if (useSectionsGroupLayout) {
setCollapsedSectionKeys(new Set(sectionSummaries.map((g) => g.value === null || g.value === undefined ? '__empty__' : String(g.value))));
}
}}
onExpandAll={() => {
setCollapsedGroups(new Set());
setCollapsedSectionKeys(new Set());
}}
/>
)}
@ -3341,13 +3366,23 @@ export function FormGeneratorTable<T extends Record<string, any>>({
)}
<div className={styles.groupSections}>
{sectionSummaries.map((g) => {
const field = groupByLevels[0].field;
const sectionFilter: Record<string, unknown> = {
[field]: g.value === null || g.value === undefined ? null : g.value,
};
const isMultiLevel = groupByLevels.length > 1 && (g as any).filters;
const sectionFilter: Record<string, unknown> = isMultiLevel
? (g as any).filters
: { [groupByLevels[0].field]: g.value === null || g.value === undefined ? null : g.value };
const groupFields = isMultiLevel
? groupByLevels.map((l) => l.field)
: [groupByLevels[0].field];
const sk =
g.value === null || g.value === undefined ? '__empty__' : String(g.value);
const sectionCollapsed = collapsedSectionKeys.has(sk);
const groupFieldSet = new Set(groupFields);
const sectionColumns = providedColumns.map((col: any) =>
groupFieldSet.has(col.key) ? { ...col, filterable: false } : col,
);
const sectionInitialFilters = Object.fromEntries(
Object.entries(filters).filter(([k]) => !groupFieldSet.has(k)),
);
return (
<section
key={sk}
@ -3382,9 +3417,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</button>
{!sectionCollapsed && (
<FormGeneratorTable<T>
key={`${sk}-r${refreshNonce}-${JSON.stringify(filters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`}
key={`${sk}-r${refreshNonce}-${JSON.stringify(sectionInitialFilters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`}
className={styles.groupSectionTableWrap}
columns={providedColumns}
columns={sectionColumns}
data={[]}
searchable={false}
filterable={filterable}
@ -3415,7 +3450,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
localDataMode
viewKeyForQueries={activeViewKey}
initialSearchTerm={debouncedSearchTerm}
initialFilters={filters}
initialFilters={sectionInitialFilters}
initialSort={sortConfigs}
apiEndpoint={apiEndpoint}
csvExportQueryParams={hookDataProp?.csvExportQueryParams}
@ -3427,13 +3462,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
if (!hookDataProp?.refetchForSection) {
return { items: [], pagination: null };
}
return hookDataProp.refetchForSection(p, sectionFilter, filters);
return hookDataProp.refetchForSection(p, sectionFilter, sectionInitialFilters);
},
...(hookDataProp?.fetchFilterValues && typeof hookDataProp.fetchFilterValues === 'function'
? {
fetchFilterValues: async (columnKey: string, crossFilters?: Record<string, any>) => {
const merged: Record<string, any> = {
...filters,
...sectionInitialFilters,
...(crossFilters || {}),
...sectionFilter,
};

View file

@ -153,6 +153,51 @@
white-space: nowrap;
}
.layoutToggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
cursor: pointer;
font-size: 18px;
transition: background 0.15s, border-color 0.15s;
}
.layoutToggle:hover {
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
border-color: var(--color-primary, #64748b);
}
.collapseExpandGroup {
display: inline-flex;
gap: 2px;
}
.collapseBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--text-secondary, #94a3b8);
cursor: pointer;
font-size: 16px;
transition: background 0.15s, color 0.15s;
}
.collapseBtn:hover {
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
color: var(--color-text, #0f172a);
}
.viewBlock {
display: flex;
flex-wrap: wrap;

View file

@ -1,5 +1,7 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { FaLayerGroup, FaTrash } from 'react-icons/fa';
import { TbLayoutList, TbLayoutRows } from 'react-icons/tb';
import { FiChevronsDown, FiChevronsUp } from 'react-icons/fi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './TableViewsBar.module.css';
@ -30,6 +32,12 @@ export interface TableViewsBarProps {
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
onDeleteView?: (viewId: string) => void | Promise<void>;
onReloadViews: () => void;
canUseSections?: boolean;
groupLayoutMode?: 'inline' | 'sections';
onGroupLayoutModeChange?: (mode: 'inline' | 'sections') => void;
hasGroupBands?: boolean;
onCollapseAll?: () => void;
onExpandAll?: () => void;
}
function slugify(name: string): string {
@ -74,6 +82,12 @@ export function TableViewsBar({
onUpdateViewGrouping,
onDeleteView,
onReloadViews,
canUseSections,
groupLayoutMode,
onGroupLayoutModeChange,
hasGroupBands,
onCollapseAll,
onExpandAll,
}: TableViewsBarProps) {
const { t } = useLanguage();
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
@ -249,6 +263,41 @@ export function TableViewsBar({
: `${t('Aktiv')}: ${summary}`}
</span>
{canUseSections && groupByLevels.length > 0 && onGroupLayoutModeChange && (
<button
type="button"
className={styles.layoutToggle}
title={groupLayoutMode === 'inline' ? t('Zu Sektionen wechseln') : t('Zu Inline wechseln')}
aria-label={groupLayoutMode === 'inline' ? t('Zu Sektionen wechseln') : t('Zu Inline wechseln')}
onClick={() => onGroupLayoutModeChange(groupLayoutMode === 'inline' ? 'sections' : 'inline')}
>
{groupLayoutMode === 'inline' ? <TbLayoutRows /> : <TbLayoutList />}
</button>
)}
{hasGroupBands && onCollapseAll && onExpandAll && (
<div className={styles.collapseExpandGroup}>
<button
type="button"
className={styles.collapseBtn}
title={t('Alle zuklappen')}
aria-label={t('Alle zuklappen')}
onClick={onCollapseAll}
>
<FiChevronsUp />
</button>
<button
type="button"
className={styles.collapseBtn}
title={t('Alle aufklappen')}
aria-label={t('Alle aufklappen')}
onClick={onExpandAll}
>
<FiChevronsDown />
</button>
</div>
)}
<div className={styles.viewBlock}>
<span className={styles.viewLabel}>{t('Ansicht')}</span>
<select

View file

@ -0,0 +1,95 @@
.badgeContainer {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9000;
}
.badge {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: transform 0.15s, box-shadow 0.15s;
}
.badge:hover {
transform: scale(1.04);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.pulseIcon {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.badgeText {
white-space: nowrap;
}
.dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 240px;
max-height: 200px;
overflow-y: auto;
}
.dropdownHeader {
padding: 10px 14px 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary, #666);
border-bottom: 1px solid var(--border-color, #eee);
}
.jobRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
font-size: 12px;
}
.jobRow:not(:last-child) {
border-bottom: 1px solid var(--border-color, #f0f0f0);
}
.jobLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jobProgress {
flex-shrink: 0;
margin-left: 8px;
font-weight: 600;
color: var(--primary-color, #F25843);
}

View file

@ -0,0 +1,71 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useApiRequest } from '../../hooks/useApi';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './RagRunningBadge.module.css';
interface _RagJob {
jobId: string;
connectionId: string;
connectionLabel?: string;
jobType: string;
progress: number | null;
progressMessage: string;
}
const _POLL_INTERVAL_MS = 60_000;
export const RagRunningBadge: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [jobs, setJobs] = useState<_RagJob[]>([]);
const [expanded, setExpanded] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const _fetchJobs = useCallback(async () => {
try {
const result = await request({ url: '/api/rag/inventory/jobs', method: 'get' });
setJobs(Array.isArray(result) ? result : []);
} catch {
setJobs([]);
}
}, [request]);
useEffect(() => {
_fetchJobs();
timerRef.current = setInterval(_fetchJobs, _POLL_INTERVAL_MS);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [_fetchJobs]);
if (jobs.length === 0) return null;
return (
<div className={styles.badgeContainer}>
<button
className={styles.badge}
onClick={() => setExpanded(prev => !prev)}
title={t('RAG-Indexierung aktiv')}
>
<span className={styles.pulseIcon} />
<span className={styles.badgeText}>
{jobs.length} {jobs.length === 1 ? t('Job') : t('Jobs')}
</span>
</button>
{expanded && (
<div className={styles.dropdown}>
<div className={styles.dropdownHeader}>
{t('Aktive RAG-Jobs')}
</div>
{jobs.map(job => (
<div key={job.jobId} className={styles.jobRow}>
<span className={styles.jobLabel}>{job.connectionLabel || job.jobType}</span>
<span className={styles.jobProgress}>
{job.progress != null ? `${Math.round(job.progress * 100)}%` : '...'}
</span>
</div>
))}
</div>
)}
</div>
);
};

View file

@ -42,6 +42,7 @@ interface UdbDataSource {
displayPath?: string;
scope: string;
neutralize: boolean;
ragIndexEnabled?: boolean;
}
interface UdbFeatureDataSource {
@ -689,6 +690,17 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
}
}, []);
/* ── RAG-Index toggle (personal data source, optimistic) ── */
const _togglePersonalRagIndex = useCallback(async (ds: UdbDataSource) => {
const newValue = !ds.ragIndexEnabled;
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: newValue } : d));
try {
await api.patch(`/api/datasources/${ds.id}/rag-index`, { ragIndexEnabled: newValue });
} catch {
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: ds.ragIndexEnabled } : d));
}
}, []);
/* ── Scope change (feature data source, optimistic) ── */
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
const newScope = _nextScope(fds.scope);
@ -1018,6 +1030,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
dataSources={dataSources}
onCycleScope={_cyclePersonalScope}
onToggleNeutralize={_togglePersonalNeutralize}
onToggleRagIndex={_togglePersonalRagIndex}
onSendToChat={_sendNodeToChat}
scopeCycleTitle={_scopeCycleTitle}
selectedKeys={selectedKeys}
@ -1105,18 +1118,20 @@ interface _TreeNodeViewProps {
dataSources: UdbDataSource[];
onCycleScope: (ds: UdbDataSource) => void;
onToggleNeutralize: (ds: UdbDataSource) => void;
onToggleRagIndex: (ds: UdbDataSource) => void;
onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
scopeCycleTitle: (scope: string) => string;
selectedKeys: Set<string>;
onSelect: (node: TreeNode, e: React.MouseEvent) => void;
inheritedScope?: string;
inheritedNeutralize?: boolean;
inheritedRagIndex?: boolean;
}
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
node, depth, onToggle, onEnsureDs, isAdded, addingPath,
dataSources, onCycleScope, onToggleNeutralize, onSendToChat, scopeCycleTitle,
selectedKeys, onSelect, inheritedScope, inheritedNeutralize,
dataSources, onCycleScope, onToggleNeutralize, onToggleRagIndex, onSendToChat, scopeCycleTitle,
selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
@ -1128,8 +1143,10 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
const effectiveScope = ds?.scope ?? inheritedScope;
const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false;
const effectiveRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex ?? false;
const childInheritedScope = ds?.scope ?? inheritedScope;
const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize;
const childInheritedRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex;
const _dragPayload = {
connectionId: node.connectionId,
@ -1261,6 +1278,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
>
{'\uD83D\uDD12'}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (ds) { onToggleRagIndex(ds); return; }
const newId = await onEnsureDs(node);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/rag-index`, { ragIndexEnabled: !effectiveRagIndex }); } catch {}
}
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (ds?.ragIndexEnabled ?? effectiveRagIndex) ? 1 : 0.35,
}}
title={(ds?.ragIndexEnabled ?? effectiveRagIndex) ? t('RAG-Indexierung an') : t('RAG-Indexierung aus')}
>
{'\uD83E\uDDE0'}
</button>
</div>
@ -1278,12 +1313,14 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
dataSources={dataSources}
onCycleScope={onCycleScope}
onToggleNeutralize={onToggleNeutralize}
onToggleRagIndex={onToggleRagIndex}
onSendToChat={onSendToChat}
scopeCycleTitle={scopeCycleTitle}
selectedKeys={selectedKeys}
onSelect={onSelect}
inheritedScope={childInheritedScope}
inheritedNeutralize={childInheritedNeutralize}
inheritedRagIndex={childInheritedRagIndex}
/>
))}
</div>

View file

@ -53,6 +53,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.system.billingAdmin': <FaMoneyBillAlt />,
'page.system.statistics': <FaChartBar />,
'page.system.automations': <FaRobot />,
'page.system.ragInventory': <FaDatabase />,
// Billing pages (legacy compat)
'page.billing.dashboard': <FaWallet />,

View file

@ -101,17 +101,15 @@ export function useConnections() {
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
groupByLevels: levels,
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;

View file

@ -149,17 +149,15 @@ export function useUserFiles() {
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
groupByLevels: levels,
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;

View file

@ -98,17 +98,15 @@ export function usePrompts() {
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
groupByLevels: levels,
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;

View file

@ -14,6 +14,7 @@ import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
@ -132,6 +133,8 @@ const MainLayoutInner: React.FC = () => {
<Outlet />
</div>
</main>
<RagRunningBadge />
</div>
);
};

View file

@ -35,7 +35,6 @@ import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalE
import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage';
// Teamsbot Views
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
@ -155,7 +154,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
workspace: {
dashboard: WorkspacePage,
editor: WorkspaceEditorPage,
'rag-insights': WorkspaceRagInsightsPage,
settings: WorkspaceSettingsPage,
},
teamsbot: {

View file

@ -0,0 +1,334 @@
.page {
padding: 24px 32px;
max-width: 1100px;
}
/* ── Page Header ── */
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.headerLeft {
display: flex;
align-items: center;
gap: 14px;
}
.headerIcon {
font-size: 24px;
color: var(--color-primary, #2563eb);
flex-shrink: 0;
}
.pageTitle {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.pageDesc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
margin: 2px 0 0;
}
.headerRight {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.filterGroup {
display: flex;
align-items: center;
gap: 8px;
}
.filterLabel {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
.scopeSelect {
padding: 7px 12px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
background: var(--color-surface, #fff);
color: var(--color-text, #111);
cursor: pointer;
min-width: 180px;
}
.scopeSelect:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
}
.checkboxLabel {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
cursor: pointer;
white-space: nowrap;
}
.checkboxLabel input {
cursor: pointer;
}
/* ── Loading / Error ── */
.loading {
padding: 32px;
text-align: center;
color: var(--color-text-muted, #6b7280);
}
.error {
padding: 12px 16px;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 0.875rem;
margin-bottom: 16px;
}
/* ── Totals ── */
.totals {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
font-size: 0.875rem;
}
.totalLabel {
color: var(--color-text-muted, #6b7280);
}
.totalValue {
font-size: 1.125rem;
}
.totalBytes {
color: var(--color-text-muted, #6b7280);
font-size: 0.8125rem;
}
/* ── Content ── */
.content {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Connection Card ── */
.connectionCard {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 16px;
background: var(--color-surface, #fff);
}
.connectionHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.authority {
font-weight: 600;
font-size: 0.875rem;
text-transform: capitalize;
}
.email {
color: var(--color-text-muted, #6b7280);
font-size: 0.8125rem;
flex: 1;
}
.connChunks {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-primary, #2563eb);
background: var(--color-info-bg, #eff6ff);
padding: 2px 8px;
border-radius: 10px;
}
.consentToggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary, #2563eb);
display: flex;
align-items: center;
}
/* ── Consent Warning ── */
.consentWarning {
padding: 8px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
color: #92400e;
}
/* ── Error Banner ── */
.errorBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
color: #b91c1c;
}
.reindexBtn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--color-info-bg, #eff6ff);
color: var(--color-primary, #2563eb);
border: 1px solid var(--color-primary-light, #93c5fd);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
margin-left: auto;
white-space: nowrap;
}
.reindexBtn:hover {
background: #dbeafe;
}
.reindexHint {
display: flex;
padding: 6px 0;
margin-bottom: 4px;
}
/* ── Job Banner ── */
.jobBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-info-bg, #eff6ff);
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
}
.spinIcon {
animation: spin 1.5s linear infinite;
color: var(--color-primary, #2563eb);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.stopBtn {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.stopBtn:hover {
background: #fee2e2;
}
/* ── DataSource List ── */
.dsList {
display: flex;
flex-direction: column;
gap: 0;
}
.dsRow {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
border-bottom: 1px solid var(--color-border, #f3f4f6);
font-size: 0.8125rem;
}
.dsRow:last-child {
border-bottom: none;
}
.dsActive {
background: rgba(37, 99, 235, 0.03);
}
.dsLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dsType {
color: var(--color-text-muted, #6b7280);
font-size: 0.75rem;
min-width: 90px;
}
.dsChunks {
font-size: 0.75rem;
min-width: 70px;
text-align: right;
}
.dsIndex {
width: 24px;
text-align: center;
}
.dsEmpty {
padding: 12px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
font-size: 0.8125rem;
font-style: italic;
}
/* ── Empty State ── */
.emptyState {
padding: 48px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
font-size: 0.9rem;
}

View file

@ -0,0 +1,255 @@
/**
* RagInventoryPage Global RAG knowledge store management.
*
* Accessible via Start > Nutzung > RAG-Inventar.
* Context selector top-right (same pattern as BillingDataView / Statistiken):
* Dropdown: "Meine Verbindungen" | "Mandant: XY" | "Plattform (alle)"
* Checkbox: "nur meine Daten"
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useLanguage } from '../providers/language/LanguageContext';
import { useApiRequest } from '../hooks/useApi';
import { useUserMandates } from '../hooks/useUserMandates';
import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle } from 'react-icons/fa';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import styles from './RagInventoryPage.module.css';
export const RagInventoryPage: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { fetchMandates } = useUserMandates();
const [mandates, setMandates] = useState<any[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
const [selectedScope, setSelectedScope] = useState<string>('personal');
const [onlyMyData, setOnlyMyData] = useState(false);
const [inventory, setInventory] = useState<RagInventoryDto | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setMandatesLoading(true);
try {
const data = await fetchMandates();
if (!cancelled) {
const list = Array.isArray(data) ? data : [];
setMandates(list);
if (list.length === 1) setSelectedScope(list[0].id);
}
} catch {}
finally { if (!cancelled) setMandatesLoading(false); }
})();
return () => { cancelled = true; };
}, [fetchMandates]);
const _apiEndpoint = useMemo(() => {
if (selectedScope === 'personal') return '/api/rag/inventory/me';
if (selectedScope === 'platform') return '/api/rag/inventory/platform';
return '/api/rag/inventory/mandate';
}, [selectedScope]);
const _fetchInventory = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params: Record<string, string> = {};
if (selectedScope !== 'personal' && selectedScope !== 'platform') {
params.mandateId = selectedScope;
}
if (onlyMyData) params.onlyMine = 'true';
const data = await request({ url: _apiEndpoint, method: 'get', params });
setInventory(data);
} catch (err: any) {
if (err?.message?.includes('403')) {
setError(t('Keine Berechtigung für diese Sicht.'));
} else {
setError(err?.message || t('Fehler beim Laden'));
}
setInventory(null);
} finally {
setLoading(false);
}
}, [request, _apiEndpoint, selectedScope, onlyMyData, t]);
useEffect(() => {
_fetchInventory();
}, [_fetchInventory]);
useEffect(() => {
pollRef.current = setInterval(() => {
if (document.visibilityState === 'visible') _fetchInventory();
}, 60000);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [_fetchInventory]);
const _handleStop = async (connectionId: string) => {
try {
await request({ url: `/api/connections/${connectionId}/knowledge-stop`, method: 'post' });
_fetchInventory();
} catch {}
};
const _handleReindex = async (connectionId: string) => {
try {
await request({ url: `/api/rag/inventory/reindex/${connectionId}`, method: 'post' });
_fetchInventory();
} catch {}
};
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) {
try {
await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled: !currentEnabled },
});
_fetchInventory();
} catch {}
}
};
const scopeOptions = useMemo(() => {
const opts: { value: string; label: string }[] = [
{ value: 'personal', label: t('Meine Verbindungen') },
];
for (const m of mandates) {
opts.push({ value: m.id, label: t('Mandant: {name}', { name: mandateDisplayLabel(m) }) });
}
opts.push({ value: 'platform', label: t('Plattform (alle)') });
return opts;
}, [mandates, t]);
return (
<div className={styles.page}>
<header className={styles.pageHeader}>
<div className={styles.headerLeft}>
<FaDatabase className={styles.headerIcon} />
<div>
<h1 className={styles.pageTitle}>{t('RAG-Inventar')}</h1>
<p className={styles.pageDesc}>
{t('Übersicht und Steuerung der indexierten Wissensdaten.')}
</p>
</div>
</div>
<div className={styles.headerRight}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Kontext:')}</label>
<select
className={styles.scopeSelect}
value={selectedScope}
onChange={e => setSelectedScope(e.target.value)}
disabled={mandatesLoading}
>
{scopeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={onlyMyData}
onChange={e => setOnlyMyData(e.target.checked)}
/>
{t('nur meine Daten')}
</label>
</div>
</header>
{loading && !inventory && <div className={styles.loading}>{t('Laden...')}</div>}
{error && <div className={styles.error}>{error}</div>}
{inventory && (
<div className={styles.content}>
<div className={styles.totals}>
<span className={styles.totalLabel}>{t('Total Chunks')}:</span>
<strong className={styles.totalValue}>{inventory.totals?.chunks ?? 0}</strong>
{inventory.totals?.bytes != null && inventory.totals.bytes > 0 && (
<span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span>
)}
</div>
{(inventory.connections || []).map((conn: RagConnectionDto) => (
<div key={conn.id} className={styles.connectionCard}>
<div className={styles.connectionHeader}>
<span className={styles.authority}>{conn.authority}</span>
<span className={styles.email}>{conn.externalEmail}</span>
{conn.totalChunks > 0 && (
<span className={styles.connChunks}>{conn.totalChunks} chunks</span>
)}
<button
className={styles.consentToggle}
onClick={() => _handleConsentToggle(conn.id, conn.knowledgeIngestionEnabled)}
title={conn.knowledgeIngestionEnabled ? t('Wissensdatenbank deaktivieren') : t('Wissensdatenbank aktivieren')}
>
{conn.knowledgeIngestionEnabled ? <FaToggleOn size={20} /> : <FaToggleOff size={20} />}
</button>
</div>
{!conn.knowledgeIngestionEnabled && conn.dataSources.length > 0 && (
<div className={styles.consentWarning}>
{t('Wissensdatenbank deaktiviert — Indexierung pausiert. Toggle aktivieren um Daten zu indexieren.')}
</div>
)}
{conn.lastError && conn.runningJobs.length === 0 && (
<div className={styles.errorBanner}>
<FaExclamationTriangle />
<span>{t('Letzter Job fehlgeschlagen')}: {conn.lastError.errorMessage || t('unbekannter Fehler')}</span>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Neu indexieren')}>
<FaRedo size={12} /> {t('Neu indexieren')}
</button>
</div>
)}
{conn.runningJobs.length > 0 && (
<div className={styles.jobBanner}>
<FaSync className={styles.spinIcon} />
<span>{conn.runningJobs[0].progressMessage || `${Math.round(conn.runningJobs[0].progress * 100)}%`}</span>
<button className={styles.stopBtn} onClick={() => _handleStop(conn.id)} title={t('Indexierung stoppen')}>
<FaStop size={12} /> {t('Stop')}
</button>
</div>
)}
{!conn.lastError && conn.runningJobs.length === 0 && conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.totalChunks === 0 && conn.knowledgeIngestionEnabled && (
<div className={styles.reindexHint}>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Indexierung starten')}>
<FaRedo size={12} /> {t('Indexierung starten')}
</button>
</div>
)}
<div className={styles.dsList}>
{conn.dataSources.map(ds => (
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
<span className={styles.dsLabel}>{ds.label || ds.path}</span>
<span className={styles.dsType}>{ds.sourceType}</span>
<span className={styles.dsChunks}>{ds.chunkCount} chunks</span>
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
</div>
))}
{conn.dataSources.length === 0 && (
<div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div>
)}
</div>
</div>
))}
{(inventory.connections || []).length === 0 && (
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
)}
</div>
)}
</div>
);
};
export default RagInventoryPage;

View file

@ -9,8 +9,7 @@ import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config';
import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa';
import styles from '../admin/Admin.module.css';
import bannerStyles from './ConnectionsPage.module.css';
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
@ -42,8 +41,6 @@ export const ConnectionsPage: React.FC = () => {
deleteConnection,
handleInlineUpdate,
createConnectionAndAuth,
createInfomaniakConnection,
submitInfomaniakToken,
connectWithPopup,
refreshMicrosoftToken,
refreshGoogleToken,
@ -54,7 +51,6 @@ export const ConnectionsPage: React.FC = () => {
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
const [adminConsentPending, setAdminConsentPending] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
// Banner shown while knowledge bootstrap is running in the background
const [syncBanner, setSyncBanner] = useState<{
@ -73,15 +69,6 @@ export const ConnectionsPage: React.FC = () => {
setSyncBanner(null);
};
// Infomaniak PAT modal: holds the pending connectionId (created up-front so the
// user only commits if they actually paste a valid token; on cancel we delete it).
const [infomaniakModal, setInfomaniakModal] = useState<{
connectionId: string;
token: string;
submitting: boolean;
error: string | null;
} | null>(null);
// Initial fetch
useEffect(() => {
refetch();
@ -242,76 +229,6 @@ export const ConnectionsPage: React.FC = () => {
}
};
const handleCreateInfomaniak = async () => {
if (isConnecting || infomaniakModal) return;
try {
const newConnection = await createInfomaniakConnection();
setInfomaniakModal({
connectionId: newConnection.id,
token: '',
submitting: false,
error: null,
});
refetch();
} catch (error) {
console.error('Error creating Infomaniak connection:', error);
}
};
const handleInfomaniakCancel = async () => {
if (!infomaniakModal) return;
const { connectionId, submitting } = infomaniakModal;
if (submitting) return;
setInfomaniakModal(null);
try {
await deleteConnection(connectionId);
refetch();
} catch (error) {
console.error('Error rolling back pending Infomaniak connection:', error);
}
};
const handleInfomaniakSubmit = async () => {
if (!infomaniakModal) return;
const trimmed = infomaniakModal.token.trim();
if (!trimmed) {
setInfomaniakModal({ ...infomaniakModal, error: t('Bitte Personal Access Token einfügen') });
return;
}
setInfomaniakModal({ ...infomaniakModal, submitting: true, error: null });
try {
await submitInfomaniakToken(infomaniakModal.connectionId, trimmed);
setInfomaniakModal(null);
refetch();
} catch (error: any) {
const detail =
error?.response?.data?.detail ||
error?.message ||
t('Token konnte nicht gespeichert werden');
setInfomaniakModal((prev) =>
prev ? { ...prev, submitting: false, error: String(detail) } : prev
);
}
};
// Open Microsoft Admin Consent flow in a popup
const handleAdminConsent = () => {
setAdminConsentPending(true);
const consentUrl = `${getApiBaseUrl()}/api/msft/adminconsent`;
const popup = window.open(consentUrl, 'msft-admin-consent', 'width=600,height=700,scrollbars=yes,resizable=yes');
if (!popup) {
setAdminConsentPending(false);
return;
}
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
setAdminConsentPending(false);
refetch();
}
}, 1000);
};
// Form attributes for edit modal
const formAttributes = useMemo(() => {
const excludedFields = [
@ -348,14 +265,6 @@ export const ConnectionsPage: React.FC = () => {
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={handleAdminConsent}
disabled={adminConsentPending}
title={t('Microsoft Admin-Zustimmung für die gesamte Organisation erteilen')}
>
<FaShieldAlt /> {t('Admin-Zustimmung')}
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
@ -364,25 +273,14 @@ export const ConnectionsPage: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<>
<button
type="button"
className={styles.primaryButton}
onClick={() => setWizardOpen(true)}
disabled={isConnecting}
>
<FaPlus /> {t('Verbindung hinzufügen')}
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={handleCreateInfomaniak}
disabled={isConnecting}
title={t('Infomaniak-Konto verbinden (kDrive + Mail)')}
>
<FaCloud /> Infomaniak
</button>
</>
<button
type="button"
className={styles.primaryButton}
onClick={() => setWizardOpen(true)}
disabled={isConnecting}
>
<FaPlus /> {t('Verbindung hinzufügen')}
</button>
)}
</div>
</div>
@ -419,7 +317,7 @@ export const ConnectionsPage: React.FC = () => {
columns={columns}
apiEndpoint="/api/connections/"
tableContextKey="connections"
tableGroupLayoutMode="sections"
tableGroupLayoutMode="inline"
loading={loading}
pagination={true}
pageSize={25}
@ -519,137 +417,6 @@ export const ConnectionsPage: React.FC = () => {
</div>
)}
{/* Infomaniak Personal Access Token Modal */}
{infomaniakModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ maxWidth: 640 }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Infomaniak verbinden')}</h2>
<button
className={styles.modalClose}
onClick={handleInfomaniakCancel}
disabled={infomaniakModal.submitting}
>
</button>
</div>
<div className={styles.modalContent}>
<p style={{ marginTop: 0 }}>
{t(
'Infomaniak nutzt für kDrive und kSuite keine OAuth-Anmeldung, sondern ein persönliches API-Token (PAT). Erstelle das Token einmalig im Infomaniak Manager und füge es unten ein.'
)}
</p>
<ol style={{ paddingLeft: 20 }}>
<li>
{t('Öffne den Infomaniak-Manager:')}{' '}
<a
href="https://manager.infomaniak.com/v3/ng/accounts/token/list"
target="_blank"
rel="noopener noreferrer"
>
manager.infomaniak.com API-Tokens
</a>
</li>
<li>
{t('Klicke auf')} <code>{t('Token erstellen')}</code>{' '}
{t('und vergib einen aussagekräftigen Namen, z. B.')}{' '}
<code>PowerOn</code>.{' '}
{t('Application bleibt auf')} <code>Default application</code>.
</li>
<li>
{t('Suche im Scope-Feld nach')}{' '}
<strong>{t('allen vier')}</strong>{' '}
{t('Berechtigungen und kreuze sie an:')}
<ul style={{ marginTop: 6, marginBottom: 6, paddingLeft: 20 }}>
<li>
<code>drive</code> {t('kDrive (Pflicht, heute aktiv)')}
</li>
<li>
<code>workspace:calendar</code> {' '}
{t('Kalender (Pflicht, heute aktiv)')}
</li>
<li>
<code>workspace:contact</code> {' '}
{t('Kontakte (heute aktiv)')}
</li>
<li>
<code>workspace:mail</code> {' '}
{t('Mail (in Vorbereitung, Scope schon mitnehmen)')}
</li>
</ul>
<em>
{t(
'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.'
)}
</em>
</li>
<li>
{t(
'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:'
)}
</li>
</ol>
<input
type="password"
value={infomaniakModal.token}
onChange={(e) =>
setInfomaniakModal((prev) =>
prev ? { ...prev, token: e.target.value, error: null } : prev
)
}
placeholder={t('Personal Access Token einfügen')}
disabled={infomaniakModal.submitting}
autoFocus
style={{
width: '100%',
padding: '8px 10px',
fontFamily: 'monospace',
fontSize: 13,
border: '1px solid var(--border, #ccc)',
borderRadius: 4,
marginBottom: 12,
}}
/>
{infomaniakModal.error && (
<div className={styles.errorMessage} style={{ marginBottom: 12 }}>
{infomaniakModal.error}
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-secondary, #666)' }}>
{t(
'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.'
)}
</p>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 16,
}}
>
<button
type="button"
className={styles.secondaryButton}
onClick={handleInfomaniakCancel}
disabled={infomaniakModal.submitting}
>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleInfomaniakSubmit}
disabled={infomaniakModal.submitting || !infomaniakModal.token.trim()}
>
{infomaniakModal.submitting ? t('Prüfen…') : t('Verbinden')}
</button>
</div>
</div>
</div>
</div>
)}
<AddConnectionWizard
open={wizardOpen}
onClose={() => setWizardOpen(false)}

View file

@ -448,7 +448,7 @@ export const FilesPage: React.FC = () => {
columns={columns}
apiEndpoint="/api/files/list"
tableContextKey="files/list"
tableGroupLayoutMode="sections"
tableGroupLayoutMode="inline"
loading={tableLoading}
pagination={true}
pageSize={25}

View file

@ -209,7 +209,7 @@ export const PromptsPage: React.FC = () => {
columns={columns}
apiEndpoint="/api/prompts"
tableContextKey="prompts"
tableGroupLayoutMode="sections"
tableGroupLayoutMode="inline"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -527,17 +527,15 @@ export const BillingDataView: React.FC = () => {
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
groupByLevels: levels,
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
@ -837,7 +835,7 @@ export const BillingDataView: React.FC = () => {
columns={columns}
apiEndpoint="/api/billing/view/users/transactions"
tableContextKey="billing/view/users/transactions"
tableGroupLayoutMode="sections"
tableGroupLayoutMode="inline"
loading={transactionsLoading}
pagination={true}
pageSize={25}

View file

@ -1,6 +1,13 @@
/* CommCoach Shared Styles — Assistant, Modules, Session views */
.assistantContainer,
.assistantContainer {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.modulesContainer {
padding: 1.5rem;
display: flex;
@ -14,6 +21,14 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.wizardHeaderRight {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.stepIndicator {
@ -33,7 +48,6 @@
}
.wizardContent {
flex: 1;
display: flex;
flex-direction: column;
}
@ -98,9 +112,8 @@
.wizardActions {
display: flex;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
gap: 0.5rem;
align-items: center;
}
.wizardHint {

View file

@ -75,10 +75,24 @@ export const CommcoachAssistantView: React.FC = () => {
<div className={styles.assistantContainer}>
<div className={styles.wizardHeader}>
<h2>{t('Neues Modul erstellen')}</h2>
<div className={styles.stepIndicator}>
{STEPS.map((s, i) => (
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
<div className={styles.wizardHeaderRight}>
<div className={styles.stepIndicator}>
{STEPS.map((s, i) => (
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
</button>
)}
</div>
</div>
</div>
@ -156,19 +170,6 @@ export const CommcoachAssistantView: React.FC = () => {
)}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
<div style={{ flex: 1 }} />
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
</button>
)}
</div>
</div>
);
};

View file

@ -1372,7 +1372,14 @@
margin-top: 0.25rem;
}
.assistantContainer,
.assistantContainer {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.modulesContainer {
padding: 1.5rem;
display: flex;
@ -1386,6 +1393,14 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.wizardHeaderRight {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.stepIndicator {
@ -1405,7 +1420,6 @@
}
.wizardContent {
flex: 1;
display: flex;
flex-direction: column;
}
@ -1438,9 +1452,8 @@
.wizardActions {
display: flex;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
gap: 0.5rem;
align-items: center;
}
.moduleChoice {

View file

@ -149,10 +149,32 @@ export const TeamsbotAssistantView: React.FC = () => {
<div className={styles.assistantContainer}>
<div className={styles.wizardHeader}>
<h2>{t('Neues Meeting starten')}</h2>
<div className={styles.stepIndicator}>
{STEPS.map((s, i) => (
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
<div className={styles.wizardHeaderRight}>
<div className={styles.stepIndicator}>
{STEPS.map((s, i) => (
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button
className={styles.btnPrimary}
onClick={_handleStart}
disabled={
loading
|| savingCredentials
|| (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))
}
>
{loading ? t('Starte...') : t('Bot starten')}
</button>
)}
</div>
</div>
</div>
@ -328,27 +350,6 @@ export const TeamsbotAssistantView: React.FC = () => {
)}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
<div style={{ flex: 1 }} />
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button
className={styles.btnPrimary}
onClick={_handleStart}
disabled={
loading
|| savingCredentials
|| (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))
}
>
{loading ? t('Starte...') : t('Bot starten')}
</button>
)}
</div>
</div>
);
};

View file

@ -1,107 +0,0 @@
.wrap {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1200px;
}
.disclaimer {
font-size: 0.85rem;
line-height: 1.45;
color: var(--text-secondary, #666);
padding: 0.75rem 1rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
border: 1px solid var(--border-color, #e8e8e8);
}
.kpiGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.kpiCard {
padding: 1rem;
border-radius: 8px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.kpiValue {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
margin: 0 0 0.25rem;
}
.kpiLabel {
font-size: 0.8rem;
color: var(--text-secondary, #666);
margin: 0;
line-height: 1.3;
}
.chartBlock {
padding: 1rem;
border-radius: 8px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
min-height: 280px;
}
.chartTitle {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 0.75rem;
color: var(--text-primary, #1a1a1a);
}
.row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 900px) {
.row2 {
grid-template-columns: 1fr;
}
}
.meta {
font-size: 0.75rem;
color: var(--text-secondary, #888);
margin-top: 0.5rem;
}
.error {
color: #c62828;
padding: 1rem;
}
.recentTable {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.recentTable th {
text-align: left;
font-weight: 600;
color: var(--text-secondary, #666);
padding: 0.5rem 0.75rem;
border-bottom: 2px solid var(--border-color, #e0e0e0);
white-space: nowrap;
}
.recentTable td {
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--border-color, #f0f0f0);
color: var(--text-primary, #1a1a1a);
}
.recentTable tbody tr:hover {
background: var(--bg-secondary, #fafafa);
}

View file

@ -1,352 +0,0 @@
/**
* WorkspaceRagInsightsPage Aggregierte, nicht personenbezogene Kennzahlen zum
* Knowledge Store / RAG dieser Workspace-Instanz (Präsentationen, Monitoring).
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
BarChart,
Bar,
PieChart,
Pie,
Cell,
} from 'recharts';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceRagInsightsPage.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import { useLanguage } from '../../../providers/language/LanguageContext';
function _mimeLabel(key: string, t: (k: string) => string): string {
switch (key) {
case 'pdf': return t('PDF');
case 'office_doc': return t('Office (Text)');
case 'office_sheet': return t('Office (Tabellen)');
case 'office_slides': return t('Office (Folien)');
case 'text': return t('Text');
case 'image': return t('Bild');
case 'html': return t('HTML');
case 'other': return t('Sonstige');
default: return key;
}
}
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
function _formatTimestamp(ts: number | null | undefined): string {
if (ts == null || ts <= 0) return '';
try {
const d = new Date(ts * 1000);
return d.toLocaleString('de-CH', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
} catch {
return '';
}
}
function _shortMime(mime: string): string {
const m = (mime || '').toLowerCase();
if (m.includes('pdf')) return 'PDF';
if (m.includes('wordprocessing') || m.includes('msword')) return 'Word';
if (m.includes('spreadsheet') || m.includes('excel')) return 'Excel';
if (m.includes('presentation') || m.includes('powerpoint')) return 'PowerPoint';
if (m.startsWith('text/')) return 'Text';
if (m.startsWith('image/')) return 'Bild';
if (m.includes('html')) return 'HTML';
return mime || '';
}
const _STATUS_COLORS: Record<string, string> = {
indexed: '#2e7d32',
extracted: '#1565c0',
embedding: '#6a1b9a',
pending: '#e65100',
failed: '#c62828',
};
interface RagKpis {
indexedDocuments: number;
indexedBytesTotal: number;
contributorUsers: number;
contentChunks: number;
chunksWithEmbedding: number;
embeddingCoveragePercent: number;
workflowEntities: number;
}
interface RecentlyIndexedDoc {
fileName: string;
mimeType: string;
status: string;
extractedAt: number | null;
totalSize: number;
}
interface RagStatsResponse {
error?: string;
scope?: {
featureInstanceId?: string;
mandateScopedShared?: boolean;
workspaceFileIdsResolved?: number;
};
kpis?: RagKpis;
indexedDocumentsByStatus?: Record<string, number>;
documentsByMimeCategory?: Record<string, number>;
chunksByContentType?: Record<string, number>;
timelineIndexedDocuments?: Array<{ date: string; indexedDocuments: number }>;
recentlyIndexedDocuments?: RecentlyIndexedDoc[];
generatedAtUtc?: string;
}
export const WorkspaceRagInsightsPage: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const { request } = useApiRequest();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stats, setStats] = useState<RagStatsResponse | null>(null);
const load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const data = (await request({
url: `/api/workspace/${instanceId}/rag-statistics`,
method: 'get',
})) as RagStatsResponse;
if (data?.error) {
setError(String(data.error));
setStats(null);
} else {
setStats(data ?? null);
}
} catch (e) {
setError(e instanceof Error ? e.message : t('Laden fehlgeschlagen'));
setStats(null);
} finally {
setLoading(false);
}
}, [instanceId, request, t]);
useEffect(() => {
void load();
}, [load]);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
{t('Keine Workspace-Instanz ausgewählt.')}
</div>
);
}
if (loading) {
return <div className={styles.wrap} style={{ padding: 24 }}>{t('Lade Kennzahlen')}</div>;
}
if (error) {
return <div className={styles.error}>{error}</div>;
}
const kpis = stats?.kpis;
const timeline = stats?.timelineIndexedDocuments ?? [];
const mimeRows = Object.entries(stats?.documentsByMimeCategory ?? {}).map(([key, value]) => ({
name: _mimeLabel(key, t),
value,
}));
const statusRows = Object.entries(stats?.indexedDocumentsByStatus ?? {}).map(([name, value]) => ({
name,
value,
}));
const chunkTypeRows = Object.entries(stats?.chunksByContentType ?? {}).map(([name, value]) => ({
name,
value,
}));
return (
<div className={styles.wrap}>
<p className={styles.disclaimer}>
{t(
'Dargestellt sind ausschliesslich aggregierte technische Masszahlen dieser Instanz (Anzahl Dokumente, Fragmente, Speicherumfang, Verteilungen). Es werden keine Inhalte, Dateinamen oder personenbezogene Angaben ausgewiesen. Geeignet für interne Berichte und Präsentationen.',
)}
</p>
{stats?.scope?.workspaceFileIdsResolved !== undefined && (
<p className={styles.meta} style={{ marginTop: 0 }}>
{t(
'Zuordnung Knowledge ↔ Dateien: {workspaceFileIdsResolved} Datei-ID(s) mit dieser Feature-Instanz in der Dateiverwaltung. Neu indexierte Uploads erhalten die Instanz automatisch; ältere Einträge ohne Zuordnung erscheinen erst nach erneuter Indexierung.',
{ workspaceFileIdsResolved: stats.scope.workspaceFileIdsResolved },
)}
</p>
)}
{kpis && (
<div className={styles.kpiGrid}>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.indexedDocuments}</p>
<p className={styles.kpiLabel}>{t('Indexierte Dokumente')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}</p>
<p className={styles.kpiLabel}>{t('Indexiertes Datenvolumen (geschätzt)')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contentChunks}</p>
<p className={styles.kpiLabel}>{t('Inhaltsfragmente (Chunks)')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>
{kpis.embeddingCoveragePercent}%
</p>
<p className={styles.kpiLabel}>{t('Anteil Fragmente mit Embedding')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contributorUsers}</p>
<p className={styles.kpiLabel}>{t('Beitragende Benutzeranzahl')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.workflowEntities}</p>
<p className={styles.kpiLabel}>{t('Workflowentitäten-Cache')}</p>
</div>
</div>
)}
{(stats?.recentlyIndexedDocuments ?? []).length > 0 && (
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Zuletzt indexierte Dokumente')}</h3>
<div style={{ overflowX: 'auto' }}>
<table className={styles.recentTable}>
<thead>
<tr>
<th>{t('Dateiname')}</th>
<th>{t('Format')}</th>
<th>{t('Grösse')}</th>
<th>{t('Status')}</th>
<th>{t('Indexiert am')}</th>
</tr>
</thead>
<tbody>
{(stats?.recentlyIndexedDocuments ?? []).map((doc, i) => (
<tr key={i}>
<td title={doc.fileName} style={{ maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.fileName || ''}
</td>
<td>{_shortMime(doc.mimeType)}</td>
<td style={{ whiteSpace: 'nowrap' }}>{formatBinaryDataSizeBytes(doc.totalSize)}</td>
<td>
<span style={{
color: _STATUS_COLORS[doc.status] ?? '#666',
fontWeight: 500,
}}>
{doc.status}
</span>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{_formatTimestamp(doc.extractedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Neu indexierte Dokumente pro Tag')}</h3>
{timeline.length === 0 ? (
<p className={styles.meta}>{t('Keine Zeitreihendaten für den gewählten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={timeline}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="indexedDocuments" name={t('Dokumente')} stroke="#1976d2" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.row2}>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Dokumente nach Formatkategorie')}</h3>
{mimeRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={mimeRows} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" allowDecimals={false} />
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name={t('Anzahl')} fill="#00897b" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Index-Status')}</h3>
{statusRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={statusRows}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={88}
label={({ name, percent }) =>
`${name ?? ''} ${percent != null ? (percent * 100).toFixed(0) : '0'}%`}
>
{statusRows.map((_, i) => (
<Cell key={`st-${i}`} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Fragmente nach Inhaltstyp')}</h3>
{chunkTypeRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Chunkdaten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chunkTypeRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name={t('Fragmente')} fill="#6a1b9a" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{stats?.generatedAtUtc && (
<p className={styles.meta}>
{t('Stand (UTC):')} {stats.generatedAtUtc}
</p>
)}
</div>
);
};