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 { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
||||||
|
import { RagInventoryPage } from './pages/RagInventoryPage';
|
||||||
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
||||||
function App() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
|
|
@ -127,6 +128,11 @@ function App() {
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
<Route path="automations" element={<AutomationsDashboardPage />} />
|
<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) */}
|
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
||||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,11 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
export interface KnowledgePreferences {
|
export interface KnowledgePreferences {
|
||||||
schemaVersion?: number;
|
schemaVersion?: number;
|
||||||
neutralizeBeforeEmbed?: boolean;
|
|
||||||
mailContentDepth?: 'metadata' | 'snippet' | 'full';
|
mailContentDepth?: 'metadata' | 'snippet' | 'full';
|
||||||
mailIndexAttachments?: boolean;
|
mailIndexAttachments?: boolean;
|
||||||
filesIndexBinaries?: boolean;
|
filesIndexBinaries?: boolean;
|
||||||
mimeAllowlist?: string[];
|
|
||||||
clickupScope?: 'titles' | 'title_description' | 'with_comments';
|
clickupScope?: 'titles' | 'title_description' | 'with_comments';
|
||||||
clickupIndexAttachments?: boolean;
|
clickupIndexAttachments?: boolean;
|
||||||
surfaceToggles?: {
|
|
||||||
google?: { gmail?: boolean; drive?: boolean };
|
|
||||||
msft?: { sharepoint?: boolean; outlook?: boolean };
|
|
||||||
};
|
|
||||||
maxAgeDays?: number;
|
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) */
|
/* Connector grid (Step 0) */
|
||||||
.connectorGrid {
|
.connectorGrid {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.connectorCard {
|
.connectorCard {
|
||||||
flex: 1 1 140px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -447,6 +446,22 @@
|
||||||
cursor: not-allowed;
|
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 */
|
/* Dark theme */
|
||||||
:global(.dark-theme) .connectorCard {
|
:global(.dark-theme) .connectorCard {
|
||||||
background: var(--surface-color);
|
background: var(--surface-color);
|
||||||
|
|
|
||||||
|
|
@ -1,153 +1,52 @@
|
||||||
/**
|
/**
|
||||||
* AddConnectionWizard
|
* AddConnectionWizard
|
||||||
*
|
*
|
||||||
* Multi-step modal for adding a new connector with optional knowledge
|
* Streamlined multi-step modal for adding a new connector.
|
||||||
* ingestion consent and per-connection preferences (§2.6).
|
* Steps are connector-type-aware:
|
||||||
*
|
* Base: Connector → Consent → Connect
|
||||||
* Steps:
|
* Microsoft: Connector → Consent → Admin Consent (optional) → Connect
|
||||||
* 0 — Connector wählen
|
* Infomaniak: Connector → Consent → PAT Input → (done)
|
||||||
* 1 — Consent (Wissensdatenbank Ja/Nein)
|
|
||||||
* 2 — Präferenzen (nur wenn Ja)
|
|
||||||
* 3 — Zusammenfassung + OAuth starten
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Modal } from '../UiComponents/Modal/Modal';
|
import { Modal } from '../UiComponents/Modal/Modal';
|
||||||
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa';
|
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
|
||||||
import type { KnowledgePreferences } from '../../api/connectionApi';
|
|
||||||
import styles from './AddConnectionWizard.module.css';
|
import styles from './AddConnectionWizard.module.css';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type ConnectorType = 'google' | 'msft' | 'clickup';
|
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
|
||||||
|
|
||||||
|
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
|
||||||
|
|
||||||
interface WizardState {
|
interface WizardState {
|
||||||
step: 0 | 1 | 2 | 3;
|
currentStep: StepId;
|
||||||
connector: ConnectorType | null;
|
connector: ConnectorType | null;
|
||||||
knowledgeEnabled: boolean;
|
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> = {
|
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
|
||||||
google: 'Google',
|
google: 'Google',
|
||||||
msft: 'Microsoft 365',
|
msft: 'Microsoft 365',
|
||||||
clickup: 'ClickUp',
|
clickup: 'ClickUp',
|
||||||
|
infomaniak: 'Infomaniak',
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
|
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
|
||||||
google: <FaGoogle style={{ color: '#4285f4' }} />,
|
google: <FaGoogle style={{ color: '#4285f4' }} />,
|
||||||
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
|
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
|
||||||
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
|
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
|
||||||
|
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
function _getSteps(connector: ConnectorType | null): StepId[] {
|
||||||
// Cost estimate helper
|
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
|
||||||
// ---------------------------------------------------------------------------
|
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
|
||||||
|
return ['connector', 'consent', 'connect'];
|
||||||
/**
|
|
||||||
* 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).',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -157,11 +56,9 @@ function computeCostEstimate(
|
||||||
interface AddConnectionWizardProps {
|
interface AddConnectionWizardProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConnect: (
|
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
|
||||||
type: ConnectorType,
|
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
|
||||||
knowledgeEnabled: boolean,
|
onMsftAdminConsent?: () => void;
|
||||||
prefs: KnowledgePreferences | null,
|
|
||||||
) => Promise<void>;
|
|
||||||
isConnecting?: boolean;
|
isConnecting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,84 +70,91 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
onInfomaniakConnect,
|
||||||
|
onMsftAdminConsent,
|
||||||
isConnecting = false,
|
isConnecting = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [state, setState] = useState<WizardState>({
|
const [state, setState] = useState<WizardState>({
|
||||||
step: 0,
|
currentStep: 'connector',
|
||||||
connector: null,
|
connector: null,
|
||||||
knowledgeEnabled: false,
|
knowledgeEnabled: false,
|
||||||
prefs: { ...DEFAULT_PREFS },
|
infomaniakToken: '',
|
||||||
|
adminConsentDone: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reset = () =>
|
const reset = () =>
|
||||||
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } });
|
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => { reset(); onClose(); };
|
||||||
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 goBack = () => {
|
||||||
const setConnector = (connector: ConnectorType) =>
|
const prevIdx = stepIndex - 1;
|
||||||
setState(s => ({ ...s, connector, step: 1 }));
|
if (prevIdx >= 0) {
|
||||||
const setKnowledgeEnabled = (v: boolean) =>
|
setState(s => ({ ...s, currentStep: steps[prevIdx] }));
|
||||||
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 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;
|
if (!state.connector) return;
|
||||||
await onConnect(
|
if (state.connector === 'infomaniak' && onInfomaniakConnect) {
|
||||||
state.connector,
|
await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
|
||||||
state.knowledgeEnabled,
|
} else {
|
||||||
state.knowledgeEnabled ? state.prefs : null,
|
await onConnect(state.connector, state.knowledgeEnabled);
|
||||||
);
|
}
|
||||||
reset();
|
reset();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibleSteps = state.knowledgeEnabled
|
|
||||||
? [0, 1, 2, 3]
|
|
||||||
: [0, 1, 3];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal open={open} onClose={handleClose} title="Verbindung hinzufügen" size="md" closeOnEscape>
|
||||||
open={open}
|
|
||||||
onClose={handleClose}
|
|
||||||
title="Verbindung hinzufügen"
|
|
||||||
size="md"
|
|
||||||
closeOnEscape
|
|
||||||
>
|
|
||||||
{/* Stepper */}
|
{/* Stepper */}
|
||||||
<div className={styles.stepper}>
|
<div className={styles.stepper}>
|
||||||
{[0, 1, 2, 3].map(i => (
|
{steps.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={s}
|
||||||
className={[
|
className={[
|
||||||
styles.stepDot,
|
styles.stepDot,
|
||||||
state.step === i ? styles.stepDotActive : '',
|
stepIndex === i ? styles.stepDotActive : '',
|
||||||
state.step > i ? styles.stepDotDone : '',
|
stepIndex > i ? styles.stepDotDone : '',
|
||||||
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{state.step > i ? <FaCheck size={10} /> : i + 1}
|
{stepIndex > i ? <FaCheck size={10} /> : i + 1}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{/* ---- Step 0: Connector ---- */}
|
{/* ---- Step: Connector ---- */}
|
||||||
{state.step === 0 && (
|
{state.currentStep === 'connector' && (
|
||||||
<div className={styles.stepContent}>
|
<div className={styles.stepContent}>
|
||||||
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
|
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
|
||||||
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
|
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
|
||||||
<div className={styles.connectorGrid}>
|
<div className={styles.connectorGrid}>
|
||||||
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
|
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.connectorCard}
|
className={styles.connectorCard}
|
||||||
onClick={() => setConnector(type)}
|
onClick={() => selectConnector(type)}
|
||||||
>
|
>
|
||||||
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
|
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
|
||||||
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
|
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
|
||||||
|
|
@ -260,151 +164,103 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- Step 1: Consent ---- */}
|
{/* ---- Step: Consent ---- */}
|
||||||
{state.step === 1 && (
|
{state.currentStep === 'consent' && (
|
||||||
<div className={styles.stepContent}>
|
<div className={styles.stepContent}>
|
||||||
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
|
|
||||||
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
|
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
|
||||||
<p className={styles.stepBody}>
|
<p className={styles.stepBody}>
|
||||||
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
|
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
|
||||||
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
|
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
|
||||||
aus{' '}
|
aus {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'} zurückgreifen kann?
|
||||||
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
|
|
||||||
zurückgreifen kann?
|
|
||||||
</p>
|
</p>
|
||||||
<p className={styles.stepHint}>
|
<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>
|
</p>
|
||||||
<div className={styles.consentButtons}>
|
<div className={styles.consentButtons}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.consentButtonYes}
|
className={styles.consentButtonYes}
|
||||||
onClick={() => setKnowledgeEnabled(true)}
|
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
|
||||||
>
|
>
|
||||||
<FaCheck /> Ja, aufnehmen
|
<FaShieldAlt /> Admin-Zustimmung erteilen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
|
||||||
type="button"
|
Überspringen
|
||||||
className={styles.consentButtonNo}
|
|
||||||
onClick={() => setKnowledgeEnabled(false)}
|
|
||||||
>
|
|
||||||
Nein, überspringen
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.stepNavLeft}>
|
<div className={styles.stepNavLeft}>
|
||||||
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
|
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||||
Zurück
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- Step 2: Preferences ---- */}
|
{/* ---- Step: Infomaniak PAT ---- */}
|
||||||
{state.step === 2 && (
|
{state.currentStep === 'infomaniakPat' && (
|
||||||
<div className={styles.stepContent}>
|
<div className={styles.stepContent}>
|
||||||
<h3 className={styles.stepTitle}>Einstellungen</h3>
|
<h3 className={styles.stepTitle}>Infomaniak Personal Access Token</h3>
|
||||||
<p className={styles.stepHint}>
|
<p className={styles.stepBody}>
|
||||||
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
|
Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.
|
||||||
</p>
|
</p>
|
||||||
|
<input
|
||||||
<div className={styles.prefGroup}>
|
type="password"
|
||||||
<label className={styles.prefLabel}>
|
placeholder="pat_..."
|
||||||
<FaShieldAlt className={styles.prefIcon} />
|
value={state.infomaniakToken}
|
||||||
Anonymisierung vor dem Indexieren
|
onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
|
||||||
<input
|
className={styles.patInput}
|
||||||
type="checkbox"
|
autoFocus
|
||||||
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>
|
|
||||||
|
|
||||||
<div className={styles.stepNav}>
|
<div className={styles.stepNav}>
|
||||||
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
|
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||||
Zurück
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
|
className={styles.navConnect}
|
||||||
Weiter <FaArrowRight size={12} />
|
onClick={handleFinalConnect}
|
||||||
|
disabled={isConnecting || !state.infomaniakToken.trim()}
|
||||||
|
>
|
||||||
|
{isConnecting ? 'Verbinden…' : 'Verbinden'}
|
||||||
|
{!isConnecting && <FaArrowRight size={12} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- Step 3: Summary ---- */}
|
{/* ---- Step: Connect ---- */}
|
||||||
{state.step === 3 && (
|
{state.currentStep === 'connect' && (
|
||||||
<div className={styles.stepContent}>
|
<div className={styles.stepContent}>
|
||||||
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
|
<h3 className={styles.stepTitle}>Verbindung herstellen</h3>
|
||||||
<div className={styles.summary}>
|
<div className={styles.summary}>
|
||||||
<div className={styles.summaryRow}>
|
<div className={styles.summaryRow}>
|
||||||
<span className={styles.summaryKey}>Anbieter</span>
|
<span className={styles.summaryKey}>Anbieter</span>
|
||||||
<span className={styles.summaryVal}>
|
<span className={styles.summaryVal}>
|
||||||
{CONNECTOR_ICONS[state.connector!]}
|
{state.connector && CONNECTOR_ICONS[state.connector]}
|
||||||
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
|
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -414,96 +270,13 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
||||||
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
|
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</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}>
|
<div className={styles.stepNav}>
|
||||||
<button
|
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||||
type="button"
|
|
||||||
className={styles.navBack}
|
|
||||||
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
|
|
||||||
>
|
|
||||||
Zurück
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.navConnect}
|
className={styles.navConnect}
|
||||||
onClick={handleConnect}
|
onClick={handleFinalConnect}
|
||||||
disabled={isConnecting}
|
disabled={isConnecting}
|
||||||
>
|
>
|
||||||
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
|
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,8 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
/* Share remaining viewport among expanded groups; scroll when many groups */
|
flex: 1 1 400px;
|
||||||
flex: 1 1 280px;
|
min-height: 350px;
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupSectionCollapsed {
|
.groupSectionCollapsed {
|
||||||
|
|
|
||||||
|
|
@ -681,7 +681,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
resizable = true,
|
resizable = true,
|
||||||
pagination = true,
|
pagination = true,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
pageSizeOptions = [10, 25, 50, 100, 500],
|
pageSizeOptions = [10, 25, 50, 100, 500, 1000, 2000, 10000],
|
||||||
showPageSizeSelector = true,
|
showPageSizeSelector = true,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
onRowSelect,
|
onRowSelect,
|
||||||
|
|
@ -740,13 +740,16 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const [activeViewKey, setActiveViewKey] = useState<string | null>(null);
|
const [activeViewKey, setActiveViewKey] = useState<string | null>(null);
|
||||||
const [activeViewId, setActiveViewId] = useState<string | null>(null);
|
const [activeViewId, setActiveViewId] = useState<string | null>(null);
|
||||||
const [groupByLevels, setGroupByLevels] = useState<GroupByLevelSpec[]>([]);
|
const [groupByLevels, setGroupByLevels] = useState<GroupByLevelSpec[]>([]);
|
||||||
const useSectionsGroupLayout =
|
const [groupLayoutMode, setGroupLayoutMode] = useState<'inline' | 'sections'>(tableGroupLayoutMode ?? 'inline');
|
||||||
tableGroupLayoutMode === 'sections' &&
|
|
||||||
|
const canUseSections =
|
||||||
!!tableContextKey &&
|
!!tableContextKey &&
|
||||||
groupByLevels.length === 1 &&
|
groupByLevels.length > 0 &&
|
||||||
typeof hookDataProp?.fetchGroupSectionSummaries === 'function' &&
|
typeof hookDataProp?.fetchGroupSectionSummaries === 'function' &&
|
||||||
typeof hookDataProp?.refetchForSection === 'function';
|
typeof hookDataProp?.refetchForSection === 'function';
|
||||||
|
|
||||||
|
const useSectionsGroupLayout = canUseSections && groupLayoutMode === 'sections';
|
||||||
|
|
||||||
const [sectionSummaries, setSectionSummaries] = useState<
|
const [sectionSummaries, setSectionSummaries] = useState<
|
||||||
Array<{ value: string | null; label: string; totalCount: number }>
|
Array<{ value: string | null; label: string; totalCount: number }>
|
||||||
>([]);
|
>([]);
|
||||||
|
|
@ -1360,6 +1363,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
viewKey: activeViewKey,
|
viewKey: activeViewKey,
|
||||||
groupField: spec.field,
|
groupField: spec.field,
|
||||||
groupDirection: spec.direction || 'asc',
|
groupDirection: spec.direction || 'asc',
|
||||||
|
groupByLevels: groupLevelsToApiPayload(groupByLevels),
|
||||||
});
|
});
|
||||||
if (!cancelled) setSectionSummaries(Array.isArray(list) ? list : []);
|
if (!cancelled) setSectionSummaries(Array.isArray(list) ? list : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -2750,6 +2754,27 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
onUpdateViewGrouping={(id, levels) => void handleUpdateViewGrouping(id, levels)}
|
onUpdateViewGrouping={(id, levels) => void handleUpdateViewGrouping(id, levels)}
|
||||||
onDeleteView={(id) => void handleDeleteView(id)}
|
onDeleteView={(id) => void handleDeleteView(id)}
|
||||||
onReloadViews={() => void reloadViews()}
|
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}>
|
<div className={styles.groupSections}>
|
||||||
{sectionSummaries.map((g) => {
|
{sectionSummaries.map((g) => {
|
||||||
const field = groupByLevels[0].field;
|
const isMultiLevel = groupByLevels.length > 1 && (g as any).filters;
|
||||||
const sectionFilter: Record<string, unknown> = {
|
const sectionFilter: Record<string, unknown> = isMultiLevel
|
||||||
[field]: g.value === null || g.value === undefined ? null : g.value,
|
? (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 =
|
const sk =
|
||||||
g.value === null || g.value === undefined ? '__empty__' : String(g.value);
|
g.value === null || g.value === undefined ? '__empty__' : String(g.value);
|
||||||
const sectionCollapsed = collapsedSectionKeys.has(sk);
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
key={sk}
|
key={sk}
|
||||||
|
|
@ -3382,9 +3417,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
</button>
|
</button>
|
||||||
{!sectionCollapsed && (
|
{!sectionCollapsed && (
|
||||||
<FormGeneratorTable<T>
|
<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}
|
className={styles.groupSectionTableWrap}
|
||||||
columns={providedColumns}
|
columns={sectionColumns}
|
||||||
data={[]}
|
data={[]}
|
||||||
searchable={false}
|
searchable={false}
|
||||||
filterable={filterable}
|
filterable={filterable}
|
||||||
|
|
@ -3415,7 +3450,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
localDataMode
|
localDataMode
|
||||||
viewKeyForQueries={activeViewKey}
|
viewKeyForQueries={activeViewKey}
|
||||||
initialSearchTerm={debouncedSearchTerm}
|
initialSearchTerm={debouncedSearchTerm}
|
||||||
initialFilters={filters}
|
initialFilters={sectionInitialFilters}
|
||||||
initialSort={sortConfigs}
|
initialSort={sortConfigs}
|
||||||
apiEndpoint={apiEndpoint}
|
apiEndpoint={apiEndpoint}
|
||||||
csvExportQueryParams={hookDataProp?.csvExportQueryParams}
|
csvExportQueryParams={hookDataProp?.csvExportQueryParams}
|
||||||
|
|
@ -3427,13 +3462,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
if (!hookDataProp?.refetchForSection) {
|
if (!hookDataProp?.refetchForSection) {
|
||||||
return { items: [], pagination: null };
|
return { items: [], pagination: null };
|
||||||
}
|
}
|
||||||
return hookDataProp.refetchForSection(p, sectionFilter, filters);
|
return hookDataProp.refetchForSection(p, sectionFilter, sectionInitialFilters);
|
||||||
},
|
},
|
||||||
...(hookDataProp?.fetchFilterValues && typeof hookDataProp.fetchFilterValues === 'function'
|
...(hookDataProp?.fetchFilterValues && typeof hookDataProp.fetchFilterValues === 'function'
|
||||||
? {
|
? {
|
||||||
fetchFilterValues: async (columnKey: string, crossFilters?: Record<string, any>) => {
|
fetchFilterValues: async (columnKey: string, crossFilters?: Record<string, any>) => {
|
||||||
const merged: Record<string, any> = {
|
const merged: Record<string, any> = {
|
||||||
...filters,
|
...sectionInitialFilters,
|
||||||
...(crossFilters || {}),
|
...(crossFilters || {}),
|
||||||
...sectionFilter,
|
...sectionFilter,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,51 @@
|
||||||
white-space: nowrap;
|
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 {
|
.viewBlock {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
import { FaLayerGroup, FaTrash } from 'react-icons/fa';
|
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 { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from './TableViewsBar.module.css';
|
import styles from './TableViewsBar.module.css';
|
||||||
|
|
||||||
|
|
@ -30,6 +32,12 @@ export interface TableViewsBarProps {
|
||||||
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
|
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
|
||||||
onDeleteView?: (viewId: string) => void | Promise<void>;
|
onDeleteView?: (viewId: string) => void | Promise<void>;
|
||||||
onReloadViews: () => void;
|
onReloadViews: () => void;
|
||||||
|
canUseSections?: boolean;
|
||||||
|
groupLayoutMode?: 'inline' | 'sections';
|
||||||
|
onGroupLayoutModeChange?: (mode: 'inline' | 'sections') => void;
|
||||||
|
hasGroupBands?: boolean;
|
||||||
|
onCollapseAll?: () => void;
|
||||||
|
onExpandAll?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function slugify(name: string): string {
|
function slugify(name: string): string {
|
||||||
|
|
@ -74,6 +82,12 @@ export function TableViewsBar({
|
||||||
onUpdateViewGrouping,
|
onUpdateViewGrouping,
|
||||||
onDeleteView,
|
onDeleteView,
|
||||||
onReloadViews,
|
onReloadViews,
|
||||||
|
canUseSections,
|
||||||
|
groupLayoutMode,
|
||||||
|
onGroupLayoutModeChange,
|
||||||
|
hasGroupBands,
|
||||||
|
onCollapseAll,
|
||||||
|
onExpandAll,
|
||||||
}: TableViewsBarProps) {
|
}: TableViewsBarProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
|
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
|
||||||
|
|
@ -249,6 +263,41 @@ export function TableViewsBar({
|
||||||
: `${t('Aktiv')}: ${summary}`}
|
: `${t('Aktiv')}: ${summary}`}
|
||||||
</span>
|
</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}>
|
<div className={styles.viewBlock}>
|
||||||
<span className={styles.viewLabel}>{t('Ansicht')}</span>
|
<span className={styles.viewLabel}>{t('Ansicht')}</span>
|
||||||
<select
|
<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;
|
displayPath?: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
neutralize: boolean;
|
neutralize: boolean;
|
||||||
|
ragIndexEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UdbFeatureDataSource {
|
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) ── */
|
/* ── Scope change (feature data source, optimistic) ── */
|
||||||
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
|
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
|
||||||
const newScope = _nextScope(fds.scope);
|
const newScope = _nextScope(fds.scope);
|
||||||
|
|
@ -1018,6 +1030,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
||||||
dataSources={dataSources}
|
dataSources={dataSources}
|
||||||
onCycleScope={_cyclePersonalScope}
|
onCycleScope={_cyclePersonalScope}
|
||||||
onToggleNeutralize={_togglePersonalNeutralize}
|
onToggleNeutralize={_togglePersonalNeutralize}
|
||||||
|
onToggleRagIndex={_togglePersonalRagIndex}
|
||||||
onSendToChat={_sendNodeToChat}
|
onSendToChat={_sendNodeToChat}
|
||||||
scopeCycleTitle={_scopeCycleTitle}
|
scopeCycleTitle={_scopeCycleTitle}
|
||||||
selectedKeys={selectedKeys}
|
selectedKeys={selectedKeys}
|
||||||
|
|
@ -1105,18 +1118,20 @@ interface _TreeNodeViewProps {
|
||||||
dataSources: UdbDataSource[];
|
dataSources: UdbDataSource[];
|
||||||
onCycleScope: (ds: UdbDataSource) => void;
|
onCycleScope: (ds: UdbDataSource) => void;
|
||||||
onToggleNeutralize: (ds: UdbDataSource) => void;
|
onToggleNeutralize: (ds: UdbDataSource) => void;
|
||||||
|
onToggleRagIndex: (ds: UdbDataSource) => void;
|
||||||
onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
|
onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
|
||||||
scopeCycleTitle: (scope: string) => string;
|
scopeCycleTitle: (scope: string) => string;
|
||||||
selectedKeys: Set<string>;
|
selectedKeys: Set<string>;
|
||||||
onSelect: (node: TreeNode, e: React.MouseEvent) => void;
|
onSelect: (node: TreeNode, e: React.MouseEvent) => void;
|
||||||
inheritedScope?: string;
|
inheritedScope?: string;
|
||||||
inheritedNeutralize?: boolean;
|
inheritedNeutralize?: boolean;
|
||||||
|
inheritedRagIndex?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
node, depth, onToggle, onEnsureDs, isAdded, addingPath,
|
node, depth, onToggle, onEnsureDs, isAdded, addingPath,
|
||||||
dataSources, onCycleScope, onToggleNeutralize, onSendToChat, scopeCycleTitle,
|
dataSources, onCycleScope, onToggleNeutralize, onToggleRagIndex, onSendToChat, scopeCycleTitle,
|
||||||
selectedKeys, onSelect, inheritedScope, inheritedNeutralize,
|
selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
@ -1128,8 +1143,10 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
|
|
||||||
const effectiveScope = ds?.scope ?? inheritedScope;
|
const effectiveScope = ds?.scope ?? inheritedScope;
|
||||||
const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false;
|
const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false;
|
||||||
|
const effectiveRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex ?? false;
|
||||||
const childInheritedScope = ds?.scope ?? inheritedScope;
|
const childInheritedScope = ds?.scope ?? inheritedScope;
|
||||||
const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize;
|
const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize;
|
||||||
|
const childInheritedRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex;
|
||||||
|
|
||||||
const _dragPayload = {
|
const _dragPayload = {
|
||||||
connectionId: node.connectionId,
|
connectionId: node.connectionId,
|
||||||
|
|
@ -1261,6 +1278,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
>
|
>
|
||||||
{'\uD83D\uDD12'}
|
{'\uD83D\uDD12'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1278,12 +1313,14 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
dataSources={dataSources}
|
dataSources={dataSources}
|
||||||
onCycleScope={onCycleScope}
|
onCycleScope={onCycleScope}
|
||||||
onToggleNeutralize={onToggleNeutralize}
|
onToggleNeutralize={onToggleNeutralize}
|
||||||
|
onToggleRagIndex={onToggleRagIndex}
|
||||||
onSendToChat={onSendToChat}
|
onSendToChat={onSendToChat}
|
||||||
scopeCycleTitle={scopeCycleTitle}
|
scopeCycleTitle={scopeCycleTitle}
|
||||||
selectedKeys={selectedKeys}
|
selectedKeys={selectedKeys}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
inheritedScope={childInheritedScope}
|
inheritedScope={childInheritedScope}
|
||||||
inheritedNeutralize={childInheritedNeutralize}
|
inheritedNeutralize={childInheritedNeutralize}
|
||||||
|
inheritedRagIndex={childInheritedRagIndex}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.system.billingAdmin': <FaMoneyBillAlt />,
|
'page.system.billingAdmin': <FaMoneyBillAlt />,
|
||||||
'page.system.statistics': <FaChartBar />,
|
'page.system.statistics': <FaChartBar />,
|
||||||
'page.system.automations': <FaRobot />,
|
'page.system.automations': <FaRobot />,
|
||||||
|
'page.system.ragInventory': <FaDatabase />,
|
||||||
|
|
||||||
// Billing pages (legacy compat)
|
// Billing pages (legacy compat)
|
||||||
'page.billing.dashboard': <FaWallet />,
|
'page.billing.dashboard': <FaWallet />,
|
||||||
|
|
|
||||||
|
|
@ -101,17 +101,15 @@ export function useConnections() {
|
||||||
viewKey?: string | null;
|
viewKey?: string | null;
|
||||||
groupField: string;
|
groupField: string;
|
||||||
groupDirection?: 'asc' | 'desc';
|
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> = {
|
const pObj: Record<string, unknown> = {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 25,
|
pageSize: 25,
|
||||||
groupByLevels: [
|
groupByLevels: levels,
|
||||||
{
|
|
||||||
field: base.groupField,
|
|
||||||
nullLabel: '—',
|
|
||||||
direction: base.groupDirection || 'asc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
if (base.search) (pObj as { search?: string }).search = base.search;
|
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;
|
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;
|
viewKey?: string | null;
|
||||||
groupField: string;
|
groupField: string;
|
||||||
groupDirection?: 'asc' | 'desc';
|
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> = {
|
const pObj: Record<string, unknown> = {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 25,
|
pageSize: 25,
|
||||||
groupByLevels: [
|
groupByLevels: levels,
|
||||||
{
|
|
||||||
field: base.groupField,
|
|
||||||
nullLabel: '—',
|
|
||||||
direction: base.groupDirection || 'asc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
if (base.search) (pObj as { search?: string }).search = base.search;
|
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;
|
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;
|
viewKey?: string | null;
|
||||||
groupField: string;
|
groupField: string;
|
||||||
groupDirection?: 'asc' | 'desc';
|
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> = {
|
const pObj: Record<string, unknown> = {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 25,
|
pageSize: 25,
|
||||||
groupByLevels: [
|
groupByLevels: levels,
|
||||||
{
|
|
||||||
field: base.groupField,
|
|
||||||
nullLabel: '—',
|
|
||||||
direction: base.groupDirection || 'asc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
if (base.search) (pObj as { search?: string }).search = base.search;
|
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;
|
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 { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
||||||
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
|
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
|
||||||
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
|
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
|
||||||
|
import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
@ -132,6 +133,8 @@ const MainLayoutInner: React.FC = () => {
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<RagRunningBadge />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalE
|
||||||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||||
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
||||||
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
||||||
import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage';
|
|
||||||
|
|
||||||
// Teamsbot Views
|
// Teamsbot Views
|
||||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||||
|
|
@ -155,7 +154,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
workspace: {
|
workspace: {
|
||||||
dashboard: WorkspacePage,
|
dashboard: WorkspacePage,
|
||||||
editor: WorkspaceEditorPage,
|
editor: WorkspaceEditorPage,
|
||||||
'rag-insights': WorkspaceRagInsightsPage,
|
|
||||||
settings: WorkspaceSettingsPage,
|
settings: WorkspaceSettingsPage,
|
||||||
},
|
},
|
||||||
teamsbot: {
|
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 { useConnections, type Connection } from '../../hooks/useConnections';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa';
|
import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa';
|
||||||
import { getApiBaseUrl } from '../../../config/config';
|
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
import bannerStyles from './ConnectionsPage.module.css';
|
import bannerStyles from './ConnectionsPage.module.css';
|
||||||
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
|
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
|
||||||
|
|
@ -42,8 +41,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
deleteConnection,
|
deleteConnection,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
createConnectionAndAuth,
|
createConnectionAndAuth,
|
||||||
createInfomaniakConnection,
|
|
||||||
submitInfomaniakToken,
|
|
||||||
connectWithPopup,
|
connectWithPopup,
|
||||||
refreshMicrosoftToken,
|
refreshMicrosoftToken,
|
||||||
refreshGoogleToken,
|
refreshGoogleToken,
|
||||||
|
|
@ -54,7 +51,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
|
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
|
||||||
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
|
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
|
||||||
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
|
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
|
||||||
const [adminConsentPending, setAdminConsentPending] = useState(false);
|
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
// Banner shown while knowledge bootstrap is running in the background
|
// Banner shown while knowledge bootstrap is running in the background
|
||||||
const [syncBanner, setSyncBanner] = useState<{
|
const [syncBanner, setSyncBanner] = useState<{
|
||||||
|
|
@ -73,15 +69,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
setSyncBanner(null);
|
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
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refetch();
|
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
|
// Form attributes for edit modal
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = [
|
const excludedFields = [
|
||||||
|
|
@ -348,14 +265,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<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
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
|
|
@ -364,25 +273,14 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
</button>
|
</button>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className={styles.primaryButton}
|
||||||
className={styles.primaryButton}
|
onClick={() => setWizardOpen(true)}
|
||||||
onClick={() => setWizardOpen(true)}
|
disabled={isConnecting}
|
||||||
disabled={isConnecting}
|
>
|
||||||
>
|
<FaPlus /> {t('Verbindung hinzufügen')}
|
||||||
<FaPlus /> {t('Verbindung hinzufügen')}
|
</button>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.secondaryButton}
|
|
||||||
onClick={handleCreateInfomaniak}
|
|
||||||
disabled={isConnecting}
|
|
||||||
title={t('Infomaniak-Konto verbinden (kDrive + Mail)')}
|
|
||||||
>
|
|
||||||
<FaCloud /> Infomaniak
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -419,7 +317,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/connections/"
|
apiEndpoint="/api/connections/"
|
||||||
tableContextKey="connections"
|
tableContextKey="connections"
|
||||||
tableGroupLayoutMode="sections"
|
tableGroupLayoutMode="inline"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -519,137 +417,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
</div>
|
</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
|
<AddConnectionWizard
|
||||||
open={wizardOpen}
|
open={wizardOpen}
|
||||||
onClose={() => setWizardOpen(false)}
|
onClose={() => setWizardOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,7 @@ export const FilesPage: React.FC = () => {
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/files/list"
|
apiEndpoint="/api/files/list"
|
||||||
tableContextKey="files/list"
|
tableContextKey="files/list"
|
||||||
tableGroupLayoutMode="sections"
|
tableGroupLayoutMode="inline"
|
||||||
loading={tableLoading}
|
loading={tableLoading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/prompts"
|
apiEndpoint="/api/prompts"
|
||||||
tableContextKey="prompts"
|
tableContextKey="prompts"
|
||||||
tableGroupLayoutMode="sections"
|
tableGroupLayoutMode="inline"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
|
||||||
|
|
@ -527,17 +527,15 @@ export const BillingDataView: React.FC = () => {
|
||||||
viewKey?: string | null;
|
viewKey?: string | null;
|
||||||
groupField: string;
|
groupField: string;
|
||||||
groupDirection?: 'asc' | 'desc';
|
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> = {
|
const pObj: Record<string, unknown> = {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 25,
|
pageSize: 25,
|
||||||
groupByLevels: [
|
groupByLevels: levels,
|
||||||
{
|
|
||||||
field: base.groupField,
|
|
||||||
nullLabel: '—',
|
|
||||||
direction: base.groupDirection || 'asc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
if (base.search) (pObj as { search?: string }).search = base.search;
|
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;
|
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}
|
columns={columns}
|
||||||
apiEndpoint="/api/billing/view/users/transactions"
|
apiEndpoint="/api/billing/view/users/transactions"
|
||||||
tableContextKey="billing/view/users/transactions"
|
tableContextKey="billing/view/users/transactions"
|
||||||
tableGroupLayoutMode="sections"
|
tableGroupLayoutMode="inline"
|
||||||
loading={transactionsLoading}
|
loading={transactionsLoading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
/* CommCoach Shared Styles — Assistant, Modules, Session views */
|
/* CommCoach Shared Styles — Assistant, Modules, Session views */
|
||||||
|
|
||||||
.assistantContainer,
|
.assistantContainer {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.modulesContainer {
|
.modulesContainer {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -14,6 +21,14 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardHeaderRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepIndicator {
|
.stepIndicator {
|
||||||
|
|
@ -33,7 +48,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizardContent {
|
.wizardContent {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
@ -98,9 +112,8 @@
|
||||||
|
|
||||||
.wizardActions {
|
.wizardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
padding-top: 1rem;
|
align-items: center;
|
||||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizardHint {
|
.wizardHint {
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,24 @@ export const CommcoachAssistantView: React.FC = () => {
|
||||||
<div className={styles.assistantContainer}>
|
<div className={styles.assistantContainer}>
|
||||||
<div className={styles.wizardHeader}>
|
<div className={styles.wizardHeader}>
|
||||||
<h2>{t('Neues Modul erstellen')}</h2>
|
<h2>{t('Neues Modul erstellen')}</h2>
|
||||||
<div className={styles.stepIndicator}>
|
<div className={styles.wizardHeaderRight}>
|
||||||
{STEPS.map((s, i) => (
|
<div className={styles.stepIndicator}>
|
||||||
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -156,19 +170,6 @@ export const CommcoachAssistantView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1372,7 +1372,14 @@
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistantContainer,
|
.assistantContainer {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.modulesContainer {
|
.modulesContainer {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1386,6 +1393,14 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardHeaderRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepIndicator {
|
.stepIndicator {
|
||||||
|
|
@ -1405,7 +1420,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizardContent {
|
.wizardContent {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
@ -1438,9 +1452,8 @@
|
||||||
|
|
||||||
.wizardActions {
|
.wizardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
padding-top: 1rem;
|
align-items: center;
|
||||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.moduleChoice {
|
.moduleChoice {
|
||||||
|
|
|
||||||
|
|
@ -149,10 +149,32 @@ export const TeamsbotAssistantView: React.FC = () => {
|
||||||
<div className={styles.assistantContainer}>
|
<div className={styles.assistantContainer}>
|
||||||
<div className={styles.wizardHeader}>
|
<div className={styles.wizardHeader}>
|
||||||
<h2>{t('Neues Meeting starten')}</h2>
|
<h2>{t('Neues Meeting starten')}</h2>
|
||||||
<div className={styles.stepIndicator}>
|
<div className={styles.wizardHeaderRight}>
|
||||||
{STEPS.map((s, i) => (
|
<div className={styles.stepIndicator}>
|
||||||
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -328,27 +350,6 @@ export const TeamsbotAssistantView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</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