diff --git a/src/App.tsx b/src/App.tsx index 499bb37..ecba5f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { {/* ============================================== */} } /> + {/* ============================================== */} + {/* RAG INVENTORY */} + {/* ============================================== */} + } /> + {/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */} } /> } /> diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index 8c47c6d..5190f4b 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -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 { + return await request({ url: '/api/rag/inventory/me', method: 'get' }); +} + +export async function getRagInventoryMandate(request: ApiRequestFunction): Promise { + return await request({ url: '/api/rag/inventory/mandate', method: 'get' }); +} + +export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise { + return await request({ url: '/api/rag/inventory/platform', method: 'get' }); +} + +export async function getRagActiveJobs(request: ApiRequestFunction): Promise { + return await request({ url: '/api/rag/inventory/jobs', method: 'get' }); +} + diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.module.css b/src/components/AddConnectionWizard/AddConnectionWizard.module.css index 5cabd64..65cf185 100644 --- a/src/components/AddConnectionWizard/AddConnectionWizard.module.css +++ b/src/components/AddConnectionWizard/AddConnectionWizard.module.css @@ -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); diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.tsx b/src/components/AddConnectionWizard/AddConnectionWizard.tsx index 85c9336..34c5cb0 100644 --- a/src/components/AddConnectionWizard/AddConnectionWizard.tsx +++ b/src/components/AddConnectionWizard/AddConnectionWizard.tsx @@ -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 = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp', + infomaniak: 'Infomaniak', }; const CONNECTOR_ICONS: Record = { google: , msft: , clickup: , + infomaniak: , }; -// --------------------------------------------------------------------------- -// 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 = { 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; + onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise; + onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise; + onMsftAdminConsent?: () => void; isConnecting?: boolean; } @@ -173,84 +70,91 @@ export const AddConnectionWizard: React.FC = ({ open, onClose, onConnect, + onInfomaniakConnect, + onMsftAdminConsent, isConnecting = false, }) => { const [state, setState] = useState({ - 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 = (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 ( - + {/* Stepper */}
- {[0, 1, 2, 3].map(i => ( + {steps.map((s, i) => (
i ? styles.stepDotDone : '', - !visibleSteps.includes(i) ? styles.stepDotHidden : '', + stepIndex === i ? styles.stepDotActive : '', + stepIndex > i ? styles.stepDotDone : '', ].join(' ')} > - {state.step > i ? : i + 1} + {stepIndex > i ? : i + 1}
))}
- {/* ---- Step 0: Connector ---- */} - {state.step === 0 && ( + {/* ---- Step: Connector ---- */} + {state.currentStep === 'connector' && (

Anbieter wählen

Welchen Dienst möchtest du verbinden?

- {(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => ( + {(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
)} - {/* ---- Step 1: Consent ---- */} - {state.step === 1 && ( + {/* ---- Step: Consent ---- */} + {state.currentStep === 'consent' && (
-

Wissensdatenbank

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?

- Du kannst diese Einstellung später in den Verbindungsdetails ändern. + Du kannst dies später jederzeit in der UDB pro Datenquelle steuern. +

+
+ + +
+
+ +
+
+ )} + + {/* ---- Step: MSFT Admin Consent ---- */} + {state.currentStep === 'msftAdminConsent' && ( +
+
+ +
+

Organisations-Zustimmung (optional)

+

+ Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. + So müssen andere Benutzer nicht einzeln bestätigen. +

+

+ Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.

-
- +
)} - {/* ---- Step 2: Preferences ---- */} - {state.step === 2 && ( + {/* ---- Step: Infomaniak PAT ---- */} + {state.currentStep === 'infomaniakPat' && (
-

Einstellungen

-

- Steuere, welche Inhalte und in welcher Form sie indexiert werden. +

Infomaniak Personal Access Token

+

+ Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.

- -
- -

- Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt. -

-
- - {(state.connector === 'google' || state.connector === 'msft') && ( - <> -
- -
-
- -
- - )} - - {state.connector === 'clickup' && ( -
- -
- )} - -
- -

0 = kein Limit

-
- + setState(s => ({ ...s, infomaniakToken: e.target.value }))} + className={styles.patInput} + autoFocus + />
- - +
)} - {/* ---- Step 3: Summary ---- */} - {state.step === 3 && ( + {/* ---- Step: Connect ---- */} + {state.currentStep === 'connect' && (
-

Zusammenfassung

+

Verbindung herstellen

Anbieter - {CONNECTOR_ICONS[state.connector!]}  + {state.connector && CONNECTOR_ICONS[state.connector]}  {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
@@ -414,96 +270,13 @@ export const AddConnectionWizard: React.FC = ({ {state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
- {state.knowledgeEnabled && ( - <> -
- Anonymisierung - - {state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'} - -
- {(state.connector === 'google' || state.connector === 'msft') && ( -
- E-Mail-Tiefe - - {{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[ - state.prefs.mailContentDepth ?? 'full' - ] ?? state.prefs.mailContentDepth} - -
- )} - {state.connector === 'clickup' && ( -
- Aufgaben-Inhalt - - {{ - titles: 'Nur Titel', - title_description: 'Titel + Beschreibung', - with_comments: 'Titel + Beschreibung + Kommentare', - }[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope} - -
- )} -
- Zeitfenster - - {state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'} - -
- - )}
- - {/* Cost estimate — only shown when knowledge ingestion is enabled */} - {state.knowledgeEnabled && (() => { - const est = computeCostEstimate(state.connector, state.prefs); - if (!est) return null; - return ( -
- -
- Geschätzte Kosten (erster Sync) - - - - - - - {est.neutralizationLow && ( - - - - - )} - -
Embedding - {est.embeddingLow} – {est.embeddingHigh} -
Anonymisierung (Private LLM) - {est.neutralizationLow} – {est.neutralizationHigh} -
- {est.neutralizationLow && ( - - ⚠ Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise). - - )} - {est.note} -
-
- ); - })()} -
- + {!sectionCollapsed && ( - 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>({ localDataMode viewKeyForQueries={activeViewKey} initialSearchTerm={debouncedSearchTerm} - initialFilters={filters} + initialFilters={sectionInitialFilters} initialSort={sortConfigs} apiEndpoint={apiEndpoint} csvExportQueryParams={hookDataProp?.csvExportQueryParams} @@ -3427,13 +3462,13 @@ export function FormGeneratorTable>({ 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) => { const merged: Record = { - ...filters, + ...sectionInitialFilters, ...(crossFilters || {}), ...sectionFilter, }; diff --git a/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css b/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css index 090715d..82696c2 100644 --- a/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css +++ b/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css @@ -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; diff --git a/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx b/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx index e59743c..80ac03a 100644 --- a/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx +++ b/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx @@ -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; onDeleteView?: (viewId: string) => void | Promise; 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}`} + {canUseSections && groupByLevels.length > 0 && onGroupLayoutModeChange && ( + + )} + + {hasGroupBands && onCollapseAll && onExpandAll && ( +
+ + +
+ )} +
{t('Ansicht')} setSelectedScope(e.target.value)} + disabled={mandatesLoading} + > + {scopeOptions.map(opt => ( + + ))} + +
+ +
+ + + {loading && !inventory &&
{t('Laden...')}
} + {error &&
{error}
} + + {inventory && ( +
+
+ {t('Total Chunks')}: + {inventory.totals?.chunks ?? 0} + {inventory.totals?.bytes != null && inventory.totals.bytes > 0 && ( + {(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB + )} +
+ + {(inventory.connections || []).map((conn: RagConnectionDto) => ( +
+
+ {conn.authority} + {conn.externalEmail} + {conn.totalChunks > 0 && ( + {conn.totalChunks} chunks + )} + +
+ + {!conn.knowledgeIngestionEnabled && conn.dataSources.length > 0 && ( +
+ {t('Wissensdatenbank deaktiviert — Indexierung pausiert. Toggle aktivieren um Daten zu indexieren.')} +
+ )} + + {conn.lastError && conn.runningJobs.length === 0 && ( +
+ + {t('Letzter Job fehlgeschlagen')}: {conn.lastError.errorMessage || t('unbekannter Fehler')} + +
+ )} + + {conn.runningJobs.length > 0 && ( +
+ + {conn.runningJobs[0].progressMessage || `${Math.round(conn.runningJobs[0].progress * 100)}%`} + +
+ )} + + {!conn.lastError && conn.runningJobs.length === 0 && conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.totalChunks === 0 && conn.knowledgeIngestionEnabled && ( +
+ +
+ )} + +
+ {conn.dataSources.map(ds => ( +
+ {ds.label || ds.path} + {ds.sourceType} + {ds.chunkCount} chunks + {ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'} +
+ ))} + {conn.dataSources.length === 0 && ( +
{t('Keine Datenquellen konfiguriert')}
+ )} +
+
+ ))} + + {(inventory.connections || []).length === 0 && ( +
{t('Keine Daten für diese Sicht vorhanden.')}
+ )} +
+ )} +
+ ); +}; + +export default RagInventoryPage; diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 64e7826..695d10a 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -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>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState>(new Set()); const [reconnectingConnections, setReconnectingConnections] = useState>(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 = () => {

- {canCreate && ( - <> - - - + )}
@@ -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 = () => { )} - {/* Infomaniak Personal Access Token Modal */} - {infomaniakModal && ( -
-
-
-

{t('Infomaniak verbinden')}

- -
-
-

- {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.' - )} -

-
    -
  1. - {t('Öffne den Infomaniak-Manager:')}{' '} - - manager.infomaniak.com – API-Tokens - -
  2. -
  3. - {t('Klicke auf')} {t('Token erstellen')}{' '} - {t('und vergib einen aussagekräftigen Namen, z. B.')}{' '} - PowerOn.{' '} - {t('Application bleibt auf')} Default application. -
  4. -
  5. - {t('Suche im Scope-Feld nach')}{' '} - {t('allen vier')}{' '} - {t('Berechtigungen und kreuze sie an:')} -
      -
    • - drive — {t('kDrive (Pflicht, heute aktiv)')} -
    • -
    • - workspace:calendar —{' '} - {t('Kalender (Pflicht, heute aktiv)')} -
    • -
    • - workspace:contact —{' '} - {t('Kontakte (heute aktiv)')} -
    • -
    • - workspace:mail —{' '} - {t('Mail (in Vorbereitung, Scope schon mitnehmen)')} -
    • -
    - - {t( - 'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.' - )} - -
  6. -
  7. - {t( - 'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:' - )} -
  8. -
- - 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 && ( -
- {infomaniakModal.error} -
- )} -

- {t( - 'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.' - )} -

-
- - -
-
-
-
- )} - setWizardOpen(false)} diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 5e69bd1..e5dbecd 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -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} diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 3cbc1fa..31732e6 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -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} diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index ade7771..22d4300 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -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 = { 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} diff --git a/src/pages/views/commcoach/Commcoach.module.css b/src/pages/views/commcoach/Commcoach.module.css index 4101cbf..396969a 100644 --- a/src/pages/views/commcoach/Commcoach.module.css +++ b/src/pages/views/commcoach/Commcoach.module.css @@ -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 { diff --git a/src/pages/views/commcoach/CommcoachAssistantView.tsx b/src/pages/views/commcoach/CommcoachAssistantView.tsx index 4747597..ba61cf1 100644 --- a/src/pages/views/commcoach/CommcoachAssistantView.tsx +++ b/src/pages/views/commcoach/CommcoachAssistantView.tsx @@ -75,10 +75,24 @@ export const CommcoachAssistantView: React.FC = () => {

{t('Neues Modul erstellen')}

-
- {STEPS.map((s, i) => ( -
- ))} +
+
+ {STEPS.map((s, i) => ( +
+ ))} +
+
+ {stepIdx > 0 && ( + + )} + {step !== 'confirm' ? ( + + ) : ( + + )} +
@@ -156,19 +170,6 @@ export const CommcoachAssistantView: React.FC = () => { )}
-
- {stepIdx > 0 && ( - - )} -
- {step !== 'confirm' ? ( - - ) : ( - - )} -
); }; diff --git a/src/pages/views/teamsbot/Teamsbot.module.css b/src/pages/views/teamsbot/Teamsbot.module.css index 73d6a02..a096bdf 100644 --- a/src/pages/views/teamsbot/Teamsbot.module.css +++ b/src/pages/views/teamsbot/Teamsbot.module.css @@ -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 { diff --git a/src/pages/views/teamsbot/TeamsbotAssistantView.tsx b/src/pages/views/teamsbot/TeamsbotAssistantView.tsx index b80493c..0718217 100644 --- a/src/pages/views/teamsbot/TeamsbotAssistantView.tsx +++ b/src/pages/views/teamsbot/TeamsbotAssistantView.tsx @@ -149,10 +149,32 @@ export const TeamsbotAssistantView: React.FC = () => {

{t('Neues Meeting starten')}

-
- {STEPS.map((s, i) => ( -
- ))} +
+
+ {STEPS.map((s, i) => ( +
+ ))} +
+
+ {stepIdx > 0 && ( + + )} + {step !== 'confirm' ? ( + + ) : ( + + )} +
@@ -328,27 +350,6 @@ export const TeamsbotAssistantView: React.FC = () => { )}
-
- {stepIdx > 0 && ( - - )} -
- {step !== 'confirm' ? ( - - ) : ( - - )} -
); }; diff --git a/src/pages/views/workspace/WorkspaceRagInsightsPage.module.css b/src/pages/views/workspace/WorkspaceRagInsightsPage.module.css deleted file mode 100644 index 2633881..0000000 --- a/src/pages/views/workspace/WorkspaceRagInsightsPage.module.css +++ /dev/null @@ -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); -} diff --git a/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx b/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx deleted file mode 100644 index c473326..0000000 --- a/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx +++ /dev/null @@ -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 = { - 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; - documentsByMimeCategory?: Record; - chunksByContentType?: Record; - 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(null); - const [stats, setStats] = useState(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 ( -
- {t('Keine Workspace-Instanz ausgewählt.')} -
- ); - } - - if (loading) { - return
{t('Lade Kennzahlen')}
; - } - - if (error) { - return
{error}
; - } - - 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 ( -
-

- {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.', - )} -

- - {stats?.scope?.workspaceFileIdsResolved !== undefined && ( -

- {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 }, - )} -

- )} - - {kpis && ( -
-
-

{kpis.indexedDocuments}

-

{t('Indexierte Dokumente')}

-
-
-

{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}

-

{t('Indexiertes Datenvolumen (geschätzt)')}

-
-
-

{kpis.contentChunks}

-

{t('Inhaltsfragmente (Chunks)')}

-
-
-

- {kpis.embeddingCoveragePercent}% -

-

{t('Anteil Fragmente mit Embedding')}

-
-
-

{kpis.contributorUsers}

-

{t('Beitragende Benutzeranzahl')}

-
-
-

{kpis.workflowEntities}

-

{t('Workflowentitäten-Cache')}

-
-
- )} - - {(stats?.recentlyIndexedDocuments ?? []).length > 0 && ( -
-

{t('Zuletzt indexierte Dokumente')}

-
- - - - - - - - - - - - {(stats?.recentlyIndexedDocuments ?? []).map((doc, i) => ( - - - - - - - - ))} - -
{t('Dateiname')}{t('Format')}{t('Grösse')}{t('Status')}{t('Indexiert am')}
- {doc.fileName || '–'} - {_shortMime(doc.mimeType)}{formatBinaryDataSizeBytes(doc.totalSize)} - - {doc.status} - - {_formatTimestamp(doc.extractedAt)}
-
-
- )} - -
-

{t('Neu indexierte Dokumente pro Tag')}

- {timeline.length === 0 ? ( -

{t('Keine Zeitreihendaten für den gewählten')}

- ) : ( - - - - - - - - - - )} -
- -
-
-

{t('Dokumente nach Formatkategorie')}

- {mimeRows.length === 0 ? ( -

{t('Keine Daten')}

- ) : ( - - - - - - - - - - )} -
- -
-

{t('Index-Status')}

- {statusRows.length === 0 ? ( -

{t('Keine Daten')}

- ) : ( - - - - `${name ?? ''} ${percent != null ? (percent * 100).toFixed(0) : '0'}%`} - > - {statusRows.map((_, i) => ( - - ))} - - - - - )} -
-
- -
-

{t('Fragmente nach Inhaltstyp')}

- {chunkTypeRows.length === 0 ? ( -

{t('Keine Chunkdaten')}

- ) : ( - - - - - - - - - - )} -
- - {stats?.generatedAtUtc && ( -

- {t('Stand (UTC):')} {stats.generatedAtUtc} -

- )} -
- ); -};