rag
This commit is contained in:
parent
791d575b7d
commit
a6b37ed684
29 changed files with 1319 additions and 1174 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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!]}
|
||||
{state.connector && CONNECTOR_ICONS[state.connector]}
|
||||
{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`}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
95
src/components/RagRunningBadge/RagRunningBadge.module.css
Normal file
95
src/components/RagRunningBadge/RagRunningBadge.module.css
Normal 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);
|
||||
}
|
||||
71
src/components/RagRunningBadge/RagRunningBadge.tsx
Normal file
71
src/components/RagRunningBadge/RagRunningBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
334
src/pages/RagInventoryPage.module.css
Normal file
334
src/pages/RagInventoryPage.module.css
Normal 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;
|
||||
}
|
||||
255
src/pages/RagInventoryPage.tsx
Normal file
255
src/pages/RagInventoryPage.tsx
Normal 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;
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue