/** * DataSourceSettingsModal * * Single modal for editing DataSource-scoped + Connection-scoped settings * from the UDB tree (Settings ⚙️ icon). Three sections: * * 1. Connection — knowledgeIngestionEnabled master switch + mail/clickup prefs * 2. DataSource RAG-Limits — maxBytes/maxFileSize/maxItems/maxDepth (or clickup variants) * 3. Cost estimate — indicative, non-binding CHF figure * * Why a single modal: * - The architectural rule is "no icon inflation in the UDB". One ⚙️ opens * the only place where ANY setting for a node is managed. * * Why both scopes in one modal: * - Editing a DataSource without seeing whether the parent Connection's * master switch is on is confusing. Surface both, with a clear visual * separation between Connection vs. DataSource sections. */ import React, { useEffect, useMemo, useState } from 'react'; import { FaTimes, FaToggleOn, FaToggleOff } from 'react-icons/fa'; import { useApiRequest } from '../../hooks/useApi'; import { useLanguage } from '../../providers/language/LanguageContext'; import { useConfirm } from '../../hooks/useConfirm'; import { patchDataSourceSettings, getDataSourceCostEstimate, patchKnowledgeConsent, type RagLimits, type CostEstimate, } from '../../api/connectionApi'; interface Props { open: boolean; title: string; dataSourceId?: string; connectionId?: string; initialKnowledgeIngestionEnabled?: boolean; initialRagLimits?: RagLimits | null; /** * When false the RAG-Limits and Cost-Estimate sections are hidden. * Only the DataSource-Root (Level 2 in the UDB tree) should show RAG * settings — sub-elements inherit their parent's limits via the walker. */ showRagSection?: boolean; /** Triggered after a successful save so the parent can refetch its lists. */ onSaved?: () => void; onClose: () => void; } const _CLICKUP_KEYS: (keyof RagLimits)[] = ['maxTasks', 'maxWorkspaces', 'maxListsPerWorkspace']; const _FILES_KEYS: (keyof RagLimits)[] = ['maxItems', 'maxBytes', 'maxFileSize', 'maxDepth']; function _isByteLimit(key: keyof RagLimits): boolean { return key === 'maxBytes' || key === 'maxFileSize'; } function _displayValue(key: keyof RagLimits, value: number | undefined): string { if (value == null) return ''; if (_isByteLimit(key)) { return String(Math.round(value / 1024 / 1024)); } return String(value); } function _parseInput(key: keyof RagLimits, raw: string): number | null { if (raw == null || raw === '') return null; const n = Number(raw); if (!Number.isFinite(n) || n <= 0) return null; return _isByteLimit(key) ? Math.round(n * 1024 * 1024) : Math.round(n); } function _labelFor(key: keyof RagLimits, t: (k: string) => string): string { switch (key) { case 'maxBytes': return t('Max. Datenvolumen (MB)'); case 'maxFileSize': return t('Max. Dateigrösse (MB)'); case 'maxItems': return t('Max. Dateien (Anzahl)'); case 'maxDepth': return t('Max. Ordnertiefe'); case 'maxTasks': return t('Max. Tasks (Anzahl)'); case 'maxWorkspaces': return t('Max. Workspaces'); case 'maxListsPerWorkspace':return t('Max. Listen pro Workspace'); default: return String(key); } } export const DataSourceSettingsModal: React.FC = ({ open, title, dataSourceId, connectionId, initialKnowledgeIngestionEnabled, initialRagLimits, showRagSection = true, onSaved, onClose, }) => { const { t } = useLanguage(); const { request } = useApiRequest(); const { confirm, ConfirmDialog } = useConfirm(); const [knowledgeOn, setKnowledgeOn] = useState(!!initialKnowledgeIngestionEnabled); const [ragLimits, setRagLimits] = useState(initialRagLimits || {}); const [cost, setCost] = useState(null); const [costLoading, setCostLoading] = useState(false); const [saving, setSaving] = useState(false); const [errorMsg, setErrorMsg] = useState(null); const limitKeys: (keyof RagLimits)[] = useMemo(() => { if (cost?.basis?.kind === 'clickup') return _CLICKUP_KEYS; if (ragLimits.maxTasks != null) return _CLICKUP_KEYS; return _FILES_KEYS; }, [cost, ragLimits]); useEffect(() => { if (!open) return; setKnowledgeOn(!!initialKnowledgeIngestionEnabled); setRagLimits(initialRagLimits || {}); setErrorMsg(null); setCost(null); if (!dataSourceId) return; setCostLoading(true); getDataSourceCostEstimate(request, dataSourceId) .then(result => { setCost(result); if (Object.keys(initialRagLimits || {}).length === 0 && result?.basis?.limits) { setRagLimits(result.basis.limits as RagLimits); } }) .catch(err => { setErrorMsg(typeof err === 'string' ? err : (err?.message || t('Kostenschätzung konnte nicht geladen werden.'))); }) .finally(() => setCostLoading(false)); }, [open, dataSourceId, initialKnowledgeIngestionEnabled, initialRagLimits, request, t]); if (!open) return null; const _handleConsentToggle = async () => { if (!connectionId) return; const newValue = !knowledgeOn; if (!newValue) { const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') }); if (!ok) return; } setSaving(true); try { await patchKnowledgeConsent(request, connectionId, newValue); setKnowledgeOn(newValue); onSaved?.(); } catch (err: any) { setErrorMsg(err?.message || t('Master-Switch konnte nicht geändert werden.')); } finally { setSaving(false); } }; const _handleLimitChange = (key: keyof RagLimits, raw: string) => { setRagLimits(prev => { const next = { ...prev }; if (raw === '') { delete next[key]; return next; } const parsed = _parseInput(key, raw); if (parsed == null) return prev; next[key] = parsed; return next; }); }; const _handleSaveLimits = async () => { if (!dataSourceId) return; setSaving(true); setErrorMsg(null); try { const cleaned: RagLimits = {}; for (const k of limitKeys) { const v = ragLimits[k]; if (v != null) cleaned[k] = v; } await patchDataSourceSettings(request, dataSourceId, { ragLimits: cleaned }); const refreshed = await getDataSourceCostEstimate(request, dataSourceId); setCost(refreshed); onSaved?.(); } catch (err: any) { setErrorMsg(err?.message || t('Speichern fehlgeschlagen.')); } finally { setSaving(false); } }; return ( <>
e.stopPropagation()} style={{ background: '#fff', borderRadius: 8, padding: 0, width: 'min(540px, 92vw)', maxHeight: '85vh', display: 'flex', flexDirection: 'column', boxShadow: '0 12px 40px rgba(0,0,0,0.2)', }} >

{'\u2699\uFE0F '}{t('Einstellungen')} — {title}

{errorMsg && (
{errorMsg}
)} {/* --- Section: Connection --- */} {connectionId && (

{t('Verbindung')}

{t('Wissensdatenbank aktiv')}
{t('Master-Schalter — wirkt auf ALLE Datenquellen dieser Verbindung.')}
)} {/* --- Section: RAG Limits (only on DataSource-Root, not sub-elements) --- */} {dataSourceId && showRagSection && (

{t('RAG-Indexierungs-Limits')}

{t('Walker stoppt bei den ersten erreichten Limit. Defaults greifen, wenn ein Feld leer ist.')}
{limitKeys.map(key => ( _handleLimitChange(key, e.target.value)} placeholder={cost?.basis?.limits?.[key] != null ? _displayValue(key, cost.basis.limits[key]) : ''} style={{ padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4, textAlign: 'right', }} /> ))}
)} {/* --- Section: Cost estimate (only on DataSource-Root) --- */} {dataSourceId && showRagSection && (

{t('Kostenschätzung (indikativ)')}

{costLoading &&
{t('Wird berechnet…')}
} {!costLoading && cost && (
{t('Voll-Sync (geschätzt)')} ~ {cost.estimatedChf.toFixed(4)} CHF
~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes}
)}
)}
); }; export default DataSourceSettingsModal;