truastee sync and charting

This commit is contained in:
ValueOn AG 2026-03-18 23:04:59 +01:00
parent c6d43340ff
commit 2efc5fce86
5 changed files with 580 additions and 20 deletions

View file

@ -6,7 +6,7 @@
* testing the connection, and removing the integration. * testing the connection, and removing the integration.
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
@ -35,6 +35,19 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message?: string } | null>(null); const [testResult, setTestResult] = useState<{ success: boolean; message?: string } | null>(null);
const [importing, setImporting] = useState(false);
const [importDone, setImportDone] = useState(false);
const [importResult, setImportResult] = useState<Record<string, any> | null>(null);
const [importStatus, setImportStatus] = useState<Record<string, any> | null>(null);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const mountedRef = useRef(true);
useEffect(() => {
if (!importDone) return;
const t = setTimeout(() => { setImporting(false); setImportDone(false); }, 5000);
return () => clearTimeout(t);
}, [importDone]);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
@ -62,8 +75,21 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
useEffect(() => { useEffect(() => {
loadData(); loadData();
return () => { mountedRef.current = false; };
}, [loadData]); }, [loadData]);
const _loadImportStatus = useCallback(async () => {
if (!instanceId) return;
try {
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-status`, method: 'get' });
if (mountedRef.current) setImportStatus(res.data);
} catch { /* ignore */ }
}, [instanceId, request]);
useEffect(() => {
if (existingConfig?.configured) _loadImportStatus();
}, [existingConfig, _loadImportStatus]);
const _getSelectedConnector = (): AccountingConnectorInfo | undefined => { const _getSelectedConnector = (): AccountingConnectorInfo | undefined => {
return connectors.find(c => c.connectorType === selectedType); return connectors.find(c => c.connectorType === selectedType);
}; };
@ -291,6 +317,109 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Step 4: Import Accounting Data */}
{existingConfig?.configured && (
<div className={styles.setupStep}>
<div className={styles.stepNumber}>4</div>
<div className={styles.stepContent}>
<h4>Buchhaltungsdaten importieren</h4>
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.75rem' }}>
Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen.
Diese Daten stehen anschliessend im AI Workspace fuer Analysen zur Verfuegung.
</p>
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>Von (optional)</label>
<input type="date" className={styles.folderSelect} value={dateFrom} onChange={e => setDateFrom(e.target.value)} style={{ width: '160px' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>Bis (optional)</label>
<input type="date" className={styles.folderSelect} value={dateTo} onChange={e => setDateTo(e.target.value)} style={{ width: '160px' }} />
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
{[
{ label: 'YTD', from: `${new Date().getFullYear()}-01-01`, to: new Date().toISOString().slice(0, 10) },
{
label: 'Letztes Jahr',
from: `${new Date().getFullYear() - 1}-01-01`,
to: `${new Date().getFullYear() - 1}-12-31`,
},
{
label: 'Letzter Monat',
from: (() => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); })(),
to: (() => { const d = new Date(); d.setDate(0); return d.toISOString().slice(0, 10); })(),
},
].map(s => (
<button
key={s.label}
type="button"
className={styles.secondaryButton}
style={{ fontSize: '0.75rem', padding: '0.25rem 0.6rem', minWidth: 0 }}
onClick={() => { setDateFrom(s.from); setDateTo(s.to); }}
>
{s.label}
</button>
))}
</div>
<button
className={styles.primaryButton}
disabled={importing}
onClick={async () => {
if (!instanceId) return;
setImporting(true);
setImportResult(null);
try {
const body: Record<string, string> = {};
if (dateFrom) body.dateFrom = dateFrom;
if (dateTo) body.dateTo = dateTo;
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
if (mountedRef.current) {
setImportResult(res.data);
if (res.data.errors?.length) {
showError('Import teilweise fehlgeschlagen', res.data.errors.join('; '));
} else {
showSuccess('Import abgeschlossen',
`${res.data.accounts || 0} Konten, ${res.data.journalEntries || 0} Buchungen, ` +
`${res.data.contacts || 0} Kontakte, ${res.data.accountBalances || 0} Salden importiert.`);
}
_loadImportStatus();
}
} catch (err: any) {
showError('Import fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler');
} finally {
setImportDone(true);
}
}}
>
{importing ? 'Importiere...' : 'Daten jetzt einlesen'}
</button>
{importResult && !importResult.errors?.length && (
<div className={styles.successMessage} style={{ marginTop: '0.75rem' }}>
Import abgeschlossen in {importResult.durationSeconds}s:
{' '}{importResult.accounts} Konten, {importResult.journalEntries} Buchungen ({importResult.journalLines} Zeilen),
{' '}{importResult.contacts} Kontakte, {importResult.accountBalances} Salden
</div>
)}
{importStatus && (importStatus.accounts > 0 || importStatus.journalEntries > 0) && (
<div style={{ marginTop: '0.75rem', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
<strong>Aktueller Datenbestand:</strong>{' '}
{importStatus.accounts} Konten, {importStatus.journalEntries} Buchungen,
{' '}{importStatus.journalLines} Zeilen, {importStatus.contacts} Kontakte,
{' '}{importStatus.accountBalances} Salden
{importStatus.lastSyncAt && (
<> &middot; Letzter Import: {new Date(importStatus.lastSyncAt * 1000).toLocaleString()}</>
)}
</div>
)}
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -11,7 +11,8 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import api from '../../../api'; import api from '../../../api';
import type { DataSource } from './useWorkspace'; import { getPageIcon } from '../../../config/pageRegistry';
import type { DataSource, FeatureDataSource } from './useWorkspace';
/* ─── Types ─────────────────────────────────────────────────────────── */ /* ─── Types ─────────────────────────────────────────────────────────── */
@ -29,10 +30,30 @@ interface TreeNode {
authority?: string; authority?: string;
} }
interface FeatureConnectionNode {
featureInstanceId: string;
featureCode: string;
label: string;
icon: string;
tableCount: number;
expanded: boolean;
loading: boolean;
tables: FeatureTableNode[] | null;
}
interface FeatureTableNode {
objectKey: string;
tableName: string;
label: Record<string, string>;
fields: string[];
}
interface DataSourcePanelProps { interface DataSourcePanelProps {
instanceId: string; instanceId: string;
dataSources: DataSource[]; dataSources: DataSource[];
featureDataSources: FeatureDataSource[];
onRefresh: () => void; onRefresh: () => void;
onRefreshFeatureDataSources: () => void;
} }
/* ─── Icons ─────────────────────────────────────────────────────────── */ /* ─── Icons ─────────────────────────────────────────────────────────── */
@ -86,11 +107,16 @@ function _getSourceIcon(sourceType: string): string {
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
instanceId, instanceId,
dataSources, dataSources,
featureDataSources,
onRefresh, onRefresh,
onRefreshFeatureDataSources,
}) => { }) => {
const [tree, setTree] = useState<TreeNode[]>([]); const [tree, setTree] = useState<TreeNode[]>([]);
const [loadingRoot, setLoadingRoot] = useState(false); const [loadingRoot, setLoadingRoot] = useState(false);
const [addingPath, setAddingPath] = useState<string | null>(null); const [addingPath, setAddingPath] = useState<string | null>(null);
const [featureTree, setFeatureTree] = useState<FeatureConnectionNode[]>([]);
const [loadingFeatures, setLoadingFeatures] = useState(false);
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
@ -205,6 +231,110 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
); );
}, [dataSources]); }, [dataSources]);
/* ── Feature Connections: Load Level 1 ── */
const _loadFeatureConnections = useCallback(() => {
if (!instanceId) return;
setLoadingFeatures(true);
api.get(`/api/workspace/${instanceId}/feature-connections`)
.then(res => {
if (!mountedRef.current) return;
const conns = res.data.featureConnections || [];
setFeatureTree(conns.map((c: any) => ({
featureInstanceId: c.featureInstanceId,
featureCode: c.featureCode,
label: c.label,
icon: c.icon || '\uD83D\uDDC3\uFE0F',
tableCount: c.tableCount || 0,
expanded: false,
loading: false,
tables: null,
})));
})
.catch(() => { if (mountedRef.current) setFeatureTree([]); })
.finally(() => { if (mountedRef.current) setLoadingFeatures(false); });
}, [instanceId]);
useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]);
/* ── Feature Connections: Toggle expand ── */
const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => {
if (node.expanded) {
setFeatureTree(prev => prev.map(n =>
n.featureInstanceId === node.featureInstanceId ? { ...n, expanded: false } : n
));
return;
}
if (node.tables !== null) {
setFeatureTree(prev => prev.map(n =>
n.featureInstanceId === node.featureInstanceId ? { ...n, expanded: true } : n
));
return;
}
setFeatureTree(prev => prev.map(n =>
n.featureInstanceId === node.featureInstanceId ? { ...n, loading: true, expanded: true } : n
));
try {
const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`);
const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({
objectKey: t.objectKey,
tableName: t.tableName,
label: t.label || {},
fields: t.fields || [],
}));
if (mountedRef.current) {
setFeatureTree(prev => prev.map(n =>
n.featureInstanceId === node.featureInstanceId ? { ...n, loading: false, tables } : n
));
}
} catch {
if (mountedRef.current) {
setFeatureTree(prev => prev.map(n =>
n.featureInstanceId === node.featureInstanceId ? { ...n, loading: false, tables: [] } : n
));
}
}
}, [instanceId]);
/* ── Feature: Add table as FeatureDataSource ── */
const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode) => {
const key = `${node.featureInstanceId}-${table.tableName}`;
setAddingFeatureKey(key);
try {
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: node.featureInstanceId,
featureCode: node.featureCode,
tableName: table.tableName,
objectKey: table.objectKey,
label: table.label?.en || table.label?.de || table.tableName,
});
onRefreshFeatureDataSources();
} catch (err) {
console.error('Failed to add feature data source:', err);
} finally {
if (mountedRef.current) setAddingFeatureKey(null);
}
}, [instanceId, onRefreshFeatureDataSources]);
/* ── Feature: Remove FeatureDataSource ── */
const _removeFeatureDataSource = useCallback(async (fdsId: string) => {
try {
await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`);
onRefreshFeatureDataSources();
} catch (err) {
console.error('Failed to remove feature data source:', err);
}
}, [instanceId, onRefreshFeatureDataSources]);
/* ── Feature: check if table already added ── */
const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => {
return featureDataSources.some(fds =>
fds.featureInstanceId === featureInstanceId && fds.tableName === tableName,
);
}, [featureDataSources]);
return ( return (
<div style={{ padding: 8, fontSize: 13 }}> <div style={{ padding: 8, fontSize: 13 }}>
{/* Active DataSources */} {/* Active DataSources */}
@ -217,7 +347,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
const connColor = _getSourceColor(ds.sourceType); const connColor = _getSourceColor(ds.sourceType);
const connNode = tree.find(n => n.connectionId === ds.connectionId); const connNode = tree.find(n => n.connectionId === ds.connectionId);
const connLabel = connNode?.label || ds.connectionId; const connLabel = connNode?.label || ds.connectionId;
const fullPath = `${connLabel} ${ds.sourceType} ${ds.path}`; const folder = ds.label || ds.path || ds.id;
return ( return (
<div key={ds.id} style={{ <div key={ds.id} style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
@ -225,10 +355,10 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
background: `${connColor}18`, background: `${connColor}18`,
borderLeft: `3px solid ${connColor}`, borderLeft: `3px solid ${connColor}`,
fontSize: 12, fontSize: 12,
}} title={fullPath}> }} title={`${connLabel} ${ds.path || ds.label}`}>
<span style={{ fontSize: 12, flexShrink: 0 }}>{_getSourceIcon(ds.sourceType)}</span> <span style={{ fontSize: 12, flexShrink: 0 }}>{_getSourceIcon(ds.sourceType)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label} {connLabel} {folder}
</span> </span>
<button <button
onClick={() => _removeDatasource(ds.id)} onClick={() => _removeDatasource(ds.id)}
@ -282,6 +412,81 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
addingPath={addingPath} addingPath={addingPath}
/> />
))} ))}
{/* ── Feature Data Section ── */}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />
{/* Active Feature Data Sources */}
{featureDataSources.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
Active Feature Sources
</div>
{featureDataSources.map(fds => {
const fdsConnLabel = featureTree.find(n => n.featureInstanceId === fds.featureInstanceId)?.label || fds.label;
return (
<div key={fds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: '#7b1fa218',
borderLeft: '3px solid #7b1fa2',
fontSize: 12,
}} title={`${fdsConnLabel} - ${fds.tableName}`}>
<span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fdsConnLabel} {fds.tableName}
</span>
<button
onClick={() => _removeFeatureDataSource(fds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title="Entfernen"
>
{'\u2715'}
</button>
</div>
); })}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
</div>
)}
{/* Feature Connections Tree */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Feature Data
</span>
<button
onClick={_loadFeatureConnections}
disabled={loadingFeatures}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#7b1fa2' }}
>
{loadingFeatures ? '...' : '\u21BB'}
</button>
</div>
{loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading feature instances...
</div>
)}
{!loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
No feature instances found.
</div>
)}
{featureTree.map(fNode => (
<_FeatureNodeView
key={fNode.featureInstanceId}
node={fNode}
onToggle={_toggleFeatureNode}
onAddTable={_addFeatureTable}
isTableAdded={_isFeatureTableAdded}
addingKey={addingFeatureKey}
/>
))}
</div> </div>
); );
}; };
@ -391,6 +596,129 @@ const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
); );
}; };
/* ─── FeatureNodeView (feature instance + tables) ─────────────────── */
interface FeatureNodeViewProps {
node: FeatureConnectionNode;
onToggle: (node: FeatureConnectionNode) => void;
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
isTableAdded: (featureInstanceId: string, tableName: string) => boolean;
addingKey: string | null;
}
const _FeatureNodeView: React.FC<FeatureNodeViewProps> = ({
node, onToggle, onAddTable, isTableAdded, addingKey,
}) => {
const [hovered, setHovered] = useState(false);
const chevron = node.expanded ? '\u25BE' : '\u25B8';
return (
<div>
<div
onClick={() => onToggle(node)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
cursor: 'pointer', borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s', userSelect: 'none',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 13, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
{getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 600 }}>
{node.label}
</span>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{node.tableCount} tables
</span>
</div>
{node.expanded && node.tables && node.tables.length > 0 && (
<div>
{node.tables.map(table => (
<_FeatureTableRow
key={table.objectKey}
featureNode={node}
table={table}
onAdd={onAddTable}
isAdded={isTableAdded(node.featureInstanceId, table.tableName)}
isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`}
/>
))}
</div>
)}
{node.expanded && node.tables && node.tables.length === 0 && !node.loading && (
<div style={{ paddingLeft: 36, fontSize: 11, color: '#bbb', padding: '2px 0 2px 36px' }}>
(no tables)
</div>
)}
</div>
);
};
interface FeatureTableRowProps {
featureNode: FeatureConnectionNode;
table: FeatureTableNode;
onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
isAdded: boolean;
isAdding: boolean;
}
const _FeatureTableRow: React.FC<FeatureTableRowProps> = ({
featureNode, table, onAdd, isAdded, isAdding,
}) => {
const [hovered, setHovered] = useState(false);
const tableLabel = table.label?.en || table.label?.de || table.tableName;
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s', userSelect: 'none',
}}
title={`${table.tableName}: ${table.fields.join(', ')}`}
>
<span style={{ fontSize: 14, flexShrink: 0 }}>{'\uD83D\uDCC1'}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
{tableLabel}
</span>
{hovered && !isAdded && (
<button
onClick={() => onAdd(featureNode, table)}
disabled={isAdding}
style={{
background: 'none', border: '1px solid #7b1fa2', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
fontSize: 10, color: '#7b1fa2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1, flexShrink: 0,
}}
title="Add as feature data source"
>
{isAdding ? '...' : '+ Add'}
</button>
)}
{isAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
{'\u2713'}
</span>
)}
</div>
);
};
/* ─── Spinner (inline) ──────────────────────────────────────────────── */ /* ─── Spinner (inline) ──────────────────────────────────────────────── */
function _Spinner(): React.ReactElement { function _Spinner(): React.ReactElement {

View file

@ -5,8 +5,9 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ProviderMultiSelect } from '../../../components/ProviderSelector'; import { ProviderMultiSelect } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource } from './useWorkspace'; import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
const _STT_LANGUAGES = [ const _STT_LANGUAGES = [
{ code: 'de-DE', label: 'Deutsch' }, { code: 'de-DE', label: 'Deutsch' },
@ -37,11 +38,12 @@ interface TreeItemDrop {
interface WorkspaceInputProps { interface WorkspaceInputProps {
instanceId: string; instanceId: string;
onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[]) => void; onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[]) => void;
isProcessing: boolean; isProcessing: boolean;
onStop: () => void; onStop: () => void;
files: WorkspaceFile[]; files: WorkspaceFile[];
dataSources: DataSource[]; dataSources: DataSource[];
featureDataSources?: FeatureDataSource[];
pendingFiles?: PendingFile[]; pendingFiles?: PendingFile[];
onRemovePendingFile?: (fileId: string) => void; onRemovePendingFile?: (fileId: string) => void;
onFileUploadClick?: () => void; onFileUploadClick?: () => void;
@ -60,6 +62,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
onStop, onStop,
files, files,
dataSources, dataSources,
featureDataSources = [],
pendingFiles = [], pendingFiles = [],
onRemovePendingFile, onRemovePendingFile,
onFileUploadClick, onFileUploadClick,
@ -79,6 +82,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
const [showLangPicker, setShowLangPicker] = useState(false); const [showLangPicker, setShowLangPicker] = useState(false);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]); const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const promptBeforeVoiceRef = useRef(''); const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef(''); const finalizedTextRef = useRef('');
@ -112,12 +116,12 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
if (!trimmed || isProcessing) return; if (!trimmed || isProcessing) return;
const inlineFileIds = _extractFileRefs(trimmed); const inlineFileIds = _extractFileRefs(trimmed);
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
onSend(trimmed, allFileIds, attachedDataSourceIds); onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds);
setPrompt(''); setPrompt('');
setShowAutocomplete(false); setShowAutocomplete(false);
setShowSourcePicker(false); setShowSourcePicker(false);
setAttachedFileIds([]); setAttachedFileIds([]);
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, onSend]); }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]);
const _handleKeyDown = useCallback( const _handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -178,6 +182,12 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
); );
}, []); }, []);
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
setAttachedFeatureDataSourceIds(prev =>
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
);
}, []);
const _buildPromptFromRefs = useCallback(() => { const _buildPromptFromRefs = useCallback(() => {
const parts = [ const parts = [
promptBeforeVoiceRef.current, promptBeforeVoiceRef.current,
@ -237,7 +247,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter)) ? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
: []; : [];
const hasAttachments = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0; const hasAttachments = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0;
const _horizontalPadding = isMobile ? 12 : 24; const _horizontalPadding = isMobile ? 12 : 24;
const _controlSize = isMobile ? 38 : 40; const _controlSize = isMobile ? 38 : 40;
@ -371,7 +381,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500, background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}} }}
> >
🔗 {ds?.label || dsId} 🔗 {ds?.label || ds?.path || dsId}
<button <button
onClick={() => _removeAttachedDataSource(dsId)} onClick={() => _removeAttachedDataSource(dsId)}
style={{ style={{
@ -384,6 +394,32 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
</span> </span>
); );
})} })}
{attachedFeatureDataSourceIds.map(fdsId => {
const fds = featureDataSources.find(d => d.id === fdsId);
const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null;
return (
<span
key={fdsId}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
}}
>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId} {fds?.tableName || ''}
<button
onClick={() => _toggleFeatureDataSource(fdsId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#7b1fa2', padding: 0, lineHeight: 1,
}}
>
×
</button>
</span>
);
})}
</div> </div>
)} )}
@ -480,8 +516,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
title="Datenquellen anhängen" title="Datenquellen anhängen"
style={{ style={{
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)', width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: attachedDataSourceIds.length > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)', background: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: attachedDataSourceIds.length > 0 ? '#2e7d32' : '#666', color: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#2e7d32' : '#666',
cursor: isProcessing ? 'not-allowed' : 'pointer', cursor: isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1, opacity: isProcessing ? 0.5 : 1,
@ -489,14 +525,14 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
}} }}
> >
🔗 🔗
{attachedDataSourceIds.length > 0 && ( {(attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 && (
<span style={{ <span style={{
position: 'absolute', top: -4, right: -4, position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700, background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16, borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
{attachedDataSourceIds.length} {attachedDataSourceIds.length + attachedFeatureDataSourceIds.length}
</span> </span>
)} )}
</button> </button>
@ -539,6 +575,45 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
</div> </div>
); );
})} })}
{featureDataSources.length > 0 && (
<>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
Feature Data Sources
</div>
{featureDataSources.map(fds => {
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
return (
<div
key={fds.id}
onClick={() => _toggleFeatureDataSource(fds.id)}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#f3e5f5' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
>
<span style={{
width: 16, height: 16, borderRadius: 3,
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
background: isSelected ? '#7b1fa2' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{isSelected ? '✓' : ''}
</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 13, color: '#7b1fa2', flexShrink: 0 }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fds.label || fds.featureCode} {fds.tableName}
</span>
</div>
);
})}
</>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -240,7 +240,9 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
<DataSourcePanel <DataSourcePanel
instanceId={instanceId} instanceId={instanceId}
dataSources={workspace.dataSources} dataSources={workspace.dataSources}
featureDataSources={workspace.featureDataSources}
onRefresh={workspace.refreshDataSources} onRefresh={workspace.refreshDataSources}
onRefreshFeatureDataSources={workspace.refreshFeatureDataSources}
/> />
)} )}
</div> </div>
@ -395,15 +397,16 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
/> />
<WorkspaceInput <WorkspaceInput
instanceId={instanceId} instanceId={instanceId}
onSend={(prompt, fileIds, dataSourceIds) => { onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds) => {
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])]; const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders); workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds);
setPendingFiles([]); setPendingFiles([]);
}} }}
isProcessing={workspace.isProcessing} isProcessing={workspace.isProcessing}
onStop={workspace.stopProcessing} onStop={workspace.stopProcessing}
files={workspace.files} files={workspace.files}
dataSources={workspace.dataSources} dataSources={workspace.dataSources}
featureDataSources={workspace.featureDataSources}
pendingFiles={pendingFiles} pendingFiles={pendingFiles}
onRemovePendingFile={_handleRemovePendingFile} onRemovePendingFile={_handleRemovePendingFile}
onFileUploadClick={() => fileInputRef.current?.click()} onFileUploadClick={() => fileInputRef.current?.click()}

View file

@ -56,6 +56,17 @@ export interface DataSource {
label: string; label: string;
} }
export interface FeatureDataSource {
id: string;
featureInstanceId: string;
featureCode: string;
tableName: string;
objectKey: string;
label: string;
mandateId: string;
workspaceInstanceId: string;
}
export interface FileEditProposal { export interface FileEditProposal {
id: string; id: string;
fileId: string; fileId: string;
@ -78,13 +89,15 @@ export interface DataSourceAccessEvent {
interface UseWorkspaceReturn { interface UseWorkspaceReturn {
messages: Message[]; messages: Message[];
isProcessing: boolean; isProcessing: boolean;
sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[]) => void; sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[], featureDataSourceIds?: string[]) => void;
stopProcessing: () => void; stopProcessing: () => void;
loadWorkflow: (workflowId: string) => void; loadWorkflow: (workflowId: string) => void;
resetToNew: () => void; resetToNew: () => void;
files: WorkspaceFile[]; files: WorkspaceFile[];
folders: WorkspaceFolder[]; folders: WorkspaceFolder[];
dataSources: DataSource[]; dataSources: DataSource[];
featureDataSources: FeatureDataSource[];
refreshFeatureDataSources: () => void;
agentProgress: AgentProgress | null; agentProgress: AgentProgress | null;
toolActivities: ToolActivity[]; toolActivities: ToolActivity[];
pendingEdits: FileEditProposal[]; pendingEdits: FileEditProposal[];
@ -104,6 +117,7 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
const [files, setFiles] = useState<WorkspaceFile[]>([]); const [files, setFiles] = useState<WorkspaceFile[]>([]);
const [folders, setFolders] = useState<WorkspaceFolder[]>([]); const [folders, setFolders] = useState<WorkspaceFolder[]>([]);
const [dataSources, setDataSources] = useState<DataSource[]>([]); const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [featureDataSources, setFeatureDataSources] = useState<FeatureDataSource[]>([]);
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null); const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
const [toolActivities, setToolActivities] = useState<ToolActivity[]>([]); const [toolActivities, setToolActivities] = useState<ToolActivity[]>([]);
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]); const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
@ -133,12 +147,20 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
.catch(() => {}); .catch(() => {});
}, [instanceId]); }, [instanceId]);
const refreshFeatureDataSources = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/feature-datasources`)
.then(res => setFeatureDataSources(res.data.featureDataSources || []))
.catch(() => {});
}, [instanceId]);
useEffect(() => { useEffect(() => {
if (!instanceId) return; if (!instanceId) return;
refreshFiles(); refreshFiles();
refreshFolders(); refreshFolders();
refreshDataSources(); refreshDataSources();
}, [instanceId, refreshFiles, refreshFolders, refreshDataSources]); refreshFeatureDataSources();
}, [instanceId, refreshFiles, refreshFolders, refreshDataSources, refreshFeatureDataSources]);
const loadWorkflow = useCallback((wfId: string) => { const loadWorkflow = useCallback((wfId: string) => {
if (!instanceId || !wfId) return; if (!instanceId || !wfId) return;
@ -173,7 +195,7 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
}, []); }, []);
const sendMessage = useCallback( const sendMessage = useCallback(
(prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = []) => { (prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = []) => {
if (!instanceId || isProcessing) return; if (!instanceId || isProcessing) return;
setIsProcessing(true); setIsProcessing(true);
@ -202,6 +224,7 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
prompt, prompt,
fileIds, fileIds,
dataSourceIds, dataSourceIds,
featureDataSourceIds,
userLanguage: navigator.language?.slice(0, 2) || 'en', userLanguage: navigator.language?.slice(0, 2) || 'en',
}; };
if (workflowId) { if (workflowId) {
@ -415,6 +438,8 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
files, files,
folders, folders,
dataSources, dataSources,
featureDataSources,
refreshFeatureDataSources,
agentProgress, agentProgress,
toolActivities, toolActivities,
pendingEdits, pendingEdits,