Merge pull request #19 from valueonag/feat/code-editor

Feat/code editor
This commit is contained in:
Patrick Motsch 2026-03-18 23:42:55 +01:00 committed by GitHub
commit 735a6f3d3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 616 additions and 22 deletions

View file

@ -12,7 +12,7 @@
*/
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt } from 'react-icons/fa';
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
import styles from './FolderTree.module.css';
/* ── Public types ──────────────────────────────────────────────────────── */
@ -61,6 +61,7 @@ export interface FolderTreeProps {
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
}
/* ── Helpers ───────────────────────────────────────────────────────────── */
@ -285,12 +286,14 @@ interface TreeNodeProps {
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
}
function _TreeNode({
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder,
}: TreeNodeProps) {
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(node.name);
@ -436,6 +439,11 @@ function _TreeNode({
<span className={styles.folderName}>{node.name}</span>
)}
<span className={styles.actions}>
{onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title="Ordner herunterladen (ZIP)">
<FaDownload />
</button>
)}
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title="Neuer Unterordner">
<FaPlus />
@ -489,6 +497,7 @@ function _TreeNode({
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
/>
))}
{folderFiles.map((file) => (
@ -507,7 +516,7 @@ export default function FolderTree({
selectedItemIds: externalSelectedIds, onSelectionChange,
expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
}: FolderTreeProps) {
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const [rootDropOver, setRootDropOver] = useState(false);
@ -720,6 +729,7 @@ export default function FolderTree({
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
/>
))}
{rootFiles.map((file) => (

View file

@ -28,6 +28,7 @@ interface FileContextType {
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
expandedFolderIds: Set<string>;
toggleFolderExpanded: (id: string) => void;
}
@ -122,6 +123,24 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
await refreshFolders();
}, [refreshFolders]);
const handleDownloadFolder = useCallback(async (folderId: string, folderName: string) => {
try {
const response = await api.get(`/api/files/folders/${folderId}/download`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${folderName}.zip`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to download folder:', err);
}
}, []);
// ── File operations ────────────────────────────────────────────────────
const handleFileUpload = useCallback(async (file: File, workflowId?: string) => {
@ -174,6 +193,7 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
handleMoveFile,
handleMoveFiles,
handleMoveFolders,
handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
}}

View file

@ -77,6 +77,7 @@ export const FilesPage: React.FC = () => {
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext();
@ -367,6 +368,7 @@ export const FilesPage: React.FC = () => {
onDeleteFile={_handleDeleteTreeFile}
onDeleteFiles={_handleDeleteTreeFiles}
onDeleteFolders={_handleDeleteTreeFolders}
onDownloadFolder={handleDownloadFolder}
/>
</div>

View file

@ -6,7 +6,7 @@
* 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 { useApiRequest } from '../../../hooks/useApi';
import { useToast } from '../../../contexts/ToastContext';
@ -35,6 +35,19 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
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 () => {
if (!instanceId) return;
@ -62,8 +75,21 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
useEffect(() => {
loadData();
return () => { mountedRef.current = false; };
}, [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 => {
return connectors.find(c => c.connectorType === selectedType);
};
@ -291,6 +317,109 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
</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>
);

View file

@ -11,7 +11,8 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import api from '../../../api';
import type { DataSource } from './useWorkspace';
import { getPageIcon } from '../../../config/pageRegistry';
import type { DataSource, FeatureDataSource } from './useWorkspace';
/* ─── Types ─────────────────────────────────────────────────────────── */
@ -29,10 +30,30 @@ interface TreeNode {
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 {
instanceId: string;
dataSources: DataSource[];
featureDataSources: FeatureDataSource[];
onRefresh: () => void;
onRefreshFeatureDataSources: () => void;
}
/* ─── Icons ─────────────────────────────────────────────────────────── */
@ -86,11 +107,16 @@ function _getSourceIcon(sourceType: string): string {
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
instanceId,
dataSources,
featureDataSources,
onRefresh,
onRefreshFeatureDataSources,
}) => {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loadingRoot, setLoadingRoot] = useState(false);
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);
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
@ -205,6 +231,110 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
);
}, [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 (
<div style={{ padding: 8, fontSize: 13 }}>
{/* Active DataSources */}
@ -217,7 +347,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
const connColor = _getSourceColor(ds.sourceType);
const connNode = tree.find(n => n.connectionId === ds.connectionId);
const connLabel = connNode?.label || ds.connectionId;
const fullPath = `${connLabel} ${ds.sourceType} ${ds.path}`;
const folder = ds.label || ds.path || ds.id;
return (
<div key={ds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
@ -225,10 +355,10 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
background: `${connColor}18`,
borderLeft: `3px solid ${connColor}`,
fontSize: 12,
}} title={fullPath}>
}} title={`${connLabel} ${ds.path || ds.label}`}>
<span style={{ fontSize: 12, flexShrink: 0 }}>{_getSourceIcon(ds.sourceType)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label}
{connLabel} {folder}
</span>
<button
onClick={() => _removeDatasource(ds.id)}
@ -282,6 +412,81 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
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>
);
};
@ -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) ──────────────────────────────────────────────── */
function _Spinner(): React.ReactElement {

View file

@ -42,6 +42,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleFileDelete,
handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext();
@ -238,6 +239,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
onDeleteFile={_onDeleteFile}
onDeleteFiles={_onDeleteFiles}
onDeleteFolders={_onDeleteFolders}
onDownloadFolder={handleDownloadFolder}
/>
{_fileNodes.length === 0 && (

View file

@ -5,8 +5,9 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource } from './useWorkspace';
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
const _STT_LANGUAGES = [
{ code: 'de-DE', label: 'Deutsch' },
@ -37,11 +38,12 @@ interface TreeItemDrop {
interface WorkspaceInputProps {
instanceId: string;
onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[]) => void;
onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[]) => void;
isProcessing: boolean;
onStop: () => void;
files: WorkspaceFile[];
dataSources: DataSource[];
featureDataSources?: FeatureDataSource[];
pendingFiles?: PendingFile[];
onRemovePendingFile?: (fileId: string) => void;
onFileUploadClick?: () => void;
@ -60,6 +62,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
onStop,
files,
dataSources,
featureDataSources = [],
pendingFiles = [],
onRemovePendingFile,
onFileUploadClick,
@ -79,6 +82,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
const [showLangPicker, setShowLangPicker] = useState(false);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef('');
@ -112,12 +116,12 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
if (!trimmed || isProcessing) return;
const inlineFileIds = _extractFileRefs(trimmed);
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
onSend(trimmed, allFileIds, attachedDataSourceIds);
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds);
setPrompt('');
setShowAutocomplete(false);
setShowSourcePicker(false);
setAttachedFileIds([]);
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, onSend]);
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]);
const _handleKeyDown = useCallback(
(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 parts = [
promptBeforeVoiceRef.current,
@ -237,7 +247,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
? 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 _controlSize = isMobile ? 38 : 40;
@ -371,7 +381,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}}
>
🔗 {ds?.label || dsId}
🔗 {ds?.label || ds?.path || dsId}
<button
onClick={() => _removeAttachedDataSource(dsId)}
style={{
@ -384,6 +394,32 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
</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>
)}
@ -480,8 +516,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
title="Datenquellen anhängen"
style={{
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: attachedDataSourceIds.length > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: attachedDataSourceIds.length > 0 ? '#2e7d32' : '#666',
background: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#2e7d32' : '#666',
cursor: isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
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={{
position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{attachedDataSourceIds.length}
{attachedDataSourceIds.length + attachedFeatureDataSourceIds.length}
</span>
)}
</button>
@ -539,6 +575,45 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
</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>

View file

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

View file

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