fixing round 1
This commit is contained in:
parent
9a7e3f42d2
commit
f5f6cad542
29 changed files with 2397 additions and 2014 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
/* ============================================ */
|
/* ============================================ */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
>
|
|
||||||
✎
|
|
||||||
</button>
|
|
||||||
{conv.status === 'archived' ? (
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); _handleReactivate(conv.id); }}
|
|
||||||
style={{ ..._actionBtnStyle, color: '#4caf50' }}
|
|
||||||
title="Reaktivieren"
|
|
||||||
>
|
|
||||||
↩
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); _handleArchive(conv.id); }}
|
|
||||||
style={_actionBtnStyle}
|
|
||||||
title="Archivieren"
|
|
||||||
>
|
|
||||||
📦
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); }}
|
|
||||||
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
|
|
||||||
title="Abbrechen"
|
|
||||||
>
|
|
||||||
✗
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); setConfirmDeleteId(conv.id); }}
|
|
||||||
style={{ ..._actionBtnStyle, color: '#d32f2f' }}
|
|
||||||
title="Loeschen"
|
|
||||||
>
|
|
||||||
🗑
|
|
||||||
</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 }}
|
|
||||||
>
|
|
||||||
<
|
|
||||||
</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 }}
|
|
||||||
>
|
|
||||||
>
|
|
||||||
</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',
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue