Compare commits

..

No commits in common. "2a09579f45ab3e7915ee462cc6f610be28d39c22" and "ec4592c056769f19377bdac1344cf2de83ec64ef" have entirely different histories.

93 changed files with 1166 additions and 3944 deletions

View file

@ -31,7 +31,6 @@ import { LanguageProvider } from './providers/language/LanguageContext';
import { ToastProvider } from './contexts/ToastContext'; import { ToastProvider } from './contexts/ToastContext';
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext'; import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
import { FileProvider } from './contexts/FileContext'; import { FileProvider } from './contexts/FileContext';
import { VoiceCatalogProvider } from './contexts/VoiceCatalogContext';
import { MainLayout } from './layouts/MainLayout'; import { MainLayout } from './layouts/MainLayout';
import { FeatureLayout } from './layouts/FeatureLayout'; import { FeatureLayout } from './layouts/FeatureLayout';
import { DashboardPage } from './pages/Dashboard'; import { DashboardPage } from './pages/Dashboard';
@ -40,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store'; import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage'; import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView'; import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -73,7 +72,6 @@ function App() {
<LanguageProvider> <LanguageProvider>
<AuthProvider> <AuthProvider>
<ToastProvider> <ToastProvider>
<VoiceCatalogProvider>
<WorkflowSelectionProvider> <WorkflowSelectionProvider>
<Router> <Router>
<Routes> <Routes>
@ -215,7 +213,6 @@ function App() {
<Route path="subscriptions" element={<AdminSubscriptionsPage />} /> <Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} /> <Route path="logs" element={<AdminLogsPage />} />
<Route path="languages" element={null} /> <Route path="languages" element={null} />
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
<Route path="demo-config" element={<AdminDemoConfigPage />} /> <Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} /> <Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} /> <Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
@ -233,7 +230,6 @@ function App() {
</Routes> </Routes>
</Router> </Router>
</WorkflowSelectionProvider> </WorkflowSelectionProvider>
</VoiceCatalogProvider>
</ToastProvider> </ToastProvider>
</AuthProvider> </AuthProvider>
</LanguageProvider> </LanguageProvider>

View file

@ -101,7 +101,6 @@ export interface AuthUser {
roleLabels?: string[]; roleLabels?: string[];
authenticationAuthority: string; authenticationAuthority: string;
isSysAdmin?: boolean; isSysAdmin?: boolean;
isPlatformAdmin?: boolean;
[key: string]: any; [key: string]: any;
} }

View file

@ -14,7 +14,6 @@ import type {
InstancePermissions, InstancePermissions,
AccessLevel, AccessLevel,
} from '../types/mandate'; } from '../types/mandate';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
// ============================================================================= // =============================================================================
// MOCK DATA (Temporär bis Backend bereit) // MOCK DATA (Temporär bis Backend bereit)
@ -72,8 +71,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
mandates: [ mandates: [
{ {
id: 'mand-soha', id: 'mand-soha',
name: 'soha-treuhand', name: 'Soha Treuhand',
label: 'Soha Treuhand',
code: 'soha', code: 'soha',
features: [ features: [
{ {
@ -121,8 +119,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
}, },
{ {
id: 'mand-swiss', id: 'mand-swiss',
name: 'swisstreu', name: 'SwissTreu',
label: 'SwissTreu',
code: 'swisstreu', code: 'swisstreu',
features: [ features: [
{ {
@ -192,7 +189,7 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
if (feature.code === 'chatbot') { if (feature.code === 'chatbot') {
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', { console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
mandateId: mandate.id, mandateId: mandate.id,
mandateName: mandateDisplayLabel(mandate), mandateName: mandate.label || mandate.name,
featureCode: feature.code, featureCode: feature.code,
instanceCount: feature.instances.length, instanceCount: feature.instances.length,
}); });

View file

@ -212,8 +212,6 @@ export interface FolderInfo {
mandateId?: string; mandateId?: string;
featureInstanceId?: string; featureInstanceId?: string;
createdAt?: number; createdAt?: number;
scope?: string;
neutralize?: boolean;
} }
export async function fetchFolders( export async function fetchFolders(

View file

@ -4,40 +4,11 @@ import { ApiRequestOptions } from '../hooks/useApi';
// TYPES & INTERFACES // TYPES & INTERFACES
// ============================================================================ // ============================================================================
/**
* Mandate (Mandant) represents one tenant in PowerOn PORTA.
*
* Field semantics (must stay in sync with the backend `Mandate` Pydantic model):
* - `id` UUID, immutable.
* - `name` Kurzzeichen / slug. Globally unique, lowercase [a-z0-9] with
* hyphen-separated segments (length 232). Used for audit/tracking
* and stable references. Only PlatformAdmin can change it after
* creation.
* - `label` Voller Name. Mandatory, human-readable display name shown in the
* UI. Freely changeable by a Mandate-Admin.
*/
export interface Mandate { export interface Mandate {
id: string; id: string;
name: string;
label: string;
enabled?: boolean;
isSystem?: boolean;
deletedAt?: number | null;
[key: string]: any; // Allow additional properties from backend [key: string]: any; // Allow additional properties from backend
} }
/** Payload for creating a mandate. `label` is required, `name` is optional. */
export interface MandateCreateData {
label: string;
name?: string;
enabled?: boolean;
[key: string]: any;
}
/**
* Payload for updating a mandate. Only PlatformAdmin may change `name`;
* Mandate-Admin can update `label` and other UI fields.
*/
export type MandateUpdateData = Partial<Omit<Mandate, 'id'>>; export type MandateUpdateData = Partial<Omit<Mandate, 'id'>>;
export interface PaginationParams { export interface PaginationParams {
@ -141,7 +112,7 @@ export async function updateMandate(
*/ */
export async function createMandate( export async function createMandate(
request: ApiRequestFunction, request: ApiRequestFunction,
mandateData: MandateCreateData | Partial<Mandate> mandateData: Partial<Mandate>
): Promise<Mandate> { ): Promise<Mandate> {
return await request({ return await request({
url: '/api/mandates/', url: '/api/mandates/',

View file

@ -1,5 +1,4 @@
import api from '../api'; import api from '../api';
import type { VoiceOption } from './voiceCatalogApi';
// ============================================================================ // ============================================================================
// TYPES & INTERFACES // TYPES & INTERFACES
@ -103,11 +102,18 @@ export interface ConfigUpdateRequest {
debugMode?: boolean; debugMode?: boolean;
} }
// Voice option type re-exported from the central voice catalog API // Voice/Language Types (from Google TTS API)
// (imported above so it's also in scope for local signatures below). export interface VoiceLanguage {
// The legacy teamsbot-specific {code,name} language type is gone — consumers code: string;
// should use VoiceLanguage from voiceCatalogApi (catalog SSOT). name: string;
export type { VoiceOption }; }
export interface VoiceOption {
name: string;
languageCodes: string[];
ssmlGender: string;
naturalSampleRateHertz: number;
}
// Auth Detection Test Types // Auth Detection Test Types
export interface StepScreenshot { export interface StepScreenshot {
@ -307,19 +313,25 @@ export async function testVoice(
} }
/** /**
* Fetch the curated voice/language catalog (single source of truth). * Fetch available TTS languages from Google Cloud.
* Re-exports the central voiceCatalogApi.fetchVoiceCatalog so legacy * Returns array of language codes (e.g. ["de-DE", "en-US", ...])
* teamsbot consumers stay on one import surface.
*/ */
export { fetchVoiceCatalog as fetchLanguages } from './voiceCatalogApi'; export async function fetchLanguages(): Promise<string[]> {
try {
const response = await api.get('/voice-google/languages');
return response.data?.languages || [];
} catch {
return [];
}
}
/** /**
* Fetch available TTS voices for a language from Google Cloud. * Fetch available TTS voices for a language from Google Cloud.
*/ */
export async function fetchVoices(languageCode: string): Promise<VoiceOption[]> { export async function fetchVoices(languageCode: string): Promise<VoiceOption[]> {
try { try {
const response = await api.get('/api/voice/voices', { const response = await api.get('/voice-google/voices', {
params: { language: languageCode }, params: { languageCode },
}); });
return response.data?.voices || []; return response.data?.voices || [];
} catch { } catch {

View file

@ -13,8 +13,7 @@ export interface User {
enabled: boolean; enabled: boolean;
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"]) roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
authenticationAuthority: string; authenticationAuthority: string;
isSysAdmin?: boolean; // Infrastructure/System Operator (RBAC bypass) isSysAdmin?: boolean; // System-Administrator Flag
isPlatformAdmin?: boolean; // Cross-Mandate Governance (no RBAC bypass)
// mandateId ist nicht mehr Teil des User-Objekts (Multi-Tenant-Konzept) // mandateId ist nicht mehr Teil des User-Objekts (Multi-Tenant-Konzept)
// Der Mandant-Kontext wird über Feature-Instanzen bestimmt // Der Mandant-Kontext wird über Feature-Instanzen bestimmt
[key: string]: any; // Allow additional properties [key: string]: any; // Allow additional properties

View file

@ -1,47 +0,0 @@
/**
* Voice / Language Catalog API.
*
* Single source of truth for every voice-language picker, default-voice
* lookup, and ISO BCP-47 mapping in the frontend. Mirrors
* gateway/modules/shared/voiceCatalog.py 1:1.
*
* Hard-coded language lists or ad-hoc maps in components are forbidden
* consume `useVoiceCatalog()` instead.
*/
import api from '../api';
export interface VoiceLanguage {
bcp47: string;
iso: string;
label: string;
flag: string;
defaultVoice: string | null;
}
export interface VoiceOption {
name: string;
languageCodes: string[];
ssmlGender: string;
naturalSampleRateHertz: number;
}
interface CatalogResponse {
languages: VoiceLanguage[];
}
interface VoicesResponse {
voices: VoiceOption[];
}
export async function fetchVoiceCatalog(): Promise<VoiceLanguage[]> {
const response = await api.get<CatalogResponse>('/api/voice/languages');
return response.data?.languages ?? [];
}
export async function fetchVoicesForLanguage(bcp47: string): Promise<VoiceOption[]> {
const response = await api.get<VoicesResponse>('/api/voice/voices', {
params: { language: bcp47 },
});
return response.data?.voices ?? [];
}

View file

@ -60,8 +60,6 @@ export interface NodeType {
meta?: { meta?: {
icon?: string; icon?: string;
color?: string; color?: string;
/** True if this node performs an LLM / AI call (credits). */
usesAi?: boolean;
method?: string; method?: string;
action?: string; action?: string;
}; };

View file

@ -152,18 +152,8 @@
min-width: 0; min-width: 0;
} }
.nodeItemLabelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
width: 100%;
}
.nodeItemLabel { .nodeItemLabel {
display: block; display: block;
flex: 1;
min-width: 0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--text-primary, #333); color: var(--text-primary, #333);
@ -277,7 +267,7 @@
overflow: hidden; overflow: hidden;
border-radius: 8px; border-radius: 8px;
/* Infinite grid: on viewport, moves with pan/zoom via inline style */ /* Infinite grid: on viewport, moves with pan/zoom via inline style */
background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px); background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px);
background-repeat: repeat; background-repeat: repeat;
} }

View file

@ -48,7 +48,6 @@ import { Automation2DataFlowProvider } from '../context/Automation2DataFlowConte
import { usePrompt } from '../../../hooks/usePrompt'; import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel'; import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel'; import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
import { EditorWorkflowChatList } from './EditorWorkflowChatList';
import { RunTracingPanel } from './RunTracingPanel'; import { RunTracingPanel } from './RunTracingPanel';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
@ -115,8 +114,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [tracingRunId, setTracingRunId] = useState<string | null>(null); const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({}); const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes'); const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
type LeftTab = UdbTab | 'ai'; const [udbTab, setUdbTab] = useState<UdbTab>('chats');
const [udbTab, setUdbTab] = useState<LeftTab>('ai');
const udbContext: UdbContext = useMemo(() => ({ const udbContext: UdbContext = useMemo(() => ({
instanceId, instanceId,
@ -651,30 +649,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
{leftPanelOpen && (<> {leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}> <div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
<div className={styles.rightTabBar}> <div className={styles.rightTabBar}>
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => ( {(['chats', 'files', 'sources'] as const).map((tab) => (
<button <button
key={tab} key={tab}
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`} className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
onClick={() => setUdbTab(tab)} onClick={() => setUdbTab(tab)}
> >
{{ ai: t('KI'), chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]} {{ chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
</button> </button>
))} ))}
</div> </div>
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}> <div style={{ flex: 1, overflow: 'hidden' }}>
{/* {udbTab === 'chats' ? (
KI-Panel bleibt gemountet, damit der Chatverlauf beim Tab-Wechsel
(Chats / Dateien / Quellen) erhalten bleibt. Nur per CSS umblenden.
`key={currentWorkflowId}` setzt den Verlauf sauber zurück, wenn der
Nutzer einen anderen Workflow wählt.
*/}
<div style={{
display: udbTab === 'ai' ? 'flex' : 'none',
flexDirection: 'column',
height: '100%',
}}>
<EditorChatPanel <EditorChatPanel
key={currentWorkflowId || '__noWorkflow__'}
instanceId={instanceId} instanceId={instanceId}
workflowId={currentWorkflowId} workflowId={currentWorkflowId}
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }} onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
@ -683,21 +670,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
dataSources={dataSources} dataSources={dataSources}
featureDataSources={featureDataSources} featureDataSources={featureDataSources}
/> />
</div> ) : (
{udbTab === 'chats' && (
<EditorWorkflowChatList
workflows={workflows}
currentWorkflowId={currentWorkflowId}
onSelect={handleWorkflowSelect}
onNew={handleNew}
t={t}
/>
)}
{(udbTab === 'files' || udbTab === 'sources') && (
<UnifiedDataBar <UnifiedDataBar
context={udbContext} context={udbContext}
activeTab={udbTab as UdbTab} activeTab={udbTab}
onTabChange={(tab) => setUdbTab(tab as LeftTab)} onTabChange={setUdbTab}
hideTabs={['chats']} hideTabs={['chats']}
onFileSelect={onFileSelect} onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged} onSourcesChanged={onSourcesChanged}

View file

@ -7,25 +7,11 @@
* - Files: drag & drop from FolderTree onto input area, or click in UDB * - Files: drag & drop from FolderTree onto input area, or click in UDB
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources) * - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
*/ */
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { startSseStream } from '../../../utils/sseClient'; import { startSseStream } from '../../../utils/sseClient';
import { ChatMessageList } from '../../Chat'; import { ChatMessageList } from '../../Chat';
import type { ChatMessage } from '../../Chat'; import type { ChatMessage } from '../../Chat';
import { getPageIcon } from '../../../config/pageRegistry'; import { getPageIcon } from '../../../config/pageRegistry';
import api from '../../../api';
interface PersistedEditorChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: number;
sequenceNr?: number;
}
interface PersistedEditorChatResponse {
chatWorkflowId: string | null;
messages: PersistedEditorChatMessage[];
}
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -72,55 +58,16 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [showSourcePicker, setShowSourcePicker] = useState(false); const [showSourcePicker, setShowSourcePicker] = useState(false);
const [treeDropOver, setTreeDropOver] = useState(false); const [treeDropOver, setTreeDropOver] = useState(false);
const [stopping, setStopping] = useState(false);
const abortRef = useRef<(() => void) | null>(null); const abortRef = useRef<(() => void) | null>(null);
const assistantIdRef = useRef<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null); const pickerRef = useRef<HTMLDivElement>(null);
// Load persisted chat history from the backend whenever the workflow changes.
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`.
// For an unsaved workflow (workflowId == null) we just clear the panel.
useEffect(() => {
if (!workflowId) {
setMessages([]);
return;
}
let cancelled = false;
const _loadHistory = async () => {
setHistoryLoading(true);
try {
const res = await api.get<PersistedEditorChatResponse>(
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
);
if (cancelled) return;
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
id: m.id || `persisted-${++_msgCounter}`,
role: m.role,
content: m.content,
timestamp: m.timestamp ? Math.round(Number(m.timestamp) * 1000) : Date.now(),
}));
setMessages(persisted);
} catch (err) {
if (cancelled) return;
console.warn('EditorChatPanel: failed to load chat history', err);
setMessages([]);
} finally {
if (!cancelled) setHistoryLoading(false);
}
};
_loadHistory();
return () => { cancelled = true; };
}, [instanceId, workflowId]);
const _toggleDataSource = useCallback((dsId: string) => { const _toggleDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev => setAttachedDataSourceIds(prev =>
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId], prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
@ -138,10 +85,9 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
if (!workflowId || loading || !trimmed) return; if (!workflowId || loading || !trimmed) return;
const fileIds = pendingFiles.map(f => f.fileId); const fileIds = pendingFiles.map(f => f.fileId);
// Note: conversationHistory is no longer sent — the backend loads it
// server-side from the persisted ChatWorkflow (linkedWorkflowId).
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
message: trimmed, message: trimmed,
conversationHistory: messages.map(m => ({ role: m.role, message: m.content })),
userLanguage: navigator.language?.slice(0, 2) || 'de', userLanguage: navigator.language?.slice(0, 2) || 'de',
}; };
if (fileIds.length > 0) body.fileIds = fileIds; if (fileIds.length > 0) body.fileIds = fileIds;
@ -160,13 +106,11 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
setLoading(true); setLoading(true);
const assistantId = `asst-${++_msgCounter}`; const assistantId = `asst-${++_msgCounter}`;
assistantIdRef.current = assistantId;
let accumulated = ''; let accumulated = '';
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]); setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
const baseURL = api.defaults.baseURL || '';
const cleanup = startSseStream({ const cleanup = startSseStream({
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`, url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
body, body,
handlers: { handlers: {
onChunk: (event) => { onChunk: (event) => {
@ -198,40 +142,17 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
} }
setLoading(false); setLoading(false);
}, },
onStopped: () => { onStopped: () => setLoading(false),
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Gestoppt.')}_` }
: m));
setLoading(false);
setStopping(false);
},
}, },
onConnectionError: (err) => { onConnectionError: (err) => {
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m)); setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m));
setLoading(false); setLoading(false);
setStopping(false);
}, },
onStreamEnd: () => { setLoading(false); setStopping(false); }, onStreamEnd: () => setLoading(false),
}); });
abortRef.current = cleanup; abortRef.current = cleanup;
}, [prompt, loading, workflowId, instanceId, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]); }, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
const _handleStop = useCallback(async () => {
if (!workflowId || stopping) return;
setStopping(true);
const assistantId = assistantIdRef.current;
if (assistantId) {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Stoppen…')}_` }
: m));
}
try {
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
} catch {
}
abortRef.current?.();
}, [workflowId, instanceId, stopping, t]);
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => { const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@ -267,8 +188,8 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
<ChatMessageList <ChatMessageList
messages={messages} messages={messages}
isProcessing={loading || historyLoading} isProcessing={loading}
emptyMessage={historyLoading ? t('Lade Verlauf…') : t('Beschreiben Sie, was Sie tun möchten')} emptyMessage={t('Beschreiben Sie, was Sie tun möchten')}
/> />
{/* Pending files (from UDB drag/click) */} {/* Pending files (from UDB drag/click) */}
@ -470,12 +391,10 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
)} )}
{loading ? ( {loading ? (
<button onClick={_handleStop} disabled={stopping} title={stopping ? t('Stoppen…') : t('Anfrage stoppen')} style={{ <button onClick={() => abortRef.current?.()} style={{
padding: '8px 14px', borderRadius: 8, border: 'none', padding: '8px 14px', borderRadius: 8, border: 'none',
background: stopping ? '#9e9e9e' : '#f44336', color: '#fff', background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12,
cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12, }}>{t('Stopp')}</button>
opacity: stopping ? 0.7 : 1,
}}>{stopping ? t('Stoppen…') : t('Stopp')}</button>
) : ( ) : (
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{ <button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
padding: '8px 14px', borderRadius: 8, border: 'none', padding: '8px 14px', borderRadius: 8, border: 'none',

View file

@ -1,127 +0,0 @@
/**
* EditorWorkflowChatList
*
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
* as one editor chat session. Lists workflows already loaded by the parent
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
* GraphicalEditor data instead of the workspace endpoint.
*/
import React, { useMemo, useState } from 'react';
import type { Automation2Workflow } from '../../../api/workflowApi';
interface EditorWorkflowChatListProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onSelect: (workflowId: string | null) => void;
onNew: () => void;
t: (key: string) => string;
}
function _formatRelative(ts?: number): string {
if (!ts) return '';
const date = new Date(ts * 1000);
if (isNaN(date.getTime())) return '';
const diffMs = Date.now() - date.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 date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
workflows,
currentWorkflowId,
onSelect,
onNew,
t,
}) => {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const list = q
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
: [...workflows];
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
return list;
}, [workflows, search]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-primary, #fff)' }}>
<div style={{ padding: '8px 10px', display: 'flex', gap: 6, borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('Workflow suchen…')}
style={{
flex: 1, padding: '6px 8px', borderRadius: 6,
border: '1px solid var(--border-color, #ddd)', fontSize: 12, outline: 'none',
}}
/>
<button
onClick={onNew}
title={t('Neuer Workflow')}
style={{
padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border-color, #ddd)',
background: 'var(--secondary-bg, #f5f5f5)', cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>+ {t('Neu')}</button>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{filtered.length === 0 ? (
<div style={{ padding: 16, fontSize: 12, color: '#999', textAlign: 'center' }}>
{workflows.length === 0
? t('Noch keine Workflows. Klicken Sie auf „+ Neu", um einen Workflow-Chat zu starten.')
: t('Keine Treffer.')}
</div>
) : (
filtered.map((wf) => {
const isActive = wf.id === currentWorkflowId;
const ts = wf.lastStartedAt || wf.createdAt;
return (
<div
key={wf.id}
onClick={() => onSelect(wf.id)}
style={{
padding: '10px 12px', cursor: 'pointer',
borderBottom: '1px solid var(--border-color-soft, #f0f0f0)',
background: isActive ? 'rgba(242, 88, 67, 0.08)' : 'transparent',
borderLeft: isActive ? '3px solid var(--primary-color, #F25843)' : '3px solid transparent',
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = '#f7f7f7'; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: '#333', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{wf.label || t('(unbenannt)')}
</span>
{wf.isRunning && (
<span title={t('läuft')} style={{
width: 8, height: 8, borderRadius: '50%', background: '#4caf50', flexShrink: 0,
}} />
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4, fontSize: 11, color: '#999' }}>
{typeof wf.runCount === 'number' && (
<span>{wf.runCount} {wf.runCount === 1 ? t('Lauf') : t('Läufe')}</span>
)}
{ts ? <span>· {_formatRelative(ts)}</span> : null}
</div>
</div>
);
})
)}
</div>
</div>
);
};
export default EditorWorkflowChatList;

View file

@ -8,7 +8,6 @@ import type { NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { AiBadge } from '../nodes/shared/AiBadge';
export interface CanvasNode { export interface CanvasNode {
id: string; id: string;
@ -799,12 +798,6 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
handleNodeMouseDown(e, node.id); handleNodeMouseDown(e, node.id);
}} }}
> >
{nt?.meta?.usesAi === true && (
<AiBadge
variant="canvas"
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
/>
)}
{handles.map(({ index, isOutput }) => { {handles.map(({ index, isOutput }) => {
const pos = getHandlePosition(node, index); const pos = getHandlePosition(node, index);
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`); const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);

View file

@ -5,11 +5,9 @@
import React from 'react'; import React from 'react';
import type { NodeType } from '../../../api/workflowApi'; import type { NodeType } from '../../../api/workflowApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getCategoryIcon } from '../nodes/shared/utils'; import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils'; import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { AiBadge } from '../nodes/shared/AiBadge';
interface NodeListItemProps { interface NodeListItemProps {
node: NodeType; node: NodeType;
@ -24,7 +22,6 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
getLabel, getLabel,
getCategoryIcon: getIcon = getCategoryIcon, getCategoryIcon: getIcon = getCategoryIcon,
}) => { }) => {
const { t } = useLanguage();
const desc = getLabel(node.description, language); const desc = getLabel(node.description, language);
return ( return (
<div <div
@ -47,15 +44,7 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
{getIcon(node.category)} {getIcon(node.category)}
</div> </div>
<div className={styles.nodeItemInfo}> <div className={styles.nodeItemInfo}>
<span className={styles.nodeItemLabelRow}> <span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
{node.meta?.usesAi === true && (
<AiBadge
variant="palette"
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
/>
)}
</span>
<span className={styles.nodeItemDesc}>{desc}</span> <span className={styles.nodeItemDesc}>{desc}</span>
</div> </div>
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>} {desc && <div className={styles.nodeItemTooltip}>{desc}</div>}

View file

@ -1,24 +0,0 @@
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.12rem 0.38rem;
border-radius: 4px;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: linear-gradient(135deg, #7c4dff 0%, #9c27b0 100%);
color: #fff;
line-height: 1;
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
.badgeCanvas {
position: absolute;
top: 4px;
right: 6px;
z-index: 3;
pointer-events: auto;
}

View file

@ -1,25 +0,0 @@
/**
* Small label for workflow nodes that consume AI credits (LLM calls).
*/
import React from 'react';
import badgeStyles from './AiBadge.module.css';
export interface AiBadgeProps {
/** Tooltip (e.g. cost / credits hint). */
title: string;
/** Canvas nodes: fixed top-right on the node card. */
variant?: 'canvas' | 'palette';
}
export const AiBadge: React.FC<AiBadgeProps> = ({ title, variant = 'palette' }) => {
const cls =
variant === 'canvas'
? `${badgeStyles.badge} ${badgeStyles.badgeCanvas}`
: badgeStyles.badge;
return (
<span className={cls} title={title} aria-label={title}>
AI
</span>
);
};

View file

@ -12,11 +12,9 @@ export const CATEGORY_ORDER = [
'input', 'input',
'flow', 'flow',
'data', 'data',
'context',
'ai', 'ai',
'file', 'file',
'email', 'email',
'sharepoint', 'sharepoint',
'clickup', 'clickup',
'trustee',
] as const; ] as const;

View file

@ -86,21 +86,10 @@
min-width: 0; min-width: 0;
} }
/* Right zone: contains dynamic on-hover actions + always-visible stable trio.
* The stable trio (chat / scope / neutralize) sits at the right edge in a
* fixed slot order so icons never jump. Dynamic actions appear on hover
* to the left of the trio without displacing it. */
.rightZone {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.actions { .actions {
display: none; display: none;
gap: 2px; gap: 2px;
margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
} }
@ -108,26 +97,6 @@
display: flex; display: flex;
} }
.stableActions {
display: flex;
gap: 2px;
flex-shrink: 0;
align-items: center;
}
.iconSlot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 20px;
flex-shrink: 0;
}
.iconSlot.placeholder {
visibility: hidden;
}
.actionBtn { .actionBtn {
background: none; background: none;
border: none; border: none;
@ -179,6 +148,25 @@
flex-shrink: 0; flex-shrink: 0;
} }
.scopeIcons {
display: flex;
gap: 2px;
flex-shrink: 0;
align-items: center;
}
.rightZone {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.rightZone .actions {
margin-left: 0;
}
.rootActions { .rootActions {
display: flex; display: flex;
gap: 2px; gap: 2px;

View file

@ -29,8 +29,6 @@ export interface FolderNode {
isProtected?: boolean; isProtected?: boolean;
isReadonly?: boolean; isReadonly?: boolean;
icon?: string; icon?: string;
neutralize?: boolean;
scope?: string;
} }
export interface FileNode { export interface FileNode {
@ -77,9 +75,6 @@ export interface FolderTreeProps {
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>; onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void; onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onFolderScopeChange?: (folderId: string, newScope: string) => void;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
} }
/* ── Helpers ───────────────────────────────────────────────────────────── */ /* ── Helpers ───────────────────────────────────────────────────────────── */
@ -185,79 +180,6 @@ interface SelectionCtx {
onDeleteFolders?: (folderIds: string[]) => Promise<void>; onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void; onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
}
/* Stable trio (chat | scope | neutralize)
* Always rendered in this order, always at the right edge of the row.
* Each slot has a fixed width so missing actions render an invisible
* placeholder icons never jump position between rows. */
interface StableTrioProps {
scope?: string;
neutralize?: boolean;
scopeLabels: Record<string, string>;
onChat?: () => void;
onScopeChange?: (newScope: string) => void;
onNeutralizeToggle?: (newValue: boolean) => void;
chatTitle: string;
}
function _StableTrio({
scope, neutralize,
scopeLabels,
onChat, onScopeChange, onNeutralizeToggle,
chatTitle,
}: StableTrioProps) {
const { t } = useLanguage();
const _cycleScope = (current: string | undefined) => {
const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal');
return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
};
return (
<span className={styles.stableActions}>
{/* Slot 1: Chat */}
{onChat ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onChat(); }}
title={chatTitle}
style={{ fontSize: 12 }}
>
{'\u{1F4AC}'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\u{1F4AC}'}</span>
)}
{/* Slot 2: Scope */}
{onScopeChange && scope != null ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onScopeChange(_cycleScope(scope)); }}
title={`${t('Scope')}: ${scopeLabels[scope] || scope} (${t('klicken zum Wechseln')})`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[scope] || _SCOPE_ICONS.personal}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{_SCOPE_ICONS.personal}</span>
)}
{/* Slot 3: Neutralize */}
{onNeutralizeToggle ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onNeutralizeToggle(!neutralize); }}
title={neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
style={{ fontSize: 14, opacity: neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\uD83D\uDD12'}</span>
)}
</span>
);
} }
/* ── File node (leaf) ─────────────────────────────────────────────────── */ /* ── File node (leaf) ─────────────────────────────────────────────────── */
@ -339,11 +261,6 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
)} )}
{!renaming && ( {!renaming && (
<span className={styles.rightZone}> <span className={styles.rightZone}>
{file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
<span className={styles.actions}> <span className={styles.actions}>
{sel.onRenameFile && !multiSelected && ( {sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}> <button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
@ -373,15 +290,40 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
) )
)} )}
</span> </span>
<_StableTrio {file.fileSize != null && (
scope={file.scope} <span className={styles.fileSize}>
neutralize={file.neutralize} {(file.fileSize / 1024).toFixed(0)}K
scopeLabels={scopeLabels} </span>
onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined} )}
onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined} {file.scope != null && (
onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined} <span className={styles.scopeIcons}>
chatTitle={t('In Chat senden')} <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={`${t('Scope')}: ${scopeLabels[file.scope!] || file.scope} (${t('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 ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
</span>
)}
</span> </span>
)} )}
</div> </div>
@ -409,8 +351,6 @@ interface TreeNodeProps {
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>; onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>; onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>; onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onFolderScopeChange?: (folderId: string, newScope: string) => void;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
} }
function _TreeNode({ function _TreeNode({
@ -418,15 +358,9 @@ function _TreeNode({
promptFolderName, promptFolderName,
onToggle, onSelect, onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder, onFolderScopeChange, onFolderNeutralizeToggle, onDownloadFolder,
}: TreeNodeProps) { }: TreeNodeProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const scopeLabels = useMemo((): Record<string, string> => ({
personal: t('Persönlich'),
featureInstance: t('Instanz'),
mandate: t('Mandant'),
global: t('Global'),
}), [t]);
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(node.name); const [renameValue, setRenameValue] = useState(node.name);
const [dropOver, setDropOver] = useState(false); const [dropOver, setDropOver] = useState(false);
@ -579,53 +513,42 @@ function _TreeNode({
<span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span> <span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span>
)} )}
{!isProtected && ( {!isProtected && (
<span className={styles.rightZone}> <span className={styles.actions}>
<span className={styles.actions}> {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( <button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}> <FaDownload />
<FaDownload /> </button>
</button> )}
)} {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( <button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}> <FaPlus />
<FaPlus /> </button>
</button> )}
)} {!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( <button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}> <FaPen />
<FaPen /> </button>
</button> )}
)} {isMultiSelected && sel.selectedItemIds.size > 1 ? (
{isMultiSelected && sel.selectedItemIds.size > 1 ? ( <>
<> {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( <button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}>
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}> <FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash /> <span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span> </button>
</button> )}
)} {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( <button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}> <FaTrash />
<FaTrash /> <span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span> </button>
</button> )}
)} </>
</> ) : !notEditable && onDeleteFolder && (
) : !notEditable && onDeleteFolder && ( <button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}> <FaTrash />
<FaTrash /> </button>
</button> )}
)}
</span>
<_StableTrio
scope={node.scope}
neutralize={node.neutralize}
scopeLabels={scopeLabels}
onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined}
onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined}
onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined}
chatTitle={t('In Chat senden')}
/>
</span> </span>
)} )}
</div> </div>
@ -652,8 +575,6 @@ function _TreeNode({
onMoveFile={onMoveFile} onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles} onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder} onDownloadFolder={onDownloadFolder}
onFolderScopeChange={onFolderScopeChange}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/> />
))} ))}
{folderFiles.map((file) => ( {folderFiles.map((file) => (
@ -673,7 +594,7 @@ export default function FolderTree({
expandedIds: externalExpandedIds, onToggleExpand, expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat, onScopeChange, onNeutralizeToggle,
}: FolderTreeProps) { }: FolderTreeProps) {
const { t } = useLanguage(); const { t } = useLanguage();
@ -814,9 +735,8 @@ export default function FolderTree({
onDeleteFolders, onDeleteFolders,
onScopeChange, onScopeChange,
onNeutralizeToggle, onNeutralizeToggle,
onSendToChat,
}; };
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, onSendToChat]); }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]);
// Root drop handler: items dropped on the empty area go to root (null) // Root drop handler: items dropped on the empty area go to root (null)
const _handleRootDrop = useCallback(async (e: React.DragEvent) => { const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
@ -901,8 +821,6 @@ export default function FolderTree({
onMoveFile={onMoveFile} onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles} onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder} onDownloadFolder={onDownloadFolder}
onFolderScopeChange={onFolderScopeChange}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/> />
))} ))}
{rootFiles.map((file) => ( {rootFiles.map((file) => (

View file

@ -15,21 +15,6 @@ import {
getDefaultValueForType getDefaultValueForType
} from '../../../utils/attributeTypeMapper'; } from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper'; import type { AttributeType } from '../../../utils/attributeTypeMapper';
import {
SLUG_HINT,
maskSlugInput,
slugify,
validateSlug,
} from '../../../utils/slugUtils';
const _isSlugType = (attrType: AttributeType | undefined): boolean => attrType === 'slug';
/**
* Default source field used to auto-derive a slug in `create` mode. A specific
* attribute can override this by setting `slugSource` in its definition
* (json_schema_extra.slug_source on the backend).
*/
const _DEFAULT_SLUG_SOURCE_FIELD = 'label';
const isTextMultilingual = (value: any): boolean => { const isTextMultilingual = (value: any): boolean => {
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) { if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
@ -385,58 +370,33 @@ export function FormGeneratorForm<T extends Record<string, any>>({
})); }));
}; };
// Tracks slug fields that have been touched manually so we don't override them
// when the user keeps editing the source label afterwards.
const slugFieldsManuallyEdited = useRef<Set<string>>(new Set());
// Handle field value changes // Handle field value changes
// For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds) // For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds)
const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => { const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => {
let processedValue = value; let processedValue = value;
// If field type is timestamp, convert datetime-local string to Unix timestamp
if (fieldType === 'timestamp' && typeof value === 'string' && value) { if (fieldType === 'timestamp' && typeof value === 'string' && value) {
const date = new Date(value); const date = new Date(value);
if (!isNaN(date.getTime())) { if (!isNaN(date.getTime())) {
// Convert to Unix timestamp in seconds (float)
processedValue = date.getTime() / 1000; processedValue = date.getTime() / 1000;
} }
} }
if (_isSlugType(fieldType)) { setFormData(prev => ({
processedValue = maskSlugInput(String(value ?? '')); ...prev,
slugFieldsManuallyEdited.current.add(fieldName); [fieldName]: processedValue
}));
// Clear error for this field when user starts typing
if (errors[fieldName]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
});
} }
const autoFilledSlugFields = new Set<string>();
setFormData(prev => {
const next: any = { ...prev, [fieldName]: processedValue };
// Generic auto-suggest: any slug attribute can declare its source field
// via attr.slugSource (default: 'label'). When that source changes in
// create mode and the slug is still untouched, derive a suggestion.
if (mode === 'create' && !_isSlugType(fieldType)) {
const attrs = attributes ?? [];
for (const a of attrs) {
if (!_isSlugType(a.type as AttributeType)) continue;
const source = (a as any).slugSource || _DEFAULT_SLUG_SOURCE_FIELD;
if (source !== fieldName) continue;
if (slugFieldsManuallyEdited.current.has(a.name)) continue;
const sourceStr = typeof processedValue === 'string' ? processedValue : '';
if (sourceStr.trim().length === 0) continue;
next[a.name] = slugify(sourceStr);
autoFilledSlugFields.add(a.name);
}
}
return next;
});
setErrors(prev => {
if (!prev[fieldName] && autoFilledSlugFields.size === 0) return prev;
const newErrors = { ...prev };
if (newErrors[fieldName]) delete newErrors[fieldName];
autoFilledSlugFields.forEach(n => delete newErrors[n]);
return newErrors;
});
}; };
// Convert Unix timestamp (seconds) to datetime-local input format // Convert Unix timestamp (seconds) to datetime-local input format
@ -549,14 +509,6 @@ export function FormGeneratorForm<T extends Record<string, any>>({
} }
} }
if (_isSlugType(attr.type as AttributeType)) {
const slugErr = validateSlug(String(value));
if (slugErr) {
newErrors[attr.name] = t(slugErr);
return;
}
}
// Select/Multiselect option validation // Select/Multiselect option validation
if (isSelectType(attr.type)) { if (isSelectType(attr.type)) {
const options = normalizeOptions(attr); const options = normalizeOptions(attr);
@ -1067,38 +1019,6 @@ export function FormGeneratorForm<T extends Record<string, any>>({
); );
} }
if (_isSlugType(attr.type as AttributeType)) {
const slugValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<input
type="text"
value={slugValue}
inputMode="text"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
pattern="^[a-z0-9]+(-[a-z0-9]+)*$"
onChange={(e) => handleFieldChange(attr.name, e.target.value, 'slug')}
onFocus={() => handleFieldFocus(attr.name, true)}
onBlur={() => handleFieldFocus(attr.name, false)}
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
/>
<label className={getLabelClass(attr.name, slugValue)}>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>
<span
className={styles.helperText ?? ''}
style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: '4px', display: 'block' }}
>
{t(SLUG_HINT)}
</span>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
}
// Default input field (text, email, date, time, url, password, number, integer, float, timestamp) // Default input field (text, email, date, time, url, password, number, integer, float, timestamp)
const inputType = attributeTypeToInputType(attr.type); const inputType = attributeTypeToInputType(attr.type);

View file

@ -1553,95 +1553,62 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return isCheckboxType(column.type); return isCheckboxType(column.type);
}, []); }, []);
// Always-current snapshot of `data` so a queued toggle reads the freshly // Handle inline toggle for boolean fields
// refetched row (server truth from the previous PUT+refetch) instead of the
// stale `row` captured by React at render time.
const dataRef = useRef<T[]>(data);
useEffect(() => { dataRef.current = data; }, [data]);
// Per-row update queue: every toggle on the same row awaits the previous
// one so PUT + refetch are strictly serialized. Combined with a refetch
// after every PUT, this guarantees that the next queued PUT merges its
// payload from confirmed server state — never from an unconfirmed UI guess.
const inlineUpdateQueueRef = useRef<Map<string, Promise<void>>>(new Map());
// Handle inline toggle for boolean fields.
//
// Design contract (no optimistic UI):
// 1. The cell shows a spinner immediately on click.
// 2. We send the PUT.
// 3. We always trigger a refetch — the table only ever displays values
// that the backend has returned.
// 4. The cell re-renders from the refetched server data.
//
// We deliberately do NOT call ``hookData.updateOptimistically`` here:
// flipping the cell client-side before the backend confirmed leads to
// (a) misleading UX (a click that silently reverts on error) and
// (b) clobber-PUTs when the user toggles a sibling cell while the previous
// change is still in flight (its payload would be merged from the
// unconfirmed local state).
const handleInlineToggle = useCallback(async (row: T, column: ColumnConfig, currentValue: boolean) => { const handleInlineToggle = useCallback(async (row: T, column: ColumnConfig, currentValue: boolean) => {
if (!canInlineEdit || !isInlineEditableColumn(column)) return; if (!canInlineEdit || !isInlineEditableColumn(column)) return;
const rowId = row[idField]; const rowId = row[idField];
const cellKey = `${rowId}-${column.key}`; const cellKey = `${rowId}-${column.key}`;
// Check if update function is available (either from prop or hookData)
const updateFn = onInlineUpdate || hookData?.handleInlineUpdate; const updateFn = onInlineUpdate || hookData?.handleInlineUpdate;
if (!updateFn) { if (!updateFn) {
// Inline editing is optional — silently noop when no handler is wired. // Silent return - inline editing is optional, no warning needed
return; return;
} }
// Mark cell as updating
setUpdatingCells(prev => new Set(prev).add(cellKey)); setUpdatingCells(prev => new Set(prev).add(cellKey));
const newValue = !currentValue; const newValue = !currentValue;
const hasOptimisticUpdate = !!hookData?.updateOptimistically;
const previous = inlineUpdateQueueRef.current.get(String(rowId)) || Promise.resolve(); // If updateOptimistically is available, use it for immediate UI feedback
if (hasOptimisticUpdate) {
hookData.updateOptimistically(rowId, { [column.key]: newValue });
}
const work: Promise<void> = previous
.catch(() => undefined)
.then(async () => {
try {
// Re-resolve the row from the latest refetched snapshot so the
// merged payload reflects every server-confirmed change made by
// earlier queued toggles on this row.
const latestRow = (dataRef.current.find(
(r: any) => String(r?.[idField]) === String(rowId),
) as T | undefined) ?? row;
if (onInlineUpdate) {
await onInlineUpdate(latestRow, column.key, newValue);
} else if (hookData?.handleInlineUpdate) {
await hookData.handleInlineUpdate(rowId, { [column.key]: newValue }, latestRow);
}
// Always refetch on success — the cell only ever shows backend truth.
if (hookData?.refetch) {
await hookData.refetch();
}
} catch (error) {
console.error('FormGeneratorTable: Inline update failed:', error);
// Refetch on error too: restores the row to confirmed server state
// (the cell snaps back to the original value).
if (hookData?.refetch) {
try { await hookData.refetch(); } catch { /* swallow */ }
}
throw error;
} finally {
setUpdatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
});
inlineUpdateQueueRef.current.set(String(rowId), work);
try { try {
await work; // Call the update function (generic - no entity-specific logic)
} finally { if (onInlineUpdate) {
if (inlineUpdateQueueRef.current.get(String(rowId)) === work) { await onInlineUpdate(row, column.key, newValue);
inlineUpdateQueueRef.current.delete(String(rowId)); } else if (hookData?.handleInlineUpdate) {
// Pass row as third parameter for hooks that need to merge changes with existing data
await hookData.handleInlineUpdate(rowId, { [column.key]: newValue }, row);
} }
// Only refetch if we DON'T have optimistic update (to get fresh data)
// With optimistic update, local state is already correct
if (!hasOptimisticUpdate && hookData?.refetch) {
await hookData.refetch();
}
} catch (error) {
console.error('FormGeneratorTable: Inline update failed:', error);
// Revert optimistic update on error
if (hasOptimisticUpdate) {
hookData.updateOptimistically(rowId, { [column.key]: currentValue });
}
// Refetch to restore consistent state on error
if (hookData?.refetch) {
await hookData.refetch();
}
} finally {
// Remove cell from updating state
setUpdatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
} }
}, [canInlineEdit, isInlineEditableColumn, idField, onInlineUpdate, hookData]); }, [canInlineEdit, isInlineEditableColumn, idField, onInlineUpdate, hookData]);

View file

@ -140,8 +140,8 @@ export const UserSection: React.FC = () => {
{/* Legal Modal */} {/* Legal Modal */}
{showLegalModal && ( {showLegalModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowLegalModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2>{t('Legal notices')}</h2> <h2>{t('Legal notices')}</h2>
<button <button

View file

@ -1,36 +0,0 @@
.wrapper {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
border-radius: 6px;
background: var(--color-surface, rgba(255, 255, 255, 0.6));
color: var(--color-text, #1f2937);
}
.icon {
font-size: 0.85rem;
opacity: 0.7;
flex-shrink: 0;
}
.select {
appearance: none;
background: transparent;
border: none;
color: inherit;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
padding: 0.05rem 0.5rem 0.05rem 0.15rem;
border-radius: 4px;
opacity: 0.95;
transition: opacity 0.15s;
}
.select:hover,
.select:focus {
opacity: 1;
outline: none;
}

View file

@ -1,35 +0,0 @@
import { FaGlobe } from 'react-icons/fa';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './LanguageSelector.module.css';
export function LanguageSelector() {
const { currentLanguage, setLanguage, availableLanguages } = useLanguage();
// Always show the selector. If the backend has not (yet) returned a list,
// fall back to a static option for the currently active language so the
// control is visible even on pre-login screens / before the codes endpoint
// resolves.
const optionList = availableLanguages.length > 0
? availableLanguages
: [{ code: currentLanguage, label: currentLanguage.toUpperCase() } as { code: string; label: string }];
return (
<div className={styles.wrapper}>
<FaGlobe className={styles.icon} />
<select
className={styles.select}
value={currentLanguage}
onChange={(e) => setLanguage(e.target.value as typeof currentLanguage)}
aria-label="Sprache / Language"
>
{optionList.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label || lang.code.toUpperCase()}
</option>
))}
</select>
</div>
);
}
export default LanguageSelector;

View file

@ -1,2 +0,0 @@
export { LanguageSelector } from './LanguageSelector';
export { default } from './LanguageSelector';

View file

@ -1,77 +0,0 @@
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--surface-color);
border-radius: 12px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.sizeSm { max-width: 420px; }
.sizeMd { max-width: 600px; }
.sizeLg { max-width: 880px; }
.sizeXl { max-width: 1200px; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.closeButton {
background: none;
border: none;
font-size: 1.25rem;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25rem;
line-height: 1;
transition: color 0.2s;
}
.closeButton:hover {
color: var(--text-primary);
}
.content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
}
:global(.dark-theme) .overlay {
background: rgba(0, 0, 0, 0.7);
}

View file

@ -1,133 +0,0 @@
/**
* Modal central, consistent dialog component for the whole UI.
*
* Behavior contract (intentional, documented):
* - The dialog stays open until the user explicitly closes it via the X button
* (top-right) or an explicit Cancel/OK button rendered by the consumer.
* - Clicking on the dimmed overlay does NOT close the dialog (default: false).
* - Pressing Escape does NOT close the dialog (default: false).
* - Both behaviors can be opted-in via ``closeOnOverlayClick`` /
* ``closeOnEscape`` for the rare cases where this is desired.
*
* Layout: standard 3-row flex (header / scrollable content / optional footer).
* The component traps body scroll while open and is accessible via ``role=dialog``.
*/
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import styles from './Modal.module.css';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
export interface ModalProps {
open: boolean;
onClose: () => void;
title?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
size?: ModalSize;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
hideCloseButton?: boolean;
ariaLabel?: string;
className?: string;
contentClassName?: string;
testId?: string;
}
const _SIZE_CLASS: Record<ModalSize, string> = {
sm: styles.sizeSm,
md: styles.sizeMd,
lg: styles.sizeLg,
xl: styles.sizeXl,
};
export const Modal: React.FC<ModalProps> = ({
open,
onClose,
title,
children,
footer,
size = 'md',
closeOnOverlayClick = false,
closeOnEscape = false,
hideCloseButton = false,
ariaLabel,
className,
contentClassName,
testId,
}) => {
const dialogRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
useEffect(() => {
if (!open || !closeOnEscape) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [open, closeOnEscape, onClose]);
if (!open) return null;
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!closeOnOverlayClick) return;
if (e.target === e.currentTarget) onClose();
};
const titleId = title ? 'modal-title' : undefined;
const node = (
<div
className={styles.overlay}
onClick={handleOverlayClick}
data-testid={testId}
>
<div
ref={dialogRef}
className={`${styles.modal} ${_SIZE_CLASS[size]} ${className ?? ''}`.trim()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-label={!title ? ariaLabel : undefined}
>
{(title || !hideCloseButton) && (
<div className={styles.header}>
{title ? (
<h2 id={titleId} className={styles.title}>{title}</h2>
) : <span aria-hidden="true" />}
{!hideCloseButton && (
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
>
</button>
)}
</div>
)}
<div className={`${styles.content} ${contentClassName ?? ''}`.trim()}>
{children}
</div>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
</div>
);
return createPortal(node, document.body);
};
export default Modal;

View file

@ -1,3 +0,0 @@
export { Modal } from './Modal';
export type { ModalProps, ModalSize } from './Modal';
export { default } from './Modal';

View file

@ -23,8 +23,6 @@ export interface PopupProps {
className?: string; className?: string;
size?: 'small' | 'medium' | 'large' | 'fullscreen'; size?: 'small' | 'medium' | 'large' | 'fullscreen';
closable?: boolean; closable?: boolean;
closeOnBackdropClick?: boolean;
closeOnEscape?: boolean;
actions?: PopupAction[]; actions?: PopupAction[];
} }
@ -38,8 +36,6 @@ export function Popup({
className = '', className = '',
size = 'medium', size = 'medium',
closable = true, closable = true,
closeOnBackdropClick = false,
closeOnEscape = true,
actions = [] actions = []
}: PopupProps) { }: PopupProps) {
const { t } = useLanguage(); const { t } = useLanguage();
@ -47,7 +43,7 @@ export function Popup({
// Handle escape key // Handle escape key
React.useEffect(() => { React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && closable && closeOnEscape) { if (e.key === 'Escape' && closable) {
onClose(); onClose();
} }
}; };
@ -62,13 +58,13 @@ export function Popup({
document.removeEventListener('keydown', handleEscape); document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
}; };
}, [isOpen, closable, closeOnEscape, onClose]); }, [isOpen, closable, onClose]);
if (!isOpen) return null; if (!isOpen) return null;
// Handle backdrop click // Handle backdrop click
const handleBackdropClick = (e: React.MouseEvent) => { const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && closable && closeOnBackdropClick) { if (e.target === e.currentTarget && closable) {
onClose(); onClose();
} }
}; };

View file

@ -1,29 +1,67 @@
/** /**
* VoiceLanguageSelect * VoiceLanguageSelect
* *
* Reusable picker for voice/speech-recognition language. Reads the language * Reusable component for selecting voice/speech recognition language.
* list from the central VoiceCatalog (single source of truth) never * Defaults to user's profile language.
* hard-coded here. * Can be used for speech-to-text, text-to-speech, and translation features.
*/ */
import React from 'react'; import React from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { useVoiceCatalog, useDefaultVoiceLocale } from '../../../contexts/VoiceCatalogContext';
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
import styles from './VoiceLanguageSelect.module.css'; import styles from './VoiceLanguageSelect.module.css';
export type VoiceLanguageOption = VoiceLanguage; // Voice language options with full locale codes for Google Cloud Speech
export interface VoiceLanguageOption {
code: string; // Full locale code (e.g., 'de-DE')
label: string; // Display label
shortCode: string; // Short code for mapping (e.g., 'de')
flag?: string; // Optional flag emoji
}
// Supported languages for speech recognition
export const voiceLanguages: VoiceLanguageOption[] = [
{ code: 'de-DE', label: 'Deutsch', shortCode: 'de', flag: '🇩🇪' },
{ code: 'de-CH', label: 'Deutsch (Schweiz)', shortCode: 'de', flag: '🇨🇭' },
{ code: 'en-US', label: 'English (US)', shortCode: 'en', flag: '🇺🇸' },
{ code: 'en-GB', label: 'English (UK)', shortCode: 'en', flag: '🇬🇧' },
{ code: 'fr-FR', label: 'Français', shortCode: 'fr', flag: '🇫🇷' },
{ code: 'fr-CH', label: 'Français (Suisse)', shortCode: 'fr', flag: '🇨🇭' },
{ code: 'it-IT', label: 'Italiano', shortCode: 'it', flag: '🇮🇹' },
{ code: 'it-CH', label: 'Italiano (Svizzera)', shortCode: 'it', flag: '🇨🇭' },
{ code: 'es-ES', label: 'Español', shortCode: 'es', flag: '🇪🇸' },
{ code: 'pt-BR', label: 'Português', shortCode: 'pt', flag: '🇧🇷' },
];
// Map user profile language (short code) to default voice language (full code)
const profileToVoiceLanguage: Record<string, string> = {
'de': 'de-DE',
'en': 'en-US',
'fr': 'fr-FR',
'it': 'it-IT',
'es': 'es-ES',
'pt': 'pt-BR',
};
export interface VoiceLanguageSelectProps { export interface VoiceLanguageSelectProps {
value: string; value: string;
onChange: (languageCode: string) => void; onChange: (languageCode: string) => void;
disabled?: boolean; disabled?: boolean;
compact?: boolean; compact?: boolean; // Compact mode shows only flag/short code
showFlags?: boolean; showFlags?: boolean; // Show flag emojis
className?: string; className?: string;
title?: string; title?: string;
} }
/**
* Get the default voice language based on user's profile language
*/
export const getDefaultVoiceLanguage = (profileLanguage?: string): string => {
if (profileLanguage && profileToVoiceLanguage[profileLanguage]) {
return profileToVoiceLanguage[profileLanguage];
}
return 'de-DE'; // Default fallback
};
export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({ export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
value, value,
onChange, onChange,
@ -33,8 +71,6 @@ export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
className = '', className = '',
title = 'Sprache für Spracherkennung', title = 'Sprache für Spracherkennung',
}) => { }) => {
const { languages, isLoading } = useVoiceCatalog();
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => { const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(e.target.value); onChange(e.target.value);
}; };
@ -45,13 +81,13 @@ export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
className={styles.select} className={styles.select}
value={value} value={value}
onChange={handleChange} onChange={handleChange}
disabled={disabled || isLoading} disabled={disabled}
title={title} title={title}
> >
{languages.map((lang) => ( {voiceLanguages.map((lang) => (
<option key={lang.bcp47} value={lang.bcp47}> <option key={lang.code} value={lang.code}>
{showFlags && lang.flag ? `${lang.flag} ` : ''} {showFlags && lang.flag ? `${lang.flag} ` : ''}
{compact ? lang.iso.toUpperCase() : lang.label} {compact ? lang.code.split('-')[0].toUpperCase() : lang.label}
</option> </option>
))} ))}
</select> </select>
@ -60,25 +96,28 @@ export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
}; };
/** /**
* Hook to manage voice language state with user profile default. * Hook to manage voice language state with user profile default
* Initial value falls back to the catalog-derived default for the profile language.
*/ */
export const useVoiceLanguage = (initialValue?: string) => { export const useVoiceLanguage = (initialValue?: string) => {
const { currentLanguage } = useLanguage(); const { currentLanguage } = useLanguage();
const { languages } = useVoiceCatalog();
const defaultLocale = useDefaultVoiceLocale(currentLanguage);
// Track if user has manually changed the language
const hasManuallyChanged = React.useRef(false); const hasManuallyChanged = React.useRef(false);
// Initialize with user's profile language (or provided initial value)
const [voiceLanguage, setVoiceLanguage] = React.useState<string>( const [voiceLanguage, setVoiceLanguage] = React.useState<string>(
initialValue || defaultLocale, initialValue || getDefaultVoiceLanguage(currentLanguage)
); );
// Update voice language when user profile language changes (only if not manually set)
React.useEffect(() => { React.useEffect(() => {
if (!initialValue && !hasManuallyChanged.current) { if (!initialValue && !hasManuallyChanged.current) {
setVoiceLanguage(defaultLocale); const newDefault = getDefaultVoiceLanguage(currentLanguage);
setVoiceLanguage(newDefault);
} }
}, [defaultLocale, initialValue]); }, [currentLanguage, initialValue]);
// Wrapper to track manual changes
const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => { const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => {
hasManuallyChanged.current = true; hasManuallyChanged.current = true;
setVoiceLanguage(newLanguage); setVoiceLanguage(newLanguage);
@ -87,7 +126,7 @@ export const useVoiceLanguage = (initialValue?: string) => {
return { return {
voiceLanguage, voiceLanguage,
setVoiceLanguage: handleSetVoiceLanguage, setVoiceLanguage: handleSetVoiceLanguage,
voiceLanguages: languages, voiceLanguages,
}; };
}; };

View file

@ -1,6 +1,8 @@
export { export {
VoiceLanguageSelect, VoiceLanguageSelect,
useVoiceLanguage, useVoiceLanguage,
getDefaultVoiceLanguage,
voiceLanguages,
type VoiceLanguageOption, type VoiceLanguageOption,
type VoiceLanguageSelectProps, type VoiceLanguageSelectProps
} from './VoiceLanguageSelect'; } from './VoiceLanguageSelect';

View file

@ -21,4 +21,3 @@ export * from './Tabs';
export type { TabsProps, Tab } from './Tabs'; export type { TabsProps, Tab } from './Tabs';
export * from './Toast'; export * from './Toast';
export * from './VoiceLanguageSelect'; export * from './VoiceLanguageSelect';
export * from './Modal';

View file

@ -52,22 +52,6 @@ function _formatRelativeTime(dateStr?: string | number): string {
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
} }
/**
* Timestamp der letzten Aktivität eines Chats: `lastMessageAt` (Backend liefert
* den Zeitstempel der letzten Nachricht) bevorzugt, sonst Fallback auf
* `updatedAt` (Workflow-Lifecycle-Zeit). Wird sowohl für Anzeige als auch
* Sortierung verwendet, damit Liste und Label konsistent sind.
*/
function _lastTouchValue(chat: ChatItem): string | number | undefined {
return chat.lastMessageAt ?? chat.updatedAt;
}
function _lastTouchTs(chat: ChatItem): number {
const v = _lastTouchValue(chat);
if (v === undefined || v === null) return 0;
return typeof v === 'number' ? v : new Date(v).getTime();
}
const ChatsTab: React.FC<ChatsTabProps> = ({ context, const ChatsTab: React.FC<ChatsTabProps> = ({ context,
onSelectChat, onSelectChat,
onDragStart, onDragStart,
@ -129,7 +113,13 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
} }
const sorted = Array.from(groupMap.values()); const sorted = Array.from(groupMap.values());
sorted.forEach(g => g.chats.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a))); sorted.forEach(g =>
g.chats.sort((a, b) => {
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
return tb - ta;
}),
);
setGroups(sorted); setGroups(sorted);
if (expandedGroups.size === 0 && sorted.length > 0) { if (expandedGroups.size === 0 && sorted.length > 0) {
@ -228,9 +218,16 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
.map(g => ({ ...g, chats: _applyFilter(g.chats) })) .map(g => ({ ...g, chats: _applyFilter(g.chats) }))
.filter(g => g.chats.length > 0); .filter(g => g.chats.length > 0);
const _toTs = (v?: string | number): number =>
typeof v === 'number' ? v : new Date(v || 0).getTime();
const _allChats = _filteredGroups const _allChats = _filteredGroups
.flatMap(g => g.chats) .flatMap(g => g.chats)
.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a)); .sort((a, b) => {
const ta = _toTs(a.lastMessageAt ?? a.updatedAt);
const tb = _toTs(b.lastMessageAt ?? b.updatedAt);
return tb - ta;
});
const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0); 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 _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0);
@ -273,7 +270,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
) : ( ) : (
<> <>
<span className={styles.chatDate}> <span className={styles.chatDate}>
{_formatRelativeTime(_lastTouchValue(chat))} {_formatRelativeTime(chat.updatedAt)}
</span> </span>
<span <span
className={styles.chatLabel} className={styles.chatLabel}

View file

@ -10,10 +10,9 @@ import { useLanguage } from '../../providers/language/LanguageContext';
interface FilesTabProps { interface FilesTabProps {
context: UdbContext; context: UdbContext;
onFileSelect?: (fileId: string, fileName?: string) => void; onFileSelect?: (fileId: string, fileName?: string) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
} }
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat }) => { const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@ -47,8 +46,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
name: f.name, name: f.name,
parentId: f.parentId ?? null, parentId: f.parentId ?? null,
fileCount: f.fileCount ?? 0, fileCount: f.fileCount ?? 0,
neutralize: f.neutralize ?? false,
scope: f.scope ?? 'personal',
})); }));
}, [folders]); }, [folders]);
@ -169,26 +166,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
} }
}, [updateTreeFileNode, refreshTreeFiles]); }, [updateTreeFileNode, refreshTreeFiles]);
const _onFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
try {
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
await refreshFolders();
await refreshTreeFiles();
} catch (err) {
console.error('Failed to toggle folder neutralize:', err);
}
}, [refreshFolders, refreshTreeFiles]);
const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
try {
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
await refreshFolders();
await refreshTreeFiles();
} catch (err) {
console.error('Failed to change folder scope:', err);
}
}, [refreshFolders, refreshTreeFiles]);
if (treeFilesLoading && treeFileNodes.length === 0) { if (treeFilesLoading && treeFileNodes.length === 0) {
return <div className={styles.loading}>{t('Dateien laden')}</div>; return <div className={styles.loading}>{t('Dateien laden')}</div>;
} }
@ -279,9 +256,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onDownloadFolder={handleDownloadFolder} onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange} onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle} onNeutralizeToggle={_onNeutralizeToggle}
onFolderScopeChange={_onFolderScopeChange}
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
onSendToChat={onSendToChat}
/> />
{_fileNodes.length === 0 && ( {_fileNodes.length === 0 && (

File diff suppressed because it is too large Load diff

View file

@ -14,20 +14,6 @@ export interface UdbContext {
userId?: string; userId?: string;
} }
export interface AddToChat_FileItem {
id: string;
type: 'file' | 'folder';
name: string;
}
export interface AddToChat_FeatureSource {
featureInstanceId: string;
featureCode: string;
tableName?: string;
objectKey: string;
label: string;
}
interface UnifiedDataBarProps { interface UnifiedDataBarProps {
context: UdbContext; context: UdbContext;
activeTab?: UdbTab; activeTab?: UdbTab;
@ -41,9 +27,6 @@ interface UnifiedDataBarProps {
onChatDragStart?: (chatId: string, event: React.DragEvent) => void; onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
onFileSelect?: (fileId: string, fileName?: string) => void; onFileSelect?: (fileId: string, fileName?: string) => void;
onSourcesChanged?: () => void; onSourcesChanged?: () => void;
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
onAttachDataSource?: (dsId: string) => void;
className?: string; className?: string;
} }
@ -69,9 +52,6 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
onChatDragStart, onChatDragStart,
onFileSelect, onFileSelect,
onSourcesChanged, onSourcesChanged,
onSendToChat_Files,
onSendToChat_FeatureSource,
onAttachDataSource,
className, className,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -115,16 +95,10 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
<FilesTab <FilesTab
context={context} context={context}
onFileSelect={onFileSelect} onFileSelect={onFileSelect}
onSendToChat={onSendToChat_Files}
/> />
)} )}
{currentTab === 'sources' && !hideTabs?.includes('sources') && ( {currentTab === 'sources' && !hideTabs?.includes('sources') && (
<SourcesTab <SourcesTab context={context} onSourcesChanged={onSourcesChanged} />
context={context}
onSourcesChanged={onSourcesChanged}
onSendToChat_FeatureSource={onSendToChat_FeatureSource}
onAttachDataSource={onAttachDataSource}
/>
)} )}
</div> </div>
</div> </div>

View file

@ -1,3 +1,3 @@
export { default as UnifiedDataBar } from './UnifiedDataBar'; export { default as UnifiedDataBar } from './UnifiedDataBar';
export type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from './UnifiedDataBar'; export type { UdbContext, UdbTab } from './UnifiedDataBar';
export { useUdlContext } from './useUdlContext'; export { useUdlContext } from './useUdlContext';

View file

@ -83,8 +83,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.automation-logs': <FaClipboardList />, 'page.admin.automation-logs': <FaClipboardList />,
'page.admin.logs': <FaFileAlt />, 'page.admin.logs': <FaFileAlt />,
'page.admin.languages': <FaGlobe />, 'page.admin.languages': <FaGlobe />,
'page.admin.databaseHealth': <FaDatabase />,
'page.admin.database-health': <FaDatabase />,
'page.admin.demoConfig': <FaCubes />, 'page.admin.demoConfig': <FaCubes />,
'page.admin.demo-config': <FaCubes />, 'page.admin.demo-config': <FaCubes />,
'page.admin.mandate-wizard': <FaHatWizard />, 'page.admin.mandate-wizard': <FaHatWizard />,

View file

@ -1,151 +0,0 @@
/**
* VoiceCatalogContext
*
* Loads the central voice/language catalog from the backend exactly once and
* makes it available to every component via `useVoiceCatalog()`.
*
* Provides convenience helpers for ISO BCP-47 lookups and curated default
* voices, mirroring the backend `voiceCatalog` API. Components MUST NOT keep
* their own static language lists.
*/
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { fetchVoiceCatalog, VoiceLanguage } from '../api/voiceCatalogApi';
interface VoiceCatalogContextType {
languages: VoiceLanguage[];
isLoading: boolean;
error: string | null;
getByBcp47: (code: string | null | undefined) => VoiceLanguage | undefined;
getByIso: (iso: string | null | undefined) => VoiceLanguage | undefined;
isoToBcp47: (iso: string | null | undefined) => string | undefined;
getDefaultVoice: (bcp47: string | null | undefined) => string | null;
}
const VoiceCatalogContext = createContext<VoiceCatalogContextType | undefined>(undefined);
interface VoiceCatalogProviderProps {
children: ReactNode;
}
export const VoiceCatalogProvider: React.FC<VoiceCatalogProviderProps> = ({ children }) => {
const [languages, setLanguages] = useState<VoiceLanguage[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await fetchVoiceCatalog();
if (!cancelled) {
setLanguages(data);
setError(null);
}
} catch (err: any) {
if (!cancelled) {
setError(err?.message || 'Failed to load voice catalog');
setLanguages([]);
}
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const byBcp47 = useMemo(() => {
const map = new Map<string, VoiceLanguage>();
for (const v of languages) map.set(v.bcp47.toLowerCase(), v);
return map;
}, [languages]);
const byIso = useMemo(() => {
const map = new Map<string, VoiceLanguage>();
for (const v of languages) {
if (!map.has(v.iso.toLowerCase())) map.set(v.iso.toLowerCase(), v);
}
return map;
}, [languages]);
const getByBcp47 = useCallback(
(code: string | null | undefined) =>
code ? byBcp47.get(code.trim().toLowerCase()) : undefined,
[byBcp47],
);
const getByIso = useCallback(
(iso: string | null | undefined) =>
iso ? byIso.get(iso.trim().toLowerCase()) : undefined,
[byIso],
);
const isoToBcp47 = useCallback(
(iso: string | null | undefined): string | undefined => {
if (!iso) return undefined;
const trimmed = iso.trim();
if (!trimmed) return undefined;
if (trimmed.includes('-')) {
const canonical = byBcp47.get(trimmed.toLowerCase());
return canonical ? canonical.bcp47 : trimmed;
}
const entry = byIso.get(trimmed.toLowerCase());
if (entry) return entry.bcp47;
return `${trimmed.toLowerCase()}-${trimmed.toUpperCase()}`;
},
[byBcp47, byIso],
);
const getDefaultVoice = useCallback(
(bcp47: string | null | undefined): string | null => {
const entry = getByBcp47(bcp47);
return entry?.defaultVoice ?? null;
},
[getByBcp47],
);
const value = useMemo<VoiceCatalogContextType>(
() => ({
languages,
isLoading,
error,
getByBcp47,
getByIso,
isoToBcp47,
getDefaultVoice,
}),
[languages, isLoading, error, getByBcp47, getByIso, isoToBcp47, getDefaultVoice],
);
return (
<VoiceCatalogContext.Provider value={value}>{children}</VoiceCatalogContext.Provider>
);
};
export const useVoiceCatalog = (): VoiceCatalogContextType => {
const ctx = useContext(VoiceCatalogContext);
if (!ctx) {
throw new Error('useVoiceCatalog must be used within VoiceCatalogProvider');
}
return ctx;
};
/**
* Map a profile language (ISO short code) to a default voice locale.
* Returns the catalog's BCP-47 for the ISO if available, else falls back to
* `de-DE` so the UI always has a deterministic starting value.
*/
export const useDefaultVoiceLocale = (profileLanguage?: string | null): string => {
const { isoToBcp47 } = useVoiceCatalog();
return isoToBcp47(profileLanguage) || 'de-DE';
};

View file

@ -17,16 +17,14 @@ import {
deleteMandate as deleteMandateApi, deleteMandate as deleteMandateApi,
hardDeleteMandate as hardDeleteMandateApi, hardDeleteMandate as hardDeleteMandateApi,
type Mandate, type Mandate,
type MandateCreateData,
type MandateUpdateData, type MandateUpdateData,
type PaginationParams type PaginationParams
} from '../api/mandateApi'; } from '../api/mandateApi';
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm'; import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge'; import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
import { validateMandateName } from '../utils/mandateNameUtils';
// Re-export types // Re-export types
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams }; export type { Mandate, MandateUpdateData, PaginationParams };
export interface AttributeDefinition { export interface AttributeDefinition {
name: string; name: string;
@ -171,19 +169,7 @@ export function useAdminMandates() {
// Create mandate // Create mandate
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => { const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
try { try {
const label = typeof mandateData.label === 'string' ? mandateData.label.trim() : ''; const created = await createMandateApi(request, mandateData);
if (!label) {
console.error('createMandate: label (Voller Name) is required');
return null;
}
if (typeof mandateData.name === 'string' && mandateData.name.length > 0) {
const slugErr = validateMandateName(mandateData.name);
if (slugErr) {
console.error(`createMandate: invalid Kurzzeichen — ${slugErr}`);
return null;
}
}
const created = await createMandateApi(request, { ...mandateData, label } as MandateCreateData);
await fetchMandates(); await fetchMandates();
return created ?? null; return created ?? null;
} catch (error: any) { } catch (error: any) {
@ -195,21 +181,6 @@ export function useAdminMandates() {
// Update mandate // Update mandate
const handleUpdate = useCallback(async (mandateId: string, updateData: MandateUpdateData): Promise<boolean> => { const handleUpdate = useCallback(async (mandateId: string, updateData: MandateUpdateData): Promise<boolean> => {
try { try {
if ('label' in updateData) {
const lbl = typeof updateData.label === 'string' ? updateData.label.trim() : '';
if (!lbl) {
console.error('updateMandate: label (Voller Name) must not be empty');
return false;
}
updateData = { ...updateData, label: lbl };
}
if ('name' in updateData && typeof updateData.name === 'string') {
const slugErr = validateMandateName(updateData.name);
if (slugErr) {
console.error(`updateMandate: invalid Kurzzeichen — ${slugErr}`);
return false;
}
}
updateOptimistically(mandateId, updateData); updateOptimistically(mandateId, updateData);
await updateMandateApi(request, mandateId, updateData); await updateMandateApi(request, mandateId, updateData);
return true; return true;
@ -324,17 +295,9 @@ export function useMandateFormAttributes() {
}, [load]); }, [load]);
const formAttributes: FormGenAttr[] = useMemo(() => { const formAttributes: FormGenAttr[] = useMemo(() => {
const list = attributes return attributes
.filter(attr => attr.name !== 'id') .filter(attr => attr.name !== 'id')
.map(attr => ({ ...attr, type: attr.type })) as FormGenAttr[]; .map(attr => ({ ...attr, type: attr.type })) as FormGenAttr[];
const labelIdx = list.findIndex(a => a.name === 'label');
const nameIdx = list.findIndex(a => a.name === 'name');
if (labelIdx >= 0 && nameIdx >= 0 && nameIdx < labelIdx) {
const [labelAttr] = list.splice(labelIdx, 1);
list.splice(nameIdx, 0, labelAttr);
}
return list;
}, [attributes]); }, [attributes]);
const createFormAttributes: FormGenAttr[] = useMemo( const createFormAttributes: FormGenAttr[] = useMemo(

View file

@ -19,23 +19,12 @@ import {
} from '../api/storeApi'; } from '../api/storeApi';
import { useFeatureStore } from '../stores/featureStore'; import { useFeatureStore } from '../stores/featureStore';
/**
* Build a stable key identifying a single Store action button so the spinner
* can be scoped to exactly that button (one feature × one mandate / instance)
* instead of greying out every button of the feature.
*/
export const _storeActionKey = {
activate: (featureCode: string, mandateId?: string) => `activate:${featureCode}:${mandateId ?? ''}`,
deactivate: (featureCode: string, instanceId: string) => `deactivate:${featureCode}:${instanceId}`,
};
interface UseStoreReturn { interface UseStoreReturn {
features: StoreFeature[]; features: StoreFeature[];
mandates: UserMandate[]; mandates: UserMandate[];
subscriptionInfo: SubscriptionInfo | null; subscriptionInfo: SubscriptionInfo | null;
loading: boolean; loading: boolean;
/** Set of in-flight action keys (see ``_storeActionKey``) — one entry per button currently processing. */ actionLoading: string | null;
actionLoading: Set<string>;
error: string | null; error: string | null;
loadStore: () => Promise<void>; loadStore: () => Promise<void>;
loadSubscriptionInfo: (mandateId?: string) => Promise<void>; loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
@ -48,27 +37,10 @@ export function useStore(): UseStoreReturn {
const [mandates, setMandates] = useState<UserMandate[]>([]); const [mandates, setMandates] = useState<UserMandate[]>([]);
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null); const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<Set<string>>(() => new Set()); const [actionLoading, setActionLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const featureStore = useFeatureStore(); const featureStore = useFeatureStore();
const _markBusy = useCallback((key: string) => {
setActionLoading(prev => {
const next = new Set(prev);
next.add(key);
return next;
});
}, []);
const _markIdle = useCallback((key: string) => {
setActionLoading(prev => {
if (!prev.has(key)) return prev;
const next = new Set(prev);
next.delete(key);
return next;
});
}, []);
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => { const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
try { try {
const info = await fetchSubscriptionInfo(mandateId); const info = await fetchSubscriptionInfo(mandateId);
@ -109,8 +81,7 @@ export function useStore(): UseStoreReturn {
}, [featureStore, loadStore]); }, [featureStore, loadStore]);
const activate = useCallback(async (featureCode: string, mandateId?: string) => { const activate = useCallback(async (featureCode: string, mandateId?: string) => {
const key = _storeActionKey.activate(featureCode, mandateId); setActionLoading(featureCode);
_markBusy(key);
setError(null); setError(null);
try { try {
await activateStoreFeature(featureCode, mandateId); await activateStoreFeature(featureCode, mandateId);
@ -119,13 +90,12 @@ export function useStore(): UseStoreReturn {
const msg = err instanceof Error ? err.message : 'Activation failed'; const msg = err instanceof Error ? err.message : 'Activation failed';
setError(msg); setError(msg);
} finally { } finally {
_markIdle(key); setActionLoading(null);
} }
}, [_refreshAfterAction, _markBusy, _markIdle]); }, [_refreshAfterAction]);
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => { const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
const key = _storeActionKey.deactivate(featureCode, instanceId); setActionLoading(featureCode);
_markBusy(key);
setError(null); setError(null);
try { try {
await deactivateStoreFeature(featureCode, mandateId, instanceId); await deactivateStoreFeature(featureCode, mandateId, instanceId);
@ -134,9 +104,9 @@ export function useStore(): UseStoreReturn {
const msg = err instanceof Error ? err.message : 'Deactivation failed'; const msg = err instanceof Error ? err.message : 'Deactivation failed';
setError(msg); setError(msg);
} finally { } finally {
_markIdle(key); setActionLoading(null);
} }
}, [_refreshAfterAction, _markBusy, _markIdle]); }, [_refreshAfterAction]);
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate }; return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
} }

View file

@ -32,12 +32,11 @@ export function useCurrentUser() {
const cachedUser = getUserDataCache(); const cachedUser = getUserDataCache();
if (cachedUser && cachedUser.username) { if (cachedUser && cachedUser.username) {
// Use cached user data - permissions are checked via RBAC API, not client-side // Use cached user data - permissions are checked via RBAC API, not client-side
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead // Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin flag instead
setUser(cachedUser); setUser(cachedUser);
console.log('✅ Using cached user data from sessionStorage (persists during session):', { console.log('✅ Using cached user data from sessionStorage (persists during session):', {
username: cachedUser.username, username: cachedUser.username,
isSysAdmin: cachedUser.isSysAdmin, isSysAdmin: cachedUser.isSysAdmin
isPlatformAdmin: cachedUser.isPlatformAdmin
}); });
return; return;
} }
@ -73,7 +72,6 @@ export function useCurrentUser() {
console.log('📦 User data received from API:', { console.log('📦 User data received from API:', {
username: data?.username, username: data?.username,
isSysAdmin: data?.isSysAdmin, isSysAdmin: data?.isSysAdmin,
isPlatformAdmin: data?.isPlatformAdmin,
allKeys: data ? Object.keys(data) : [] allKeys: data ? Object.keys(data) : []
}); });
@ -87,12 +85,11 @@ export function useCurrentUser() {
} }
// Cache user data (permissions are checked via RBAC API) // Cache user data (permissions are checked via RBAC API)
// Note: roleLabels is deprecated - use isSysAdmin/isPlatformAdmin flags for admin checks // Note: roleLabels is deprecated - use isSysAdmin flag for admin checks
setUserDataCache(data); setUserDataCache(data);
console.log('✅ User data fetched from API and cached:', { console.log('✅ User data fetched from API and cached:', {
username: data.username, username: data.username,
isSysAdmin: data.isSysAdmin, isSysAdmin: data.isSysAdmin
isPlatformAdmin: data.isPlatformAdmin
}); });
setUser(data); setUser(data);
} catch (error: any) { } catch (error: any) {
@ -218,12 +215,11 @@ export function useCurrentUser() {
const cachedUser = getUserDataCache(); const cachedUser = getUserDataCache();
if (cachedUser && cachedUser.username) { if (cachedUser && cachedUser.username) {
// Use cached user data - permissions are checked via RBAC API // Use cached user data - permissions are checked via RBAC API
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead // Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin flag instead
setUser(cachedUser); setUser(cachedUser);
console.log('✅ Using cached user data from sessionStorage on mount:', { console.log('✅ Using cached user data from sessionStorage on mount:', {
username: cachedUser.username, username: cachedUser.username,
isSysAdmin: cachedUser.isSysAdmin, isSysAdmin: cachedUser.isSysAdmin
isPlatformAdmin: cachedUser.isPlatformAdmin
}); });
} }
@ -804,26 +800,24 @@ export function useUserOperations() {
} }
}; };
// Generic inline update handler for FormGeneratorTable. // Generic inline update handler for FormGeneratorTable
// // Must merge changes with existing row data because backend requires full object
// The User PUT endpoint accepts PARTIAL payloads — only fields explicitly // The existingRow parameter is passed from FormGeneratorTable which has access to row data
// present are applied; missing fields keep their stored value. We therefore const handleInlineUpdate = async (userId: string, changes: Partial<UserUpdateData>, existingRow?: any) => {
// forward ONLY the changed cells. This avoids two classes of bugs: if (!existingRow) {
// 1. Stale snapshot: spreading ``existingRow`` onto the payload would throw new Error(`Existing row data required for inline update`);
// overwrite fields with whatever the client last loaded, even if the
// backend has been updated since (e.g. by a parallel admin action).
// 2. Missing-field default-flip: previously, any non-listed field (e.g.
// ``isSysAdmin`` while toggling ``isPlatformAdmin``) was absent from
// the merged payload and the Pydantic ``User`` body on the backend
// filled it with ``False``, silently dropping the other privileged flag.
//
// ``existingRow`` is kept in the signature for forward-compat with table
// hooks but is no longer consulted to build the payload.
const handleInlineUpdate = async (userId: string, changes: Partial<UserUpdateData>, _existingRow?: any) => {
if (!changes || Object.keys(changes).length === 0) {
throw new Error('No fields to update');
} }
const result = await handleUserUpdate(userId, changes);
// Merge changes with existing row data (backend requires full object with required fields)
const mergedData: UserUpdateData = {
username: existingRow.username,
email: existingRow.email,
enabled: existingRow.enabled,
roleLabels: existingRow.roleLabels,
...changes
};
const result = await handleUserUpdate(userId, mergedData);
if (!result.success) { if (!result.success) {
throw new Error(result.error || 'Failed to update'); throw new Error(result.error || 'Failed to update');
} }

View file

@ -11,7 +11,6 @@ import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore'; import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore';
import useNavigation from '../hooks/useNavigation'; import useNavigation from '../hooks/useNavigation';
import styles from './FeatureLayout.module.css'; import styles from './FeatureLayout.module.css';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
@ -116,9 +115,7 @@ export const FeatureLayout: React.FC = () => {
{/* Header mit Instanz-Info */} {/* Header mit Instanz-Info */}
<header className={styles.featureHeader}> <header className={styles.featureHeader}>
<div className={styles.breadcrumb}> <div className={styles.breadcrumb}>
<span className={styles.mandateName}> <span className={styles.mandateName}>{navLabels?.mandate || mandate?.label || mandate?.name}</span>
{navLabels?.mandate || (mandate ? mandateDisplayLabel(mandate) : '')}
</span>
<span className={styles.separator}>/</span> <span className={styles.separator}>/</span>
<span className={styles.featureName}>{navLabels?.feature || feature?.code}</span> <span className={styles.featureName}>{navLabels?.feature || feature?.code}</span>
<span className={styles.separator}>/</span> <span className={styles.separator}>/</span>

View file

@ -320,10 +320,11 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
}, [steps]); }, [steps]);
return ( return (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={onClose}>
<div <div
className={styles.modal} className={styles.modal}
style={{ maxWidth: 800, height: '80vh' }} style={{ maxWidth: 800, height: '80vh' }}
onClick={(e) => e.stopPropagation()}
> >
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<div> <div>

View file

@ -19,7 +19,6 @@ import { useUserMandates } from '../hooks/useUserMandates';
import { useConfirm } from '../hooks/useConfirm'; import { useConfirm } from '../hooks/useConfirm';
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
import styles from './ComplianceAuditPage.module.css'; import styles from './ComplianceAuditPage.module.css';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32']; const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
@ -111,11 +110,7 @@ interface AuditStats {
neutralizationPercent: number; neutralizationPercent: number;
} }
interface Mandate { interface Mandate { id: string; name?: string; label?: string; }
id: string;
name?: string;
label?: string;
}
interface ContentModalData { interface ContentModalData {
row: any; row: any;
@ -559,7 +554,7 @@ export const ComplianceAuditPage: React.FC = () => {
> >
<option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option> <option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
{mandates.map(m => ( {mandates.map(m => (
<option key={m.id} value={m.id}>{mandateDisplayLabel(m)}</option> <option key={m.id} value={m.id}>{m.label || m.name || m.id}</option>
))} ))}
</select> </select>
</div> </div>
@ -820,8 +815,8 @@ export const ComplianceAuditPage: React.FC = () => {
{/* ── Content View Modal ── */} {/* ── Content View Modal ── */}
{contentModal && ( {contentModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setContentModal(null)}>
<div className={styles.modalContainer}> <div className={styles.modalContainer} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>{t('AI-Audit Inhalt')}</h3> <h3 className={styles.modalTitle}>{t('AI-Audit Inhalt')}</h3>
<div className={styles.modalMeta}> <div className={styles.modalMeta}>

View file

@ -8,7 +8,7 @@ import { PENDING_INVITATION_KEY } from './InvitePage';
import OnboardingWizard from '../components/OnboardingWizard'; import OnboardingWizard from '../components/OnboardingWizard';
import styles from './Login.module.css'; import styles from './Login.module.css';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
@ -131,9 +131,6 @@ function Login() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.mainContent}> <div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
<LanguageSelector />
</div>
<div className={styles.logo}> <div className={styles.logo}>
<img <img
src="/logos/poweron-logo.png" src="/logos/poweron-logo.png"

View file

@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
import styles from './PasswordResetRequest.module.css'; import styles from './PasswordResetRequest.module.css';
import { usePasswordResetRequest } from '../hooks/useAuthentication'; import { usePasswordResetRequest } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
@ -58,9 +57,6 @@ function PasswordResetRequest() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.mainContent}> <div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
<LanguageSelector />
</div>
<div className={styles.logo}> <div className={styles.logo}>
<img <img
src="/logos/poweron-logo.png" src="/logos/poweron-logo.png"

View file

@ -6,7 +6,6 @@ import styles from './Register.module.css';
import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication'; import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage'; import { PENDING_INVITATION_KEY } from './InvitePage';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
@ -17,7 +16,7 @@ interface RegisterFormData {
} }
function Register() { function Register() {
const { t, currentLanguage } = useLanguage(); const { t } = useLanguage();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { register, error: registerError, isLoading } = useRegister(); const { register, error: registerError, isLoading } = useRegister();
@ -92,7 +91,7 @@ function Register() {
return; return;
} }
await register({ ...formData, language: currentLanguage, registrationType: 'personal' }); await register({ ...formData, registrationType: 'personal' });
let message = t('Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.'); let message = t('Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.');
if (hasPendingInvitation) { if (hasPendingInvitation) {
@ -126,9 +125,6 @@ function Register() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.mainContent}> <div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
<LanguageSelector />
</div>
<div className={styles.logo}> <div className={styles.logo}>
<img <img
src="/logos/poweron-logo.png" src="/logos/poweron-logo.png"

View file

@ -4,7 +4,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import styles from './Reset.module.css'; import styles from './Reset.module.css';
import { usePasswordReset } from '../hooks/useAuthentication'; import { usePasswordReset } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
@ -99,9 +98,6 @@ function Reset() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.mainContent}> <div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
<LanguageSelector />
</div>
<div className={styles.logo}> <div className={styles.logo}>
<img <img
src="/logos/poweron-logo.png" src="/logos/poweron-logo.png"
@ -142,9 +138,6 @@ function Reset() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.mainContent}> <div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
<LanguageSelector />
</div>
<div className={styles.logo}> <div className={styles.logo}>
<img <img
src="/logos/poweron-logo.png" src="/logos/poweron-logo.png"

View file

@ -11,7 +11,6 @@ import { setUserDataCache, getUserDataCache } from '../utils/userCache';
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
import { useApiRequest } from '../hooks/useApi'; import { useApiRequest } from '../hooks/useApi';
import { useVoiceCatalog } from '../contexts/VoiceCatalogContext';
import styles from './Settings.module.css'; import styles from './Settings.module.css';
// ============================================================================= // =============================================================================
@ -69,8 +68,8 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.modalContent}> <div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2>{t('Profil bearbeiten')}</h2> <h2>{t('Profil bearbeiten')}</h2>
<button className={styles.closeButton} onClick={onClose}>&times;</button> <button className={styles.closeButton} onClick={onClose}>&times;</button>
@ -93,7 +92,6 @@ interface VoiceMapEntry { language: string; voiceName: string; }
const VoiceSettingsTab: React.FC = () => { const VoiceSettingsTab: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { request } = useApiRequest(); const { request } = useApiRequest();
const { languages: voiceCatalog } = useVoiceCatalog();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -102,6 +100,7 @@ const VoiceSettingsTab: React.FC = () => {
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [sttLanguage, setSttLanguage] = useState('de-DE'); const [sttLanguage, setSttLanguage] = useState('de-DE');
const [languages, setLanguages] = useState<any[]>([]);
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]); const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
const [addLanguage, setAddLanguage] = useState('de-DE'); const [addLanguage, setAddLanguage] = useState('de-DE');
@ -112,7 +111,13 @@ const VoiceSettingsTab: React.FC = () => {
const _loadSettings = useCallback(async () => { const _loadSettings = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const prefsData = await request({ url: '/api/voice/preferences', method: 'get' }); const [prefsData, languagesData] = await Promise.all([
request({ url: '/api/voice/preferences', method: 'get' }),
request({ url: '/api/voice/languages', method: 'get' }),
]);
const langList = (languagesData as any)?.languages || [];
setLanguages(langList);
const prefs = prefsData as any; const prefs = prefsData as any;
setSttLanguage(prefs?.sttLanguage || 'de-DE'); setSttLanguage(prefs?.sttLanguage || 'de-DE');
@ -198,9 +203,16 @@ const VoiceSettingsTab: React.FC = () => {
}, [request]); }, [request]);
const _getLanguageName = useCallback((code: string) => { const _getLanguageName = useCallback((code: string) => {
const entry = voiceCatalog.find(l => l.bcp47.toLowerCase() === code.toLowerCase()); const found = languages.find((l: any) => (l.code || l) === code);
return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code; return found?.name || found?.code || code;
}, [voiceCatalog]); }, [languages]);
const _defaultLangs = [
{ code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' },
{ code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' },
{ code: 'es-ES', name: 'Espanol' },
];
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Einstellungen werden geladen')}</div>; if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Einstellungen werden geladen')}</div>;
@ -218,10 +230,8 @@ const VoiceSettingsTab: React.FC = () => {
</div> </div>
<div className={styles.settingControl}> <div className={styles.settingControl}>
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}> <select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
{voiceCatalog.map(lang => ( {_displayLanguages.map((lang: any) => (
<option key={lang.bcp47} value={lang.bcp47}> <option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
{lang.flag ? `${lang.flag} ` : ''}{lang.label}
</option>
))} ))}
</select> </select>
</div> </div>
@ -264,10 +274,8 @@ const VoiceSettingsTab: React.FC = () => {
<div> <div>
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>{t('Sprache')}</label> <label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>{t('Sprache')}</label>
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}> <select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
{voiceCatalog.map(lang => ( {_displayLanguages.map((lang: any) => (
<option key={lang.bcp47} value={lang.bcp47}> <option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
{lang.flag ? `${lang.flag} ` : ''}{lang.label}
</option>
))} ))}
</select> </select>
</div> </div>

View file

@ -5,9 +5,9 @@
.store { .store {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
max-width: 1600px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
padding: 2rem clamp(1rem, 2vw, 2.5rem); padding: 2rem;
min-width: 0; min-width: 0;
} }
@ -78,9 +78,8 @@
/* Grid */ /* Grid */
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(260px, 22vw, 340px), 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem; gap: 1.25rem;
width: 100%;
} }
/* Card */ /* Card */
@ -172,47 +171,18 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem;
background: var(--surface-alt, rgba(0, 0, 0, 0.025));
border-radius: 8px;
} }
.instanceRow { .instanceRow {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
} }
.instanceInfo { .instanceInfo {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0; min-width: 0;
flex: 1; overflow: hidden;
}
.instanceLabel {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
line-height: 1.3;
overflow-wrap: anywhere;
}
.instanceMandate {
font-size: 0.75rem;
color: var(--text-secondary, #555);
font-weight: 400;
line-height: 1.3;
overflow-wrap: anywhere;
} }
.deactivateButtonSmall { .deactivateButtonSmall {
@ -356,23 +326,6 @@
color: var(--success-color, #34d399); color: var(--success-color, #34d399);
} }
:global(.dark-theme) .instanceList {
background: rgba(255, 255, 255, 0.03);
}
:global(.dark-theme) .instanceRow {
background: var(--surface-dark, #1f1f1f);
border-color: var(--border-dark, #333);
}
:global(.dark-theme) .instanceLabel {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .instanceMandate {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .statusInactive { :global(.dark-theme) .statusInactive {
background: var(--surface-dark, #2a2a2a); background: var(--surface-dark, #2a2a2a);
color: var(--text-secondary-dark, #aaa); color: var(--text-secondary-dark, #aaa);

View file

@ -5,10 +5,9 @@
*/ */
import React from 'react'; import React from 'react';
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram, FaShieldAlt } from 'react-icons/fa'; import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; import { useStore } from '../hooks/useStore';
import { useStore, _storeActionKey } from '../hooks/useStore';
import type { StoreFeature, UserMandate } from '../api/storeApi'; import type { StoreFeature, UserMandate } from '../api/storeApi';
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize'; import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
import styles from './Store.module.css'; import styles from './Store.module.css';
@ -19,7 +18,6 @@ const FEATURE_ICONS: Record<string, React.ReactNode> = {
teamsbot: <FaHeadset />, teamsbot: <FaHeadset />,
workspace: <FaComments />, workspace: <FaComments />,
commcoach: <FaComments />, commcoach: <FaComments />,
trustee: <FaShieldAlt />,
}; };
/** Fallback when GET /store/features omits description (German i18n keys). */ /** Fallback when GET /store/features omits description (German i18n keys). */
@ -29,7 +27,6 @@ const STORE_FEATURE_DESCRIPTION_FALLBACK: Record<string, string> = {
teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.', teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
workspace: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.', workspace: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.',
commcoach: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.', commcoach: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.',
trustee: 'Trustee: Intelligentes Dokumentenmanagement mit KI-gestützter Analyse und Verarbeitung.',
}; };
function _storeCardDescription(feature: StoreFeature): string { function _storeCardDescription(feature: StoreFeature): string {
@ -40,7 +37,7 @@ function _storeCardDescription(feature: StoreFeature): string {
interface FeatureCardProps { interface FeatureCardProps {
feature: StoreFeature; feature: StoreFeature;
mandates: UserMandate[]; mandates: UserMandate[];
actionLoading: Set<string>; actionLoading: string | null;
onActivate: (code: string, mandateId?: string) => void; onActivate: (code: string, mandateId?: string) => void;
onDeactivate: (code: string, mandateId: string, instanceId: string) => void; onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
} }
@ -53,6 +50,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
onDeactivate, onDeactivate,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const isProcessing = actionLoading === feature.featureCode;
const icon = FEATURE_ICONS[feature.featureCode]; const icon = FEATURE_ICONS[feature.featureCode];
const activeInstances = feature.instances.filter(inst => inst.isActive); const activeInstances = feature.instances.filter(inst => inst.isActive);
const hasActive = activeInstances.length > 0; const hasActive = activeInstances.length > 0;
@ -74,37 +72,23 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
{activeInstances.length > 0 && ( {activeInstances.length > 0 && (
<div className={styles.instanceList}> <div className={styles.instanceList}>
{activeInstances.map((inst) => { {activeInstances.map((inst) => (
const instanceLabel = (inst.label && inst.label.trim()) || feature.label; <div key={inst.instanceId} className={styles.instanceRow}>
const mandateLabel = inst.mandateName || ''; <div className={styles.instanceInfo}>
const deactivateKey = _storeActionKey.deactivate(feature.featureCode, inst.instanceId); <span className={`${styles.statusBadge} ${styles.statusActive}`}>
const isDeactivating = actionLoading.has(deactivateKey); <span className={styles.statusDot} />
return ( {inst.mandateName || inst.label}
<div key={inst.instanceId} className={styles.instanceRow}> </span>
<div className={styles.instanceInfo}>
<span className={styles.instanceLabel}>
<span
className={`${styles.statusDot} ${styles.statusActive}`}
aria-label={t('Aktiv')}
/>
{instanceLabel}
</span>
{mandateLabel && (
<span className={styles.instanceMandate}>
{t('Mandant')}: {mandateLabel}
</span>
)}
</div>
<button
className={styles.deactivateButtonSmall}
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
disabled={isDeactivating}
>
{isDeactivating ? '…' : t('Deaktivieren')}
</button>
</div> </div>
); <button
})} className={styles.deactivateButtonSmall}
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
disabled={isProcessing}
>
{isProcessing ? '...' : t('Deaktivieren')}
</button>
</div>
))}
</div> </div>
)} )}
@ -118,22 +102,18 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
)} )}
<div className={styles.cardActions}> <div className={styles.cardActions}>
{feature.canActivate && mandates.map((m) => { {feature.canActivate && mandates.map((m) => (
const activateKey = _storeActionKey.activate(feature.featureCode, m.id); <button
const isActivating = actionLoading.has(activateKey); key={m.id}
return ( className={styles.activateButton}
<button onClick={() => onActivate(feature.featureCode, m.id)}
key={m.id} disabled={isProcessing}
className={styles.activateButton} >
onClick={() => onActivate(feature.featureCode, m.id)} {isProcessing
disabled={isActivating} ? t('Wird aktiviert…')
> : t('Aktivieren für {name}', { name: String(m.label || m.name) })}
{isActivating </button>
? t('Wird aktiviert…') ))}
: t('Aktivieren für {name}', { name: mandateDisplayLabel(m) })}
</button>
);
})}
</div> </div>
</div> </div>
); );

View file

@ -24,10 +24,9 @@ import { FeatureInstanceWizard } from './wizards/FeatureInstanceWizard';
import { InstanceHierarchyView } from './InstanceHierarchyView'; import { InstanceHierarchyView } from './InstanceHierarchyView';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
function getMandateName(mandate: Mandate): string { function getMandateName(mandate: Mandate): string {
return mandateDisplayLabel(mandate); return mandate.label || mandate.name || mandate.id;
} }
function getFeatureLabel(feature: Feature): string { function getFeatureLabel(feature: Feature): string {

View file

@ -1,6 +0,0 @@
/**
* AdminDatabaseHealthPage Styles
*
* Minimal table rendering is handled by FormGeneratorTable.
* Only page-specific overrides live here.
*/

View file

@ -1,638 +0,0 @@
/**
* AdminDatabaseHealthPage
*
* SysAdmin-only page with two tabs:
* 1. Table Statistics pg_stat data for every table across all databases
* 2. Orphan Cleanup FK orphan detection with per-relation + batch cleanup
*
* Both tabs use FormGeneratorTable with a client-side pagination/sort/filter
* adapter (the backend returns all rows at once; the dataset is small enough).
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm';
import { Tabs } from '../../components/UiComponents/Tabs/Tabs';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TableStat {
id: string;
db: string;
table: string;
estimatedRows: number;
totalSizeBytes: number;
indexSizeBytes: number;
lastVacuum: string | null;
lastAnalyze: string | null;
}
interface OrphanEntry {
id: string;
sourceDb: string;
sourceTable: string;
sourceColumn: string;
targetDb: string;
targetTable: string;
targetColumn: string;
orphanCount: number;
}
interface CleanResult {
db: string;
table: string;
column: string;
deleted: number;
error?: string;
}
interface PaginationParams {
page?: number;
pageSize?: number;
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
interface PaginationMeta {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function _formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function _formatNumber(n: number): string {
return n.toLocaleString('de-CH');
}
// ---------------------------------------------------------------------------
// useClientPagination — adapts a static array to FormGeneratorTable's
// hookData.refetch / hookData.pagination contract.
// ---------------------------------------------------------------------------
function _useClientPagination<T extends Record<string, any>>(allData: T[]) {
const [visibleData, setVisibleData] = useState<T[]>([]);
const [pagination, setPagination] = useState<PaginationMeta>({
currentPage: 1, pageSize: 50, totalItems: 0, totalPages: 1,
});
const allDataRef = useRef(allData);
allDataRef.current = allData;
const lastParamsRef = useRef<PaginationParams>({});
const fetchFilterValues = useCallback(async (columnKey: string, crossFilters?: Record<string, any>) => {
let source = allDataRef.current;
if (crossFilters && Object.keys(crossFilters).length > 0) {
source = source.filter(row => {
for (const [key, val] of Object.entries(crossFilters)) {
if (val === undefined || val === null || val === '') continue;
const cell = String(row[key] ?? '');
if (Array.isArray(val)) {
if (val.length > 0 && !val.includes(cell)) return false;
} else {
if (cell !== String(val)) return false;
}
}
return true;
});
}
const seen = new Set<string>();
for (const row of source) {
const v = row[columnKey];
if (v !== undefined && v !== null && String(v).trim()) {
seen.add(String(v));
}
}
return Array.from(seen).sort();
}, []);
const refetch = useCallback(async (params?: PaginationParams) => {
const p = params || lastParamsRef.current;
lastParamsRef.current = p;
const source = allDataRef.current;
const page = p.page || 1;
const pageSize = p.pageSize || 50;
const search = (p.search || '').toLowerCase();
const filters = p.filters || {};
const sorts = p.sort || [];
// 1) Filter
let filtered = source.filter(row => {
for (const [key, val] of Object.entries(filters)) {
if (val === undefined || val === null || val === '') continue;
const cell = String(row[key] ?? '');
if (Array.isArray(val)) {
if (val.length > 0 && !val.includes(cell)) return false;
} else {
if (cell !== String(val)) return false;
}
}
return true;
});
// 2) Search
if (search) {
filtered = filtered.filter(row =>
Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search)),
);
}
// 3) Sort
if (sorts.length > 0) {
filtered.sort((a, b) => {
for (const s of sorts) {
const aVal = a[s.field];
const bVal = b[s.field];
let cmp = 0;
if (typeof aVal === 'number' && typeof bVal === 'number') {
cmp = aVal - bVal;
} else {
cmp = String(aVal ?? '').localeCompare(String(bVal ?? ''));
}
if (cmp !== 0) return s.direction === 'desc' ? -cmp : cmp;
}
return 0;
});
}
// 4) Paginate
const totalItems = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const paged = filtered.slice(start, start + pageSize);
setVisibleData(paged);
setPagination({ currentPage: safePage, pageSize, totalItems, totalPages });
}, []);
// Re-apply whenever allData changes
useEffect(() => {
refetch(lastParamsRef.current);
}, [allData, refetch]);
return { visibleData, pagination, refetch, fetchFilterValues };
}
// ---------------------------------------------------------------------------
// StatsTab
// ---------------------------------------------------------------------------
const StatsTab: React.FC = () => {
const { t } = useLanguage();
const [allStats, setAllStats] = useState<TableStat[]>([]);
const [loading, setLoading] = useState(false);
const [dbFilter, setDbFilter] = useState<string>('');
const _fetchStats = useCallback(async () => {
try {
setLoading(true);
const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : '';
const res = await api.get(`/api/admin/database-health/stats${params}`);
const rows = (res.data.stats || []).map((s: any, i: number) => ({
...s,
id: `${s.db}-${s.table}-${i}`,
}));
setAllStats(rows);
} catch {
setAllStats([]);
} finally {
setLoading(false);
}
}, [dbFilter]);
useEffect(() => { _fetchStats(); }, [_fetchStats]);
const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(allStats);
const databases = useMemo(
() => Array.from(new Set(allStats.map(s => s.db))).sort(),
[allStats],
);
const totals = useMemo(() => {
let rows = 0, size = 0, idx = 0;
for (const s of allStats) {
rows += s.estimatedRows;
size += s.totalSizeBytes;
idx += s.indexSizeBytes;
}
return { rows, size, idx, tables: allStats.length, dbs: databases.length };
}, [allStats, databases]);
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'db',
label: t('Datenbank'),
sortable: true,
filterable: true,
searchable: true,
width: 200,
filterOptions: databases,
},
{
key: 'table',
label: t('Tabelle'),
sortable: true,
searchable: true,
width: 200,
},
{
key: 'estimatedRows',
label: t('Zeilen (ca.)'),
type: 'number',
sortable: true,
width: 120,
formatter: (v: number) => _formatNumber(v),
},
{
key: 'totalSizeBytes',
label: t('Total Size'),
type: 'number',
sortable: true,
width: 120,
formatter: (v: number) => _formatBytes(v),
},
{
key: 'indexSizeBytes',
label: t('Index Size'),
type: 'number',
sortable: true,
width: 120,
formatter: (v: number) => _formatBytes(v),
},
{
key: 'lastVacuum',
label: t('Last Vacuum'),
sortable: true,
width: 170,
formatter: (v: string | null) => v ?? '—',
},
{
key: 'lastAnalyze',
label: t('Last Analyze'),
sortable: true,
width: 170,
formatter: (v: string | null) => v ?? '—',
},
], [t, databases]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{/* Controls */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label>
<select
className={styles.filterSelect}
value={dbFilter}
onChange={e => setDbFilter(e.target.value)}
>
<option value="">{t('Alle')}</option>
{databases.map(db => <option key={db} value={db}>{db}</option>)}
</select>
</div>
<button className={styles.secondaryButton} onClick={_fetchStats} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
{/* Summary */}
<div className={styles.filterSection} style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
<span className={styles.filterLabel}>{t('{dbs} Datenbanken', { dbs: totals.dbs })}</span>
<span className={styles.filterLabel}>{t('{tables} Tabellen', { tables: totals.tables })}</span>
<span className={styles.filterLabel}>{t('{rows} Zeilen (ca.)', { rows: _formatNumber(totals.rows) })}</span>
<span className={styles.filterLabel}>{t('Total {size}', { size: _formatBytes(totals.size) })}</span>
<span className={styles.filterLabel}>{t('Index {size}', { size: _formatBytes(totals.idx) })}</span>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable
data={visibleData}
columns={columns}
loading={loading}
sortable={true}
searchable={true}
filterable={true}
pagination={true}
pageSize={50}
selectable={false}
hookData={{
refetch,
pagination,
fetchFilterValues,
}}
emptyMessage={t('Keine Tabellen gefunden')}
/>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// OrphansTab
// ---------------------------------------------------------------------------
const OrphansTab: React.FC = () => {
const { t } = useLanguage();
const toast = useToast();
const { confirm, ConfirmDialog } = useConfirm();
const [allOrphans, setAllOrphans] = useState<OrphanEntry[]>([]);
const [loading, setLoading] = useState(false);
const [cleaning, setCleaning] = useState<string | null>(null);
const [cleaningAll, setCleaningAll] = useState(false);
const [onlyProblems, setOnlyProblems] = useState(true);
const [dbFilter, setDbFilter] = useState<string>('');
const _fetchOrphans = useCallback(async () => {
try {
setLoading(true);
const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : '';
const res = await api.get(`/api/admin/database-health/orphans${params}`);
const rows = (res.data.orphans || []).map((o: any, i: number) => ({
...o,
id: `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}-${i}`,
}));
setAllOrphans(rows);
} catch {
setAllOrphans([]);
} finally {
setLoading(false);
}
}, [dbFilter]);
useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]);
const displayed = useMemo(
() => onlyProblems ? allOrphans.filter(o => o.orphanCount > 0) : allOrphans,
[allOrphans, onlyProblems],
);
const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(displayed);
const databases = useMemo(
() => Array.from(new Set(allOrphans.map(o => o.sourceDb))).sort(),
[allOrphans],
);
const totalOrphans = useMemo(() => allOrphans.reduce((s, o) => s + o.orphanCount, 0), [allOrphans]);
const _cleanOne = async (o: OrphanEntry) => {
const ok = await confirm(
t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn }),
{ title: t('Orphans bereinigen'), variant: 'danger' },
);
if (!ok) return;
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
setCleaning(key);
try {
const res = await api.post('/api/admin/database-health/orphans/clean', {
db: o.sourceDb,
table: o.sourceTable,
column: o.sourceColumn,
});
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: res.data.deleted }));
_fetchOrphans();
} catch (err: any) {
toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen'));
} finally {
setCleaning(null);
}
};
const _cleanAll = async () => {
const ok = await confirm(
t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', {
count: totalOrphans,
relations: allOrphans.filter(o => o.orphanCount > 0).length,
}),
{ title: t('Alle Orphans bereinigen'), variant: 'danger' },
);
if (!ok) return;
setCleaningAll(true);
try {
const res = await api.post('/api/admin/database-health/orphans/clean-all');
const results: CleanResult[] = res.data.results || [];
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
const errors = results.filter(r => r.error);
if (errors.length > 0) {
toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted: totalDeleted, errors: errors.length }));
} else {
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted }));
}
_fetchOrphans();
} catch (err: any) {
toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen'));
} finally {
setCleaningAll(false);
}
};
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'sourceDb',
label: t('Source DB'),
sortable: true,
filterable: true,
searchable: true,
width: 180,
filterOptions: databases,
},
{
key: 'sourceTable',
label: t('Tabelle'),
sortable: true,
searchable: true,
width: 180,
},
{
key: 'sourceColumn',
label: t('FK-Spalte'),
sortable: true,
searchable: true,
width: 150,
},
{
key: 'targetTable',
label: t('Referenz'),
sortable: true,
width: 220,
formatter: (_val: string, row: OrphanEntry) => {
const isCrossDb = row.sourceDb !== row.targetDb;
return (
<span>
<code>{row.targetTable}.{row.targetColumn}</code>
{isCrossDb && (
<span style={{
marginLeft: '0.4rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
fontSize: '0.625rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.03em',
background: 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))',
color: 'var(--primary-color, #f25843)',
}}>
{t('cross-db')}
</span>
)}
</span>
);
},
},
{
key: 'orphanCount',
label: t('Orphans'),
type: 'number',
sortable: true,
width: 100,
formatter: (v: number) => (
<span style={v > 0 ? { color: 'var(--danger-color, #e53e3e)', fontWeight: 600 } : undefined}>
{_formatNumber(v)}
</span>
),
},
], [t, databases]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<ConfirmDialog />
{/* Controls */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label>
<select
className={styles.filterSelect}
value={dbFilter}
onChange={e => setDbFilter(e.target.value)}
>
<option value="">{t('Alle')}</option>
{databases.map(db => <option key={db} value={db}>{db}</option>)}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.checkboxLabel}>
<input type="checkbox" checked={onlyProblems} onChange={e => setOnlyProblems(e.target.checked)} />
{t('Nur Probleme')}
</label>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchOrphans} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
</button>
{totalOrphans > 0 && (
<button className={styles.dangerButton} onClick={_cleanAll} disabled={cleaningAll || loading}>
<FaBroom className={cleaningAll ? 'spinning' : ''} /> {t('Alle bereinigen')} ({_formatNumber(totalOrphans)})
</button>
)}
</div>
</div>
{totalOrphans > 0 && (
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
{t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', {
count: _formatNumber(totalOrphans),
relations: allOrphans.filter(o => o.orphanCount > 0).length,
})}
</div>
)}
<div className={styles.tableContainer}>
<FormGeneratorTable
data={visibleData}
columns={columns}
loading={loading}
sortable={true}
searchable={true}
filterable={true}
pagination={true}
pageSize={50}
selectable={false}
customActions={[
{
id: 'clean',
icon: <FaTrashAlt />,
onClick: (row: OrphanEntry) => _cleanOne(row),
visible: (row: OrphanEntry) => row.orphanCount > 0,
loading: (row: OrphanEntry) => cleaning === `${row.sourceDb}.${row.sourceTable}.${row.sourceColumn}` || cleaningAll,
title: t('Orphans löschen'),
},
]}
hookData={{
refetch,
pagination,
fetchFilterValues,
}}
emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')}
/>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export const AdminDatabaseHealthPage: React.FC = () => {
const { t } = useLanguage();
const tabs = useMemo(() => [
{
id: 'stats',
label: t('Statistiken'),
content: <StatsTab />,
},
{
id: 'orphans',
label: t('Orphan Cleanup'),
content: <OrphansTab />,
},
], [t]);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken und verwaiste Datensätze')}</p>
</div>
</div>
<Tabs tabs={tabs} defaultTabId="stats" />
</div>
);
};
export default AdminDatabaseHealthPage;

View file

@ -15,11 +15,11 @@ import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import api from '../../api'; import api from '../../api';
import { ChatbotConfigSection } from './ChatbotConfigSection'; import { ChatbotConfigSection } from './ChatbotConfigSection';
import { DropdownSelect } from '../../components/UiComponents/DropdownSelect';
import { TextField } from '../../components/UiComponents/TextField'; import { TextField } from '../../components/UiComponents/TextField';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
export const AdminFeatureAccessPage: React.FC = () => { export const AdminFeatureAccessPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -337,6 +337,11 @@ export const AdminFeatureAccessPage: React.FC = () => {
} }
}; };
// Get mandate name
const getMandateName = (mandate: Mandate) => {
return mandate.label || mandate.name || mandate.id;
};
// Get feature label // Get feature label
const getFeatureLabel = (code: string) => { const getFeatureLabel = (code: string) => {
const feature = features.find(f => f.code === code); const feature = features.find(f => f.code === code);
@ -381,7 +386,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<option value="">{t('Mandant wählen')}</option> <option value="">{t('Mandant wählen')}</option>
{mandates.map(m => ( {mandates.map(m => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{mandateDisplayLabel(m)} {getMandateName(m)}
</option> </option>
))} ))}
</select> </select>
@ -507,8 +512,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
{/* Create Instance Modal */} {/* Create Instance Modal */}
{showCreateModal && ( {showCreateModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz erstellen')}</h2> <h2 className={styles.modalTitle}>{t('Neue Feature-Instanz erstellen')}</h2>
<button <button
@ -528,38 +533,35 @@ export const AdminFeatureAccessPage: React.FC = () => {
</div> </div>
) : ( ) : (
<div> <div>
{/* Feature Code Selector — buttons instead of dropdown */} {/* Feature Code Selector - Required for chatbot config */}
<div className={styles.configField} style={{ marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border-color)' }}> <div className={styles.configField} style={{ marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border-color)' }}>
<label className={styles.configLabel} style={{ fontWeight: 600 }}> <label className={styles.configLabel} style={{ fontWeight: 600 }}>
{t('Feature auswählen')}: <span style={{ color: 'var(--error-color)' }}>*</span> {t('Feature auswählen')}: <span style={{ color: 'var(--error-color)' }}>*</span>
</label> </label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '0.5rem' }}> <DropdownSelect
{features.map(f => ( items={features.map(f => ({
<button id: f.code,
key={f.code} label: f.label || f.code,
type="button" value: f.code
className={styles.secondaryButton} }))}
style={{ selectedItemId={createFeatureCode}
padding: '0.5rem 1rem', onSelect={(item) => {
borderRadius: '6px', const selectedCode = item?.value || '';
cursor: 'pointer', setCreateFeatureCode(selectedCode);
fontWeight: createFeatureCode === f.code ? 600 : 400, // Reset chatbot config when switching
background: createFeatureCode === f.code ? 'var(--primary-color)' : undefined, setChatbotConnectors(['preprocessor']);
color: createFeatureCode === f.code ? '#fff' : undefined, setChatbotSystemPrompt('');
borderColor: createFeatureCode === f.code ? 'var(--primary-color)' : undefined, setChatbotEnableWebResearch(true);
}} setChatbotAllowedProviders([]);
onClick={() => { }}
setCreateFeatureCode(f.code); placeholder={t('Feature-Auswahl erforderlich')}
setChatbotConnectors(['preprocessor']); className={styles.configSelect}
setChatbotSystemPrompt(''); />
setChatbotEnableWebResearch(true); {!createFeatureCode && (
setChatbotAllowedProviders([]); <p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
}} {t('Bitte wählen Sie ein Feature aus, um fortzufahren.')}
> </p>
{f.label || f.code} )}
</button>
))}
</div>
</div> </div>
{/* Chatbot Configuration Title - Show when chatbot is selected */} {/* Chatbot Configuration Title - Show when chatbot is selected */}
@ -632,8 +634,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
{/* Edit Instance Modal */} {/* Edit Instance Modal */}
{showEditModal && editingInstance && ( {showEditModal && editingInstance && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => { setShowEditModal(false); setEditingInstance(null); }}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Feature-Instanz bearbeiten')}</h2> <h2 className={styles.modalTitle}>{t('Feature-Instanz bearbeiten')}</h2>
<button <button

View file

@ -17,7 +17,6 @@ import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
export const AdminFeatureInstanceUsersPage: React.FC = () => { export const AdminFeatureInstanceUsersPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -93,7 +92,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
allOptions.push({ allOptions.push({
mandateId: mandate.id, mandateId: mandate.id,
instanceId: inst.id, instanceId: inst.id,
mandateName: mandateDisplayLabel(mandate), mandateName: mandate.label || mandate.name || mandate.id,
instanceLabel: inst.label || inst.id, instanceLabel: inst.label || inst.id,
featureCode: inst.featureCode, featureCode: inst.featureCode,
combinedKey: `${mandate.id}:${inst.id}`, combinedKey: `${mandate.id}:${inst.id}`,
@ -562,8 +561,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{/* Add User Modal */} {/* Add User Modal */}
{showAddModal && ( {showAddModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer zur Feature-Instanz hinzufügen')}</h2> <h2 className={styles.modalTitle}>{t('Benutzer zur Feature-Instanz hinzufügen')}</h2>
<button <button
@ -595,8 +594,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{/* Edit Roles Modal */} {/* Edit Roles Modal */}
{editingUser && ( {editingUser && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}> <h2 className={styles.modalTitle}>
{t('Rollen bearbeiten')}: {editingUser.username} {t('Rollen bearbeiten')}: {editingUser.username}

View file

@ -397,8 +397,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
{/* Create Role Modal */} {/* Create Role Modal */}
{showCreateModal && ( {showCreateModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Feature-Rolle erstellen')}</h2> <h2 className={styles.modalTitle}>{t('Neue Feature-Rolle erstellen')}</h2>
<button <button
@ -430,8 +430,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
{/* Edit Role Modal */} {/* Edit Role Modal */}
{editingRole && ( {editingRole && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Feature-Rolle bearbeiten')}</h2> <h2 className={styles.modalTitle}>{t('Feature-Rolle bearbeiten')}</h2>
<button <button
@ -462,8 +462,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
{/* Permissions Modal */} {/* Permissions Modal */}
{permissionsRole && ( {permissionsRole && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setPermissionsRole(null)}>
<div className={styles.modal} style={{ maxWidth: '900px', width: '90%' }}> <div className={styles.modal} style={{ maxWidth: '900px', width: '90%' }} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}> <h2 className={styles.modalTitle}>
<FaShieldAlt style={{ marginRight: 8 }} /> <FaShieldAlt style={{ marginRight: 8 }} />

View file

@ -16,7 +16,6 @@ import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
export const AdminInvitationsPage: React.FC = () => { export const AdminInvitationsPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -236,6 +235,10 @@ export const AdminInvitationsPage: React.FC = () => {
} }
}; };
// Get mandate name
const getMandateName = (mandate: Mandate) => {
return mandate.label || mandate.name || mandate.id;
};
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
@ -277,7 +280,7 @@ export const AdminInvitationsPage: React.FC = () => {
<option value="">{t('Mandant wählen')}</option> <option value="">{t('Mandant wählen')}</option>
{mandates.map(m => ( {mandates.map(m => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{mandateDisplayLabel(m)} {getMandateName(m)}
</option> </option>
))} ))}
</select> </select>
@ -369,8 +372,8 @@ export const AdminInvitationsPage: React.FC = () => {
{/* Create Invitation Modal */} {/* Create Invitation Modal */}
{showCreateModal && ( {showCreateModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Einladung erstellen')}</h2> <h2 className={styles.modalTitle}>{t('Neue Einladung erstellen')}</h2>
<button <button
@ -408,8 +411,8 @@ export const AdminInvitationsPage: React.FC = () => {
{/* URL Display Modal */} {/* URL Display Modal */}
{showUrlModal && ( {showUrlModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowUrlModal(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Einladungs-Link')}</h2> <h2 className={styles.modalTitle}>{t('Einladungs-Link')}</h2>
<button <button

View file

@ -36,7 +36,6 @@ import {
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
// Types for cleanup result // Types for cleanup result
interface DuplicateGroup { interface DuplicateGroup {
@ -280,7 +279,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
> >
{mandates.map(mandate => ( {mandates.map(mandate => (
<option key={mandate.id} value={mandate.id}> <option key={mandate.id} value={mandate.id}>
{mandateDisplayLabel(mandate)} {mandate.label || getTextValue(mandate.name)}
</option> </option>
))} ))}
</select> </select>
@ -389,8 +388,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{/* Cleanup Duplicates Modal */} {/* Cleanup Duplicates Modal */}
{showCleanupModal && ( {showCleanupModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={_closeCleanupModal}>
<div className={styles.modal} style={{ maxWidth: '750px' }}> <div className={styles.modal} style={{ maxWidth: '750px' }} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h3 className={styles.modalTitle}> <h3 className={styles.modalTitle}>
<FaBroom style={{ marginRight: '0.5rem' }} /> <FaBroom style={{ marginRight: '0.5rem' }} />

View file

@ -25,7 +25,6 @@ import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
export const AdminMandateRolesPage: React.FC = () => { export const AdminMandateRolesPage: React.FC = () => {
const { t, currentLanguage } = useLanguage(); const { t, currentLanguage } = useLanguage();
@ -274,6 +273,11 @@ export const AdminMandateRolesPage: React.FC = () => {
setEditingRole(role); setEditingRole(role);
}; };
// Get mandate name
const getMandateName = (mandate: Mandate) => {
return mandate.label || mandate.name || mandate.id;
};
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
@ -330,7 +334,7 @@ export const AdminMandateRolesPage: React.FC = () => {
<option value="">{t('Mandant wählen')}</option> <option value="">{t('Mandant wählen')}</option>
{mandates.map(m => ( {mandates.map(m => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{mandateDisplayLabel(m)} {getMandateName(m)}
</option> </option>
))} ))}
</select> </select>
@ -430,8 +434,8 @@ export const AdminMandateRolesPage: React.FC = () => {
{/* Create Role Modal */} {/* Create Role Modal */}
{showCreateModal && ( {showCreateModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Rolle erstellen')}</h2> <h2 className={styles.modalTitle}>{t('Neue Rolle erstellen')}</h2>
<button <button
@ -464,8 +468,8 @@ export const AdminMandateRolesPage: React.FC = () => {
{/* Edit Role Modal */} {/* Edit Role Modal */}
{editingRole && ( {editingRole && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}> <h2 className={styles.modalTitle}>
{t('Rolle bearbeiten')}: {editingRole.roleLabel} {t('Rolle bearbeiten')}: {editingRole.roleLabel}

View file

@ -4,7 +4,7 @@
* Admin page for managing Mandates (tenants) using FormGeneratorTable. * Admin page for managing Mandates (tenants) using FormGeneratorTable.
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates'; import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
@ -16,9 +16,8 @@ import {
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { usePrompt } from '../../hooks/usePrompt'; import { usePrompt } from '../../hooks/usePrompt';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa'; import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
import { getUserDataCache } from '../../utils/userCache';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
@ -60,17 +59,6 @@ export const AdminMandatesPage: React.FC = () => {
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null); const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null); const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
// MandateAdmin: only label + billing fields editable; rest readonly
const _MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
const editFormAttrs: AttributeDefinition[] = useMemo(() => {
if (isPlatformAdmin) return formAttributesWithBilling;
return formAttributesWithBilling.map(attr =>
_MANDATE_ADMIN_EDITABLE.has(attr.name) ? attr : { ...attr, editable: false, readonly: true }
);
}, [formAttributesWithBilling, isPlatformAdmin]);
// Check if user can create // Check if user can create
const canCreate = permissions?.create !== 'n'; const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n'; const canUpdate = permissions?.update !== 'n';
@ -118,10 +106,7 @@ export const AdminMandatesPage: React.FC = () => {
const mandateId = String(editingFormData.id); const mandateId = String(editingFormData.id);
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data); const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial<Mandate>); const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial<Mandate>);
if (!mandateOk) { if (!mandateOk) return;
showWarning(t('Fehler'), t('Mandant konnte nicht gespeichert werden. Fehlende Berechtigung oder Serverfehler.'));
return;
}
try { try {
await updateSettingsAdmin(request, mandateId, billingUpdate); await updateSettingsAdmin(request, mandateId, billingUpdate);
showSuccess(t('Gespeichert'), t('Mandant und Abrechnung aktualisiert.')); showSuccess(t('Gespeichert'), t('Mandant und Abrechnung aktualisiert.'));
@ -138,15 +123,12 @@ export const AdminMandatesPage: React.FC = () => {
return; return;
} }
const entered = await prompt( const entered = await prompt(
t( t('Um den Mandanten "{name}" zu deaktivieren (Soft-Delete), geben Sie den Namen ein:', { name: mandate.name }),
'Um den Mandanten zu deaktivieren (Soft-Delete), geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).',
{ slug: mandate.name, label: mandate.label || mandate.name }
),
{ title: t('Mandat deaktivieren'), confirmLabel: t('Deaktivieren'), variant: 'danger', placeholder: mandate.name }, { title: t('Mandat deaktivieren'), confirmLabel: t('Deaktivieren'), variant: 'danger', placeholder: mandate.name },
); );
if (entered === null) return; if (entered === null) return;
if (entered !== mandate.name) { if (entered !== mandate.name) {
showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.')); showWarning(t('Abgebrochen'), t('Der eingegebene Name stimmt nicht überein.'));
return; return;
} }
await handleDelete(mandate.id); await handleDelete(mandate.id);
@ -158,23 +140,17 @@ export const AdminMandatesPage: React.FC = () => {
return; return;
} }
const entered = await prompt( const entered = await prompt(
t( t('ACHTUNG: Dies löscht den Mandanten "{name}" unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie den exakten Namen ein:', { name: mandate.name }),
'ACHTUNG: Dies löscht den Mandanten unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).',
{ slug: mandate.name, label: mandate.label || mandate.name }
),
{ title: t('Unwiderrufliches Löschen'), confirmLabel: t('Dauerhaft löschen'), variant: 'danger', placeholder: mandate.name }, { title: t('Unwiderrufliches Löschen'), confirmLabel: t('Dauerhaft löschen'), variant: 'danger', placeholder: mandate.name },
); );
if (entered === null) return; if (entered === null) return;
if (entered !== mandate.name) { if (entered !== mandate.name) {
showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.')); showWarning(t('Abgebrochen'), t('Der eingegebene Name stimmt nicht überein.'));
return; return;
} }
const ok = await handleHardDelete(mandate.id, entered); const ok = await handleHardDelete(mandate.id, entered);
if (ok) { if (ok) {
showSuccess( showSuccess(t('Gelöscht'), t('Mandant "{name}" wurde endgültig gelöscht.', { name: mandate.name }));
t('Gelöscht'),
t('Mandant «{name}» wurde endgültig gelöscht.', { name: mandate.label || mandate.name })
);
} }
}; };
@ -199,13 +175,7 @@ export const AdminMandatesPage: React.FC = () => {
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>{t('Mandanten')}</h1> <h1 className={styles.pageTitle}>{t('Mandanten')}</h1>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>{t('Verwalten Sie alle Mandanten im')}</p>
{t('Verwalten Sie alle Mandanten im')}
{' '}
{t(
'Der Volle Name erscheint in der Oberfläche; das Kurzzeichen ist systemweit eindeutig und dient Referenzierung und Bestätigungsabfragen.'
)}
</p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button <button
@ -283,8 +253,8 @@ export const AdminMandatesPage: React.FC = () => {
{/* Create Modal */} {/* Create Modal */}
{showCreateModal && ( {showCreateModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neuer Mandant')}</h2> <h2 className={styles.modalTitle}>{t('Neuer Mandant')}</h2>
<button <button
@ -323,8 +293,14 @@ export const AdminMandatesPage: React.FC = () => {
{/* Edit Modal */} {/* Edit Modal */}
{editingFormData && ( {editingFormData && (
<div className={styles.modalOverlay}> <div
<div className={styles.modal}> className={styles.modalOverlay}
onClick={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Mandant bearbeiten')}</h2> <h2 className={styles.modalTitle}>{t('Mandant bearbeiten')}</h2>
<button <button
@ -343,9 +319,7 @@ export const AdminMandatesPage: React.FC = () => {
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} /> <FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
<span> <span>
{t('Dies ist ein')} <strong>{t('System-Mandant')}</strong>.{' '} {t('Dies ist ein')} <strong>{t('System-Mandant')}</strong>.{' '}
{t( {t('Er kann nicht gelöscht werden und der Name sollte nicht geändert werden.')}
'Er kann nicht gelöscht werden. Das Kurzzeichen (technischer Identifier) soll nicht geändert werden; der Volle Name kann bei Bedarf angepasst werden.'
)}
</span> </span>
</div> </div>
)} )}
@ -364,7 +338,7 @@ export const AdminMandatesPage: React.FC = () => {
</div> </div>
) : ( ) : (
<FormGeneratorForm <FormGeneratorForm
attributes={editFormAttrs} attributes={formAttributesWithBilling}
data={editingFormData} data={editingFormData}
mode="edit" mode="edit"
onSubmit={handleEditSubmit} onSubmit={handleEditSubmit}

View file

@ -11,7 +11,6 @@ import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLineLabelThenSlug } from '../../utils/mandateDisplayUtils';
interface UserOption { interface UserOption {
id: string; id: string;
@ -19,7 +18,6 @@ interface UserOption {
email: string; email: string;
fullName: string; fullName: string;
isSysAdmin: boolean; isSysAdmin: boolean;
isPlatformAdmin: boolean;
enabled: boolean; enabled: boolean;
} }
@ -62,6 +60,14 @@ interface MandateInfo {
}[]; }[];
} }
function _mandateNameLine(mandate: MandateInfo): string {
const label = mandate.label?.trim();
if (label) {
return `${mandate.name} (${label})`;
}
return mandate.name;
}
function _roleDescriptionLine(role: RoleInfo): string { function _roleDescriptionLine(role: RoleInfo): string {
return role.description?.trim() || ''; return role.description?.trim() || '';
} }
@ -69,7 +75,6 @@ function _roleDescriptionLine(role: RoleInfo): string {
interface UserAccessOverview { interface UserAccessOverview {
user: UserOption; user: UserOption;
isSysAdmin: boolean; isSysAdmin: boolean;
isPlatformAdmin: boolean;
sysAdminNote?: string; sysAdminNote?: string;
roles: RoleInfo[]; roles: RoleInfo[];
mandates: MandateInfo[]; mandates: MandateInfo[];
@ -196,13 +201,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
{overview.isSysAdmin && ( {overview.isSysAdmin && (
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}> <div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} /> <FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
<span>{overview.sysAdminNote || t('Dieser Benutzer ist Systemadmin (Infrastruktur-Operator) und hat vollen Datenzugriff (RBAC-Bypass).')}</span> <span>{overview.sysAdminNote || t('Dieser Benutzer ist SysAdmin und hat vollen Systemzugriff.')}</span>
</div>
)}
{overview.isPlatformAdmin && (
<div className={styles.infoBox} style={{ background: '#dbeafe', borderColor: '#3b82f6' }}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#3b82f6' }} />
<span>{t('Dieser Benutzer ist Plattformadmin und kann mandantsübergreifend User, Mandate und RBAC-Regeln verwalten (kein RBAC-Bypass).')}</span>
</div> </div>
)} )}
@ -224,7 +223,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
) : ( ) : (
<FaChevronRight className={styles.expandIcon} /> <FaChevronRight className={styles.expandIcon} />
)} )}
<span className={styles.roleLabel}>{mandateDisplayLineLabelThenSlug(mandate)}</span> <span className={styles.roleLabel}>{_mandateNameLine(mandate)}</span>
<span className={styles.roleDescription}> <span className={styles.roleDescription}>
{t('{r} Mandantenrolle(n) · {i} Feature-Instanz(en)', { {t('{r} Mandantenrolle(n) · {i} Feature-Instanz(en)', {
r: mandateRoles.length, r: mandateRoles.length,
@ -624,7 +623,6 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
<option key={user.id} value={user.id}> <option key={user.id} value={user.id}>
{user.fullName || user.username} ({user.email}) {user.fullName || user.username} ({user.email})
{user.isSysAdmin && ` [${t('SysAdmin')}]`} {user.isSysAdmin && ` [${t('SysAdmin')}]`}
{user.isPlatformAdmin && ` [${t('PlatformAdmin')}]`}
</option> </option>
))} ))}
</select> </select>
@ -670,14 +668,6 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
</span> </span>
</> </>
)} )}
{overview.isPlatformAdmin && (
<>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span className={styles.badge} style={{ background: '#3b82f6', color: 'white' }}>
{t('PlatformAdmin')}
</span>
</>
)}
</div> </div>
{/* Tabs */} {/* Tabs */}

View file

@ -15,7 +15,6 @@ import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
export const AdminUserMandatesPage: React.FC = () => { export const AdminUserMandatesPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -251,6 +250,11 @@ export const AdminUserMandatesPage: React.FC = () => {
setEditingUser(user); setEditingUser(user);
}; };
// Get mandate name
const getMandateName = (mandate: Mandate) => {
return mandate.label || mandate.name || mandate.id;
};
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
@ -291,7 +295,7 @@ export const AdminUserMandatesPage: React.FC = () => {
<option value="">{t('Mandant wählen')}</option> <option value="">{t('Mandant wählen')}</option>
{mandates.map(m => ( {mandates.map(m => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{mandateDisplayLabel(m)} {getMandateName(m)}
</option> </option>
))} ))}
</select> </select>
@ -371,8 +375,8 @@ export const AdminUserMandatesPage: React.FC = () => {
{/* Add User Modal */} {/* Add User Modal */}
{showAddModal && ( {showAddModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2> <h2 className={styles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2>
<button <button
@ -407,8 +411,8 @@ export const AdminUserMandatesPage: React.FC = () => {
{/* Edit Roles Modal */} {/* Edit Roles Modal */}
{editingUser && ( {editingUser && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}> <h2 className={styles.modalTitle}>
{t('Rollen bearbeiten')}: {editingUser.username} {t('Rollen bearbeiten')}: {editingUser.username}

View file

@ -11,12 +11,9 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa'; import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { getUserDataCache } from '../../utils/userCache';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const;
interface User { interface User {
id: string; id: string;
username: string; username: string;
@ -24,7 +21,6 @@ interface User {
fullName: string; fullName: string;
enabled: boolean; enabled: boolean;
isSysAdmin?: boolean; isSysAdmin?: boolean;
isPlatformAdmin?: boolean;
[key: string]: any; [key: string]: any;
} }
@ -119,41 +115,17 @@ export const AdminUsersPage: React.FC = () => {
await handleSendPasswordLink(user.id); await handleSendPasswordLink(user.id);
}; };
// Privileged-flag gating mirrors the backend rules in routeDataUsers.update_user // Form attributes from backend - filter for create/edit forms
// and create_user: only a Platform-Admin may set isSysAdmin / isPlatformAdmin, const formAttributes = useMemo(() => {
// and even then never on themselves (Self-Protection).
const currentUserCache = getUserDataCache();
const callerIsPlatformAdmin = currentUserCache?.isPlatformAdmin === true;
const callerId = currentUserCache?.id;
const _buildFormAttributes = (mode: 'create' | 'edit', targetUserId?: string) => {
const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority']; const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority'];
const isSelfEdit = mode === 'edit' && targetUserId !== undefined && targetUserId === callerId;
// Caller may flip flags only when PlatformAdmin AND not editing themselves.
const flagsEditable = callerIsPlatformAdmin && !isSelfEdit;
return (attributes || []) return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name)) .filter(attr => !excludedFields.includes(attr.name))
.map(attr => { .map(attr => ({
if (_PRIVILEGED_FLAGS.includes(attr.name as any) && !flagsEditable) { ...attr,
return { ...attr, editable: false }; // Mark username as readonly for edit mode (will be handled by FormGeneratorForm)
} editable: attr.name === 'username' ? false : attr.editable,
if (attr.name === 'username') { }));
return { ...attr, editable: false }; }, [attributes]);
}
return attr;
});
};
const formAttributesCreate = useMemo(
() => _buildFormAttributes('create'),
[attributes, callerIsPlatformAdmin],
);
const formAttributesEdit = useMemo(
() => _buildFormAttributes('edit', editingUser?.id),
[attributes, callerIsPlatformAdmin, callerId, editingUser?.id],
);
if (error) { if (error) {
return ( return (
@ -258,8 +230,8 @@ export const AdminUsersPage: React.FC = () => {
{/* Create Modal */} {/* Create Modal */}
{showCreateModal && ( {showCreateModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neuer Benutzer')}</h2> <h2 className={styles.modalTitle}>{t('Neuer Benutzer')}</h2>
<button <button
@ -270,14 +242,14 @@ export const AdminUsersPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
{formAttributesCreate.length === 0 ? ( {formAttributes.length === 0 ? (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner} /> <div className={styles.spinner} />
<span>{t('Lade Formular')}</span> <span>{t('Lade Formular')}</span>
</div> </div>
) : ( ) : (
<FormGeneratorForm <FormGeneratorForm
attributes={formAttributesCreate} attributes={formAttributes}
mode="create" mode="create"
onSubmit={handleCreateSubmit} onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)} onCancel={() => setShowCreateModal(false)}
@ -292,8 +264,8 @@ export const AdminUsersPage: React.FC = () => {
{/* Edit Modal */} {/* Edit Modal */}
{editingUser && ( {editingUser && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer bearbeiten')}</h2> <h2 className={styles.modalTitle}>{t('Benutzer bearbeiten')}</h2>
<button <button
@ -304,14 +276,14 @@ export const AdminUsersPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
{formAttributesEdit.length === 0 ? ( {formAttributes.length === 0 ? (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner} /> <div className={styles.spinner} />
<span>{t('Lade Formular')}</span> <span>{t('Lade Formular')}</span>
</div> </div>
) : ( ) : (
<FormGeneratorForm <FormGeneratorForm
attributes={formAttributesEdit} attributes={formAttributes}
data={editingUser} data={editingUser}
mode="edit" mode="edit"
onSubmit={handleEditSubmit} onSubmit={handleEditSubmit}

View file

@ -292,8 +292,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
]; ];
return ( return (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={onClose}>
<div className={`${styles.modal} ${modalStyles.modal}`}> <div className={`${styles.modal} ${modalStyles.modal}`} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<div> <div>
<h2 className={styles.modalTitle}>{instance.label}</h2> <h2 className={styles.modalTitle}>{instance.label}</h2>
@ -311,8 +311,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
</div> </div>
{showAddModal && ( {showAddModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer hinzufügen')}</h2> <h2 className={styles.modalTitle}>{t('Benutzer hinzufügen')}</h2>
<button type="button" className={styles.modalClose} onClick={() => setShowAddModal(false)}> <button type="button" className={styles.modalClose} onClick={() => setShowAddModal(false)}>
@ -340,8 +340,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
)} )}
{editingUser && ( {editingUser && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}> <h2 className={styles.modalTitle}>
{t('Rollen')}: {editingUser.username} {t('Rollen')}: {editingUser.username}

View file

@ -18,4 +18,3 @@ export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
export { AdminLogsPage } from './AdminLogsPage'; export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage'; export { AdminLanguagesPage } from './AdminLanguagesPage';
export { AdminDemoConfigPage } from './AdminDemoConfigPage'; export { AdminDemoConfigPage } from './AdminDemoConfigPage';
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';

View file

@ -16,7 +16,6 @@ import { useToast } from '../../../contexts/ToastContext';
import styles from '../Admin.module.css'; import styles from '../Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
type InviteType = 'mandate' | 'featureInstance'; type InviteType = 'mandate' | 'featureInstance';
@ -115,7 +114,9 @@ export const AdminInvitationWizardPage: React.FC = () => {
// HELPERS // HELPERS
// ========================================================================== // ==========================================================================
const getMandateName = (m: Mandate): string => mandateDisplayLabel(m); const getMandateName = (m: Mandate): string => {
return m.label || m.name || m.id;
};
// ========================================================================== // ==========================================================================
// DATA LOADING // DATA LOADING

View file

@ -15,14 +15,13 @@ import {
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import { useMandateFormAttributes } from '../../../hooks/useMandates'; import { useMandateFormAttributes } from '../../../hooks/useMandates';
import { createMandate, type MandateCreateData } from '../../../api/mandateApi'; import { createMandate } from '../../../api/mandateApi';
import { updateSettingsAdmin } from '../../../api/billingApi'; import { updateSettingsAdmin } from '../../../api/billingApi';
import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge'; import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge';
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
import styles from '../Admin.module.css'; import styles from '../Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
const TOTAL_STEPS = 4; const TOTAL_STEPS = 4;
@ -104,8 +103,9 @@ export const AdminMandateWizardPage: React.FC = () => {
// HELPERS // HELPERS
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
const getMandateName = (m: Mandate | Record<string, any>): string => const getMandateName = (m: Mandate | Record<string, any>): string => {
mandateDisplayLabel(m as { label?: string | null; name?: string | null; id?: string }); return m.label || m.name || m.id;
};
const getFeatureLabel = (code: string): string => { const getFeatureLabel = (code: string): string => {
const f = features.find(feat => feat.code === code); const f = features.find(feat => feat.code === code);
@ -216,10 +216,9 @@ export const AdminMandateWizardPage: React.FC = () => {
setError(null); setError(null);
try { try {
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data); const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
const body: MandateCreateData = { const body = {
...(mandatePayload as Record<string, unknown>), ...mandatePayload,
label: String(mandatePayload.label ?? '').trim(), enabled: mandatePayload.enabled !== undefined ? mandatePayload.enabled : true,
enabled: typeof mandatePayload.enabled === 'boolean' ? mandatePayload.enabled : true,
}; };
const created = await createMandate(request, body); const created = await createMandate(request, body);
let billingSaved = false; let billingSaved = false;

View file

@ -1,100 +1,5 @@
.modal { .modal {
max-width: 640px; max-width: 520px;
}
/* Step 1 — selectable cards (mandate / feature) */
.fieldLabel {
display: block;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.required {
color: var(--primary-color, #f25843);
margin-left: 0.25rem;
}
.cardGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.5rem;
}
.cardButton {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0.75rem 0.875rem;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary, rgba(255, 255, 255, 0.03));
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
min-height: 44px;
}
.cardButton:hover {
border-color: rgba(242, 88, 67, 0.5);
background: rgba(242, 88, 67, 0.06);
}
.cardButton:focus-visible {
outline: 2px solid var(--primary-color, #f25843);
outline-offset: 2px;
}
.cardButtonActive {
border-color: rgba(242, 88, 67, 0.7);
background: rgba(242, 88, 67, 0.18);
color: var(--primary-color, #f25843);
box-shadow: 0 0 0 1px rgba(242, 88, 67, 0.3) inset;
}
.cardButton:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.textInput {
width: 100%;
padding: 0.55rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.9rem;
box-sizing: border-box;
}
.textInput:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.fieldError {
margin-top: 0.35rem;
font-size: 0.8rem;
color: var(--primary-color, #f25843);
}
.fieldGroup {
display: flex;
flex-direction: column;
}
.fieldHint {
margin-top: 0.35rem;
font-size: 0.78rem;
color: var(--text-secondary);
} }
.steps { .steps {

View file

@ -6,6 +6,7 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useFeatureAccess } from '../../../hooks/useFeatureAccess'; import { useFeatureAccess } from '../../../hooks/useFeatureAccess';
import { FormGeneratorForm, type AttributeDefinition } from '../../../components/FormGenerator/FormGeneratorForm';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api'; import api from '../../../api';
import type { Mandate } from '../../../hooks/useUserMandates'; import type { Mandate } from '../../../hooks/useUserMandates';
@ -14,10 +15,9 @@ import styles from '../Admin.module.css';
import wizardStyles from './FeatureInstanceWizard.module.css'; import wizardStyles from './FeatureInstanceWizard.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
function getMandateName(m: Mandate): string { function getMandateName(m: Mandate): string {
return mandateDisplayLabel(m); return m.label || m.name || m.id;
} }
export interface FeatureInstanceWizardProps { export interface FeatureInstanceWizardProps {
@ -55,25 +55,45 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
const [mandateUsers, setMandateUsers] = useState<Array<{ id: string; username: string; email?: string }>>([]); const [mandateUsers, setMandateUsers] = useState<Array<{ id: string; username: string; email?: string }>>([]);
const [instanceRoles, setInstanceRoles] = useState<Array<{ id: string; roleLabel: string }>>([]); const [instanceRoles, setInstanceRoles] = useState<Array<{ id: string; roleLabel: string }>>([]);
const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]); const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]);
const [labelTouched, setLabelTouched] = useState(false);
const trimmedLabel = label.trim(); const featureOptions = useMemo(
const labelMissing = trimmedLabel.length === 0; () => features.map((f) => ({ value: f.code, label: f.label || f.code })),
const canSubmitStep1 = !!mandateId && !!featureCode && !labelMissing && !submitting; [features]
);
const mandateOptions = useMemo(
() => mandates.map((m) => ({ value: m.id, label: getMandateName(m) })),
[mandates]
);
const handleStep1Submit = async () => { const createFields: AttributeDefinition[] = useMemo(
setLabelTouched(true); () => [
if (!canSubmitStep1) return; { name: 'mandateId', label: t('Mandant'), type: 'enum' as const, required: true, options: mandateOptions },
{ name: 'featureCode', label: t('Feature'), type: 'enum' as const, required: true, options: featureOptions },
{ name: 'label', label: t('Bezeichnung'), type: 'string' as const, required: true, editable: true },
{ name: 'enabled', label: t('Aktiv'), type: 'boolean' as const, required: false, editable: true },
],
[mandateOptions, featureOptions]
);
const handleStep1Submit = async (data: {
mandateId: string;
featureCode: string;
label: string;
enabled?: boolean;
}) => {
setSubmitting(true); setSubmitting(true);
try { try {
const result = await createInstance(mandateId, { const result = await createInstance(data.mandateId, {
featureCode, featureCode: data.featureCode,
label: trimmedLabel, label: data.label,
enabled, enabled: data.enabled !== false,
copyTemplateRoles, copyTemplateRoles: copyTemplateRoles,
}); });
if (result.success && result.data) { if (result.success && result.data) {
setLabel(trimmedLabel); setMandateId(data.mandateId);
setFeatureCode(data.featureCode);
setLabel(data.label);
setEnabled(data.enabled !== false);
setCreatedInstanceId(result.data.id); setCreatedInstanceId(result.data.id);
setStep(1); setStep(1);
} else { } else {
@ -145,8 +165,8 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
const currentStepId = steps[step]?.id; const currentStepId = steps[step]?.id;
return ( return (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={onClose}>
<div className={`${styles.modal} ${wizardStyles.modal}`}> <div className={`${styles.modal} ${wizardStyles.modal}`} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz')}</h2> <h2 className={styles.modalTitle}>{t('Neue Feature-Instanz')}</h2>
<button type="button" className={styles.modalClose} onClick={onClose} aria-label={t('Schließen')}> <button type="button" className={styles.modalClose} onClick={onClose} aria-label={t('Schließen')}>
@ -169,86 +189,20 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
<div className={styles.modalContent}> <div className={styles.modalContent}>
{currentStepId === 'create' && ( {currentStepId === 'create' && (
<div className={wizardStyles.stepContent}> <div className={wizardStyles.stepContent}>
<div className={wizardStyles.fieldGroup}> <FormGeneratorForm
<span className={wizardStyles.fieldLabel}> attributes={createFields}
{t('Mandant')}<span className={wizardStyles.required}>*</span> mode="create"
</span> data={{
{mandates.length === 0 ? ( mandateId: mandateId || (mandates[0]?.id ?? ''),
<p className={wizardStyles.fieldHint}>{t('Keine Mandanten verfügbar')}</p> featureCode: featureCode || (features[0]?.code ?? ''),
) : ( label,
<div className={wizardStyles.cardGrid}> enabled,
{mandates.map((m) => { }}
const isActive = mandateId === m.id; onSubmit={handleStep1Submit}
return ( onCancel={onClose}
<button submitButtonText={t('Weiter')}
key={m.id} cancelButtonText={t('Abbrechen')}
type="button" />
className={`${wizardStyles.cardButton} ${isActive ? wizardStyles.cardButtonActive : ''}`}
onClick={() => setMandateId(m.id)}
aria-pressed={isActive}
>
{getMandateName(m)}
</button>
);
})}
</div>
)}
</div>
<div className={wizardStyles.fieldGroup}>
<span className={wizardStyles.fieldLabel}>
{t('Feature')}<span className={wizardStyles.required}>*</span>
</span>
{features.length === 0 ? (
<p className={wizardStyles.fieldHint}>{t('Keine Features verfügbar')}</p>
) : (
<div className={wizardStyles.cardGrid}>
{features.map((f) => {
const isActive = featureCode === f.code;
return (
<button
key={f.code}
type="button"
className={`${wizardStyles.cardButton} ${isActive ? wizardStyles.cardButtonActive : ''}`}
onClick={() => setFeatureCode(f.code)}
aria-pressed={isActive}
>
{f.label || f.code}
</button>
);
})}
</div>
)}
</div>
<div className={wizardStyles.fieldGroup}>
<label className={wizardStyles.fieldLabel} htmlFor="featureInstanceLabel">
{t('Bezeichnung')}<span className={wizardStyles.required}>*</span>
</label>
<input
id="featureInstanceLabel"
type="text"
className={wizardStyles.textInput}
value={label}
onChange={(e) => setLabel(e.target.value)}
onBlur={() => setLabelTouched(true)}
placeholder={t('z. B. Vertrieb DE')}
autoComplete="off"
/>
{labelTouched && labelMissing && (
<p className={wizardStyles.fieldError}>{t('Bezeichnung ist erforderlich.')}</p>
)}
</div>
<label className={wizardStyles.checkLabel}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{t('Aktiv')}
</label>
<label className={wizardStyles.checkLabel}> <label className={wizardStyles.checkLabel}>
<input <input
type="checkbox" type="checkbox"
@ -257,21 +211,6 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
/> />
{t('Rollen von Feature-Vorlage übernehmen (empfohlen)')} {t('Rollen von Feature-Vorlage übernehmen (empfohlen)')}
</label> </label>
<div className={wizardStyles.stepActions}>
<button type="button" className={styles.secondaryButton} onClick={onClose}>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleStep1Submit}
disabled={!canSubmitStep1}
title={!canSubmitStep1 ? t('Bitte Mandant, Feature und Bezeichnung wählen.') : undefined}
>
{submitting ? t('Speichern…') : t('Weiter')}
</button>
</div>
</div> </div>
)} )}

View file

@ -363,8 +363,8 @@ export const ConnectionsPage: React.FC = () => {
{/* Edit Modal */} {/* Edit Modal */}
{editingConnection && ( {editingConnection && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingConnection(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Verbindung bearbeiten')}</h2> <h2 className={styles.modalTitle}>{t('Verbindung bearbeiten')}</h2>
<button <button

View file

@ -142,24 +142,6 @@ export const FilesPage: React.FC = () => {
} }
}, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]); }, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]);
const _handleFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
try {
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
} catch (err) {
console.error('Failed to update folder scope:', err);
}
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
const _handleFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
try {
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
} catch (err) {
console.error('Failed to toggle folder neutralize:', err);
}
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
// ── Folder nodes for tree (real folders only) ──────────────────────── // ── Folder nodes for tree (real folders only) ────────────────────────
const folderNodes = useMemo(() => { const folderNodes = useMemo(() => {
return folders.map(f => ({ return folders.map(f => ({
@ -167,8 +149,6 @@ export const FilesPage: React.FC = () => {
name: f.name, name: f.name,
parentId: f.parentId ?? null, parentId: f.parentId ?? null,
fileCount: f.fileCount ?? 0, fileCount: f.fileCount ?? 0,
neutralize: f.neutralize ?? false,
scope: f.scope ?? 'personal',
})); }));
}, [folders]); }, [folders]);
@ -446,8 +426,6 @@ export const FilesPage: React.FC = () => {
onDownloadFolder={handleDownloadFolder} onDownloadFolder={handleDownloadFolder}
onScopeChange={_handleScopeChange} onScopeChange={_handleScopeChange}
onNeutralizeToggle={_handleNeutralizeToggle} onNeutralizeToggle={_handleNeutralizeToggle}
onFolderScopeChange={_handleFolderScopeChange}
onFolderNeutralizeToggle={_handleFolderNeutralizeToggle}
/> />
</div> </div>
@ -551,8 +529,8 @@ export const FilesPage: React.FC = () => {
</div> </div>
{editingFile && ( {editingFile && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingFile(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Datei bearbeiten')}</h2> <h2 className={styles.modalTitle}>{t('Datei bearbeiten')}</h2>
<button className={styles.modalClose} onClick={() => setEditingFile(null)}></button> <button className={styles.modalClose} onClick={() => setEditingFile(null)}></button>

View file

@ -242,8 +242,8 @@ export const PromptsPage: React.FC = () => {
{/* Create Modal */} {/* Create Modal */}
{showCreateModal && ( {showCreateModal && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neuer Prompt')}</h2> <h2 className={styles.modalTitle}>{t('Neuer Prompt')}</h2>
<button <button
@ -276,8 +276,8 @@ export const PromptsPage: React.FC = () => {
{/* Edit Modal */} {/* Edit Modal */}
{editingPrompt && ( {editingPrompt && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingPrompt(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Prompt bearbeiten')}</h2> <h2 className={styles.modalTitle}>{t('Prompt bearbeiten')}</h2>
<button <button

View file

@ -17,7 +17,6 @@ import { SubscriptionTab } from './SubscriptionTab';
import api from '../../api'; import api from '../../api';
import { getUserDataCache } from '../../utils/userCache'; import { getUserDataCache } from '../../utils/userCache';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
type AdminTabType = 'subscription' | 'settings' | 'credit'; type AdminTabType = 'subscription' | 'settings' | 'credit';
@ -29,6 +28,9 @@ const _formatCurrency = (amount: number) => {
}).format(amount); }).format(amount);
}; };
const _mandateDisplayLabel = (m: UserMandateRow): string => {
return m.label || m.name || m.id;
};
// ============================================================================ // ============================================================================
// MANDATE SELECTOR // MANDATE SELECTOR
@ -60,7 +62,7 @@ const MandateSelector: React.FC<MandateSelectorProps> = ({
<option value="">{t('Mandant wählen')}</option> <option value="">{t('Mandant wählen')}</option>
{mandates.map(mandate => ( {mandates.map(mandate => (
<option key={mandate.id} value={mandate.id}> <option key={mandate.id} value={mandate.id}>
{mandateDisplayLabel(mandate)} {_mandateDisplayLabel(mandate)}
</option> </option>
))} ))}
</select> </select>
@ -444,7 +446,7 @@ export const BillingAdmin: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { user: currentUser } = useCurrentUser(); const { user: currentUser } = useCurrentUser();
const isSysAdmin = currentUser?.isPlatformAdmin === true; const isSysAdmin = currentUser?.isSysAdmin === true;
const [selectedMandateId, setSelectedMandateId] = useState<string | null>( const [selectedMandateId, setSelectedMandateId] = useState<string | null>(
searchParams.get('mandate') || null searchParams.get('mandate') || null

View file

@ -646,8 +646,8 @@ const PlaygroundTab: React.FC = () => {
</div> </div>
{browseTarget && ( {browseTarget && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={handleCloseBrowse}>
<div className={styles.modalContent}> <div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
<h3 className={styles.modalTitle}> <h3 className={styles.modalTitle}>
{browseTarget === 'source' {browseTarget === 'source'
? t('SharePoint-Quellordner durchsuchen') ? t('SharePoint-Quellordner durchsuchen')

View file

@ -211,8 +211,8 @@ export const RealEstateParcelsView: React.FC = () => {
</div> </div>
{(editingParcel || isCreateMode) && ( {(editingParcel || isCreateMode) && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={handleCloseModal}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}> <h2 className={styles.modalTitle}>
{isCreateMode ? t('Neue Parzelle') : t('Parzelle bearbeiten')} {isCreateMode ? t('Neue Parzelle') : t('Parzelle bearbeiten')}

View file

@ -174,8 +174,8 @@ export const RealEstateProjectsView: React.FC = () => {
</div> </div>
{(editingProject || isCreateMode) && ( {(editingProject || isCreateMode) && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={handleCloseModal}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{isCreateMode ? t('Neues Projekt') : t('Projekt bearbeiten')}</h2> <h2 className={styles.modalTitle}>{isCreateMode ? t('Neues Projekt') : t('Projekt bearbeiten')}</h2>
<button className={styles.modalClose} onClick={handleCloseModal}></button> <button className={styles.modalClose} onClick={handleCloseModal}></button>

View file

@ -2,7 +2,6 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi'; import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi'; import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
import { FaPlay, FaSpinner } from 'react-icons/fa'; import { FaPlay, FaSpinner } from 'react-icons/fa';
import styles from './Teamsbot.module.css'; import styles from './Teamsbot.module.css';
@ -37,8 +36,8 @@ export const TeamsbotSettingsView: React.FC = () => {
// Form state // Form state
const [formData, setFormData] = useState<ConfigUpdateRequest>({}); const [formData, setFormData] = useState<ConfigUpdateRequest>({});
// Voice catalog (single source of truth) + dynamic voices for the selected language // Dynamic voice data from Google TTS API
const [languages, setLanguages] = useState<VoiceLanguage[]>([]); const [languages, setLanguages] = useState<string[]>([]);
const [voices, setVoices] = useState<VoiceOption[]>([]); const [voices, setVoices] = useState<VoiceOption[]>([]);
const [loadingVoices, setLoadingVoices] = useState(false); const [loadingVoices, setLoadingVoices] = useState(false);
@ -248,13 +247,19 @@ export const TeamsbotSettingsView: React.FC = () => {
value={formData.language || 'de-DE'} value={formData.language || 'de-DE'}
onChange={(e) => _handleLanguageChange(e.target.value)} onChange={(e) => _handleLanguageChange(e.target.value)}
> >
{languages.map(lang => ( {languages.length > 0 ? (
<option key={lang.bcp47} value={lang.bcp47}> languages.map((langCode, idx) => (
{lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47}) <option key={`${langCode}-${idx}`} value={langCode}>{langCode}</option>
</option> ))
))} ) : (
<>
<option value="de-DE">{t('Deutsch (Deutschland)')}</option>
<option value="en-US">{t('Englisch (US)')}</option>
<option value="fr-FR">Francais</option>
</>
)}
</select> </select>
<span className={styles.hint}>{t('Sprache für Captions und Sprachausgabe')} ({languages.length} {t('Sprachen verfügbar')})</span> <span className={styles.hint}>Sprache fuer Captions und Sprachausgabe ({languages.length} Sprachen verfuegbar)</span>
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>

View file

@ -266,8 +266,8 @@ export const TrusteeDocumentsView: React.FC = () => {
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
{(editingDocument || isCreateMode) && ( {(editingDocument || isCreateMode) && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={handleCloseModal}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}> <h2 className={styles.modalTitle}>
{isCreateMode ? t('Neues Dokument') : t('Dokument bearbeiten')} {isCreateMode ? t('Neues Dokument') : t('Dokument bearbeiten')}

View file

@ -213,8 +213,8 @@ export const TrusteePositionDocumentsView: React.FC = () => {
{/* Create Modal */} {/* Create Modal */}
{isCreateMode && ( {isCreateMode && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={handleCloseModal}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Verknüpfung erstellen')}</h2> <h2 className={styles.modalTitle}>{t('Neue Verknüpfung erstellen')}</h2>
<button <button
@ -249,8 +249,8 @@ export const TrusteePositionDocumentsView: React.FC = () => {
{/* Edit Modal */} {/* Edit Modal */}
{editingLink && ( {editingLink && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={() => setEditingLink(null)}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Verknüpfung bearbeiten')}</h2> <h2 className={styles.modalTitle}>{t('Verknüpfung bearbeiten')}</h2>
<button <button

View file

@ -484,8 +484,8 @@ export const TrusteePositionsView: React.FC = () => {
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
{(editingPosition || isCreateMode) && ( {(editingPosition || isCreateMode) && (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay} onClick={handleCloseModal}>
<div className={styles.modal}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}> <h2 className={styles.modalTitle}>
{isCreateMode ? t('Neue Position') : t('Position bearbeiten')} {isCreateMode ? t('Neue Position') : t('Position bearbeiten')}

View file

@ -11,7 +11,21 @@ import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace'; import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext';
const _STT_LANGUAGES = [
{ code: 'de-DE', label: 'Deutsch' },
{ code: 'en-US', label: 'English (US)' },
{ code: 'en-GB', label: 'English (UK)' },
{ code: 'fr-FR', label: 'Francais' },
{ code: 'it-IT', label: 'Italiano' },
{ code: 'es-ES', label: 'Espanol' },
{ code: 'pt-BR', label: 'Portugues' },
{ code: 'nl-NL', label: 'Nederlands' },
{ code: 'pl-PL', label: 'Polski' },
{ code: 'ru-RU', label: 'Russkij' },
{ code: 'ja-JP', label: 'Japanese' },
{ code: 'zh-CN', label: 'Chinese' },
];
interface PendingFile { interface PendingFile {
fileId: string; fileId: string;
@ -41,10 +55,6 @@ interface WorkspaceInputProps {
onProviderSelectionChange?: (selection: ProviderSelection) => void; onProviderSelectionChange?: (selection: ProviderSelection) => void;
isMobile?: boolean; isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void; onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onFeatureSourceDrop?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
pendingAttachDsId?: string;
onPendingAttachDsConsumed?: () => void;
onPasteAsFile?: (file: File) => void; onPasteAsFile?: (file: File) => void;
draftAppend?: string; draftAppend?: string;
onDraftAppendConsumed?: () => void; onDraftAppendConsumed?: () => void;
@ -65,16 +75,11 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
onProviderSelectionChange, onProviderSelectionChange,
isMobile = false, isMobile = false,
onTreeItemsDrop, onTreeItemsDrop,
onFeatureSourceDrop,
onDataSourceDrop,
pendingAttachDsId,
onPendingAttachDsConsumed,
onPasteAsFile, onPasteAsFile,
draftAppend, draftAppend,
onDraftAppendConsumed, onDraftAppendConsumed,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [autocompleteFilter, setAutocompleteFilter] = useState('');
@ -96,15 +101,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
} }
}, [draftAppend, onDraftAppendConsumed]); }, [draftAppend, onDraftAppendConsumed]);
useEffect(() => {
if (pendingAttachDsId) {
setAttachedDataSourceIds(prev =>
prev.includes(pendingAttachDsId) ? prev : [...prev, pendingAttachDsId],
);
onPendingAttachDsConsumed?.();
}
}, [pendingAttachDsId, onPendingAttachDsConsumed]);
const promptBeforeVoiceRef = useRef(''); const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef(''); const finalizedTextRef = useRef('');
const currentInterimRef = useRef(''); const currentInterimRef = useRef('');
@ -146,6 +142,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options); onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
setPrompt(''); setPrompt('');
setShowAutocomplete(false); setShowAutocomplete(false);
setShowSourcePicker(false);
setAttachedFileIds([]); setAttachedFileIds([]);
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]); }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
@ -200,6 +197,14 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId)); setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId));
}, []); }, []);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const _toggleDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev =>
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
);
}, []);
const _toggleFeatureDataSource = useCallback((fdsId: string) => { const _toggleFeatureDataSource = useCallback((fdsId: string) => {
setAttachedFeatureDataSourceIds(prev => setAttachedFeatureDataSourceIds(prev =>
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId], prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
@ -283,9 +288,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
const _handlePromptDragOver = useCallback((e: React.DragEvent) => { const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
if ( if (
e.dataTransfer.types.includes('application/tree-items') || e.dataTransfer.types.includes('application/tree-items') ||
e.dataTransfer.types.includes('application/chat-id') || e.dataTransfer.types.includes('application/chat-id')
e.dataTransfer.types.includes('application/feature-source') ||
e.dataTransfer.types.includes('application/datasource')
) { ) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
@ -308,24 +311,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
return; return;
} }
const featureSourceJson = e.dataTransfer.getData('application/feature-source');
if (featureSourceJson && onFeatureSourceDrop) {
e.preventDefault();
e.stopPropagation();
const params = JSON.parse(featureSourceJson);
onFeatureSourceDrop(params);
return;
}
const dataSourceJson = e.dataTransfer.getData('application/datasource');
if (dataSourceJson && onDataSourceDrop) {
e.preventDefault();
e.stopPropagation();
const params = JSON.parse(dataSourceJson);
onDataSourceDrop(params);
return;
}
const treeItemsJson = e.dataTransfer.getData('application/tree-items'); const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson && onTreeItemsDrop) { if (treeItemsJson && onTreeItemsDrop) {
e.preventDefault(); e.preventDefault();
@ -333,7 +318,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
const items: TreeItemDrop[] = JSON.parse(treeItemsJson); const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
onTreeItemsDrop(items); onTreeItemsDrop(items);
} }
}, [onTreeItemsDrop, onFeatureSourceDrop, onDataSourceDrop]); }, [onTreeItemsDrop]);
return ( return (
<div <div
@ -560,7 +545,116 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
{uploading ? '...' : '+'} {uploading ? '...' : '+'}
</button> </button>
{/* Source picker removed — data sources are now attached directly from the UDB Sources/Files tabs via "send to chat" buttons */} {(dataSources.length > 0 || featureDataSources.length > 0) && (
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowSourcePicker(prev => !prev)}
disabled={isProcessing}
title={t('Datenquellen anhängen')}
style={{
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#2e7d32' : '#666',
cursor: isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1,
position: 'relative',
}}
>
🔗
{(attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 && (
<span style={{
position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{attachedDataSourceIds.length + attachedFeatureDataSourceIds.length}
</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 240, maxHeight: 260, overflowY: 'auto',
}}>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
Active Sources auswählen
</div>
{dataSources.map(ds => {
const isSelected = attachedDataSourceIds.includes(ds.id);
return (
<div
key={ds.id}
onClick={() => _toggleDataSource(ds.id)}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
>
<span style={{
width: 16, height: 16, borderRadius: 3,
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
background: isSelected ? '#2e7d32' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{isSelected ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label || ds.path || ds.id}
</span>
</div>
);
})}
{featureDataSources.length > 0 && (
<>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
Feature Data Sources
</div>
{featureDataSources.map(fds => {
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
return (
<div
key={fds.id}
onClick={() => _toggleFeatureDataSource(fds.id)}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#f3e5f5' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
>
<span style={{
width: 16, height: 16, borderRadius: 3,
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
background: isSelected ? '#7b1fa2' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{isSelected ? '✓' : ''}
</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 13, color: '#7b1fa2', flexShrink: 0 }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fds.label || fds.featureCode} {fds.tableName}
</span>
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
{onProviderSelectionChange && providerSelection && ( {onProviderSelectionChange && providerSelection && (
<ProviderMultiSelect <ProviderMultiSelect
@ -604,23 +698,23 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20, borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
maxHeight: 240, overflowY: 'auto', minWidth: 160, maxHeight: 240, overflowY: 'auto', minWidth: 160,
}}> }}>
{voiceCatalogLanguages.map(lang => ( {_STT_LANGUAGES.map(lang => (
<div <div
key={lang.bcp47} key={lang.code}
onClick={() => { onClick={() => {
setVoiceLanguage(lang.bcp47); setVoiceLanguage(lang.code);
setShowLangPicker(false); setShowLangPicker(false);
fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.bcp47 }) }).catch(() => {}); fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {});
}} }}
style={{ style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13, padding: '8px 12px', cursor: 'pointer', fontSize: 13,
background: lang.bcp47 === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent', background: lang.code === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent',
color: lang.bcp47 === voiceLanguage ? '#fff' : 'var(--text-primary, #333)', color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
}} }}
onMouseEnter={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }} onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }}
onMouseLeave={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = ''; }} onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }}
> >
{lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47}) {lang.label} ({lang.code})
</div> </div>
))} ))}
</div> </div>

View file

@ -17,7 +17,7 @@ import { WorkspaceInput } from './WorkspaceInput';
import { FilePreview } from './FilePreview'; import { FilePreview } from './FilePreview';
import { ToolActivityLog } from './ToolActivityLog'; import { ToolActivityLog } from './ToolActivityLog';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import api from '../../../api'; import api from '../../../api';
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector'; import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector'; import type { ProviderSelection } from '../../../components/ProviderSelector';
@ -279,59 +279,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
workspace.refreshFeatureDataSources(); workspace.refreshFeatureDataSources();
}, [workspace]); }, [workspace]);
const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => {
setPendingFiles(prev => {
const existing = new Set(prev.map(f => f.fileId));
const toAdd: PendingFile[] = [];
for (const item of items) {
if (!existing.has(item.id)) {
toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type });
existing.add(item.id);
}
}
return [...prev, ...toAdd];
});
}, []);
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
try {
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: params.featureInstanceId,
featureCode: params.featureCode,
tableName: params.tableName || '',
objectKey: params.objectKey,
label: params.label,
});
workspace.refreshFeatureDataSources();
} catch (err) {
console.error('Failed to add feature source to chat:', err);
}
}, [instanceId, workspace]);
const [pendingAttachDsId, setPendingAttachDsId] = useState<string>('');
const _handleAttachDataSource = useCallback((dsId: string) => {
setPendingAttachDsId(dsId);
}, []);
const _handleDataSourceDrop = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => {
try {
const res = await api.post(`/api/workspace/${instanceId}/datasources`, {
connectionId: params.connectionId,
sourceType: params.sourceType,
path: params.path,
label: params.label,
displayPath: params.displayPath || params.label,
});
const newId = res.data?.id || res.data?.dataSource?.id;
if (newId) {
setPendingAttachDsId(newId);
workspace.refreshDataSources();
}
} catch (err) {
console.error('Failed to drop data source to chat:', err);
}
}, [instanceId, workspace]);
const _leftPanelBody = ( const _leftPanelBody = (
<UnifiedDataBar <UnifiedDataBar
context={_udbContext} context={_udbContext}
@ -344,9 +291,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onDeleteChat={_handleDeleteChat} onDeleteChat={_handleDeleteChat}
onFileSelect={_handleFileSelect} onFileSelect={_handleFileSelect}
onSourcesChanged={_handleSourcesChanged} onSourcesChanged={_handleSourcesChanged}
onSendToChat_Files={_handleSendToChat_Files}
onSendToChat_FeatureSource={_handleSendToChat_FeatureSource}
onAttachDataSource={_handleAttachDataSource}
/> />
); );
@ -517,10 +461,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onProviderSelectionChange={setProviderSelection} onProviderSelectionChange={setProviderSelection}
isMobile={isMobile} isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop} onTreeItemsDrop={_handleTreeItemsDrop}
onFeatureSourceDrop={_handleSendToChat_FeatureSource}
onDataSourceDrop={_handleDataSourceDrop}
pendingAttachDsId={pendingAttachDsId}
onPendingAttachDsConsumed={() => setPendingAttachDsId('')}
onPasteAsFile={_uploadAndAttach} onPasteAsFile={_uploadAndAttach}
draftAppend={draftAppend} draftAppend={draftAppend}
onDraftAppendConsumed={() => setDraftAppend('')} onDraftAppendConsumed={() => setDraftAppend('')}

View file

@ -19,26 +19,6 @@ interface LanguageContextType {
const LanguageContext = createContext<LanguageContextType | undefined>(undefined); const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
const _LANGUAGE_STORAGE_KEY = 'poweron.preLoginLanguage';
function _readStoredLanguage(): Language | null {
try {
const raw = window.localStorage.getItem(_LANGUAGE_STORAGE_KEY);
if (raw && raw.trim()) return raw.trim() as Language;
} catch {
// localStorage may be disabled (private mode etc.) — ignore.
}
return null;
}
function _writeStoredLanguage(language: Language): void {
try {
window.localStorage.setItem(_LANGUAGE_STORAGE_KEY, language);
} catch {
// ignore write failures
}
}
interface LanguageProviderProps { interface LanguageProviderProps {
children: ReactNode; children: ReactNode;
} }
@ -66,11 +46,6 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
const initializeLanguage = async () => { const initializeLanguage = async () => {
let initialLanguage: Language = 'de'; let initialLanguage: Language = 'de';
// Priority order:
// 1. Logged-in user preference (server truth)
// 2. Pre-login choice persisted in localStorage (LanguageSelector on login/register/reset pages)
// 3. Browser language (if available)
// 4. Hard default 'de'
const userData = getUserDataCache(); const userData = getUserDataCache();
if (userData?.language && String(userData.language).trim()) { if (userData?.language && String(userData.language).trim()) {
initialLanguage = String(userData.language).trim() as Language; initialLanguage = String(userData.language).trim() as Language;
@ -78,12 +53,6 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
return; return;
} }
const stored = _readStoredLanguage();
if (stored) {
await loadAndSetLanguage(stored);
return;
}
const browserLang = navigator.language.split('-')[0] as Language; const browserLang = navigator.language.split('-')[0] as Language;
try { try {
const codes = await fetchAvailableLanguageCodes(); const codes = await fetchAvailableLanguageCodes();
@ -124,7 +93,6 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
}, []); }, []);
const setLanguage = async (language: Language) => { const setLanguage = async (language: Language) => {
_writeStoredLanguage(language);
await loadAndSetLanguage(language); await loadAndSetLanguage(language);
}; };

View file

@ -52,10 +52,6 @@
--bg-primary: #ffffff; --bg-primary: #ffffff;
--bg-secondary: #F7FAFC; --bg-secondary: #F7FAFC;
--bg-dark: #EDF2F7; --bg-dark: #EDF2F7;
/* Canvas surface for the GraphicalEditor flow editor (slightly off-white). */
--canvas-bg: #FAFAFA;
/* Dot color of the infinite background grid on the editor canvas. */
--canvas-grid: #D9DEE5;
/* Surface colors */ /* Surface colors */
--surface-color: #F7FAFC; --surface-color: #F7FAFC;
@ -130,12 +126,6 @@
--bg-primary: #1A202C; --bg-primary: #1A202C;
--bg-secondary: #2D3748; --bg-secondary: #2D3748;
--bg-dark: #171923; --bg-dark: #171923;
/* Canvas surface for the GraphicalEditor flow editor slightly darker than
--bg-primary so nodes and connection lines stay legible against it. */
--canvas-bg: #131820;
/* Dot color of the infinite background grid bright enough to read on
--canvas-bg, but still discreet (no visual noise). */
--canvas-grid: rgba(226, 232, 240, 0.18);
/* Surface colors */ /* Surface colors */
--surface-color: #2D3748; --surface-color: #2D3748;

View file

@ -102,21 +102,12 @@ export interface MandateFeature {
/** /**
* Ein Mandant (oberste Ebene) * Ein Mandant (oberste Ebene)
* Enthält mehrere Features mit deren Instanzen. * Enthält mehrere Features mit deren Instanzen
*
* Felder:
* - `id` UUID des Mandanten.
* - `name` Kurzzeichen / Slug (UNIQUE, audit-stable). Nur lowercase
* `az09` und `-`, Länge 232. Wird vom System aus `label`
* generiert und kann nur durch einen PlatformAdmin geändert
* werden (siehe `wiki/b-reference/platform/rbac.md`).
* - `label` Voller Name (Pflichtfeld), wird im UI gerendert. Frei
* änderbar durch Mandate-Admin.
*/ */
export interface Mandate { export interface Mandate {
id: string; // mandateId id: string; // mandateId
name: string; // Kurzzeichen (Slug, audit-stable) name: string; // Technischer Identifier
label: string; // Voller Name — Anzeige im UI (mandatory) label?: string; // Anzeige-Label (fuer FK-Referenzen und UI)
code?: string; // Optionaler Code code?: string; // Optionaler Code
features: MandateFeature[]; features: MandateFeature[];
} }
@ -149,9 +140,8 @@ export interface User {
language: string; language: string;
enabled: boolean; enabled: boolean;
authenticationAuthority: string; authenticationAuthority: string;
isSysAdmin: boolean; // Infrastructure/System Operator (RBAC bypass) isSysAdmin: boolean;
isPlatformAdmin: boolean; // Cross-Mandate Governance (no RBAC bypass) roleLabels?: string[]; // System-weite Rollen (z.B. ["sysadmin"])
roleLabels?: string[]; // Mandanten-scoped role labels
} }
// ============================================================================= // =============================================================================

View file

@ -22,7 +22,6 @@ export type AttributeType =
| 'file' | 'file'
| 'string' | 'string'
| 'enum' | 'enum'
| 'slug'
| 'readonly'; | 'readonly';
export type InputComponentType = export type InputComponentType =
@ -67,7 +66,6 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
switch (attributeType) { switch (attributeType) {
case 'text': case 'text':
case 'string': case 'string':
case 'slug':
return 'text'; return 'text';
case 'textarea': case 'textarea':

View file

@ -1,42 +0,0 @@
/**
* UI display helpers for Mandate `label` (Voller Name) vs `name` (Kurzzeichen / Slug).
* Mirrors semantics in `wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md`.
*/
function _trimOrEmpty(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.trim();
return String(value).trim();
}
/**
* Primary string for lists, dropdowns, breadcrumbs: Voller Name, then Kurzzeichen,
* then id (defensive).
*/
export function mandateDisplayLabel(m: {
label?: string | null;
name?: string | null;
id?: string;
}): string {
const lab = _trimOrEmpty(m.label);
if (lab) return lab;
const slug = _trimOrEmpty(m.name);
if (slug) return slug;
return typeof m.id === 'string' ? m.id : '';
}
/**
* One line: `"Voller Name (kurzzeichen)"` when both differ; otherwise the single value.
* Use where users should see the human name first and the technical slug second.
*/
export function mandateDisplayLineLabelThenSlug(m: {
label?: string | null;
name?: string | null;
}): string {
const lab = _trimOrEmpty(m.label);
const slug = _trimOrEmpty(m.name);
if (lab && slug && lab !== slug) {
return `${lab} (${slug})`;
}
return lab || slug;
}

View file

@ -1,77 +0,0 @@
/**
* Mandate slug helpers domain-specific wrapper around the generic
* `slugUtils.ts`. Keeps mandate-localized error strings & the historical
* "mn" fallback while delegating all formatting logic to the generic layer.
*
* The rules MUST stay in sync with the gateway counterpart at
* `gateway/modules/shared/mandateNameUtils.py`.
*
* Format: lowercase [a-z0-9], hyphen-separated segments, length 232.
* German umlauts are transliterated (äae, öoe, üue, ßss) before slugging.
*/
import {
DEFAULT_SLUG_MIN_LEN,
DEFAULT_SLUG_MAX_LEN,
SLUG_PATTERN,
SLUG_HINT,
allocateUniqueSlug,
isValidSlug,
maskSlugInput,
slugify,
transliterateGerman as _transliterateGermanGeneric,
} from './slugUtils';
export const MANDATE_NAME_MIN_LEN = DEFAULT_SLUG_MIN_LEN;
export const MANDATE_NAME_MAX_LEN = DEFAULT_SLUG_MAX_LEN;
export const MANDATE_NAME_PATTERN = SLUG_PATTERN;
export const MANDATE_NAME_HINT = SLUG_HINT;
const _MANDATE_OPTS = {
minLen: MANDATE_NAME_MIN_LEN,
maxLen: MANDATE_NAME_MAX_LEN,
};
export function transliterateGerman(text: string): string {
return _transliterateGermanGeneric(text);
}
/** Build a slug from a label. Falls back to "mn" if no valid slug can be derived. */
export function slugifyMandateName(label: string | null | undefined): string {
const trimmed = (label ?? '').toString().trim();
if (!trimmed) return 'mn';
const result = slugify(trimmed, _MANDATE_OPTS);
return isValidMandateName(result) ? result : 'mn';
}
export function maskMandateNameInput(raw: string | null | undefined): string {
return maskSlugInput(raw, _MANDATE_OPTS);
}
export function isValidMandateName(name: unknown): name is string {
return isValidSlug(name, _MANDATE_OPTS);
}
/** Returns a localized (mandate-flavored) error message, or null when valid. */
export function validateMandateName(name: unknown): string | null {
if (typeof name !== 'string' || name.length === 0) {
return 'Kurzzeichen ist erforderlich.';
}
if (name.length < MANDATE_NAME_MIN_LEN) {
return `Kurzzeichen muss mindestens ${MANDATE_NAME_MIN_LEN} Zeichen lang sein.`;
}
if (name.length > MANDATE_NAME_MAX_LEN) {
return `Kurzzeichen darf maximal ${MANDATE_NAME_MAX_LEN} Zeichen lang sein.`;
}
if (!MANDATE_NAME_PATTERN.test(name)) {
return MANDATE_NAME_HINT;
}
return null;
}
export function allocateUniqueMandateSlug(
base: string,
taken: Iterable<string>,
): string {
return allocateUniqueSlug(base, taken, _MANDATE_OPTS);
}

View file

@ -1,152 +0,0 @@
/**
* Generic slug helpers for FormGenerator inputs of type `slug`.
*
* Format: lowercase ASCII (`[a-z0-9]`), single-hyphen segments, configurable
* length range (defaults 232). German umlauts are transliterated
* (äae, öoe, üue, ßss) before slugging.
*
* Domain-specific helpers (e.g. `mandateNameUtils.ts`) MUST delegate here so
* that all slug inputs stay in sync.
*/
export const DEFAULT_SLUG_MIN_LEN = 2;
export const DEFAULT_SLUG_MAX_LEN = 32;
export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
export const SLUG_HINT =
'Nur Kleinbuchstaben (az), Ziffern (09) und Bindestriche. Ohne führende oder folgende Bindestriche.';
export interface SlugOptions {
minLen?: number;
maxLen?: number;
}
const _GERMAN_MAP: Record<string, string> = {
ä: 'ae', Ä: 'ae',
ö: 'oe', Ö: 'oe',
ü: 'ue', Ü: 'ue',
ß: 'ss',
};
function _transliterateGerman(text: string): string {
if (!text) return '';
let out = '';
for (const ch of text) {
out += _GERMAN_MAP[ch] ?? ch;
}
return out;
}
function _collapseHyphensAndTrim(raw: string): string {
const lowered = raw.toLowerCase();
const replaced = lowered.replace(/[^a-z0-9]+/g, '-');
return replaced.replace(/-+/g, '-').replace(/^-+|-+$/g, '');
}
function _ensureMinSlugLength(slug: string, minLen: number): string {
if (slug.length >= minLen) return slug;
if (slug.length === 1) return slug + slug;
return slug + 'x'.repeat(minLen - slug.length);
}
function _truncateSlugToMaxLen(slug: string, minLen: number, maxLen: number): string {
if (slug.length <= maxLen) return slug;
let cut = slug.slice(0, maxLen).replace(/-+$/g, '');
const lastHyphen = cut.lastIndexOf('-');
if (lastHyphen > 0) {
cut = cut.slice(0, lastHyphen);
}
cut = cut.replace(/^-+|-+$/g, '');
if (cut.length < minLen) {
return cut + 'x'.repeat(minLen - cut.length);
}
return cut;
}
export function transliterateGerman(text: string): string {
return _transliterateGerman(text);
}
/**
* Build a slug from a free-text source. Falls back to "x".repeat(minLen)
* when no valid slug can be derived (callers can override the fallback).
*/
export function slugify(source: string | null | undefined, opts: SlugOptions = {}): string {
const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN;
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
const fallback = 'x'.repeat(Math.max(minLen, 2));
const src = (source ?? '').toString().trim();
if (!src) return fallback;
const step1 = _transliterateGerman(src);
const step2 = _collapseHyphensAndTrim(step1);
if (!step2) return fallback;
const ensured = _ensureMinSlugLength(step2, minLen);
const truncated = _truncateSlugToMaxLen(ensured, minLen, maxLen);
return isValidSlug(truncated, opts) ? truncated : fallback;
}
/**
* Live-mask user input for a slug field. Does NOT enforce min length so users
* can keep typing; the format check happens on submit.
*/
export function maskSlugInput(raw: string | null | undefined, opts: SlugOptions = {}): string {
if (!raw) return '';
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
const transliterated = _transliterateGerman(String(raw)).toLowerCase();
const cleaned = transliterated.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-');
return cleaned.slice(0, maxLen);
}
export function isValidSlug(value: unknown, opts: SlugOptions = {}): value is string {
const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN;
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
if (typeof value !== 'string') return false;
if (value.length < minLen || value.length > maxLen) return false;
return SLUG_PATTERN.test(value);
}
/** Returns a localized error message for an invalid slug, or null when valid. */
export function validateSlug(value: unknown, opts: SlugOptions = {}): string | null {
const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN;
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
if (typeof value !== 'string' || value.length === 0) {
return 'Wert ist erforderlich.';
}
if (value.length < minLen) {
return `Wert muss mindestens ${minLen} Zeichen lang sein.`;
}
if (value.length > maxLen) {
return `Wert darf maximal ${maxLen} Zeichen lang sein.`;
}
if (!SLUG_PATTERN.test(value)) {
return SLUG_HINT;
}
return null;
}
/**
* Allocate a slug not already present in *taken*, by appending -2, -3, ...
* Mirrors `allocateUniqueMandateSlug` in the gateway.
*/
export function allocateUniqueSlug(
base: string,
taken: Iterable<string>,
opts: SlugOptions = {},
): string {
const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN;
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
const used = new Set<string>();
for (const x of taken) {
if (x) used.add(x);
}
if (!used.has(base)) return base;
for (let n = 2; n <= 100000; n += 1) {
const suffix = `-${n}`;
const room = Math.max(maxLen - suffix.length, minLen);
let root = base.slice(0, room).replace(/-+$/g, '');
if (root.length < minLen) root = 'x'.repeat(minLen);
const cand = (root + suffix).slice(0, maxLen).replace(/-+$/g, '');
if (isValidSlug(cand, opts) && !used.has(cand)) return cand;
}
throw new Error('allocateUniqueSlug: could not allocate a unique slug');
}

View file

@ -21,8 +21,7 @@ export interface CachedUserData {
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"]) roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
// mandateId entfernt - User gehört keinem Mandanten direkt an // mandateId entfernt - User gehört keinem Mandanten direkt an
// Stattdessen hat er Zugriff auf Feature-Instanzen (siehe featureStore) // Stattdessen hat er Zugriff auf Feature-Instanzen (siehe featureStore)
isSysAdmin?: boolean; // Infrastructure/System Operator (RBAC bypass) isSysAdmin?: boolean; // System-Administrator Flag
isPlatformAdmin?: boolean; // Cross-Mandate Governance (no RBAC bypass)
language: string; language: string;
enabled: boolean; enabled: boolean;
authenticationAuthority: string; authenticationAuthority: string;