truastee sync and charting
This commit is contained in:
parent
c6d43340ff
commit
2efc5fce86
5 changed files with 580 additions and 20 deletions
|
|
@ -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 && (
|
||||||
|
<> · Letzter Import: {new Date(importStatus.lastSyncAt * 1000).toLocaleString()}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue