fixing round 1

This commit is contained in:
ValueOn AG 2026-03-28 16:58:55 +01:00
parent 9a7e3f42d2
commit f5f6cad542
29 changed files with 2397 additions and 2014 deletions

View file

@ -146,7 +146,14 @@
font-size: 10px; font-size: 10px;
color: var(--color-text-secondary, #999); color: var(--color-text-secondary, #999);
flex-shrink: 0; flex-shrink: 0;
}
.scopeIcons {
display: flex;
gap: 2px;
flex-shrink: 0;
margin-left: auto; margin-left: auto;
align-items: center;
} }
.rootActions { .rootActions {

View file

@ -30,6 +30,8 @@ export interface FileNode {
mimeType?: string; mimeType?: string;
fileSize?: number; fileSize?: number;
folderId?: string | null; folderId?: string | null;
scope?: string;
neutralize?: boolean;
} }
export interface TreeItem { export interface TreeItem {
@ -62,6 +64,8 @@ export interface FolderTreeProps {
onDeleteFiles?: (fileIds: string[]) => Promise<void>; onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>; onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>; onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
} }
/* ── Helpers ───────────────────────────────────────────────────────────── */ /* ── Helpers ───────────────────────────────────────────────────────────── */
@ -146,6 +150,22 @@ function _fileIcon(mime?: string): string {
/* ── Selection context threaded through the tree ──────────────────────── */ /* ── Selection context threaded through the tree ──────────────────────── */
const _SCOPE_ICONS: Record<string, string> = {
personal: '\uD83D\uDC64',
featureInstance: '\uD83D\uDC65',
mandate: '\uD83C\uDFE2',
global: '\uD83C\uDF10',
};
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
const _SCOPE_LABELS: Record<string, string> = {
personal: 'Persönlich',
featureInstance: 'Instanz',
mandate: 'Mandant',
global: 'Global',
};
interface SelectionCtx { interface SelectionCtx {
selectedItemIds: Set<string>; selectedItemIds: Set<string>;
selectedFileIds: string[]; selectedFileIds: string[];
@ -156,6 +176,8 @@ interface SelectionCtx {
onDeleteFile?: (fileId: string) => Promise<void>; onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>; onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>; onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
} }
/* ── File node (leaf) ─────────────────────────────────────────────────── */ /* ── File node (leaf) ─────────────────────────────────────────────────── */
@ -232,6 +254,35 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
{(file.fileSize / 1024).toFixed(0)}K {(file.fileSize / 1024).toFixed(0)}K
</span> </span>
)} )}
{!renaming && file.scope != null && (
<span className={styles.scopeIcons}>
<button
className={styles.actionBtn}
onClick={(e) => {
e.stopPropagation();
if (!sel.onScopeChange) return;
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
sel.onScopeChange(file.id, next);
}}
title={`Scope: ${_SCOPE_LABELS[file.scope!] || file.scope} (klicken zum Wechseln)`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
</button>
<button
className={styles.actionBtn}
onClick={(e) => {
e.stopPropagation();
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
}}
title={file.neutralize ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
</span>
)}
{!renaming && ( {!renaming && (
<span className={styles.actions}> <span className={styles.actions}>
{sel.onRenameFile && !multiSelected && ( {sel.onRenameFile && !multiSelected && (
@ -517,6 +568,7 @@ export default function FolderTree({
expandedIds: externalExpandedIds, onToggleExpand, expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle,
}: FolderTreeProps) { }: FolderTreeProps) {
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set()); const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const [rootDropOver, setRootDropOver] = useState(false); const [rootDropOver, setRootDropOver] = useState(false);
@ -634,8 +686,10 @@ export default function FolderTree({
onDeleteFile, onDeleteFile,
onDeleteFiles, onDeleteFiles,
onDeleteFolders, onDeleteFolders,
onScopeChange,
onNeutralizeToggle,
}; };
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]); }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]);
const _handleRootDrop = useCallback(async (e: React.DragEvent) => { const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();

View file

@ -282,6 +282,27 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* Rename button (inline, hover-visible via TreeNavigation nodeActions) */
.renameButton {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
border: none;
border-radius: 3px;
background: transparent;
color: var(--text-tertiary, #888);
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.renameButton:hover {
color: var(--primary-color, #2563eb);
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
}
/* Dark Theme */ /* Dark Theme */
:global(.dark-theme) .separator { :global(.dark-theme) .separator {
background: var(--border-dark, #333); background: var(--border-dark, #333);

View file

@ -20,7 +20,7 @@
* - Users, Mandates, Roles, ... * - Users, Mandates, Roles, ...
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, useCallback } from 'react';
import { useNavigation } from '../../hooks/useNavigation'; import { useNavigation } from '../../hooks/useNavigation';
import type { import type {
DynamicBlock, DynamicBlock,
@ -31,8 +31,9 @@ import type {
FeatureView FeatureView
} from '../../hooks/useNavigation'; } from '../../hooks/useNavigation';
import { getPageIcon } from '../../config/pageRegistry'; import { getPageIcon } from '../../config/pageRegistry';
import { FaSpinner } from 'react-icons/fa'; import { FaSpinner, FaPen } from 'react-icons/fa';
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation'; import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
import api from '../../api';
import styles from './MandateNavigation.module.css'; import styles from './MandateNavigation.module.css';
// ============================================================================= // =============================================================================
@ -84,16 +85,32 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
* Convert a FeatureInstance to TreeNodeItem (with feature icon) * Convert a FeatureInstance to TreeNodeItem (with feature icon)
* Instance node gets path to first view so clicking the instance name navigates to dashboard. * Instance node gets path to first view so clicking the instance name navigates to dashboard.
* Shows the feature icon next to the instance name for visual distinction. * Shows the feature icon next to the instance name for visual distinction.
* If user is instance admin, a rename icon appears on hover.
*/ */
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem { function featureInstanceToTreeNode(
instance: FeatureInstance,
featureUiComponent: string,
onRename?: (instanceId: string, currentLabel: string) => void,
): TreeNodeItem {
const children = instance.views.map(featureViewToTreeNode); const children = instance.views.map(featureViewToTreeNode);
const renameAction = instance.isAdmin && onRename ? (
<button
className={styles.renameButton}
title="Umbenennen"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRename(instance.id, instance.uiLabel); }}
>
<FaPen size={10} />
</button>
) : undefined;
return { return {
id: instance.id, id: instance.id,
label: instance.uiLabel, label: instance.uiLabel,
icon: getPageIcon(featureUiComponent), // Use feature icon for instance icon: getPageIcon(featureUiComponent),
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
children, children,
defaultExpanded: false, defaultExpanded: false,
actions: renameAction,
}; };
} }
@ -106,16 +123,18 @@ function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent
* Before: Mandate Feature Instance Views * Before: Mandate Feature Instance Views
* Now: Mandate Instance (with feature icon) Views * Now: Mandate Instance (with feature icon) Views
*/ */
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null { function navigationMandateToTreeNode(
mandate: NavigationMandate,
onRename?: (instanceId: string, currentLabel: string) => void,
): TreeNodeItem | null {
if (mandate.features.length === 0) { if (mandate.features.length === 0) {
return null; return null;
} }
// Flatten: collect all instances from all features directly under mandate
const instanceNodes: TreeNodeItem[] = []; const instanceNodes: TreeNodeItem[] = [];
for (const feature of mandate.features) { for (const feature of mandate.features) {
for (const instance of feature.instances) { for (const instance of feature.instances) {
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent)); instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename));
} }
} }
@ -134,9 +153,12 @@ function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem |
/** /**
* Convert a DynamicBlock to array of TreeNodeItems (mandate nodes) * Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
*/ */
function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] { function dynamicBlockToTreeNodes(
block: DynamicBlock,
onRename?: (instanceId: string, currentLabel: string) => void,
): TreeNodeItem[] {
return block.mandates return block.mandates
.map(navigationMandateToTreeNode) .map((m) => navigationMandateToTreeNode(m, onRename))
.filter((node): node is TreeNodeItem => node !== null); .filter((node): node is TreeNodeItem => node !== null);
} }
@ -169,18 +191,19 @@ const EmptyState: React.FC = () => (
// ============================================================================= // =============================================================================
export const MandateNavigation: React.FC = () => { export const MandateNavigation: React.FC = () => {
// Fetch navigation from new API (blocks structure, already filtered by permissions) const { blocks, loading, refresh } = useNavigation('de');
const { blocks, loading } = useNavigation('de');
const _handleRename = useCallback((instanceId: string, currentLabel: string) => {
// Build navigation items from blocks const newLabel = window.prompt('Neuer Name:', currentLabel);
// Groups static items into collapsible containers: if (!newLabel || newLabel.trim() === currentLabel) return;
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.) api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() })
// - "Administration": admin items, possibly with subgroups .then(() => refresh())
// - Dynamic block (mandates) renders between them .catch((err: any) => alert('Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message)));
}, [refresh]);
const navigationItems: TreeItem[] = useMemo(() => { const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = []; const items: TreeItem[] = [];
// Collect static items by category
const meineSichtItems: NavigationItem[] = []; const meineSichtItems: NavigationItem[] = [];
let adminItems: NavigationItem[] = []; let adminItems: NavigationItem[] = [];
let adminSubgroups: NavSubgroup[] = []; let adminSubgroups: NavSubgroup[] = [];
@ -199,15 +222,13 @@ export const MandateNavigation: React.FC = () => {
} }
} }
// "Meine Sicht" - collapsible container for user-facing pages
if (meineSichtItems.length > 0) { if (meineSichtItems.length > 0) {
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true)); items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
} }
// Dynamic block: mandates with feature instances
for (const block of blocks) { for (const block of blocks) {
if (block.type === 'dynamic') { if (block.type === 'dynamic') {
const mandateNodes = dynamicBlockToTreeNodes(block); const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename);
if (mandateNodes.length > 0) { if (mandateNodes.length > 0) {
if (items.length > 0) items.push({ type: 'separator' }); if (items.length > 0) items.push({ type: 'separator' });
items.push(...mandateNodes); items.push(...mandateNodes);
@ -215,7 +236,6 @@ export const MandateNavigation: React.FC = () => {
} }
} }
// "Administration" - collapsible container for admin pages (with subgroup support)
if (adminSubgroups.length > 0) { if (adminSubgroups.length > 0) {
if (items.length > 0) items.push({ type: 'separator' }); if (items.length > 0) items.push({ type: 'separator' });
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({ const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
@ -236,7 +256,7 @@ export const MandateNavigation: React.FC = () => {
} }
return items; return items;
}, [blocks]); }, [blocks, _handleRename]);
// Check if user has any navigation (static or dynamic) // Check if user has any navigation (static or dynamic)
const hasNavigation = blocks.length > 0; const hasNavigation = blocks.length > 0;

View file

@ -257,6 +257,22 @@
color: white; color: white;
} }
/* ============================================ */
/* NODE ACTIONS (hover-reveal inline icons) */
/* ============================================ */
.nodeActions {
display: none;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
margin-left: auto;
}
.treeNode:hover .nodeActions {
display: flex;
}
/* ============================================ */ /* ============================================ */
/* DARK THEME */ /* DARK THEME */
/* ============================================ */ /* ============================================ */

View file

@ -47,6 +47,8 @@ export interface TreeNodeItem {
level?: number; level?: number;
/** Data attribute for testing/identification */ /** Data attribute for testing/identification */
dataId?: string; dataId?: string;
/** Inline action element rendered at the end of the row (e.g. rename icon) */
actions?: ReactNode;
} }
export interface TreeSectionItem { export interface TreeSectionItem {
@ -219,6 +221,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
{node.badge} {node.badge}
</span> </span>
)} )}
{node.actions && (
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>
{node.actions}
</span>
)}
</> </>
); );

View file

@ -8,6 +8,7 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useCurrentUser } from '../../hooks/useUsers'; import { useCurrentUser } from '../../hooks/useUsers';
import { NotificationBell } from '../NotificationBell'; import { NotificationBell } from '../NotificationBell';
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
import styles from './UserSection.module.css'; import styles from './UserSection.module.css';
export const UserSection: React.FC = () => { export const UserSection: React.FC = () => {
@ -16,6 +17,7 @@ export const UserSection: React.FC = () => {
const [isLoggingOut, setIsLoggingOut] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [showLegalModal, setShowLegalModal] = useState(false); const [showLegalModal, setShowLegalModal] = useState(false);
const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden());
const handleLogout = async () => { const handleLogout = async () => {
setIsLoggingOut(true); setIsLoggingOut(true);
@ -41,6 +43,13 @@ export const UserSection: React.FC = () => {
setShowLegalModal(true); setShowLegalModal(true);
setShowMenu(false); setShowMenu(false);
}; };
const handleOnboarding = () => {
_showOnboarding();
setOnboardingHidden(false);
navigate('/', { state: { showOnboarding: Date.now() } });
setShowMenu(false);
};
if (!user) { if (!user) {
return null; return null;
@ -61,7 +70,7 @@ export const UserSection: React.FC = () => {
<button <button
className={styles.userButton} className={styles.userButton}
onClick={() => setShowMenu(!showMenu)} onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }}
aria-expanded={showMenu} aria-expanded={showMenu}
> >
<div className={styles.avatar}> <div className={styles.avatar}>
@ -94,6 +103,16 @@ export const UserSection: React.FC = () => {
Einstellungen Einstellungen
</button> </button>
{onboardingHidden && (
<button
className={styles.menuItem}
onClick={handleOnboarding}
>
<span className={styles.menuIcon}>{'\uD83E\uDDED'}</span>
Onboarding-Assistent
</button>
)}
<button <button
className={styles.menuItem} className={styles.menuItem}
onClick={handleLegal} onClick={handleLegal}

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import api from '../api'; import api from '../api';
interface OnboardingStep { interface OnboardingStep {
@ -11,133 +11,183 @@ interface OnboardingStep {
} }
interface OnboardingAssistantProps { interface OnboardingAssistantProps {
instanceId?: string;
mandateId?: string;
featureCode?: string;
onDismiss?: () => void; onDismiss?: () => void;
} }
const _DISMISS_KEY = 'onboarding_dismissed'; const _STORAGE_KEY = 'onboarding_hidden';
const _DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000;
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ const _CALLOUTS: Record<string, string> = {
instanceId, mandate: 'Tipp: Ein Mandant ist Ihr persoenlicher Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.',
mandateId, feature: 'Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.',
featureCode: _featureCode, connection: 'Tipp: Verbinden Sie Ihre Datenquellen (z.B. SharePoint, Google Drive), damit der AI-Assistent auf Ihre Dokumente zugreifen kann.',
onDismiss, chat: 'Tipp: Starten Sie einen Chat mit dem AI-Assistenten. Er kann Ihre verbundenen Daten analysieren und Fragen beantworten.',
}) => { };
export function _isOnboardingHidden(): boolean {
try {
return localStorage.getItem(_STORAGE_KEY) === 'true';
} catch {
return false;
}
}
export function _showOnboarding(): void {
try {
localStorage.removeItem(_STORAGE_KEY);
} catch { /* ignore */ }
}
function _hideOnboarding(): void {
try {
localStorage.setItem(_STORAGE_KEY, 'true');
} catch { /* ignore */ }
}
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [dismissed, setDismissed] = useState(false); const location = useLocation();
const [hidden, setHidden] = useState(() => _isOnboardingHidden());
const [steps, setSteps] = useState<OnboardingStep[]>([]); const [steps, setSteps] = useState<OnboardingStep[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dontShowAgain, setDontShowAgain] = useState(false);
useEffect(() => { const _checkOnboardingState = useCallback(async () => {
try {
const dismissedAt = localStorage.getItem(_DISMISS_KEY);
if (dismissedAt && Date.now() - parseInt(dismissedAt) < _DISMISS_COOLDOWN_MS) {
setDismissed(true);
}
} catch { /* ignore */ }
}, []);
useEffect(() => {
_checkOnboardingState();
}, [instanceId, mandateId]);
const _checkOnboardingState = async () => {
setLoading(true); setLoading(true);
try { try {
const onboardingSteps: OnboardingStep[] = []; const onboardingSteps: OnboardingStep[] = [];
let hasMandate = !!mandateId; let hasMandate = false;
if (!hasMandate) { try {
try { const mandatesRes = await api.get('/api/store/mandates');
const mandatesRes = await api.get('/api/store/mandates'); const mandates = mandatesRes.data?.mandates || mandatesRes.data || [];
hasMandate = (mandatesRes.data || []).length > 0; hasMandate = Array.isArray(mandates) && mandates.length > 0;
} catch { /* ignore */ } } catch { /* ignore */ }
}
onboardingSteps.push({ onboardingSteps.push({
id: 'mandate', id: 'mandate',
label: 'Mandant einrichten', label: 'Mandant einrichten',
description: hasMandate ? 'Dein Mandant ist eingerichtet.' : 'Richte deinen Mandanten ein, um loszulegen.', description: hasMandate
? 'Dein Mandant ist eingerichtet.'
: 'Richte deinen ersten Mandanten ein.',
completed: hasMandate, completed: hasMandate,
action: hasMandate ? undefined : () => navigate('/store'), action: hasMandate ? undefined : () => navigate('/store'),
}); });
let hasInstances = !!instanceId; let hasFeature = false;
if (!hasInstances) { let firstInstancePath: string | undefined;
try { try {
const storeRes = await api.get('/api/store/features'); const navRes = await api.get('/api/navigation?language=de');
const features = storeRes.data || []; const mandates = navRes.data?.mandates || [];
hasInstances = features.some((f: any) => f.instances && f.instances.length > 0); for (const m of mandates) {
} catch { /* ignore */ } for (const f of m.features || []) {
} for (const inst of f.instances || []) {
if (!hasFeature) hasFeature = true;
if (!firstInstancePath && inst.views?.length > 0) {
firstInstancePath = inst.views[0].uiPath;
}
}
}
}
} catch { /* ignore */ }
onboardingSteps.push({ onboardingSteps.push({
id: 'feature', id: 'feature',
label: 'Erstes Feature aktivieren', label: 'Erstes Feature aktivieren',
description: hasInstances ? 'Du hast aktive Features.' : 'Aktiviere dein erstes Feature im Store.', description: hasFeature
completed: hasInstances, ? 'Du hast aktive Features.'
action: hasInstances ? undefined : () => navigate('/store'), : 'Aktiviere dein erstes Feature im Store.',
completed: hasFeature,
action: hasFeature ? undefined : () => navigate('/store'),
}); });
let hasData = false; let hasConnection = false;
if (instanceId) { try {
try { const connRes = await api.get('/api/connections/');
const filesRes = await api.get(`/api/workspace/${instanceId}/files`); const connections = connRes.data?.data || connRes.data || [];
const files = filesRes.data?.data || filesRes.data || []; hasConnection = Array.isArray(connections) && connections.length > 0;
hasData = files.length > 0; } catch { /* ignore */ }
} catch { /* ignore */ }
}
onboardingSteps.push({ onboardingSteps.push({
id: 'data', id: 'connection',
label: 'Erste Datenquelle einbinden', label: 'Erste Datenquelle einbinden',
description: hasData ? 'Du hast Daten im Workspace.' : 'Lade eine Datei hoch oder verbinde eine Datenquelle.', description: hasConnection
completed: hasData, ? 'Du hast Verbindungen eingerichtet.'
: 'Verbinde deine erste Datenquelle.',
completed: hasConnection,
action: hasConnection ? undefined : () => navigate('/basedata/connections'),
}); });
let hasChats = false; let hasChat = false;
if (instanceId) { if (hasFeature && firstInstancePath) {
try { try {
const chatsRes = await api.get(`/api/workspace/${instanceId}/workflows`); const featuresRes = await api.get('/api/store/features');
const chats = chatsRes.data?.data || chatsRes.data || []; const features = featuresRes.data || [];
hasChats = chats.length > 0; for (const f of features) {
if (hasChat) break;
for (const inst of f.instances || []) {
if (hasChat) break;
try {
const wfRes = await api.get(`/api/workspace/${inst.id}/workflows`);
const wfs = wfRes.data?.workflows || wfRes.data?.data || [];
if (Array.isArray(wfs) && wfs.length > 0) hasChat = true;
} catch { /* ignore */ }
}
}
} catch { /* ignore */ } } catch { /* ignore */ }
} }
const _chatAction = firstInstancePath ? () => navigate(firstInstancePath!) : undefined;
onboardingSteps.push({ onboardingSteps.push({
id: 'chat', id: 'chat',
label: 'Ersten AI-Chat starten', label: 'Ersten AI-Chat starten',
description: hasChats ? 'Du hast bereits Chats.' : 'Starte deinen ersten Chat mit dem AI-Assistenten.', description: hasChat
completed: hasChats, ? 'Du hast bereits Chats gestartet.'
: 'Starte deinen ersten Chat mit dem AI-Assistenten.',
completed: hasChat,
action: hasChat ? undefined : _chatAction,
}); });
setSteps(onboardingSteps); setSteps(onboardingSteps);
if (onboardingSteps.every(s => s.completed)) { if (onboardingSteps.every(s => s.completed)) {
setDismissed(true); setHidden(true);
_hideOnboarding();
} }
} catch (err) { } catch (err) {
console.error('Onboarding check failed:', err); console.error('Onboarding check failed:', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [navigate]);
useEffect(() => {
const state = location.state as { showOnboarding?: number } | null;
if (state?.showOnboarding) {
setHidden(false);
window.history.replaceState({}, '');
}
}, [location.state]);
useEffect(() => {
if (!hidden) _checkOnboardingState();
}, [hidden, _checkOnboardingState]);
const _handleDismiss = () => { const _handleDismiss = () => {
setDismissed(true); if (dontShowAgain) {
_hideOnboarding();
}
setHidden(true);
onDismiss?.(); onDismiss?.();
try {
localStorage.setItem(_DISMISS_KEY, Date.now().toString());
} catch { /* ignore */ }
}; };
if (dismissed || loading) return null; if (hidden || loading) return null;
const completedCount = steps.filter(s => s.completed).length; const completedCount = steps.filter(s => s.completed).length;
if (completedCount === steps.length) return null; if (completedCount === steps.length) return null;
return ( return (
<div style={{ <div style={{
padding: 16, margin: 16, borderRadius: 12, padding: 16, margin: '0 0 20px 0', borderRadius: 12,
border: '1px solid var(--border-color, #e5e7eb)', border: '1px solid var(--border-color, #e5e7eb)',
background: 'linear-gradient(135deg, var(--bg-primary, #fff) 0%, #eef2ff 100%)', background: 'linear-gradient(135deg, var(--bg-primary, #fff) 0%, #eef2ff 100%)',
}}> }}>
@ -148,12 +198,6 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({
{completedCount} von {steps.length} Schritten abgeschlossen {completedCount} von {steps.length} Schritten abgeschlossen
</p> </p>
</div> </div>
<button
onClick={_handleDismiss}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '1.1rem', color: '#9ca3af', padding: '2px 6px' }}
>
{'\u00D7'}
</button>
</div> </div>
<div style={{ display: 'flex', gap: 4, marginBottom: 16, height: 4 }}> <div style={{ display: 'flex', gap: 4, marginBottom: 16, height: 4 }}>
@ -170,34 +214,78 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{steps.map((step) => ( {steps.map((step, idx) => {
<div const isNextStep = !step.completed && steps.slice(0, idx).every(s => s.completed);
key={step.id} return (
style={{ <div key={step.id}>
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', <div
borderRadius: 8, background: step.completed ? 'transparent' : 'var(--bg-primary, #fff)', style={{
border: step.completed ? 'none' : '1px solid var(--border-color, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
opacity: step.completed ? 0.6 : 1, borderRadius: 8, background: step.completed ? 'transparent' : 'var(--bg-primary, #fff)',
cursor: step.action ? 'pointer' : 'default', border: step.completed ? 'none' : isNextStep ? '1px solid var(--accent, #4f46e5)' : '1px solid var(--border-color, #e5e7eb)',
}} opacity: step.completed ? 0.6 : 1,
onClick={step.action} cursor: step.action ? 'pointer' : 'default',
> }}
<span style={{ fontSize: '1.1rem', flexShrink: 0, width: 24, textAlign: 'center' }}> onClick={step.action}
{step.completed ? '\u2713' : '\u25CB'} >
</span> <span style={{ fontSize: '1.1rem', flexShrink: 0, width: 24, textAlign: 'center' }}>
<div style={{ flex: 1 }}> {step.completed ? '\u2713' : '\u25CB'}
<div style={{ fontWeight: 500, fontSize: '0.85rem', textDecoration: step.completed ? 'line-through' : 'none' }}> </span>
{step.label} <div style={{ flex: 1 }}>
</div> <div style={{ fontWeight: 500, fontSize: '0.85rem', textDecoration: step.completed ? 'line-through' : 'none' }}>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #6b7280)' }}> {step.label}
{step.description} </div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #6b7280)' }}>
{step.description}
</div>
</div>
{step.action && !step.completed && (
<span style={{ fontSize: '0.8rem', color: 'var(--accent, #4f46e5)', fontWeight: 500 }}>{'\u2192'}</span>
)}
</div> </div>
{isNextStep && _CALLOUTS[step.id] && (
<div style={{
marginTop: 4, marginLeft: 34, padding: '6px 10px',
fontSize: '0.78rem', color: 'var(--accent, #4f46e5)',
background: 'rgba(79, 70, 229, 0.06)', borderRadius: 6,
borderLeft: '3px solid var(--accent, #4f46e5)',
}}>
{_CALLOUTS[step.id]}
</div>
)}
</div> </div>
{step.action && !step.completed && ( );
<span style={{ fontSize: '0.8rem', color: 'var(--accent, #4f46e5)', fontWeight: 500 }}>{'\u2192'}</span> })}
)} </div>
</div>
))} <div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginTop: 14, paddingTop: 10,
borderTop: '1px solid var(--border-color, #e5e7eb)',
}}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.8rem', color: 'var(--text-secondary, #6b7280)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={dontShowAgain}
onChange={(e) => setDontShowAgain(e.target.checked)}
style={{ margin: 0 }}
/>
Nicht wieder anzeigen
</label>
<button
onClick={_handleDismiss}
style={{
border: '1px solid var(--border-color, #d1d5db)',
background: 'transparent',
cursor: 'pointer',
fontSize: '0.8rem',
color: 'var(--text-secondary, #6b7280)',
padding: '4px 12px',
borderRadius: 6,
}}
>
Schliessen
</button>
</div> </div>
</div> </div>
); );

View file

@ -20,6 +20,23 @@
color: var(--text-primary, #111); color: var(--text-primary, #111);
} }
.createBtn {
padding: 6px 10px;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 6px;
background: var(--accent, #4f46e5);
color: #fff;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
line-height: 1;
transition: background 0.15s;
}
.createBtn:hover {
background: var(--accent-hover, #4338ca);
}
.modeToggle { .modeToggle {
padding: 6px 8px; padding: 6px 8px;
border: 1px solid var(--border-color, #d1d5db); border: 1px solid var(--border-color, #d1d5db);
@ -33,12 +50,50 @@
background: var(--bg-active, #eef2ff); background: var(--bg-active, #eef2ff);
} }
.loading { /* ── Aktiv / Archiv filter tabs ── */
.filterTabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-color, #e5e7eb);
}
.filterTab {
flex: 1;
padding: 6px 0;
font-size: 0.8rem;
font-weight: 600;
text-align: center;
border: none;
background: none;
cursor: pointer;
color: var(--text-secondary, #6b7280);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
}
.filterTab:hover {
color: var(--text-primary, #111);
}
.filterTabActive {
color: var(--accent, #4f46e5);
border-bottom-color: var(--accent, #4f46e5);
}
/* ── Loading / Empty ── */
.loading,
.emptyState {
padding: 16px; padding: 16px;
text-align: center; text-align: center;
color: var(--text-secondary, #6b7280); color: var(--text-secondary, #6b7280);
font-size: 0.85rem;
} }
/* ── Chat list ── */
.flatList, .flatList,
.tree { .tree {
display: flex; display: flex;
@ -46,33 +101,100 @@
} }
.chatItem { .chatItem {
padding: 8px 10px; padding: 6px 10px;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
font-size: 0.85rem; font-size: 0.85rem;
position: relative;
gap: 6px;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
} }
.chatItem:hover { .chatItem:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.04)); background: var(--bg-hover, rgba(0, 0, 0, 0.04));
} }
.chatItemActive {
background: var(--primary-light, #eef2ff);
border-color: var(--accent, #4f46e5);
font-weight: 500;
}
.chatItemActive:hover {
background: var(--primary-light, #eef2ff);
}
.chatItemArchived {
opacity: 0.65;
}
.chatDate {
font-size: 0.7rem;
color: var(--text-secondary, #9ca3af);
flex-shrink: 0;
min-width: 36px;
}
.chatLabel { .chatLabel {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
flex: 1; flex: 1;
min-width: 0;
} }
.chatDate { /* ── Inline action icons (show on hover) ── */
font-size: 0.75rem;
color: var(--text-secondary, #9ca3af); .chatActions {
display: none;
gap: 2px;
flex-shrink: 0; flex-shrink: 0;
margin-left: 8px; margin-left: auto;
align-items: center;
} }
.chatItem:hover .chatActions {
display: flex;
}
.actionBtn {
background: none;
border: none;
cursor: pointer;
padding: 2px 3px;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1;
transition: background 0.15s;
opacity: 0.7;
}
.actionBtn:hover {
background: rgba(0, 0, 0, 0.06);
opacity: 1;
}
.actionBtnDanger:hover {
background: rgba(220, 38, 38, 0.1);
}
.renameInput {
flex: 1;
min-width: 0;
font-size: 0.85rem;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--accent, #4f46e5);
outline: none;
background: var(--bg-input, #fff);
color: var(--text-primary, #111);
}
/* ── Tree groups ── */
.treeGroup { .treeGroup {
margin-bottom: 2px; margin-bottom: 2px;
} }
@ -118,7 +240,8 @@
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.search { .search,
.renameInput {
background: var(--bg-input-dark, #1f2937); background: var(--bg-input-dark, #1f2937);
border-color: var(--border-dark, #374151); border-color: var(--border-dark, #374151);
color: #f3f4f6; color: #f3f4f6;
@ -127,8 +250,28 @@
.treeGroupHeader:hover { .treeGroupHeader:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
.chatItemActive,
.chatItemActive:hover {
background: rgba(79, 70, 229, 0.15);
border-color: var(--accent, #4f46e5);
}
.treeGroupCount { .treeGroupCount {
background: #374151; background: #374151;
color: #9ca3af; color: #9ca3af;
} }
.actionBtn:hover {
background: rgba(255, 255, 255, 0.08);
}
.actionBtnDanger:hover {
background: rgba(220, 38, 38, 0.15);
}
.createBtn {
border-color: var(--border-dark, #374151);
}
.filterTabs {
border-bottom-color: var(--border-dark, #374151);
}
.filterTab:hover {
color: #f3f4f6;
}
} }

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { UdbContext } from './UnifiedDataBar'; import type { UdbContext } from './UnifiedDataBar';
import api from '../../api'; import api from '../../api';
import styles from './ChatsTab.module.css'; import styles from './ChatsTab.module.css';
@ -6,9 +6,10 @@ import styles from './ChatsTab.module.css';
interface ChatItem { interface ChatItem {
id: string; id: string;
label: string; label: string;
updatedAt?: string; updatedAt?: string | number;
featureInstanceId?: string; featureInstanceId?: string;
featureCode?: string; featureCode?: string;
status?: string;
} }
interface ChatGroup { interface ChatGroup {
@ -18,24 +19,63 @@ interface ChatGroup {
chats: ChatItem[]; chats: ChatItem[];
} }
type ChatFilter = 'active' | 'archived';
interface ChatsTabProps { interface ChatsTabProps {
context: UdbContext; context: UdbContext;
onSelectChat?: (chatId: string, featureInstanceId: string) => void; onSelectChat?: (chatId: string, featureInstanceId: string) => void;
onDragStart?: (chatId: string, event: React.DragEvent) => void; onDragStart?: (chatId: string, event: React.DragEvent) => void;
activeWorkflowId?: string;
onCreateNew?: () => void;
onRenameChat?: (chatId: string, newName: string) => void | Promise<void>;
onDeleteChat?: (chatId: string) => void | Promise<void>;
} }
const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart }) => { function _formatRelativeTime(dateStr?: string | number): string {
if (!dateStr) return '';
const d = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
if (isNaN(d.getTime())) return '';
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffH = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `${diffMin}m`;
if (diffH < 24) return `${diffH}h`;
if (diffDays === 1) return 'gestern';
if (diffDays < 7) return `vor ${diffDays}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
const ChatsTab: React.FC<ChatsTabProps> = ({
context,
onSelectChat,
onDragStart,
activeWorkflowId,
onCreateNew,
onRenameChat,
onDeleteChat,
}) => {
const [groups, setGroups] = useState<ChatGroup[]>([]); const [groups, setGroups] = useState<ChatGroup[]>([]);
const [flatMode, setFlatMode] = useState(false); const [flatMode, setFlatMode] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ChatFilter>('active');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const _loadChats = useCallback(async () => { const _loadChats = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const response = await api.get(`/api/workspace/${context.instanceId}/workflows`); const response = await api.get(
const workflows = response.data?.data || response.data || []; `/api/workspace/${context.instanceId}/workflows`,
{ params: { includeArchived: true } },
);
const workflows = response.data?.workflows || response.data?.data || [];
const groupMap = new Map<string, ChatGroup>(); const groupMap = new Map<string, ChatGroup>();
for (const wf of workflows) { for (const wf of workflows) {
@ -51,15 +91,20 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
groupMap.get(fiId)!.chats.push({ groupMap.get(fiId)!.chats.push({
id: wf.id, id: wf.id,
label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`, label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`,
updatedAt: wf.updatedAt || wf.createdAt, updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt,
featureInstanceId: fiId, featureInstanceId: fiId,
featureCode: wf.featureCode, featureCode: wf.featureCode,
status: wf.status || 'active',
}); });
} }
const sorted = Array.from(groupMap.values()); const sorted = Array.from(groupMap.values());
sorted.forEach(g => sorted.forEach(g =>
g.chats.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')), g.chats.sort((a, b) => {
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
return tb - ta;
}),
); );
setGroups(sorted); setGroups(sorted);
@ -75,6 +120,19 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
useEffect(() => { _loadChats(); }, [_loadChats]); useEffect(() => { _loadChats(); }, [_loadChats]);
useEffect(() => {
if (activeWorkflowId) {
_loadChats();
}
}, [activeWorkflowId]);
useEffect(() => {
if (editingId && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [editingId]);
const _toggleGroup = (id: string) => { const _toggleGroup = (id: string) => {
setExpandedGroups(prev => { setExpandedGroups(prev => {
const next = new Set(prev); const next = new Set(prev);
@ -83,18 +141,161 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
}); });
}; };
const _startEditing = (chat: ChatItem) => {
if (!onRenameChat) return;
setEditingId(chat.id);
setEditName(chat.label);
};
const _commitRename = async (chatId: string) => {
const trimmed = editName.trim();
setEditingId(null);
if (!trimmed || !onRenameChat) return;
await onRenameChat(chatId, trimmed);
_loadChats();
};
const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitRename(chatId);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
const _archiveChat = useCallback(async (chatId: string) => {
try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
_loadChats();
} catch (err) {
console.error('Failed to archive chat:', err);
}
}, [context.instanceId, _loadChats]);
const _restoreChat = useCallback(async (chatId: string) => {
try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
_loadChats();
} catch (err) {
console.error('Failed to restore chat:', err);
}
}, [context.instanceId, _loadChats]);
const _isArchived = (chat: ChatItem) => chat.status === 'archived';
const _applyFilter = (chats: ChatItem[]) =>
chats.filter(c => filter === 'archived' ? _isArchived(c) : !_isArchived(c));
const _filteredGroups = groups const _filteredGroups = groups
.map(g => ({ .map(g => {
...g, let chats = _applyFilter(g.chats);
chats: search if (search) {
? g.chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase())) chats = chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase()));
: g.chats, }
})) return { ...g, chats };
})
.filter(g => g.chats.length > 0); .filter(g => g.chats.length > 0);
const _allChats = _filteredGroups const _allChats = _filteredGroups
.flatMap(g => g.chats) .flatMap(g => g.chats)
.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')); .sort((a, b) => {
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
return tb - ta;
});
const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0);
const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0);
const _renderChatItem = (chat: ChatItem, featureInstanceId: string) => {
const isActive = activeWorkflowId === chat.id;
const isEditing = editingId === chat.id;
const archived = _isArchived(chat);
const itemClassName = [
styles.chatItem,
isActive ? styles.chatItemActive : '',
archived ? styles.chatItemArchived : '',
].filter(Boolean).join(' ');
return (
<div
key={chat.id}
className={itemClassName}
onClick={() => {
if (!isEditing) onSelectChat?.(chat.id, featureInstanceId);
}}
draggable={!!onDragStart && !isEditing}
onDragStart={(e) => {
e.dataTransfer.setData('application/chat-id', chat.id);
e.dataTransfer.setData('text/plain', chat.label);
onDragStart?.(chat.id, e);
}}
>
{isEditing ? (
<input
ref={renameInputRef}
className={styles.renameInput}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={() => _commitRename(chat.id)}
onKeyDown={(e) => _handleRenameKeyDown(e, chat.id)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<>
<span className={styles.chatDate}>
{_formatRelativeTime(chat.updatedAt)}
</span>
<span
className={styles.chatLabel}
title={chat.label}
>
{chat.label}
</span>
<span className={styles.chatActions}>
{onRenameChat && (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _startEditing(chat); }}
title="Umbenennen"
>
</button>
)}
{archived ? (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _restoreChat(chat.id); }}
title="Wiederherstellen"
>
</button>
) : (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _archiveChat(chat.id); }}
title="Archivieren"
>
📦
</button>
)}
{onDeleteChat && (
<button
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
title="Löschen"
>
🗑
</button>
)}
</span>
</>
)}
</div>
);
};
if (loading) return <div className={styles.loading}>Lade Chats...</div>; if (loading) return <div className={styles.loading}>Lade Chats...</div>;
@ -108,6 +309,11 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
{onCreateNew && (
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title="Neuer Chat">
+
</button>
)}
<button <button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`} className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)} onClick={() => setFlatMode(!flatMode)}
@ -117,28 +323,26 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
</button> </button>
</div> </div>
<div className={styles.filterTabs}>
<button
className={`${styles.filterTab} ${filter === 'active' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('active')}
>
Aktiv ({_activeCount})
</button>
<button
className={`${styles.filterTab} ${filter === 'archived' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('archived')}
>
Archiv ({_archivedCount})
</button>
</div>
{flatMode ? ( {flatMode ? (
<div className={styles.flatList}> <div className={styles.flatList}>
{_allChats.map((chat) => ( {_allChats.map((chat) =>
<div _renderChatItem(chat, chat.featureInstanceId || context.instanceId),
key={chat.id} )}
className={styles.chatItem}
onClick={() => onSelectChat?.(chat.id, chat.featureInstanceId || context.instanceId)}
draggable={!!onDragStart}
onDragStart={(e) => {
e.dataTransfer.setData('application/chat-id', chat.id);
e.dataTransfer.setData('text/plain', chat.label);
onDragStart?.(chat.id, e);
}}
>
<span className={styles.chatLabel}>{chat.label}</span>
{chat.updatedAt && (
<span className={styles.chatDate}>
{new Date(chat.updatedAt).toLocaleDateString()}
</span>
)}
</div>
))}
</div> </div>
) : ( ) : (
<div className={styles.tree}> <div className={styles.tree}>
@ -158,27 +362,21 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
</div> </div>
{expandedGroups.has(group.featureInstanceId) && ( {expandedGroups.has(group.featureInstanceId) && (
<div className={styles.treeChildren}> <div className={styles.treeChildren}>
{group.chats.map((chat) => ( {group.chats.map((chat) =>
<div _renderChatItem(chat, group.featureInstanceId),
key={chat.id} )}
className={styles.chatItem}
onClick={() => onSelectChat?.(chat.id, group.featureInstanceId)}
draggable={!!onDragStart}
onDragStart={(e) => {
e.dataTransfer.setData('application/chat-id', chat.id);
e.dataTransfer.setData('text/plain', chat.label);
onDragStart?.(chat.id, e);
}}
>
<span className={styles.chatLabel}>{chat.label}</span>
</div>
))}
</div> </div>
)} )}
</div> </div>
))} ))}
</div> </div>
)} )}
{_allChats.length === 0 && (
<div className={styles.emptyState}>
{filter === 'archived' ? 'Keine archivierten Chats.' : 'Keine aktiven Chats.'}
</div>
)}
</div> </div>
); );
}; };

View file

@ -2,6 +2,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative;
} }
.loading, .loading,

View file

@ -1,26 +1,22 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import type { UdbContext } from './UnifiedDataBar'; import type { UdbContext } from './UnifiedDataBar';
import api from '../../api'; import api from '../../api';
import FolderTree from '../../components/FolderTree/FolderTree';
import type { FileNode } from '../../components/FolderTree/FolderTree';
import { useFileContext } from '../../contexts/FileContext';
import styles from './FilesTab.module.css'; import styles from './FilesTab.module.css';
interface FileEntry { interface FileEntry {
id: string; id: string;
fileName: string; fileName: string;
mimeType?: string; mimeType?: string;
fileSize?: number;
folderId?: string | null;
tags?: string[];
scope: string; scope: string;
neutralize: boolean; neutralize: boolean;
fileSize?: number;
} }
const _SCOPE_ICONS: Record<string, string> = {
personal: '\uD83D\uDC64',
featureInstance: '\uD83D\uDC65',
mandate: '\uD83C\uDFE2',
global: '\uD83C\uDF10',
};
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
interface FilesTabProps { interface FilesTabProps {
context: UdbContext; context: UdbContext;
onFileSelect?: (fileId: string) => void; onFileSelect?: (fileId: string) => void;
@ -29,6 +25,27 @@ interface FilesTabProps {
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => { const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
const [files, setFiles] = useState<FileEntry[]>([]); const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
folders,
refreshFolders,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleFileDelete,
handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext();
const _loadFiles = useCallback(async () => { const _loadFiles = useCallback(async () => {
setLoading(true); setLoading(true);
@ -40,9 +57,11 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
id: f.id, id: f.id,
fileName: f.fileName || f.name || 'unknown', fileName: f.fileName || f.name || 'unknown',
mimeType: f.mimeType, mimeType: f.mimeType,
fileSize: f.fileSize,
folderId: f.folderId ?? null,
tags: f.tags || [],
scope: f.scope || 'personal', scope: f.scope || 'personal',
neutralize: f.neutralize || false, neutralize: f.neutralize || false,
fileSize: f.fileSize,
})), })),
); );
} catch (err) { } catch (err) {
@ -56,73 +75,245 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
_loadFiles(); _loadFiles();
}, [_loadFiles]); }, [_loadFiles]);
const _cycleScope = async (file: FileEntry) => { const _folderNodes = useMemo(() =>
const currentIdx = _SCOPE_CYCLE.indexOf(file.scope); folders.map(f => ({
const nextScope = _SCOPE_CYCLE[(currentIdx + 1) % _SCOPE_CYCLE.length]; id: f.id,
name: f.name,
parentId: f.parentId ?? null,
})),
[folders],
);
const _fileNodes: FileNode[] = useMemo(() => {
let result = files;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(f =>
f.fileName.toLowerCase().includes(q)
|| (f.tags || []).some((t: string) => t.toLowerCase().includes(q)),
);
}
return result
.sort((a, b) => a.fileName.localeCompare(b.fileName))
.map(f => ({
id: f.id,
fileName: f.fileName,
mimeType: f.mimeType,
fileSize: f.fileSize,
folderId: f.folderId ?? null,
scope: f.scope,
neutralize: f.neutralize,
}));
}, [files, searchQuery]);
const _refreshAll = useCallback(() => {
_loadFiles();
refreshFolders();
}, [_loadFiles, refreshFolders]);
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return;
setUploading(true);
try { try {
await api.patch(`/api/files/${file.id}/scope`, { scope: nextScope }); for (const file of Array.from(fileList)) {
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, scope: nextScope } : f))); const formData = new FormData();
formData.append('file', file);
formData.append('featureInstanceId', context.instanceId);
await api.post('/api/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
_refreshAll();
} catch (err) {
console.error('File upload failed:', err);
} finally {
setUploading(false);
}
}, [context.instanceId, uploading, _refreshAll]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}
}, []);
const _handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const _handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (e.dataTransfer.files.length > 0) {
_uploadFiles(e.dataTransfer.files);
}
}, [_uploadFiles]);
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
_uploadFiles(e.target.files);
e.target.value = '';
}
}, [_uploadFiles]);
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await handleMoveFile(fileId, targetFolderId);
_loadFiles();
}, [handleMoveFile, _loadFiles]);
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await contextMoveFiles(fileIds, targetFolderId);
_loadFiles();
}, [contextMoveFiles, _loadFiles]);
const _onDeleteFolder = useCallback(async (folderId: string) => {
await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null);
_loadFiles();
}, [handleDeleteFolder, selectedFolderId, _loadFiles]);
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
await api.put(`/api/files/${fileId}`, { fileName: newName });
_loadFiles();
}, [_loadFiles]);
const _onDeleteFile = useCallback(async (fileId: string) => {
await handleFileDelete(fileId);
_loadFiles();
}, [handleFileDelete, _loadFiles]);
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
await api.post('/api/files/batch-delete', { fileIds });
_loadFiles();
}, [_loadFiles]);
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
refreshFolders();
_loadFiles();
}, [refreshFolders, _loadFiles]);
const _onScopeChange = useCallback(async (fileId: string, newScope: string) => {
setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, scope: newScope } : f)));
try {
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
} catch (err) { } catch (err) {
console.error('Failed to update scope:', err); console.error('Failed to update scope:', err);
_loadFiles();
} }
}; }, [_loadFiles]);
const _toggleNeutralize = async (file: FileEntry) => { const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, neutralize: newValue } : f)));
try { try {
await api.patch(`/api/files/${file.id}/neutralize`, { neutralize: !file.neutralize }); await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
setFiles(prev =>
prev.map(f => (f.id === file.id ? { ...f, neutralize: !f.neutralize } : f)),
);
} catch (err) { } catch (err) {
console.error('Failed to toggle neutralize:', err); console.error('Failed to toggle neutralize:', err);
_loadFiles();
} }
}; }, [_loadFiles]);
if (loading) return <div className={styles.loading}>Lade Dateien...</div>; if (loading) return <div className={styles.loading}>Lade Dateien...</div>;
return ( return (
<div className={styles.filesTab}> <div
{files.length === 0 ? ( className={styles.filesTab}
<div className={styles.empty}>Keine Dateien vorhanden</div> onDragOver={_handleDragOver}
) : ( onDragLeave={_handleDragLeave}
<div className={styles.fileList}> onDrop={_handleDrop}
{files.map((file) => ( >
<div {isDragOver && (
key={file.id} <div style={{
className={styles.fileRow} position: 'absolute', inset: 0,
onClick={() => onFileSelect?.(file.id)} background: 'rgba(25, 118, 210, 0.08)',
> border: '2px dashed #1976d2', borderRadius: 8,
<span className={styles.fileName}>{file.fileName}</span> zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
<div className={styles.fileIcons}> fontSize: 13, fontWeight: 600, color: '#1976d2',
<button }}>
className={styles.scopeIcon} Dateien hier ablegen
onClick={(e) => {
e.stopPropagation();
_cycleScope(file);
}}
title={`Scope: ${file.scope} (klicken zum Wechseln)`}
>
{_SCOPE_ICONS[file.scope] || '\uD83D\uDC64'}
</button>
<button
className={`${styles.neutralizeIcon} ${
file.neutralize ? styles.neutralizeActive : ''
}`}
onClick={(e) => {
e.stopPropagation();
_toggleNeutralize(file);
}}
title={file.neutralize ? 'Neutralisierung aktiv' : 'Neutralisierung aus'}
>
\uD83D\uDD12
</button>
</div>
</div>
))}
</div> </div>
)} )}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 8px' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
title="Upload files"
>
{uploading ? '...' : '+'}
</button>
<button
onClick={_refreshAll}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{'\u21BB'}
</button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={_handleFileInputChange}
/>
<input
type="text"
placeholder="Dateien suchen..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', boxSizing: 'border-box', margin: '0 0 4px',
}}
/>
<div style={{ flex: 1, overflow: 'auto' }}>
<FolderTree
folders={_folderNodes}
files={_fileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={onFileSelect}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={_onDeleteFolder}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_onMoveFile}
onMoveFiles={_onMoveFiles}
onRenameFile={_onRenameFile}
onDeleteFile={_onDeleteFile}
onDeleteFiles={_onDeleteFiles}
onDeleteFolders={_onDeleteFolders}
onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle}
/>
{_fileNodes.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
</div>
)}
</div>
<div className={styles.legend}> <div className={styles.legend}>
<span>{'\uD83D\uDC64'} Pers\u00F6nlich</span> <span>{'\uD83D\uDC64'} Persönlich</span>
<span>{'\uD83D\uDC65'} Instanz</span> <span>{'\uD83D\uDC65'} Instanz</span>
<span>{'\uD83C\uDFE2'} Mandant</span> <span>{'\uD83C\uDFE2'} Mandant</span>
<span>{'\uD83D\uDD12'} Neutralisiert</span> <span>{'\uD83D\uDD12'} Neutralisiert</span>

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import ChatsTab from './ChatsTab';
import FilesTab from './FilesTab';
import SourcesTab from './SourcesTab';
import styles from './UnifiedDataBar.module.css'; import styles from './UnifiedDataBar.module.css';
export type UdbTab = 'chats' | 'files' | 'sources'; export type UdbTab = 'chats' | 'files' | 'sources';
@ -14,10 +17,14 @@ interface UnifiedDataBarProps {
context: UdbContext; context: UdbContext;
activeTab?: UdbTab; activeTab?: UdbTab;
onTabChange?: (tab: UdbTab) => void; onTabChange?: (tab: UdbTab) => void;
renderChats?: (context: UdbContext) => React.ReactNode; hideTabs?: UdbTab[];
renderFiles?: (context: UdbContext) => React.ReactNode; onSelectChat?: (chatId: string, featureInstanceId: string) => void;
renderSources?: (context: UdbContext) => React.ReactNode; activeWorkflowId?: string;
onCreateNewChat?: () => void;
onRenameChat?: (chatId: string, newName: string) => void;
onDeleteChat?: (chatId: string) => void;
onChatDragStart?: (chatId: string, event: React.DragEvent) => void; onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
onFileSelect?: (fileId: string) => void;
className?: string; className?: string;
} }
@ -31,12 +38,20 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
context, context,
activeTab: controlledTab, activeTab: controlledTab,
onTabChange, onTabChange,
renderChats, hideTabs,
renderFiles, onSelectChat,
renderSources, activeWorkflowId,
onCreateNewChat,
onRenameChat,
onDeleteChat,
onChatDragStart,
onFileSelect,
className, className,
}) => { }) => {
const [internalTab, setInternalTab] = useState<UdbTab>('chats'); const visibleTabs = (['chats', 'files', 'sources'] as UdbTab[]).filter(
t => !hideTabs?.includes(t),
);
const [internalTab, setInternalTab] = useState<UdbTab>(controlledTab ?? visibleTabs[0] ?? 'chats');
const currentTab = controlledTab ?? internalTab; const currentTab = controlledTab ?? internalTab;
const _handleTabChange = (tab: UdbTab) => { const _handleTabChange = (tab: UdbTab) => {
@ -47,7 +62,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
return ( return (
<div className={`${styles.udb} ${className || ''}`}> <div className={`${styles.udb} ${className || ''}`}>
<div className={styles.tabBar}> <div className={styles.tabBar}>
{(['chats', 'files', 'sources'] as UdbTab[]).map((tab) => ( {visibleTabs.map((tab) => (
<button <button
key={tab} key={tab}
className={`${styles.tab} ${currentTab === tab ? styles.tabActive : ''}`} className={`${styles.tab} ${currentTab === tab ? styles.tabActive : ''}`}
@ -58,9 +73,26 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
))} ))}
</div> </div>
<div className={styles.tabContent}> <div className={styles.tabContent}>
{currentTab === 'chats' && renderChats?.(context)} {currentTab === 'chats' && !hideTabs?.includes('chats') && (
{currentTab === 'files' && renderFiles?.(context)} <ChatsTab
{currentTab === 'sources' && renderSources?.(context)} context={context}
onSelectChat={onSelectChat}
onDragStart={onChatDragStart}
activeWorkflowId={activeWorkflowId}
onCreateNew={onCreateNewChat}
onRenameChat={onRenameChat}
onDeleteChat={onDeleteChat}
/>
)}
{currentTab === 'files' && !hideTabs?.includes('files') && (
<FilesTab
context={context}
onFileSelect={onFileSelect}
/>
)}
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
<SourcesTab context={context} />
)}
</div> </div>
</div> </div>
); );

View file

@ -1,6 +1,3 @@
export { default as UnifiedDataBar } from './UnifiedDataBar'; export { default as UnifiedDataBar } from './UnifiedDataBar';
export type { UdbContext, UdbTab } from './UnifiedDataBar'; export type { UdbContext, UdbTab } from './UnifiedDataBar';
export { default as ChatsTab } from './ChatsTab';
export { default as FilesTab } from './FilesTab';
export { default as SourcesTab } from './SourcesTab';
export { useUdlContext } from './useUdlContext'; export { useUdlContext } from './useUdlContext';

View file

@ -66,6 +66,7 @@ export interface FeatureInstance {
uiLabel: string; uiLabel: string;
order: number; order: number;
views: FeatureView[]; views: FeatureView[];
isAdmin?: boolean;
} }
/** Feature within a mandate */ /** Feature within a mandate */

View file

@ -7,11 +7,12 @@
*/ */
import React from 'react'; import React from 'react';
import { Link, Navigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import useNavigation from '../hooks/useNavigation'; import useNavigation from '../hooks/useNavigation';
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation'; import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
import { getPageIcon } from '../config/pageRegistry'; import { getPageIcon } from '../config/pageRegistry';
import { FaArrowRight, FaBuilding } from 'react-icons/fa'; import { FaArrowRight, FaBuilding } from 'react-icons/fa';
import OnboardingAssistant from '../components/OnboardingAssistant';
import styles from './Dashboard.module.css'; import styles from './Dashboard.module.css';
// ============================================================================= // =============================================================================
@ -75,19 +76,19 @@ export const DashboardPage: React.FC = () => {
); );
} }
if (totalInstances === 0) {
return <Navigate to="/store" replace />;
}
return ( return (
<div className={styles.dashboard}> <div className={styles.dashboard}>
<header className={styles.header}> <header className={styles.header}>
<h1>Übersicht</h1> <h1>Übersicht</h1>
<p className={styles.subtitle}> {totalInstances > 0 && (
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}. <p className={styles.subtitle}>
</p> Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
</p>
)}
</header> </header>
<OnboardingAssistant />
<main className={styles.content}> <main className={styles.content}>
{mandates {mandates
.filter(mandate => mandate.features.some(f => f.instances.length > 0)) .filter(mandate => mandate.features.some(f => f.instances.length > 0))

View file

@ -17,12 +17,13 @@ import styles from './Settings.module.css';
// TYPES // TYPES
// ============================================================================= // =============================================================================
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy'; type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
const _TABS: { key: SettingsTab; label: string }[] = [ const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'profile', label: 'Profil' }, { key: 'profile', label: 'Profil' },
{ key: 'appearance', label: 'Darstellung' }, { key: 'appearance', label: 'Darstellung' },
{ key: 'voice', label: 'Stimme & Sprache' }, { key: 'voice', label: 'Stimme & Sprache' },
{ key: 'neutralization', label: 'Datenneutralisierung' },
{ key: 'privacy', label: 'Datenschutz' }, { key: 'privacy', label: 'Datenschutz' },
]; ];
@ -296,6 +297,116 @@ const VoiceSettingsTab: React.FC = () => {
); );
}; };
// =============================================================================
// NEUTRALIZATION MAPPINGS TAB
// =============================================================================
interface NeutralizationMapping {
id: string;
originalText: string;
patternType: string;
fileId?: string;
featureInstanceId?: string;
}
const NeutralizationMappingsTab: React.FC = () => {
const { request } = useApiRequest();
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const _load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result: any = await request({ url: '/api/local/neutralization-mappings', method: 'get' });
const items = (result?.mappings || []).map((m: any) => ({
id: m.id,
originalText: m.originalText || '',
patternType: m.patternType || '',
fileId: m.fileId,
featureInstanceId: m.featureInstanceId,
}));
setMappings(items);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden');
} finally {
setLoading(false);
}
}, [request]);
useEffect(() => { _load(); }, [_load]);
const _handleDelete = useCallback(async (id: string) => {
try {
await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' });
setMappings(prev => prev.filter(m => m.id !== id));
} catch (err: any) {
setError(err.message || 'Fehler beim Loeschen');
}
}, [request]);
const _maskText = (text: string) => {
if (text.length <= 4) return '****';
return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2);
};
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>Mappings werden geladen...</div>;
return (
<>
{error && <div className={styles.errorMessage}>{error}</div>}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Platzhalter-Mappings</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt.
Hier sehen Sie Ihre gespeicherten Mappings und koennen sie loeschen.
</p>
{mappings.length === 0 ? (
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
Keine Neutralisierungs-Mappings vorhanden.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Platzhalter-ID</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Originaltext</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Typ</th>
<th />
</tr>
</thead>
<tbody>
{mappings.map(m => (
<tr key={m.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
<td style={{ padding: '0.5rem', fontFamily: 'monospace', fontSize: '0.75rem' }}>{m.id.slice(0, 12)}...</td>
<td style={{ padding: '0.5rem' }}>{_maskText(m.originalText)}</td>
<td style={{ padding: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: 10, background: '#f3f4f6' }}>
{m.patternType}
</span>
</td>
<td style={{ padding: '0.5rem' }}>
<button
className={styles.button}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }}
onClick={() => _handleDelete(m.id)}
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
);
};
// ============================================================================= // =============================================================================
// SETTINGS PAGE // SETTINGS PAGE
// ============================================================================= // =============================================================================
@ -421,6 +532,8 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'voice' && <VoiceSettingsTab />} {activeTab === 'voice' && <VoiceSettingsTab />}
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
{activeTab === 'privacy' && ( {activeTab === 'privacy' && (
<section className={styles.section}> <section className={styles.section}>
<h2 className={styles.sectionTitle}>Datenschutz</h2> <h2 className={styles.sectionTitle}>Datenschutz</h2>

View file

@ -211,6 +211,9 @@
/* Actions */ /* Actions */
.cardActions { .cardActions {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e0e0e0); border-top: 1px solid var(--border-color, #e0e0e0);
} }

View file

@ -1,12 +1,10 @@
/** /**
* Store Page * Feature Store -- Users activate feature instances in their own mandates.
* * Uses the Own Instance Pattern -- each activation creates a dedicated FeatureInstance
* Feature Store where users can self-activate features in the root mandate. * in the selected mandate. Explicit mandate selection required.
* Uses the Shared Instance Pattern -- each feature has one shared instance,
* and users get their own FeatureAccess + user-role upon activation.
*/ */
import React, { useState } from 'react'; import React from 'react';
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'; import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useStore } from '../hooks/useStore'; import { useStore } from '../hooks/useStore';
@ -76,22 +74,10 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
onActivate, onActivate,
onDeactivate, onDeactivate,
}) => { }) => {
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const isProcessing = actionLoading === feature.featureCode; const isProcessing = actionLoading === feature.featureCode;
const icon = FEATURE_ICONS[feature.featureCode]; const icon = FEATURE_ICONS[feature.featureCode];
const activeInstances = feature.instances.filter(inst => inst.isActive); const activeInstances = feature.instances.filter(inst => inst.isActive);
const hasActive = activeInstances.length > 0; const hasActive = activeInstances.length > 0;
const needsMandateSelection = mandates.length > 1;
const _handleActivate = () => {
if (needsMandateSelection) {
onActivate(feature.featureCode, selectedMandateId || undefined);
} else if (mandates.length === 1) {
onActivate(feature.featureCode, mandates[0].id);
} else {
onActivate(feature.featureCode);
}
};
return ( return (
<div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}> <div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}>
@ -142,43 +128,22 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
)} )}
<div className={styles.cardActions}> <div className={styles.cardActions}>
{feature.canActivate && ( {feature.canActivate && mandates.map((m) => (
<> <button
{mandates.length === 0 && ( key={m.id}
<p className={styles.mandateHint}> className={styles.activateButton}
{language === 'de' onClick={() => onActivate(feature.featureCode, m.id)}
? 'Ein persoenliches Konto wird automatisch erstellt.' disabled={isProcessing}
>
{isProcessing
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
: (language === 'de'
? `Aktivieren fuer ${m.label || m.name}`
: language === 'fr' : language === 'fr'
? 'Un compte personnel sera cree automatiquement.' ? `Activer pour ${m.label || m.name}`
: 'A personal account will be created automatically.'} : `Activate for ${m.label || m.name}`)}
</p> </button>
)} ))}
{needsMandateSelection && (
<select
className={styles.mandateSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
disabled={isProcessing}
>
<option value="">
{language === 'de' ? '-- Mandant waehlen --' : language === 'fr' ? '-- Choisir mandat --' : '-- Select mandate --'}
</option>
{mandates.map((m) => (
<option key={m.id} value={m.id}>{m.label || m.name}</option>
))}
</select>
)}
<button
className={styles.activateButton}
onClick={_handleActivate}
disabled={isProcessing || (needsMandateSelection && !selectedMandateId)}
>
{isProcessing
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
</button>
</>
)}
</div> </div>
</div> </div>
); );

View file

@ -228,7 +228,7 @@ export const ConnectionsPage: React.FC = () => {
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Verbindungen</h1> <h1 className={styles.pageTitle}>Verbindungen</h1>
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten</p> <p className={styles.pageSubtitle}>Persönliche Datenanbindungen verwalten</p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button <button

View file

@ -19,8 +19,8 @@ import {
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll'; import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { UnifiedDataBar, FilesTab, SourcesTab } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './CommcoachDossierView.module.css'; import styles from './CommcoachDossierView.module.css';
import { useVoiceController } from './useVoiceController'; import { useVoiceController } from './useVoiceController';
@ -38,6 +38,7 @@ export const CommcoachDossierView: React.FC = () => {
const [newDescription, setNewDescription] = useState(''); const [newDescription, setNewDescription] = useState('');
const [newCategory, setNewCategory] = useState('custom'); const [newCategory, setNewCategory] = useState('custom');
const [udbCollapsed, setUdbCollapsed] = useState(false); const [udbCollapsed, setUdbCollapsed] = useState(false);
const [udbTab, setUdbTab] = useState<UdbTab>('files');
const [newTaskTitle, setNewTaskTitle] = useState(''); const [newTaskTitle, setNewTaskTitle] = useState('');
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({}); const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
@ -161,10 +162,9 @@ export const CommcoachDossierView: React.FC = () => {
{!udbCollapsed && ( {!udbCollapsed && (
<UnifiedDataBar <UnifiedDataBar
context={_udbContext} context={_udbContext}
activeTab="files" activeTab={udbTab}
renderChats={() => null} onTabChange={setUdbTab}
renderFiles={(ctx) => <FilesTab context={ctx} />} hideTabs={['chats']}
renderSources={(ctx) => <SourcesTab context={ctx} />}
/> />
)} )}
</div> </div>
@ -523,6 +523,8 @@ export const CommcoachDossierView: React.FC = () => {
)} )}
</>)} </>)}
{/* #region agent log */} {/* #region agent log */}
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}> <div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
<button <button

View file

@ -171,6 +171,18 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
</div> </div>
))} ))}
</div> </div>
{(msg as any).neutralizationExcluded?.length > 0 && (
<div style={{ marginTop: 6, padding: '6px 8px', background: '#fef2f2', borderRadius: 4, border: '1px solid #fecaca' }}>
<div style={{ fontWeight: 600, color: '#991b1b', marginBottom: 4 }}>
Nicht gesendet (Neutralisierung fehlgeschlagen):
</div>
{(msg as any).neutralizationExcluded.map((docName: string, i: number) => (
<div key={i} style={{ fontSize: '0.75rem', color: '#991b1b', paddingLeft: 4 }}>
{docName}
</div>
))}
</div>
)}
</details> </details>
)} )}
</div> </div>

View file

@ -1,438 +0,0 @@
/**
* ConversationList -- Shows all workspace workflows/conversations.
*
* Features: filter, rename (double-click), delete, archive, create new,
* pagination (20 per page), last-activity display.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import api from '../../../api';
const _PAGE_SIZE = 20;
interface Conversation {
id: string;
name: string;
status: string;
startedAt?: number;
lastActivity?: number;
}
interface ConversationListProps {
instanceId: string;
activeWorkflowId: string | null;
onSelect: (workflowId: string) => void;
onCreateNew?: () => void;
refreshTrigger?: number;
}
export const ConversationList: React.FC<ConversationListProps> = ({
instanceId,
activeWorkflowId,
onSelect,
onCreateNew,
refreshTrigger,
}) => {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [filterQuery, setFilterQuery] = useState('');
const [page, setPage] = useState(0);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'active' | 'archived'>('active');
const inputRef = useRef<HTMLInputElement>(null);
const _loadConversations = useCallback(() => {
if (!instanceId) return;
setLoading(true);
api.get(`/api/workspace/${instanceId}/workflows`, { params: { includeArchived: true } })
.then(res => {
const items = (res.data.workflows || res.data || [])
.map((w: any) => ({
id: w.id,
name: w.name || w.label || 'Untitled',
status: w.status || 'unknown',
startedAt: w.startedAt || w.createdAt,
lastActivity: w.lastActivity || w.updatedAt || w.startedAt,
}))
.sort((a: Conversation, b: Conversation) =>
(b.lastActivity || 0) - (a.lastActivity || 0),
);
setConversations(items);
})
.catch(() => setConversations([]))
.finally(() => setLoading(false));
}, [instanceId]);
useEffect(() => {
_loadConversations();
}, [_loadConversations]);
useEffect(() => {
if (refreshTrigger) _loadConversations();
}, [refreshTrigger, _loadConversations]);
useEffect(() => {
if (activeWorkflowId && !conversations.find(c => c.id === activeWorkflowId)) {
_loadConversations();
}
}, [activeWorkflowId, conversations, _loadConversations]);
useEffect(() => {
if (editingId && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingId]);
const _formatTime = (ts?: number): string => {
if (!ts) return '';
const d = new Date(ts * 1000);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
if (diffDays === 1) return 'Gestern';
if (diffDays < 7) return `vor ${diffDays}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
const _formatDate = (ts?: number): string => {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const _startEditing = (conv: Conversation) => {
setEditingId(conv.id);
setEditName(conv.name);
};
const _commitRename = (convId: string) => {
const trimmed = editName.trim();
if (!trimmed) {
setEditingId(null);
return;
}
setConversations(prev =>
prev.map(c => c.id === convId ? { ...c, name: trimmed } : c),
);
setEditingId(null);
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { name: trimmed })
.catch(() => _loadConversations());
};
const _handleKeyDown = (e: React.KeyboardEvent, convId: string) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitRename(convId);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
const _handleDelete = (convId: string) => {
setConversations(prev => prev.filter(c => c.id !== convId));
if (activeWorkflowId === convId) onSelect('');
api.delete(`/api/workspace/${instanceId}/workflows/${convId}`)
.catch(() => _loadConversations());
};
const _handleArchive = (convId: string) => {
setConversations(prev => prev.map(c =>
c.id === convId ? { ...c, status: 'archived' } : c,
));
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'archived' })
.catch(() => _loadConversations());
};
const _handleReactivate = (convId: string) => {
setConversations(prev => prev.map(c =>
c.id === convId ? { ...c, status: 'active' } : c,
));
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'active' })
.catch(() => _loadConversations());
};
const _handleCreateNew = () => {
if (onCreateNew) onCreateNew();
};
const _filtered = (items: Conversation[], query: string): Conversation[] => {
if (!query.trim()) return items;
const q = query.toLowerCase();
return items.filter(c =>
c.name.toLowerCase().includes(q) || c.status.toLowerCase().includes(q),
);
};
const _byStatus = viewMode === 'archived'
? conversations.filter(c => c.status === 'archived')
: conversations.filter(c => c.status !== 'archived');
const filtered = _filtered(_byStatus, filterQuery);
const totalPages = Math.ceil(filtered.length / _PAGE_SIZE);
const paginated = filtered.slice(page * _PAGE_SIZE, (page + 1) * _PAGE_SIZE);
const _archivedCount = conversations.filter(c => c.status === 'archived').length;
const _activeCount = conversations.filter(c => c.status !== 'archived').length;
useEffect(() => { setPage(0); }, [filterQuery, viewMode]);
return (
<div style={{ padding: 8 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Conversations</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={_handleCreateNew}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#1976d2' }}
title="Neuer Chat"
>
+
</button>
<button
onClick={_loadConversations}
disabled={loading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{loading ? '...' : '\u21BB'}
</button>
</div>
</div>
{/* View mode toggle */}
<div style={{ display: 'flex', marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #ddd' }}>
<button
onClick={() => setViewMode('active')}
style={{
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
background: viewMode === 'active' ? 'var(--primary-color, #1976d2)' : 'transparent',
color: viewMode === 'active' ? '#fff' : '#888',
transition: 'background 0.15s, color 0.15s',
}}
>
Aktiv {_activeCount > 0 && <span style={{ fontWeight: 400 }}>({_activeCount})</span>}
</button>
<button
onClick={() => setViewMode('archived')}
style={{
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
borderLeft: '1px solid #ddd',
background: viewMode === 'archived' ? '#ff9800' : 'transparent',
color: viewMode === 'archived' ? '#fff' : '#888',
transition: 'background 0.15s, color 0.15s',
}}
>
Archiv {_archivedCount > 0 && <span style={{ fontWeight: 400 }}>({_archivedCount})</span>}
</button>
</div>
{/* Filter */}
{filtered.length > 3 && (
<input
type="text"
placeholder="Filter chats..."
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box',
}}
/>
)}
{/* Empty state */}
{filtered.length === 0 && !loading && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{viewMode === 'archived'
? 'Keine archivierten Chats.'
: 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'}
</div>
)}
{/* List */}
{paginated.map(conv => {
const isActive = conv.id === activeWorkflowId;
const isEditing = editingId === conv.id;
return (
<div
key={conv.id}
onClick={() => { if (!isEditing) onSelect(conv.id); }}
style={{
padding: '8px 10px',
marginBottom: 4,
borderRadius: 6,
cursor: isEditing ? 'default' : 'pointer',
background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent',
border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent',
transition: 'background 0.15s',
position: 'relative',
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.background = '#f5f5f5';
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
if (actions) actions.style.opacity = '1';
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.background = 'transparent';
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
if (actions) actions.style.opacity = '0';
if (confirmDeleteId === conv.id) setConfirmDeleteId(null);
}}
>
{/* Name row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 4 }}>
{isEditing ? (
<input
ref={inputRef}
value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={() => _commitRename(conv.id)}
onKeyDown={e => _handleKeyDown(e, conv.id)}
onClick={e => e.stopPropagation()}
style={{
flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600,
padding: '1px 4px', borderRadius: 3,
border: '1px solid var(--primary-color, #1976d2)',
outline: 'none', background: '#fff',
}}
/>
) : (
<>
<span
style={{ fontSize: 10, color: '#aaa', flexShrink: 0, marginRight: 6 }}
title={_formatDate(conv.lastActivity)}
>
{_formatTime(conv.lastActivity)}
</span>
<span
style={{
fontSize: 13,
fontWeight: isActive ? 600 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
onDoubleClick={(e) => { e.stopPropagation(); _startEditing(conv); }}
title={conv.name}
>
{conv.name}
</span>
</>
)}
{/* Action buttons (visible on hover) */}
{!isEditing && (
<span
data-actions=""
style={{ display: 'flex', gap: 2, opacity: 0, transition: 'opacity 0.15s', flexShrink: 0 }}
>
<button
onClick={e => { e.stopPropagation(); _startEditing(conv); }}
style={_actionBtnStyle}
title="Umbenennen"
>
&#x270E;
</button>
{conv.status === 'archived' ? (
<button
onClick={e => { e.stopPropagation(); _handleReactivate(conv.id); }}
style={{ ..._actionBtnStyle, color: '#4caf50' }}
title="Reaktivieren"
>
&#x21A9;
</button>
) : (
<button
onClick={e => { e.stopPropagation(); _handleArchive(conv.id); }}
style={_actionBtnStyle}
title="Archivieren"
>
&#x1F4E6;
</button>
)}
{confirmDeleteId === conv.id ? (
<span style={{
display: 'inline-flex', gap: 1, background: 'var(--color-secondary, #555)',
borderRadius: 12, padding: '1px 2px', alignItems: 'center',
}}>
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); _handleDelete(conv.id); }}
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
title="Ja, loeschen"
>
&#x2713;
</button>
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); }}
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
title="Abbrechen"
>
&#x2717;
</button>
</span>
) : (
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteId(conv.id); }}
style={{ ..._actionBtnStyle, color: '#d32f2f' }}
title="Loeschen"
>
&#x1F5D1;
</button>
)}
</span>
)}
</div>
</div>
);
})}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, marginTop: 8, fontSize: 12 }}>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
style={{ ..._pageBtnStyle, opacity: page === 0 ? 0.3 : 1 }}
>
&lt;
</button>
<span style={{ color: '#888' }}>{page + 1} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
style={{ ..._pageBtnStyle, opacity: page >= totalPages - 1 ? 0.3 : 1 }}
>
&gt;
</button>
</div>
)}
</div>
);
};
const _actionBtnStyle: React.CSSProperties = {
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 11,
color: '#999',
padding: '0 2px',
};
const _pageBtnStyle: React.CSSProperties = {
background: 'none',
border: '1px solid #ddd',
borderRadius: 4,
cursor: 'pointer',
padding: '2px 8px',
color: '#666',
};

View file

@ -1,942 +0,0 @@
/**
* DataSourcePanel -- Browse external data sources as a lazy-loading tree.
*
* Tree structure:
* UserConnection (Level 1, loaded on mount)
* Service (Level 2, loaded when connection expanded)
* Folder / Site / File (Level 3+, loaded when service/folder expanded)
*
* Each folder node can be added as a DataSource for this workspace instance.
*/
import React, { useEffect, useState, useCallback, useRef } from 'react';
import api from '../../../api';
import { getPageIcon } from '../../../config/pageRegistry';
import type { DataSource, FeatureDataSource } from './useWorkspace';
/* ─── Types ─────────────────────────────────────────────────────────── */
interface TreeNode {
key: string;
label: string;
icon: string;
type: 'connection' | 'service' | 'folder' | 'file';
expanded: boolean;
loading: boolean;
children: TreeNode[] | null;
connectionId: string;
service?: string;
path?: string;
/** Breadcrumb for tooltips and persisted displayPath (service + folder segments) */
displayPath?: string;
authority?: string;
}
interface FeatureConnectionNode {
featureInstanceId: string;
featureCode: string;
mandateId?: string;
label: string;
icon: string;
tableCount: number;
expanded: boolean;
loading: boolean;
tables: FeatureTableNode[] | null;
}
interface MandateGroupNode {
mandateId: string;
mandateLabel: string;
expanded: boolean;
featureConnections: FeatureConnectionNode[];
}
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 ─────────────────────────────────────────────────────────── */
const _AUTHORITY_ICONS: Record<string, string> = {
msft: '\uD83D\uDFE6',
google: '\uD83D\uDFE9',
'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27',
};
const _SERVICE_ICONS: Record<string, string> = {
sharepoint: '\uD83D\uDCC1',
onedrive: '\u2601\uFE0F',
outlook: '\uD83D\uDCE7',
teams: '\uD83D\uDCAC',
drive: '\uD83D\uDCC2',
gmail: '\uD83D\uDCE8',
files: '\uD83D\uDCC2',
};
/* ─── Source colors & icons ──────────────────────────────────────────── */
const _SOURCE_COLORS: Record<string, string> = {
sharepointFolder: '#0078d4',
onedriveFolder: '#0078d4',
outlookFolder: '#0078d4',
googleDriveFolder: '#34a853',
gmailFolder: '#ea4335',
ftpFolder: '#795548',
};
function _getSourceColor(sourceType: string): string {
return _SOURCE_COLORS[sourceType] || '#1976d2';
}
function _getSourceIcon(sourceType: string): string {
const map: Record<string, string> = {
sharepointFolder: '\uD83D\uDCC1',
onedriveFolder: '\u2601\uFE0F',
outlookFolder: '\uD83D\uDCE7',
googleDriveFolder: '\uD83D\uDCC2',
gmailFolder: '\uD83D\uDCE8',
ftpFolder: '\uD83D\uDD17',
};
return map[sourceType] || '\uD83D\uDCC1';
}
function _mapFeatureTreeUpdate(
prev: MandateGroupNode[],
featureInstanceId: string,
updater: (n: FeatureConnectionNode) => FeatureConnectionNode,
): MandateGroupNode[] {
return prev.map(g => ({
...g,
featureConnections: g.featureConnections.map(n =>
n.featureInstanceId === featureInstanceId ? updater(n) : n
),
}));
}
function _findFeatureInstanceMeta(
groups: MandateGroupNode[],
featureInstanceId: string,
): { mandateLabel: string; instanceLabel: string } | null {
for (const g of groups) {
const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId);
if (fc) return { mandateLabel: g.mandateLabel, instanceLabel: fc.label };
}
return null;
}
function _personalDataSourceHoverTitle(connLabel: string, ds: DataSource): string {
const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || '';
return pathPart ? `${connLabel} / ${pathPart}` : connLabel;
}
function _featureDataSourceHoverTitle(
meta: { mandateLabel: string; instanceLabel: string } | null,
fds: FeatureDataSource,
): string {
const parts: string[] = [];
if (meta) {
parts.push(meta.mandateLabel, meta.instanceLabel);
}
const labelPart = fds.label && fds.tableName && fds.label !== fds.tableName
? `${fds.label} (${fds.tableName})`
: (fds.label || fds.tableName);
parts.push(labelPart);
if (fds.objectKey && fds.objectKey !== labelPart && !labelPart.includes(fds.objectKey)) {
parts.push(fds.objectKey);
}
return parts.join(' / ');
}
/* ─── Component ─────────────────────────────────────────────────────── */
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<MandateGroupNode[]>([]);
const [loadingFeatures, setLoadingFeatures] = useState(false);
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
/* ── Load Level 1: UserConnections ── */
const _loadConnections = useCallback(() => {
if (!instanceId) return;
setLoadingRoot(true);
api.get(`/api/workspace/${instanceId}/connections`)
.then(res => {
if (!mountedRef.current) return;
const conns = res.data.connections || [];
const nodes: TreeNode[] = conns
.filter((c: any) => c.status === 'active')
.map((c: any) => ({
key: `conn-${c.id}`,
label: c.externalEmail || c.externalUsername || c.authority,
icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17',
type: 'connection' as const,
expanded: false,
loading: false,
children: null,
connectionId: c.id,
authority: c.authority,
}));
setTree(nodes);
})
.catch(() => { if (mountedRef.current) setTree([]); })
.finally(() => { if (mountedRef.current) setLoadingRoot(false); });
}, [instanceId]);
useEffect(() => { _loadConnections(); }, [_loadConnections]);
/* ── Generic tree update helper ── */
const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => {
setTree(prev => _mapTree(prev, key, updater));
}, []);
/* ── Toggle expand/collapse ── */
const _toggleNode = useCallback(async (node: TreeNode) => {
if (node.expanded) {
_updateNode(node.key, n => ({ ...n, expanded: false }));
return;
}
if (node.children !== null) {
_updateNode(node.key, n => ({ ...n, expanded: true }));
return;
}
_updateNode(node.key, n => ({ ...n, loading: true, expanded: true }));
try {
let children: TreeNode[] = [];
if (node.type === 'connection') {
children = await _loadServices(instanceId, node.connectionId);
} else if (node.type === 'service' || node.type === 'folder') {
children = await _browseService(
instanceId,
node.connectionId,
node.service!,
node.path || '/',
node.displayPath || node.label,
);
}
if (mountedRef.current) {
_updateNode(node.key, n => ({ ...n, loading: false, children }));
}
} catch {
if (mountedRef.current) {
_updateNode(node.key, n => ({ ...n, loading: false, children: [] }));
}
}
}, [instanceId, _updateNode]);
/* ── Add as DataSource ── */
const _addAsDataSource = useCallback(async (node: TreeNode) => {
if (!node.service || !node.connectionId) return;
setAddingPath(node.key);
try {
const sourceTypeMap: Record<string, string> = {
sharepoint: 'sharepointFolder',
onedrive: 'onedriveFolder',
outlook: 'outlookFolder',
drive: 'googleDriveFolder',
gmail: 'gmailFolder',
files: 'ftpFolder',
};
await api.post(`/api/workspace/${instanceId}/datasources`, {
connectionId: node.connectionId,
sourceType: sourceTypeMap[node.service] || node.service,
path: node.path || '/',
label: node.label,
displayPath: node.displayPath || node.label,
});
onRefresh();
} catch (err) {
console.error('Failed to add data source:', err);
} finally {
if (mountedRef.current) setAddingPath(null);
}
}, [instanceId, onRefresh]);
/* ── Remove DataSource ── */
const _removeDatasource = useCallback(async (dsId: string) => {
try {
await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`);
onRefresh();
} catch (err) {
console.error('Failed to remove data source:', err);
}
}, [instanceId, onRefresh]);
/* ── Check if a path is already added ── */
const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => {
return dataSources.some(ds =>
ds.connectionId === connectionId && ds.path === (path || '/'),
);
}, [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 groups = res.data.featureConnectionsByMandate || [];
setFeatureTree(groups.map((g: any) => ({
mandateId: g.mandateId,
mandateLabel: g.mandateLabel || g.mandateId,
expanded: true,
featureConnections: (g.featureConnections || []).map((c: any) => ({
featureInstanceId: c.featureInstanceId,
featureCode: c.featureCode,
mandateId: c.mandateId,
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 mandate group ── */
const _toggleMandateGroup = useCallback((mandateId: string) => {
setFeatureTree(prev => prev.map(g =>
g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g
));
}, []);
/* ── Feature Connections: Toggle expand ── */
const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => {
if (node.expanded) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false })));
return;
}
if (node.tables !== null) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true })));
return;
}
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
...n, loading: true, expanded: true,
})));
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 => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
...n, loading: false, tables,
})));
}
} catch {
if (mountedRef.current) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
...n, loading: false, tables: [],
})));
}
}
}, [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 */}
{dataSources.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
Active Personal Sources
</div>
{dataSources.map(ds => {
const connColor = _getSourceColor(ds.sourceType);
const connNode = tree.find(n => n.connectionId === ds.connectionId);
const connLabel = connNode?.label || ds.connectionId;
const folder = ds.label || ds.path || ds.id;
return (
<div key={ds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: `${connColor}18`,
borderLeft: `3px solid ${connColor}`,
fontSize: 12,
}} title={_personalDataSourceHoverTitle(connLabel, ds)}>
<span style={{ fontSize: 12, flexShrink: 0 }}>{_getSourceIcon(ds.sourceType)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{connLabel} {folder}
</span>
<button
onClick={() => _removeDatasource(ds.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>
)}
{/* Tree header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Browse Sources
</span>
<button
onClick={_loadConnections}
disabled={loadingRoot}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{loadingRoot ? '...' : '\u21BB'}
</button>
</div>
{/* Tree */}
{loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading connections...
</div>
)}
{!loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
No active connections found.
</div>
)}
{tree.map(node => (
<_TreeNodeView
key={node.key}
node={node}
depth={0}
onToggle={_toggleNode}
onAdd={_addAsDataSource}
isAdded={_isAdded}
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 meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
const fdsConnLabel = meta?.instanceLabel || fds.tableName;
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={_featureDataSourceHoverTitle(meta, fds)}>
<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(g => (
<_MandateGroupView
key={g.mandateId}
group={g}
onToggleGroup={_toggleMandateGroup}
onToggleFeature={_toggleFeatureNode}
onAddTable={_addFeatureTable}
isTableAdded={_isFeatureTableAdded}
addingKey={addingFeatureKey}
/>
))}
</div>
);
};
/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */
interface TreeNodeViewProps {
node: TreeNode;
depth: number;
onToggle: (node: TreeNode) => void;
onAdd: (node: TreeNode) => void;
isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean;
addingPath: string | null;
}
const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
node, depth, onToggle, onAdd, isAdded, addingPath,
}) => {
const [hovered, setHovered] = useState(false);
const hasChildren = node.type !== 'file';
const chevron = hasChildren
? (node.expanded ? '\u25BE' : '\u25B8')
: '\u00A0\u00A0';
const canAdd = node.type === 'folder' || node.type === 'service';
const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path);
const isAdding = addingPath === node.key;
return (
<div>
<div
onClick={() => { if (hasChildren) onToggle(node); }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
paddingLeft: depth * 16 + 4,
paddingRight: 4,
paddingTop: 3,
paddingBottom: 3,
cursor: hasChildren ? 'pointer' : 'default',
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: 14, flexShrink: 0 }}>{node.icon}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 12,
fontWeight: node.type === 'connection' ? 600 : 400,
}}>
{node.label}
</span>
{canAdd && hovered && !alreadyAdded && (
<button
onClick={e => { e.stopPropagation(); onAdd(node); }}
disabled={isAdding}
style={{
background: 'none', border: '1px solid #1976d2', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
fontSize: 10, color: '#1976d2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1,
flexShrink: 0,
}}
title="Add as data source"
>
{isAdding ? '...' : '+ Add'}
</button>
)}
{canAdd && alreadyAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
{'\u2713'}
</span>
)}
</div>
{/* Children */}
{node.expanded && node.children && node.children.length > 0 && (
<div>
{node.children.map(child => (
<_TreeNodeView
key={child.key}
node={child}
depth={depth + 1}
onToggle={onToggle}
onAdd={onAdd}
isAdded={isAdded}
addingPath={addingPath}
/>
))}
</div>
)}
{node.expanded && node.children && node.children.length === 0 && !node.loading && (
<div style={{ paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb', padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px' }}>
(empty)
</div>
)}
</div>
);
};
/* ─── MandateGroupView (mandate + feature instances) ───────────────── */
interface MandateGroupViewProps {
group: MandateGroupNode;
onToggleGroup: (mandateId: string) => void;
onToggleFeature: (node: FeatureConnectionNode) => void;
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
isTableAdded: (featureInstanceId: string, tableName: string) => boolean;
addingKey: string | null;
}
const _MandateGroupView: React.FC<MandateGroupViewProps> = ({
group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey,
}) => {
const [hovered, setHovered] = useState(false);
const chevron = group.expanded ? '\u25BE' : '\u25B8';
return (
<div>
<div
onClick={() => onToggleGroup(group.mandateId)}
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 }}>
{chevron}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 700, color: '#555' }}>
{group.mandateLabel}
</span>
</div>
{group.expanded && (
<div style={{ paddingLeft: 10 }}>
{group.featureConnections.map(fNode => (
<_FeatureNodeView
key={fNode.featureInstanceId}
node={fNode}
onToggle={onToggleFeature}
onAddTable={onAddTable}
isTableAdded={isTableAdded}
addingKey={addingKey}
/>
))}
</div>
)}
</div>
);
};
/* ─── 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 {
return (
<span style={{
display: 'inline-block', width: 10, height: 10,
border: '1.5px solid #ccc', borderTopColor: '#1976d2',
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
}} />
);
}
/* ─── Data fetching ─────────────────────────────────────────────────── */
async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
const services = res.data.services || [];
return services.map((s: any) => ({
key: `svc-${connectionId}-${s.service}`,
label: s.label || s.service,
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
type: 'service' as const,
expanded: false,
loading: false,
children: null,
connectionId,
service: s.service,
path: '/',
displayPath: s.label || s.service,
}));
}
async function _browseService(
instanceId: string,
connectionId: string,
service: string,
path: string,
parentDisplayPath: string | undefined,
): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
params: { service, path },
});
const items = res.data.items || [];
return items.map((entry: any, idx: number) => {
const seg = entry.name || '';
const displayPath = parentDisplayPath
? `${parentDisplayPath} / ${seg}`
: seg;
return {
key: `item-${connectionId}-${service}-${entry.path || idx}`,
label: entry.name,
icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
type: entry.isFolder ? 'folder' as const : 'file' as const,
expanded: false,
loading: false,
children: entry.isFolder ? null : [],
connectionId,
service,
path: entry.path,
displayPath,
};
});
}
function _fileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
};
return map[ext] || '\uD83D\uDCC4';
}
/* ─── Tree map utility ──────────────────────────────────────────────── */
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
return nodes.map(n => {
if (n.key === key) return updater(n);
if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
return n;
});
}

View file

@ -1,252 +0,0 @@
/**
* FileBrowser -- Folder-tree file browser for workspace.
*
* Uses useFileContext() for folders (shared state with Dateien page).
* Uses FolderTree with showFiles=true so folders and files render inline.
*/
import React, { useState, useCallback, useRef, useMemo } from 'react';
import api from '../../../api';
import FolderTree from '../../../components/FolderTree/FolderTree';
import type { FileNode } from '../../../components/FolderTree/FolderTree';
import { useFileContext } from '../../../contexts/FileContext';
import type { WorkspaceFile } from './useWorkspace';
interface FileBrowserProps {
instanceId: string;
files: WorkspaceFile[];
onRefresh: () => void;
onFileSelect?: (fileId: string) => void;
}
export const FileBrowser: React.FC<FileBrowserProps> = ({
instanceId,
files,
onRefresh,
onFileSelect,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
folders,
refreshFolders,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleFileDelete,
handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext();
const _folderNodes = useMemo(() =>
folders.map(f => ({
id: f.id,
name: f.name,
parentId: f.parentId ?? null,
})),
[folders],
);
const _fileNodes: FileNode[] = useMemo(() => {
let result: WorkspaceFile[] = files;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(f =>
f.fileName.toLowerCase().includes(q)
|| (f.tags || []).some((t: string) => t.toLowerCase().includes(q)),
);
}
return result
.sort((a, b) => a.fileName.localeCompare(b.fileName))
.map(f => ({
id: f.id,
fileName: f.fileName,
mimeType: f.mimeType,
fileSize: f.fileSize,
folderId: f.folderId ?? null,
}));
}, [files, searchQuery]);
const _refreshAll = useCallback(() => {
onRefresh();
refreshFolders();
}, [onRefresh, refreshFolders]);
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!instanceId || uploading) return;
setUploading(true);
try {
for (const file of Array.from(fileList)) {
const formData = new FormData();
formData.append('file', file);
formData.append('featureInstanceId', instanceId);
await api.post('/api/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
_refreshAll();
} catch (err) {
console.error('File upload failed:', err);
} finally {
setUploading(false);
}
}, [instanceId, uploading, _refreshAll]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}
}, []);
const _handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const _handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (e.dataTransfer.files.length > 0) {
_uploadFiles(e.dataTransfer.files);
}
}, [_uploadFiles]);
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
_uploadFiles(e.target.files);
e.target.value = '';
}
}, [_uploadFiles]);
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await handleMoveFile(fileId, targetFolderId);
onRefresh();
}, [handleMoveFile, onRefresh]);
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await contextMoveFiles(fileIds, targetFolderId);
onRefresh();
}, [contextMoveFiles, onRefresh]);
const _onDeleteFolder = useCallback(async (folderId: string) => {
await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null);
onRefresh();
}, [handleDeleteFolder, selectedFolderId, onRefresh]);
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
await api.put(`/api/files/${fileId}`, { fileName: newName });
onRefresh();
}, [onRefresh]);
const _onDeleteFile = useCallback(async (fileId: string) => {
await handleFileDelete(fileId);
onRefresh();
}, [handleFileDelete, onRefresh]);
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
await api.post('/api/files/batch-delete', { fileIds });
onRefresh();
}, [onRefresh]);
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
refreshFolders();
onRefresh();
}, [refreshFolders, onRefresh]);
return (
<div
style={{ padding: 8, position: 'relative', display: 'flex', flexDirection: 'column', gap: 4 }}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
{isDragOver && (
<div style={{
position: 'absolute', inset: 0,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #1976d2', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#1976d2',
}}>
Dateien hier ablegen
</div>
)}
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
title="Upload files"
>
{uploading ? '...' : '+'}
</button>
<button onClick={_refreshAll} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
</div>
</div>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
{/* Search */}
<input
type="text"
placeholder="Dateien suchen..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', boxSizing: 'border-box',
}}
/>
{/* Folder tree with inline files */}
<FolderTree
folders={_folderNodes}
files={_fileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={onFileSelect}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={_onDeleteFolder}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_onMoveFile}
onMoveFiles={_onMoveFiles}
onRenameFile={_onRenameFile}
onDeleteFile={_onDeleteFile}
onDeleteFiles={_onDeleteFiles}
onDeleteFolders={_onDeleteFolders}
onDownloadFolder={handleDownloadFolder}
/>
{_fileNodes.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
</div>
)}
</div>
);
};

View file

@ -38,7 +38,7 @@ interface TreeItemDrop {
interface WorkspaceInputProps { interface WorkspaceInputProps {
instanceId: string; instanceId: string;
onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[]) => void; onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[], options?: { requireNeutralization?: boolean }) => void;
isProcessing: boolean; isProcessing: boolean;
onStop: () => void; onStop: () => void;
files: WorkspaceFile[]; files: WorkspaceFile[];
@ -84,6 +84,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
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 [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [neutralizeActive, setNeutralizeActive] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const promptBeforeVoiceRef = useRef(''); const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef(''); const finalizedTextRef = useRef('');
@ -122,12 +123,13 @@ 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, attachedFeatureDataSourceIds); const options = neutralizeActive ? { requireNeutralization: true } : undefined;
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
setPrompt(''); setPrompt('');
setShowAutocomplete(false); setShowAutocomplete(false);
setShowSourcePicker(false); setShowSourcePicker(false);
setAttachedFileIds([]); setAttachedFileIds([]);
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]); }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
const _handleKeyDown = useCallback( const _handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -705,6 +707,21 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
)} )}
</div> </div>
<button
onClick={() => setNeutralizeActive(v => !v)}
title={neutralizeActive ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
style={{
padding: '8px 10px', borderRadius: 8, border: '1px solid',
borderColor: neutralizeActive ? '#166534' : 'var(--border-color, #d1d5db)',
background: neutralizeActive ? '#dcfce7' : 'transparent',
cursor: 'pointer', fontSize: '1rem', lineHeight: 1,
opacity: neutralizeActive ? 1 : 0.5,
transition: 'all 0.15s',
}}
>
🔒
</button>
{isProcessing ? ( {isProcessing ? (
<button <button
onClick={onStop} onClick={onStop}

View file

@ -2,7 +2,7 @@
* WorkspacePage -- Unified AI Workspace * WorkspacePage -- Unified AI Workspace
* *
* 3-column layout: * 3-column layout:
* Left sidebar: ConversationList, FileBrowser, DataSourcePanel * Left sidebar: UnifiedDataBar (Chats, Files, Sources)
* Center: ChatStream + WorkspaceInput * Center: ChatStream + WorkspaceInput
* Right sidebar: FilePreview, ToolActivityLog * Right sidebar: FilePreview, ToolActivityLog
*/ */
@ -14,14 +14,11 @@ import { useFileOperations } from '../../../hooks/useFiles';
import { useWorkspace } from './useWorkspace'; import { useWorkspace } from './useWorkspace';
import { ChatStream } from './ChatStream'; import { ChatStream } from './ChatStream';
import { WorkspaceInput } from './WorkspaceInput'; import { WorkspaceInput } from './WorkspaceInput';
import { ConversationList } from './ConversationList';
import { FileBrowser } from './FileBrowser';
import { DataSourcePanel } from './DataSourcePanel';
import { FilePreview } from './FilePreview'; import { FilePreview } from './FilePreview';
import { ToolActivityLog } from './ToolActivityLog'; import { ToolActivityLog } from './ToolActivityLog';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import OnboardingAssistant from '../../../components/OnboardingAssistant'; import api from '../../../api';
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) { function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
const [width, setWidth] = useState(initialWidth); const [width, setWidth] = useState(initialWidth);
@ -81,6 +78,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
const _leftResize = _useResizable(280, 200, 450); const _leftResize = _useResizable(280, 200, 450);
const _rightResize = _useResizable(320, 200, 500); const _rightResize = _useResizable(320, 200, 500);
const [rightTab, setRightTab] = useState<RightTab>('activity'); const [rightTab, setRightTab] = useState<RightTab>('activity');
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
const [selectedFileId, setSelectedFileId] = useState<string | null>(null); const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]); const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
@ -211,6 +209,25 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
textTransform: 'uppercase' as const, textTransform: 'uppercase' as const,
}); });
const _handleRenameChat = useCallback(async (chatId: string, newName: string) => {
try {
await api.patch(`/api/workspace/${instanceId}/workflows/${chatId}`, { name: newName });
} catch (err) {
console.error('Failed to rename chat:', err);
}
}, [instanceId]);
const _handleDeleteChat = useCallback(async (chatId: string) => {
try {
await api.delete(`/api/workspace/${instanceId}/workflows/${chatId}`);
if (workspace.workflowId === chatId) {
workspace.resetToNew();
}
} catch (err) {
console.error('Failed to delete chat:', err);
}
}, [instanceId, workspace]);
const _udbContext: UdbContext = { const _udbContext: UdbContext = {
instanceId: instanceId, instanceId: instanceId,
mandateId: mandateId, mandateId: mandateId,
@ -220,32 +237,14 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
const _leftPanelBody = ( const _leftPanelBody = (
<UnifiedDataBar <UnifiedDataBar
context={_udbContext} context={_udbContext}
renderChats={(ctx) => ( activeTab={udbTab}
<ConversationList onTabChange={setUdbTab}
instanceId={ctx.instanceId} onSelectChat={_handleConversationSelect}
activeWorkflowId={workspace.workflowId} activeWorkflowId={workspace.workflowId ?? undefined}
onSelect={_handleConversationSelect} onCreateNewChat={workspace.resetToNew}
onCreateNew={workspace.resetToNew} onRenameChat={_handleRenameChat}
refreshTrigger={workspace.workflowVersion} onDeleteChat={_handleDeleteChat}
/> onFileSelect={_handleFileSelect}
)}
renderFiles={(ctx) => (
<FileBrowser
instanceId={ctx.instanceId}
files={workspace.files}
onRefresh={workspace.refreshFiles}
onFileSelect={_handleFileSelect}
/>
)}
renderSources={(ctx) => (
<DataSourcePanel
instanceId={ctx.instanceId}
dataSources={workspace.dataSources}
featureDataSources={workspace.featureDataSources}
onRefresh={workspace.refreshDataSources}
onRefreshFeatureDataSources={workspace.refreshFeatureDataSources}
/>
)}
/> />
); );
@ -386,11 +385,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
Dateien hier ablegen Dateien hier ablegen
</div> </div>
)} )}
<OnboardingAssistant
instanceId={instanceId}
mandateId={mandateId}
featureCode={featureCode}
/>
<ChatStream <ChatStream
messages={workspace.messages} messages={workspace.messages}
agentProgress={workspace.agentProgress} agentProgress={workspace.agentProgress}

View file

@ -40,6 +40,8 @@ export interface WorkspaceFile {
description?: string; description?: string;
featureInstanceId?: string; featureInstanceId?: string;
featureInstanceLabel?: string; featureInstanceLabel?: string;
scope: string;
neutralize: boolean;
} }
export interface WorkspaceFolder { export interface WorkspaceFolder {
@ -56,6 +58,8 @@ export interface DataSource {
label: string; label: string;
/** Human-readable full path (service + folders); used for tooltips */ /** Human-readable full path (service + folders); used for tooltips */
displayPath?: string; displayPath?: string;
scope: string;
neutralize: boolean;
} }
export interface FeatureDataSource { export interface FeatureDataSource {