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;
|
||||
color: var(--color-text-secondary, #999);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scopeIcons {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rootActions {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export interface FileNode {
|
|||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
folderId?: string | null;
|
||||
scope?: string;
|
||||
neutralize?: boolean;
|
||||
}
|
||||
|
||||
export interface TreeItem {
|
||||
|
|
@ -62,6 +64,8 @@ export interface FolderTreeProps {
|
|||
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
||||
|
|
@ -146,6 +150,22 @@ function _fileIcon(mime?: string): string {
|
|||
|
||||
/* ── 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 {
|
||||
selectedItemIds: Set<string>;
|
||||
selectedFileIds: string[];
|
||||
|
|
@ -156,6 +176,8 @@ interface SelectionCtx {
|
|||
onDeleteFile?: (fileId: string) => Promise<void>;
|
||||
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
||||
|
|
@ -232,6 +254,35 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
|||
{(file.fileSize / 1024).toFixed(0)}K
|
||||
</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 && (
|
||||
<span className={styles.actions}>
|
||||
{sel.onRenameFile && !multiSelected && (
|
||||
|
|
@ -517,6 +568,7 @@ export default function FolderTree({
|
|||
expandedIds: externalExpandedIds, onToggleExpand,
|
||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
||||
onScopeChange, onNeutralizeToggle,
|
||||
}: FolderTreeProps) {
|
||||
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [rootDropOver, setRootDropOver] = useState(false);
|
||||
|
|
@ -634,8 +686,10 @@ export default function FolderTree({
|
|||
onDeleteFile,
|
||||
onDeleteFiles,
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -282,6 +282,27 @@
|
|||
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 */
|
||||
:global(.dark-theme) .separator {
|
||||
background: var(--border-dark, #333);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
* - Users, Mandates, Roles, ...
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
import type {
|
||||
DynamicBlock,
|
||||
|
|
@ -31,8 +31,9 @@ import type {
|
|||
FeatureView
|
||||
} from '../../hooks/useNavigation';
|
||||
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 api from '../../api';
|
||||
import styles from './MandateNavigation.module.css';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -84,16 +85,32 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
|
|||
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
|
||||
* 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.
|
||||
* 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 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 {
|
||||
id: instance.id,
|
||||
label: instance.uiLabel,
|
||||
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
|
||||
icon: getPageIcon(featureUiComponent),
|
||||
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
||||
children,
|
||||
defaultExpanded: false,
|
||||
actions: renameAction,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -106,16 +123,18 @@ function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent
|
|||
* Before: Mandate → Feature → Instance → 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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Flatten: collect all instances from all features directly under mandate
|
||||
const instanceNodes: TreeNodeItem[] = [];
|
||||
for (const feature of mandate.features) {
|
||||
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)
|
||||
*/
|
||||
function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] {
|
||||
function dynamicBlockToTreeNodes(
|
||||
block: DynamicBlock,
|
||||
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||
): TreeNodeItem[] {
|
||||
return block.mandates
|
||||
.map(navigationMandateToTreeNode)
|
||||
.map((m) => navigationMandateToTreeNode(m, onRename))
|
||||
.filter((node): node is TreeNodeItem => node !== null);
|
||||
}
|
||||
|
||||
|
|
@ -169,18 +191,19 @@ const EmptyState: React.FC = () => (
|
|||
// =============================================================================
|
||||
|
||||
export const MandateNavigation: React.FC = () => {
|
||||
// Fetch navigation from new API (blocks structure, already filtered by permissions)
|
||||
const { blocks, loading } = useNavigation('de');
|
||||
|
||||
// Build navigation items from blocks
|
||||
// Groups static items into collapsible containers:
|
||||
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
|
||||
// - "Administration": admin items, possibly with subgroups
|
||||
// - Dynamic block (mandates) renders between them
|
||||
const { blocks, loading, refresh } = useNavigation('de');
|
||||
|
||||
const _handleRename = useCallback((instanceId: string, currentLabel: string) => {
|
||||
const newLabel = window.prompt('Neuer Name:', currentLabel);
|
||||
if (!newLabel || newLabel.trim() === currentLabel) return;
|
||||
api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() })
|
||||
.then(() => refresh())
|
||||
.catch((err: any) => alert('Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message)));
|
||||
}, [refresh]);
|
||||
|
||||
const navigationItems: TreeItem[] = useMemo(() => {
|
||||
const items: TreeItem[] = [];
|
||||
|
||||
// Collect static items by category
|
||||
const meineSichtItems: NavigationItem[] = [];
|
||||
let adminItems: NavigationItem[] = [];
|
||||
let adminSubgroups: NavSubgroup[] = [];
|
||||
|
|
@ -199,15 +222,13 @@ export const MandateNavigation: React.FC = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// "Meine Sicht" - collapsible container for user-facing pages
|
||||
if (meineSichtItems.length > 0) {
|
||||
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
|
||||
}
|
||||
|
||||
// Dynamic block: mandates with feature instances
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'dynamic') {
|
||||
const mandateNodes = dynamicBlockToTreeNodes(block);
|
||||
const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename);
|
||||
if (mandateNodes.length > 0) {
|
||||
if (items.length > 0) items.push({ type: 'separator' });
|
||||
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 (items.length > 0) items.push({ type: 'separator' });
|
||||
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
|
||||
|
|
@ -236,7 +256,7 @@ export const MandateNavigation: React.FC = () => {
|
|||
}
|
||||
|
||||
return items;
|
||||
}, [blocks]);
|
||||
}, [blocks, _handleRename]);
|
||||
|
||||
// Check if user has any navigation (static or dynamic)
|
||||
const hasNavigation = blocks.length > 0;
|
||||
|
|
|
|||
|
|
@ -257,6 +257,22 @@
|
|||
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 */
|
||||
/* ============================================ */
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ export interface TreeNodeItem {
|
|||
level?: number;
|
||||
/** Data attribute for testing/identification */
|
||||
dataId?: string;
|
||||
/** Inline action element rendered at the end of the row (e.g. rename icon) */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export interface TreeSectionItem {
|
||||
|
|
@ -219,6 +221,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
|||
{node.badge}
|
||||
</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 { useCurrentUser } from '../../hooks/useUsers';
|
||||
import { NotificationBell } from '../NotificationBell';
|
||||
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
|
||||
import styles from './UserSection.module.css';
|
||||
|
||||
export const UserSection: React.FC = () => {
|
||||
|
|
@ -16,6 +17,7 @@ export const UserSection: React.FC = () => {
|
|||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showLegalModal, setShowLegalModal] = useState(false);
|
||||
const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden());
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
|
|
@ -41,6 +43,13 @@ export const UserSection: React.FC = () => {
|
|||
setShowLegalModal(true);
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
const handleOnboarding = () => {
|
||||
_showOnboarding();
|
||||
setOnboardingHidden(false);
|
||||
navigate('/', { state: { showOnboarding: Date.now() } });
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
|
|
@ -61,7 +70,7 @@ export const UserSection: React.FC = () => {
|
|||
|
||||
<button
|
||||
className={styles.userButton}
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }}
|
||||
aria-expanded={showMenu}
|
||||
>
|
||||
<div className={styles.avatar}>
|
||||
|
|
@ -94,6 +103,16 @@ export const UserSection: React.FC = () => {
|
|||
Einstellungen
|
||||
</button>
|
||||
|
||||
{onboardingHidden && (
|
||||
<button
|
||||
className={styles.menuItem}
|
||||
onClick={handleOnboarding}
|
||||
>
|
||||
<span className={styles.menuIcon}>{'\uD83E\uDDED'}</span>
|
||||
Onboarding-Assistent
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={styles.menuItem}
|
||||
onClick={handleLegal}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
|
||||
interface OnboardingStep {
|
||||
|
|
@ -11,133 +11,183 @@ interface OnboardingStep {
|
|||
}
|
||||
|
||||
interface OnboardingAssistantProps {
|
||||
instanceId?: string;
|
||||
mandateId?: string;
|
||||
featureCode?: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const _DISMISS_KEY = 'onboarding_dismissed';
|
||||
const _DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
const _STORAGE_KEY = 'onboarding_hidden';
|
||||
|
||||
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({
|
||||
instanceId,
|
||||
mandateId,
|
||||
featureCode: _featureCode,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const _CALLOUTS: Record<string, string> = {
|
||||
mandate: 'Tipp: Ein Mandant ist Ihr persoenlicher Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.',
|
||||
feature: 'Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.',
|
||||
connection: 'Tipp: Verbinden Sie Ihre Datenquellen (z.B. SharePoint, Google Drive), damit der AI-Assistent auf Ihre Dokumente zugreifen kann.',
|
||||
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 [dismissed, setDismissed] = useState(false);
|
||||
const location = useLocation();
|
||||
const [hidden, setHidden] = useState(() => _isOnboardingHidden());
|
||||
const [steps, setSteps] = useState<OnboardingStep[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
const _checkOnboardingState = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const onboardingSteps: OnboardingStep[] = [];
|
||||
|
||||
let hasMandate = !!mandateId;
|
||||
if (!hasMandate) {
|
||||
try {
|
||||
const mandatesRes = await api.get('/api/store/mandates');
|
||||
hasMandate = (mandatesRes.data || []).length > 0;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
let hasMandate = false;
|
||||
try {
|
||||
const mandatesRes = await api.get('/api/store/mandates');
|
||||
const mandates = mandatesRes.data?.mandates || mandatesRes.data || [];
|
||||
hasMandate = Array.isArray(mandates) && mandates.length > 0;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
onboardingSteps.push({
|
||||
id: 'mandate',
|
||||
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,
|
||||
action: hasMandate ? undefined : () => navigate('/store'),
|
||||
});
|
||||
|
||||
let hasInstances = !!instanceId;
|
||||
if (!hasInstances) {
|
||||
try {
|
||||
const storeRes = await api.get('/api/store/features');
|
||||
const features = storeRes.data || [];
|
||||
hasInstances = features.some((f: any) => f.instances && f.instances.length > 0);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
let hasFeature = false;
|
||||
let firstInstancePath: string | undefined;
|
||||
try {
|
||||
const navRes = await api.get('/api/navigation?language=de');
|
||||
const mandates = navRes.data?.mandates || [];
|
||||
for (const m of mandates) {
|
||||
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({
|
||||
id: 'feature',
|
||||
label: 'Erstes Feature aktivieren',
|
||||
description: hasInstances ? 'Du hast aktive Features.' : 'Aktiviere dein erstes Feature im Store.',
|
||||
completed: hasInstances,
|
||||
action: hasInstances ? undefined : () => navigate('/store'),
|
||||
description: hasFeature
|
||||
? 'Du hast aktive Features.'
|
||||
: 'Aktiviere dein erstes Feature im Store.',
|
||||
completed: hasFeature,
|
||||
action: hasFeature ? undefined : () => navigate('/store'),
|
||||
});
|
||||
|
||||
let hasData = false;
|
||||
if (instanceId) {
|
||||
try {
|
||||
const filesRes = await api.get(`/api/workspace/${instanceId}/files`);
|
||||
const files = filesRes.data?.data || filesRes.data || [];
|
||||
hasData = files.length > 0;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
let hasConnection = false;
|
||||
try {
|
||||
const connRes = await api.get('/api/connections/');
|
||||
const connections = connRes.data?.data || connRes.data || [];
|
||||
hasConnection = Array.isArray(connections) && connections.length > 0;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
onboardingSteps.push({
|
||||
id: 'data',
|
||||
id: 'connection',
|
||||
label: 'Erste Datenquelle einbinden',
|
||||
description: hasData ? 'Du hast Daten im Workspace.' : 'Lade eine Datei hoch oder verbinde eine Datenquelle.',
|
||||
completed: hasData,
|
||||
description: hasConnection
|
||||
? 'Du hast Verbindungen eingerichtet.'
|
||||
: 'Verbinde deine erste Datenquelle.',
|
||||
completed: hasConnection,
|
||||
action: hasConnection ? undefined : () => navigate('/basedata/connections'),
|
||||
});
|
||||
|
||||
let hasChats = false;
|
||||
if (instanceId) {
|
||||
let hasChat = false;
|
||||
if (hasFeature && firstInstancePath) {
|
||||
try {
|
||||
const chatsRes = await api.get(`/api/workspace/${instanceId}/workflows`);
|
||||
const chats = chatsRes.data?.data || chatsRes.data || [];
|
||||
hasChats = chats.length > 0;
|
||||
const featuresRes = await api.get('/api/store/features');
|
||||
const features = featuresRes.data || [];
|
||||
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 */ }
|
||||
}
|
||||
|
||||
const _chatAction = firstInstancePath ? () => navigate(firstInstancePath!) : undefined;
|
||||
onboardingSteps.push({
|
||||
id: 'chat',
|
||||
label: 'Ersten AI-Chat starten',
|
||||
description: hasChats ? 'Du hast bereits Chats.' : 'Starte deinen ersten Chat mit dem AI-Assistenten.',
|
||||
completed: hasChats,
|
||||
description: hasChat
|
||||
? 'Du hast bereits Chats gestartet.'
|
||||
: 'Starte deinen ersten Chat mit dem AI-Assistenten.',
|
||||
completed: hasChat,
|
||||
action: hasChat ? undefined : _chatAction,
|
||||
});
|
||||
|
||||
setSteps(onboardingSteps);
|
||||
|
||||
if (onboardingSteps.every(s => s.completed)) {
|
||||
setDismissed(true);
|
||||
setHidden(true);
|
||||
_hideOnboarding();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Onboarding check failed:', err);
|
||||
} finally {
|
||||
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 = () => {
|
||||
setDismissed(true);
|
||||
if (dontShowAgain) {
|
||||
_hideOnboarding();
|
||||
}
|
||||
setHidden(true);
|
||||
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;
|
||||
if (completedCount === steps.length) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: 16, margin: 16, borderRadius: 12,
|
||||
padding: 16, margin: '0 0 20px 0', borderRadius: 12,
|
||||
border: '1px solid var(--border-color, #e5e7eb)',
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={_handleDismiss}
|
||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '1.1rem', color: '#9ca3af', padding: '2px 6px' }}
|
||||
>
|
||||
{'\u00D7'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16, height: 4 }}>
|
||||
|
|
@ -170,34 +214,78 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({
|
|||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
|
||||
borderRadius: 8, background: step.completed ? 'transparent' : 'var(--bg-primary, #fff)',
|
||||
border: step.completed ? 'none' : '1px solid var(--border-color, #e5e7eb)',
|
||||
opacity: step.completed ? 0.6 : 1,
|
||||
cursor: step.action ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={step.action}
|
||||
>
|
||||
<span style={{ fontSize: '1.1rem', flexShrink: 0, width: 24, textAlign: 'center' }}>
|
||||
{step.completed ? '\u2713' : '\u25CB'}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.85rem', textDecoration: step.completed ? 'line-through' : 'none' }}>
|
||||
{step.label}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{step.description}
|
||||
{steps.map((step, idx) => {
|
||||
const isNextStep = !step.completed && steps.slice(0, idx).every(s => s.completed);
|
||||
return (
|
||||
<div key={step.id}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
|
||||
borderRadius: 8, background: step.completed ? 'transparent' : 'var(--bg-primary, #fff)',
|
||||
border: step.completed ? 'none' : isNextStep ? '1px solid var(--accent, #4f46e5)' : '1px solid var(--border-color, #e5e7eb)',
|
||||
opacity: step.completed ? 0.6 : 1,
|
||||
cursor: step.action ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={step.action}
|
||||
>
|
||||
<span style={{ fontSize: '1.1rem', flexShrink: 0, width: 24, textAlign: 'center' }}>
|
||||
{step.completed ? '\u2713' : '\u25CB'}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.85rem', textDecoration: step.completed ? 'line-through' : 'none' }}>
|
||||
{step.label}
|
||||
</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>
|
||||
{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>
|
||||
{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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,23 @@
|
|||
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 {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color, #d1d5db);
|
||||
|
|
@ -33,12 +50,50 @@
|
|||
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;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Chat list ── */
|
||||
|
||||
.flatList,
|
||||
.tree {
|
||||
display: flex;
|
||||
|
|
@ -46,33 +101,100 @@
|
|||
}
|
||||
|
||||
.chatItem {
|
||||
padding: 8px 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.chatItem:hover {
|
||||
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 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chatDate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
/* ── Inline action icons (show on hover) ── */
|
||||
|
||||
.chatActions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
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 {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
|
@ -118,7 +240,8 @@
|
|||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.search {
|
||||
.search,
|
||||
.renameInput {
|
||||
background: var(--bg-input-dark, #1f2937);
|
||||
border-color: var(--border-dark, #374151);
|
||||
color: #f3f4f6;
|
||||
|
|
@ -127,8 +250,28 @@
|
|||
.treeGroupHeader:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.chatItemActive,
|
||||
.chatItemActive:hover {
|
||||
background: rgba(79, 70, 229, 0.15);
|
||||
border-color: var(--accent, #4f46e5);
|
||||
}
|
||||
.treeGroupCount {
|
||||
background: #374151;
|
||||
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 api from '../../api';
|
||||
import styles from './ChatsTab.module.css';
|
||||
|
|
@ -6,9 +6,10 @@ import styles from './ChatsTab.module.css';
|
|||
interface ChatItem {
|
||||
id: string;
|
||||
label: string;
|
||||
updatedAt?: string;
|
||||
updatedAt?: string | number;
|
||||
featureInstanceId?: string;
|
||||
featureCode?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface ChatGroup {
|
||||
|
|
@ -18,24 +19,63 @@ interface ChatGroup {
|
|||
chats: ChatItem[];
|
||||
}
|
||||
|
||||
type ChatFilter = 'active' | 'archived';
|
||||
|
||||
interface ChatsTabProps {
|
||||
context: UdbContext;
|
||||
onSelectChat?: (chatId: string, featureInstanceId: string) => 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 [flatMode, setFlatMode] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<ChatFilter>('active');
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/api/workspace/${context.instanceId}/workflows`);
|
||||
const workflows = response.data?.data || response.data || [];
|
||||
const response = await api.get(
|
||||
`/api/workspace/${context.instanceId}/workflows`,
|
||||
{ params: { includeArchived: true } },
|
||||
);
|
||||
const workflows = response.data?.workflows || response.data?.data || [];
|
||||
|
||||
const groupMap = new Map<string, ChatGroup>();
|
||||
for (const wf of workflows) {
|
||||
|
|
@ -51,15 +91,20 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
|
|||
groupMap.get(fiId)!.chats.push({
|
||||
id: wf.id,
|
||||
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,
|
||||
featureCode: wf.featureCode,
|
||||
status: wf.status || 'active',
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = Array.from(groupMap.values());
|
||||
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);
|
||||
|
||||
|
|
@ -75,6 +120,19 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
|
|||
|
||||
useEffect(() => { _loadChats(); }, [_loadChats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeWorkflowId) {
|
||||
_loadChats();
|
||||
}
|
||||
}, [activeWorkflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId && renameInputRef.current) {
|
||||
renameInputRef.current.focus();
|
||||
renameInputRef.current.select();
|
||||
}
|
||||
}, [editingId]);
|
||||
|
||||
const _toggleGroup = (id: string) => {
|
||||
setExpandedGroups(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
|
||||
.map(g => ({
|
||||
...g,
|
||||
chats: search
|
||||
? g.chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase()))
|
||||
: g.chats,
|
||||
}))
|
||||
.map(g => {
|
||||
let chats = _applyFilter(g.chats);
|
||||
if (search) {
|
||||
chats = chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase()));
|
||||
}
|
||||
return { ...g, chats };
|
||||
})
|
||||
.filter(g => g.chats.length > 0);
|
||||
|
||||
const _allChats = _filteredGroups
|
||||
.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>;
|
||||
|
||||
|
|
@ -108,6 +309,11 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{onCreateNew && (
|
||||
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title="Neuer Chat">
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
|
||||
onClick={() => setFlatMode(!flatMode)}
|
||||
|
|
@ -117,28 +323,26 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
|
|||
</button>
|
||||
</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 ? (
|
||||
<div className={styles.flatList}>
|
||||
{_allChats.map((chat) => (
|
||||
<div
|
||||
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>
|
||||
))}
|
||||
{_allChats.map((chat) =>
|
||||
_renderChatItem(chat, chat.featureInstanceId || context.instanceId),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tree}>
|
||||
|
|
@ -158,27 +362,21 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context, onSelectChat, onDragStart
|
|||
</div>
|
||||
{expandedGroups.has(group.featureInstanceId) && (
|
||||
<div className={styles.treeChildren}>
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
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>
|
||||
))}
|
||||
{group.chats.map((chat) =>
|
||||
_renderChatItem(chat, group.featureInstanceId),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{_allChats.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
{filter === 'archived' ? 'Keine archivierten Chats.' : 'Keine aktiven Chats.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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 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';
|
||||
|
||||
interface FileEntry {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
folderId?: string | null;
|
||||
tags?: string[];
|
||||
scope: string;
|
||||
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 {
|
||||
context: UdbContext;
|
||||
onFileSelect?: (fileId: string) => void;
|
||||
|
|
@ -29,6 +25,27 @@ interface FilesTabProps {
|
|||
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
|
|
@ -40,9 +57,11 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
|||
id: f.id,
|
||||
fileName: f.fileName || f.name || 'unknown',
|
||||
mimeType: f.mimeType,
|
||||
fileSize: f.fileSize,
|
||||
folderId: f.folderId ?? null,
|
||||
tags: f.tags || [],
|
||||
scope: f.scope || 'personal',
|
||||
neutralize: f.neutralize || false,
|
||||
fileSize: f.fileSize,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
|
|
@ -56,73 +75,245 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
|||
_loadFiles();
|
||||
}, [_loadFiles]);
|
||||
|
||||
const _cycleScope = async (file: FileEntry) => {
|
||||
const currentIdx = _SCOPE_CYCLE.indexOf(file.scope);
|
||||
const nextScope = _SCOPE_CYCLE[(currentIdx + 1) % _SCOPE_CYCLE.length];
|
||||
const _folderNodes = useMemo(() =>
|
||||
folders.map(f => ({
|
||||
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 {
|
||||
await api.patch(`/api/files/${file.id}/scope`, { scope: nextScope });
|
||||
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, scope: nextScope } : f)));
|
||||
for (const file of Array.from(fileList)) {
|
||||
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) {
|
||||
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 {
|
||||
await api.patch(`/api/files/${file.id}/neutralize`, { neutralize: !file.neutralize });
|
||||
setFiles(prev =>
|
||||
prev.map(f => (f.id === file.id ? { ...f, neutralize: !f.neutralize } : f)),
|
||||
);
|
||||
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle neutralize:', err);
|
||||
_loadFiles();
|
||||
}
|
||||
};
|
||||
}, [_loadFiles]);
|
||||
|
||||
if (loading) return <div className={styles.loading}>Lade Dateien...</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.filesTab}>
|
||||
{files.length === 0 ? (
|
||||
<div className={styles.empty}>Keine Dateien vorhanden</div>
|
||||
) : (
|
||||
<div className={styles.fileList}>
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={styles.fileRow}
|
||||
onClick={() => onFileSelect?.(file.id)}
|
||||
>
|
||||
<span className={styles.fileName}>{file.fileName}</span>
|
||||
<div className={styles.fileIcons}>
|
||||
<button
|
||||
className={styles.scopeIcon}
|
||||
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
|
||||
className={styles.filesTab}
|
||||
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>
|
||||
)}
|
||||
|
||||
<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}>
|
||||
<span>{'\uD83D\uDC64'} Pers\u00F6nlich</span>
|
||||
<span>{'\uD83D\uDC64'} Persönlich</span>
|
||||
<span>{'\uD83D\uDC65'} Instanz</span>
|
||||
<span>{'\uD83C\uDFE2'} Mandant</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 ChatsTab from './ChatsTab';
|
||||
import FilesTab from './FilesTab';
|
||||
import SourcesTab from './SourcesTab';
|
||||
import styles from './UnifiedDataBar.module.css';
|
||||
|
||||
export type UdbTab = 'chats' | 'files' | 'sources';
|
||||
|
|
@ -14,10 +17,14 @@ interface UnifiedDataBarProps {
|
|||
context: UdbContext;
|
||||
activeTab?: UdbTab;
|
||||
onTabChange?: (tab: UdbTab) => void;
|
||||
renderChats?: (context: UdbContext) => React.ReactNode;
|
||||
renderFiles?: (context: UdbContext) => React.ReactNode;
|
||||
renderSources?: (context: UdbContext) => React.ReactNode;
|
||||
hideTabs?: UdbTab[];
|
||||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
||||
activeWorkflowId?: string;
|
||||
onCreateNewChat?: () => void;
|
||||
onRenameChat?: (chatId: string, newName: string) => void;
|
||||
onDeleteChat?: (chatId: string) => void;
|
||||
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||
onFileSelect?: (fileId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -31,12 +38,20 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
context,
|
||||
activeTab: controlledTab,
|
||||
onTabChange,
|
||||
renderChats,
|
||||
renderFiles,
|
||||
renderSources,
|
||||
hideTabs,
|
||||
onSelectChat,
|
||||
activeWorkflowId,
|
||||
onCreateNewChat,
|
||||
onRenameChat,
|
||||
onDeleteChat,
|
||||
onChatDragStart,
|
||||
onFileSelect,
|
||||
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 _handleTabChange = (tab: UdbTab) => {
|
||||
|
|
@ -47,7 +62,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
return (
|
||||
<div className={`${styles.udb} ${className || ''}`}>
|
||||
<div className={styles.tabBar}>
|
||||
{(['chats', 'files', 'sources'] as UdbTab[]).map((tab) => (
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`${styles.tab} ${currentTab === tab ? styles.tabActive : ''}`}
|
||||
|
|
@ -58,9 +73,26 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
))}
|
||||
</div>
|
||||
<div className={styles.tabContent}>
|
||||
{currentTab === 'chats' && renderChats?.(context)}
|
||||
{currentTab === 'files' && renderFiles?.(context)}
|
||||
{currentTab === 'sources' && renderSources?.(context)}
|
||||
{currentTab === 'chats' && !hideTabs?.includes('chats') && (
|
||||
<ChatsTab
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
export { default as UnifiedDataBar } 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';
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface FeatureInstance {
|
|||
uiLabel: string;
|
||||
order: number;
|
||||
views: FeatureView[];
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
/** Feature within a mandate */
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useNavigation from '../hooks/useNavigation';
|
||||
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
|
||||
import { getPageIcon } from '../config/pageRegistry';
|
||||
import { FaArrowRight, FaBuilding } from 'react-icons/fa';
|
||||
import OnboardingAssistant from '../components/OnboardingAssistant';
|
||||
import styles from './Dashboard.module.css';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -75,19 +76,19 @@ export const DashboardPage: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (totalInstances === 0) {
|
||||
return <Navigate to="/store" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dashboard}>
|
||||
<header className={styles.header}>
|
||||
<h1>Übersicht</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
|
||||
</p>
|
||||
{totalInstances > 0 && (
|
||||
<p className={styles.subtitle}>
|
||||
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<OnboardingAssistant />
|
||||
|
||||
<main className={styles.content}>
|
||||
{mandates
|
||||
.filter(mandate => mandate.features.some(f => f.instances.length > 0))
|
||||
|
|
|
|||
|
|
@ -17,12 +17,13 @@ import styles from './Settings.module.css';
|
|||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy';
|
||||
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
|
||||
|
||||
const _TABS: { key: SettingsTab; label: string }[] = [
|
||||
{ key: 'profile', label: 'Profil' },
|
||||
{ key: 'appearance', label: 'Darstellung' },
|
||||
{ key: 'voice', label: 'Stimme & Sprache' },
|
||||
{ key: 'neutralization', label: 'Datenneutralisierung' },
|
||||
{ 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
|
||||
// =============================================================================
|
||||
|
|
@ -421,6 +532,8 @@ export const SettingsPage: React.FC = () => {
|
|||
|
||||
{activeTab === 'voice' && <VoiceSettingsTab />}
|
||||
|
||||
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Datenschutz</h2>
|
||||
|
|
|
|||
|
|
@ -211,6 +211,9 @@
|
|||
|
||||
/* Actions */
|
||||
.cardActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
/**
|
||||
* Store Page
|
||||
*
|
||||
* Feature Store where users can self-activate features in the root mandate.
|
||||
* Uses the Shared Instance Pattern -- each feature has one shared instance,
|
||||
* and users get their own FeatureAccess + user-role upon activation.
|
||||
* Feature Store -- Users activate feature instances in their own mandates.
|
||||
* Uses the Own Instance Pattern -- each activation creates a dedicated FeatureInstance
|
||||
* in the selected mandate. Explicit mandate selection required.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useStore } from '../hooks/useStore';
|
||||
|
|
@ -76,22 +74,10 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
onActivate,
|
||||
onDeactivate,
|
||||
}) => {
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const isProcessing = actionLoading === feature.featureCode;
|
||||
const icon = FEATURE_ICONS[feature.featureCode];
|
||||
const activeInstances = feature.instances.filter(inst => inst.isActive);
|
||||
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 (
|
||||
<div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}>
|
||||
|
|
@ -142,43 +128,22 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
)}
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
{feature.canActivate && (
|
||||
<>
|
||||
{mandates.length === 0 && (
|
||||
<p className={styles.mandateHint}>
|
||||
{language === 'de'
|
||||
? 'Ein persoenliches Konto wird automatisch erstellt.'
|
||||
{feature.canActivate && mandates.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={styles.activateButton}
|
||||
onClick={() => onActivate(feature.featureCode, m.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
|
||||
: (language === 'de'
|
||||
? `Aktivieren fuer ${m.label || m.name}`
|
||||
: language === 'fr'
|
||||
? 'Un compte personnel sera cree automatiquement.'
|
||||
: 'A personal account will be created automatically.'}
|
||||
</p>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
? `Activer pour ${m.label || m.name}`
|
||||
: `Activate for ${m.label || m.name}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export const ConnectionsPage: React.FC = () => {
|
|||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<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 className={styles.headerActions}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import {
|
|||
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { UnifiedDataBar, FilesTab, SourcesTab } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext } from '../../../components/UnifiedDataBar';
|
||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||
import styles from './CommcoachDossierView.module.css';
|
||||
import { useVoiceController } from './useVoiceController';
|
||||
|
||||
|
|
@ -38,6 +38,7 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
const [newDescription, setNewDescription] = useState('');
|
||||
const [newCategory, setNewCategory] = useState('custom');
|
||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||||
|
|
@ -161,10 +162,9 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
{!udbCollapsed && (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
activeTab="files"
|
||||
renderChats={() => null}
|
||||
renderFiles={(ctx) => <FilesTab context={ctx} />}
|
||||
renderSources={(ctx) => <SourcesTab context={ctx} />}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -523,6 +523,8 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
)}
|
||||
|
||||
</>)}
|
||||
|
||||
|
||||
{/* #region agent log */}
|
||||
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -171,6 +171,18 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
|||
</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>
|
||||
)}
|
||||
</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 {
|
||||
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;
|
||||
onStop: () => void;
|
||||
files: WorkspaceFile[];
|
||||
|
|
@ -84,6 +84,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||
const [neutralizeActive, setNeutralizeActive] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const promptBeforeVoiceRef = useRef('');
|
||||
const finalizedTextRef = useRef('');
|
||||
|
|
@ -122,12 +123,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
if (!trimmed || isProcessing) return;
|
||||
const inlineFileIds = _extractFileRefs(trimmed);
|
||||
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
|
||||
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds);
|
||||
const options = neutralizeActive ? { requireNeutralization: true } : undefined;
|
||||
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
|
||||
setPrompt('');
|
||||
setShowAutocomplete(false);
|
||||
setShowSourcePicker(false);
|
||||
setAttachedFileIds([]);
|
||||
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]);
|
||||
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
|
||||
|
||||
const _handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
|
|
@ -705,6 +707,21 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
)}
|
||||
</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 ? (
|
||||
<button
|
||||
onClick={onStop}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* WorkspacePage -- Unified AI Workspace
|
||||
*
|
||||
* 3-column layout:
|
||||
* Left sidebar: ConversationList, FileBrowser, DataSourcePanel
|
||||
* Left sidebar: UnifiedDataBar (Chats, Files, Sources)
|
||||
* Center: ChatStream + WorkspaceInput
|
||||
* Right sidebar: FilePreview, ToolActivityLog
|
||||
*/
|
||||
|
|
@ -14,14 +14,11 @@ import { useFileOperations } from '../../../hooks/useFiles';
|
|||
import { useWorkspace } from './useWorkspace';
|
||||
import { ChatStream } from './ChatStream';
|
||||
import { WorkspaceInput } from './WorkspaceInput';
|
||||
import { ConversationList } from './ConversationList';
|
||||
import { FileBrowser } from './FileBrowser';
|
||||
import { DataSourcePanel } from './DataSourcePanel';
|
||||
import { FilePreview } from './FilePreview';
|
||||
import { ToolActivityLog } from './ToolActivityLog';
|
||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext } from '../../../components/UnifiedDataBar';
|
||||
import OnboardingAssistant from '../../../components/OnboardingAssistant';
|
||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||
import api from '../../../api';
|
||||
|
||||
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
|
||||
const [width, setWidth] = useState(initialWidth);
|
||||
|
|
@ -81,6 +78,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
const _leftResize = _useResizable(280, 200, 450);
|
||||
const _rightResize = _useResizable(320, 200, 500);
|
||||
const [rightTab, setRightTab] = useState<RightTab>('activity');
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
|
||||
|
|
@ -211,6 +209,25 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
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 = {
|
||||
instanceId: instanceId,
|
||||
mandateId: mandateId,
|
||||
|
|
@ -220,32 +237,14 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
const _leftPanelBody = (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
renderChats={(ctx) => (
|
||||
<ConversationList
|
||||
instanceId={ctx.instanceId}
|
||||
activeWorkflowId={workspace.workflowId}
|
||||
onSelect={_handleConversationSelect}
|
||||
onCreateNew={workspace.resetToNew}
|
||||
refreshTrigger={workspace.workflowVersion}
|
||||
/>
|
||||
)}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
onSelectChat={_handleConversationSelect}
|
||||
activeWorkflowId={workspace.workflowId ?? undefined}
|
||||
onCreateNewChat={workspace.resetToNew}
|
||||
onRenameChat={_handleRenameChat}
|
||||
onDeleteChat={_handleDeleteChat}
|
||||
onFileSelect={_handleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -386,11 +385,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
Dateien hier ablegen
|
||||
</div>
|
||||
)}
|
||||
<OnboardingAssistant
|
||||
instanceId={instanceId}
|
||||
mandateId={mandateId}
|
||||
featureCode={featureCode}
|
||||
/>
|
||||
<ChatStream
|
||||
messages={workspace.messages}
|
||||
agentProgress={workspace.agentProgress}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export interface WorkspaceFile {
|
|||
description?: string;
|
||||
featureInstanceId?: string;
|
||||
featureInstanceLabel?: string;
|
||||
scope: string;
|
||||
neutralize: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceFolder {
|
||||
|
|
@ -56,6 +58,8 @@ export interface DataSource {
|
|||
label: string;
|
||||
/** Human-readable full path (service + folders); used for tooltips */
|
||||
displayPath?: string;
|
||||
scope: string;
|
||||
neutralize: boolean;
|
||||
}
|
||||
|
||||
export interface FeatureDataSource {
|
||||
|
|
|
|||
Loading…
Reference in a new issue