From ff1caba925c0b8d2e9eb071492e6d151557cf168 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 4 Feb 2026 14:09:51 +0100 Subject: [PATCH 01/14] admin pages streamlined --- src/App.tsx | 32 +-- src/api/workflowApi.ts | 17 +- src/hooks/playground/useDashboardInputForm.ts | 8 +- src/hooks/playground/useWorkflowLifecycle.ts | 16 +- src/hooks/useUserMandates.ts | 10 +- src/hooks/useWorkflows.ts | 7 +- src/pages/FeatureView.tsx | 15 ++ src/pages/migrate/MigratePages.module.css | 223 ------------------ src/pages/migrate/PekPage.tsx | 39 --- src/pages/migrate/SpeechPage.tsx | 39 --- src/pages/migrate/index.ts | 2 - src/pages/workflows/AutomationsPage.tsx | 2 +- src/pages/workflows/PlaygroundPage.tsx | 7 +- src/types/mandate.ts | 19 ++ 14 files changed, 82 insertions(+), 354 deletions(-) delete mode 100644 src/pages/migrate/MigratePages.module.css delete mode 100644 src/pages/migrate/PekPage.tsx delete mode 100644 src/pages/migrate/SpeechPage.tsx delete mode 100644 src/pages/migrate/index.ts diff --git a/src/App.tsx b/src/App.tsx index 95e21b5..0a7591b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,15 +43,9 @@ import { GDPRPage } from './pages/GDPR'; import { FeatureViewPage } from './pages/FeatureView'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin'; -// Workflow Pages (global) -import { PlaygroundPage, WorkflowsPage, AutomationsPage, AutomationTemplatesPage } from './pages/workflows'; - // Basedata Pages (global) import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; -// Migrate Pages (temporary - to be migrated to feature instances) -import { PekPage, SpeechPage } from './pages/migrate'; - function App() { // Load saved theme preference and set app name on app mount useEffect(() => { @@ -107,16 +101,6 @@ function App() { } /> } /> - {/* ============================================== */} - {/* WORKFLOWS ROUTES (global) */} - {/* ============================================== */} - - } /> - } /> - } /> - } /> - - {/* ============================================== */} {/* BASISDATEN ROUTES (global) */} {/* ============================================== */} @@ -126,13 +110,6 @@ function App() { } /> - {/* ============================================== */} - {/* MIGRATE TO FEATURES (temporary) */} - {/* ============================================== */} - } /> - } /> - } /> - {/* ============================================== */} {/* FEATURE-INSTANZ ROUTES */} {/* /mandates/:mandateId/:featureCode/:instanceId */} @@ -159,6 +136,15 @@ function App() { } /> } /> + {/* Chat Playground Feature Views */} + } /> + } /> + + {/* Automation Feature Views */} + } /> + } /> + } /> + {/* Catch-all für unbekannte Sub-Pfade */} } /> diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 2e813f5..2d5110f 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -231,17 +231,18 @@ export async function fetchWorkflowLogs( /** * Fetch unified chat data (messages, logs, stats, documents) - * Endpoint: GET /api/chat/playground/{workflowId}/chatData + * Endpoint: GET /api/chatplayground/{instanceId}/{workflowId}/chatData * Query params: afterTimestamp (optional) - fetch only data created after this time */ export async function fetchChatData( request: ApiRequestFunction, + instanceId: string, workflowId: string, afterTimestamp?: number ): Promise { const params = afterTimestamp ? { afterTimestamp: afterTimestamp.toString() } : undefined; const requestConfig = { - url: `/api/chat/playground/${workflowId}/chatData`, + url: `/api/chatplayground/${instanceId}/${workflowId}/chatData`, method: 'get' as const, params }; @@ -314,11 +315,12 @@ export async function fetchChatData( /** * Start a new workflow or continue an existing one - * Endpoint: POST /api/chat/playground/start - * Query params: workflowId (optional), workflowMode (default: "Actionplan") + * Endpoint: POST /api/chatplayground/{instanceId}/start + * Query params: workflowId (optional), workflowMode (default: "Dynamic") */ export async function startWorkflowApi( request: ApiRequestFunction, + instanceId: string, workflowData: StartWorkflowRequest, options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' } ): Promise { @@ -345,7 +347,7 @@ export async function startWorkflowApi( }; const requestConfig = { - url: '/api/chat/playground/start', + url: `/api/chatplayground/${instanceId}/start`, method: 'post' as const, data: requestBody, params: params // Always include workflowMode @@ -368,14 +370,15 @@ export async function startWorkflowApi( /** * Stop a running workflow - * Endpoint: POST /api/chat/playground/{workflowId}/stop + * Endpoint: POST /api/chatplayground/{instanceId}/{workflowId}/stop */ export async function stopWorkflowApi( request: ApiRequestFunction, + instanceId: string, workflowId: string ): Promise { await request({ - url: `/api/chat/playground/${workflowId}/stop`, + url: `/api/chatplayground/${instanceId}/${workflowId}/stop`, method: 'post' }); } diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index 096c85b..84517fa 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -23,7 +23,7 @@ export interface WorkflowFile { source?: 'user_uploaded' | 'ai_created'; } -export function useDashboardInputForm() { +export function useDashboardInputForm(instanceId: string) { const [inputValue, setInputValue] = useState(''); const [pendingFiles, setPendingFiles] = useState([]); const [isFileAttachmentPopupOpen, setIsFileAttachmentPopupOpen] = useState(false); @@ -54,7 +54,7 @@ export function useDashboardInputForm() { resetWorkflow, selectWorkflow, setWorkflowStatusOptimistic - } = useWorkflowLifecycle(); + } = useWorkflowLifecycle(instanceId); // Dashboard log tree hook const { @@ -824,7 +824,7 @@ export function useDashboardInputForm() { }; } -export function createDashboardHook() { - return () => useDashboardInputForm(); +export function createDashboardHook(instanceId: string) { + return () => useDashboardInputForm(instanceId); } diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index bc2dbc5..c06b601 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -18,7 +18,7 @@ interface UnifiedChatDataItem { createdAt: number; } -export function useWorkflowLifecycle() { +export function useWorkflowLifecycle(instanceId: string) { const [workflowId, setWorkflowId] = useState(null); const [workflowStatus, setWorkflowStatus] = useState('idle'); const [currentRound, setCurrentRound] = useState(undefined); @@ -341,7 +341,7 @@ export function useWorkflowLifecycle() { } // Fetch unified chat data - const chatData = await fetchChatData(request, id, afterTimestamp); + const chatData = await fetchChatData(request, instanceId, id, afterTimestamp); console.log('📊 Processed chat data:', { messagesCount: chatData.messages?.length || 0, @@ -419,7 +419,7 @@ export function useWorkflowLifecycle() { // Reset lastRenderedTimestamp to fetch all historical data lastRenderedTimestampRef.current = null; try { - const chatData = await fetchChatData(request, id, undefined); + const chatData = await fetchChatData(request, instanceId, id, undefined); console.log('📥 loadWorkflowData: Fetched unified chat data:', { messagesCount: chatData.messages?.length || 0, logsCount: chatData.logs?.length || 0 @@ -514,7 +514,7 @@ export function useWorkflowLifecycle() { options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' } ) => { try { - const result = await startWorkflow(workflowData, options); + const result = await startWorkflow(instanceId, workflowData, options); if (result.success && result.data) { const workflow = result.data as Workflow; @@ -529,7 +529,7 @@ export function useWorkflowLifecycle() { } catch (error: any) { return { success: false, error: error.message || 'Failed to start workflow' }; } - }, [startWorkflow, updateWorkflowStatus]); + }, [instanceId, startWorkflow, updateWorkflowStatus]); const handleStopWorkflow = useCallback(async () => { if (!workflowId) { @@ -537,7 +537,7 @@ export function useWorkflowLifecycle() { } try { - const result = await stopWorkflow(workflowId); + const result = await stopWorkflow(instanceId, workflowId); if (result.success) { updateWorkflowStatus('stopped'); @@ -548,7 +548,7 @@ export function useWorkflowLifecycle() { } catch (error: any) { return { success: false, error: error.message || 'Failed to stop workflow' }; } - }, [workflowId, stopWorkflow, updateWorkflowStatus]); + }, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]); const resetWorkflow = useCallback(() => { setWorkflowId(null); @@ -597,7 +597,7 @@ export function useWorkflowLifecycle() { // Always fetch unified chat data to get all messages and logs (regardless of status) // This ensures completed workflows also show their logs try { - const chatData = await fetchChatData(request, workflowIdToSelect, undefined); + const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined); console.log('📥 selectWorkflow: Fetched unified chat data:', { messagesCount: chatData.messages?.length || 0, logsCount: chatData.logs?.length || 0, diff --git a/src/hooks/useUserMandates.ts b/src/hooks/useUserMandates.ts index 61758dd..8c4ec4f 100644 --- a/src/hooks/useUserMandates.ts +++ b/src/hooks/useUserMandates.ts @@ -226,7 +226,7 @@ export function useUserMandates() { }, []); /** - * Fetch all available roles (global and mandate-specific) + * Fetch all available roles (global and mandate-specific, excluding feature-instance roles) */ const fetchRoles = useCallback(async (mandateId?: string): Promise => { try { @@ -238,13 +238,15 @@ export function useUserMandates() { roles = response.data; } - // Filter to global roles and roles for this mandate + // Filter to global roles and mandate-specific roles only + // Exclude feature-instance roles (they have featureInstanceId set) if (mandateId) { return roles.filter(r => - !r.mandateId || r.mandateId === mandateId + !r.featureInstanceId && (!r.mandateId || r.mandateId === mandateId) ); } - return roles; + // Without mandateId, return only global roles (no mandateId and no featureInstanceId) + return roles.filter(r => !r.mandateId && !r.featureInstanceId); } catch (err: any) { console.error('Error fetching roles:', err); return []; diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index 942d1e5..79c12cc 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -432,6 +432,7 @@ export function useWorkflowOperations() { }; const startWorkflow = async ( + instanceId: string, workflowData: StartWorkflowRequest, options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' } ) => { @@ -439,7 +440,7 @@ export function useWorkflowOperations() { setStartingWorkflow(true); try { - const response = await startWorkflowApi(request, workflowData, options); + const response = await startWorkflowApi(request, instanceId, workflowData, options); return { success: true, data: response }; } catch (error: any) { const errorMessage = error.message || 'Failed to start workflow'; @@ -450,12 +451,12 @@ export function useWorkflowOperations() { } }; - const stopWorkflow = async (workflowId: string) => { + const stopWorkflow = async (instanceId: string, workflowId: string) => { setStopError(null); setStoppingWorkflows(prev => new Set(prev).add(workflowId)); try { - await stopWorkflowApi(request, workflowId); + await stopWorkflowApi(request, instanceId, workflowId); return { success: true }; } catch (error: any) { const errorMessage = error.message || 'Failed to stop workflow'; diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index d49700c..d1ce616 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -25,6 +25,12 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi // RealEstate Views import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; +// Chat Playground Views (reusing existing workflow pages) +import { PlaygroundPage, WorkflowsPage } from './workflows'; + +// Automation Views (reusing existing workflow pages) +import { AutomationsPage, AutomationTemplatesPage } from './workflows'; + import styles from './FeatureView.module.css'; // ============================================================================= @@ -103,6 +109,15 @@ const VIEW_COMPONENTS: Record> = { parcels: RealEstateParcelsView, 'instance-roles': RealEstateInstanceRolesPlaceholder, }, + chatplayground: { + playground: PlaygroundPage, + workflows: WorkflowsPage, + }, + automation: { + definitions: AutomationsPage, + templates: AutomationTemplatesPage, + logs: () => , + }, }; // ============================================================================= diff --git a/src/pages/migrate/MigratePages.module.css b/src/pages/migrate/MigratePages.module.css deleted file mode 100644 index 95b409f..0000000 --- a/src/pages/migrate/MigratePages.module.css +++ /dev/null @@ -1,223 +0,0 @@ -/* MigratePages.module.css - Styles for migrate-to-feature pages */ - -.page { - padding: 2rem; - max-width: 1200px; - margin: 0 auto; - height: calc(100vh - 4rem); - display: flex; - flex-direction: column; -} - -.header { - margin-bottom: 1.5rem; -} - -.header h1 { - font-size: 1.75rem; - font-weight: 600; - color: var(--color-text-primary, #1a1a2e); - margin: 0 0 0.5rem 0; -} - -.subtitle { - color: var(--color-text-secondary, #6b7280); - margin: 0; - display: flex; - align-items: center; - gap: 0.75rem; -} - -.migrateTag { - display: inline-block; - padding: 0.25rem 0.5rem; - background: var(--color-warning-bg, #fef3c7); - color: var(--color-warning, #d97706); - font-size: 0.65rem; - font-weight: 700; - border-radius: 4px; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.content { - flex: 1; - display: flex; - align-items: center; - justify-content: center; -} - -/* Placeholder for migrate pages */ -.placeholder { - text-align: center; - padding: 3rem; - background: var(--color-surface, #ffffff); - border: 2px dashed var(--color-border, #e5e7eb); - border-radius: 12px; - max-width: 500px; -} - -.placeholderIcon { - font-size: 4rem; - margin-bottom: 1rem; -} - -.placeholder h2 { - margin: 0 0 1rem 0; - color: var(--color-text-primary, #1a1a2e); -} - -.placeholder p { - color: var(--color-text-secondary, #6b7280); - margin: 0 0 0.5rem 0; -} - -.hint { - font-size: 0.875rem; - color: var(--color-text-tertiary, #9ca3af); - margin-top: 1rem !important; -} - -/* Chat container for ChatbotPage */ -.chatContainer { - flex: 1; - display: flex; - flex-direction: column; - background: var(--color-surface, #ffffff); - border: 1px solid var(--color-border, #e5e7eb); - border-radius: 8px; - overflow: hidden; -} - -.messagesArea { - flex: 1; - overflow-y: auto; - padding: 1rem; - display: flex; - flex-direction: column; - gap: 1rem; -} - -.emptyChat { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-secondary, #6b7280); -} - -.message { - max-width: 70%; - padding: 0.75rem 1rem; - border-radius: 12px; -} - -.message.user { - align-self: flex-end; - background: var(--color-primary, #4f46e5); - color: white; - border-bottom-right-radius: 4px; -} - -.message.assistant { - align-self: flex-start; - background: var(--color-surface-secondary, #f3f4f6); - color: var(--color-text-primary, #1a1a2e); - border-bottom-left-radius: 4px; -} - -.message.system { - align-self: center; - background: var(--color-warning-bg, #fef3c7); - color: var(--color-warning, #d97706); - font-size: 0.875rem; -} - -.messageContent { - word-wrap: break-word; -} - -.messageTime { - font-size: 0.7rem; - opacity: 0.7; - margin-top: 0.25rem; -} - -/* Typing indicator */ -.typing { - display: flex; - gap: 4px; - padding: 0.5rem 0; -} - -.typing span { - width: 8px; - height: 8px; - background: var(--color-text-secondary, #6b7280); - border-radius: 50%; - animation: typing 1s infinite; -} - -.typing span:nth-child(2) { - animation-delay: 0.2s; -} - -.typing span:nth-child(3) { - animation-delay: 0.4s; -} - -@keyframes typing { - 0%, 100% { - opacity: 0.3; - transform: scale(0.8); - } - 50% { - opacity: 1; - transform: scale(1); - } -} - -/* Input area */ -.inputArea { - display: flex; - gap: 0.5rem; - padding: 1rem; - border-top: 1px solid var(--color-border, #e5e7eb); - background: var(--color-surface-secondary, #f9fafb); -} - -.chatInput { - flex: 1; - padding: 0.75rem 1rem; - border: 1px solid var(--color-border, #e5e7eb); - border-radius: 24px; - font-size: 0.875rem; - background: var(--color-surface, #ffffff); -} - -.chatInput:focus { - outline: none; - border-color: var(--color-primary, #4f46e5); - box-shadow: 0 0 0 3px var(--color-primary-light, rgba(79, 70, 229, 0.1)); -} - -.sendButton { - padding: 0.75rem 1.5rem; - background: var(--color-primary, #4f46e5); - color: white; - border: none; - border-radius: 24px; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: background 0.2s; -} - -.sendButton:hover:not(:disabled) { - background: var(--color-primary-dark, #4338ca); -} - -.sendButton:disabled { - opacity: 0.5; - cursor: not-allowed; -} diff --git a/src/pages/migrate/PekPage.tsx b/src/pages/migrate/PekPage.tsx deleted file mode 100644 index 797b8b8..0000000 --- a/src/pages/migrate/PekPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * PekPage - * - * PEK (Projekt-Entwicklungs-Koordination) page - temporary global page. - * TODO: Migrate to feature instance. - */ - -import React from 'react'; -import styles from './MigratePages.module.css'; - -export const PekPage: React.FC = () => { - return ( -
-
-

PEK

-

- MIGRATE TO FEATURE - Projekt-Entwicklungs-Koordination -

-
- -
-
-
📊
-

PEK-Modul

-

- Dieses Modul wird zu einer Feature-Instanz migriert. -

-

- Nach der Migration wird PEK als Feature pro Mandant verfügbar sein, - mit instanz-spezifischen Daten und Berechtigungen. -

-
-
-
- ); -}; - -export default PekPage; diff --git a/src/pages/migrate/SpeechPage.tsx b/src/pages/migrate/SpeechPage.tsx deleted file mode 100644 index 9bfda2c..0000000 --- a/src/pages/migrate/SpeechPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * SpeechPage - * - * Speech recognition and transcription page - temporary global page. - * TODO: Migrate to feature instance. - */ - -import React from 'react'; -import styles from './MigratePages.module.css'; - -export const SpeechPage: React.FC = () => { - return ( -
-
-

Speech

-

- MIGRATE TO FEATURE - Spracherkennung und Transkription -

-
- -
-
-
🎤
-

Speech-Modul

-

- Dieses Modul wird zu einer Feature-Instanz migriert. -

-

- Nach der Migration wird Speech als Feature pro Mandant verfügbar sein, - mit instanz-spezifischen Transkriptionen und Einstellungen. -

-
-
-
- ); -}; - -export default SpeechPage; diff --git a/src/pages/migrate/index.ts b/src/pages/migrate/index.ts deleted file mode 100644 index c7ed0d3..0000000 --- a/src/pages/migrate/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { PekPage } from './PekPage'; -export { SpeechPage } from './SpeechPage'; diff --git a/src/pages/workflows/AutomationsPage.tsx b/src/pages/workflows/AutomationsPage.tsx index a0c4171..0d5e773 100644 --- a/src/pages/workflows/AutomationsPage.tsx +++ b/src/pages/workflows/AutomationsPage.tsx @@ -426,7 +426,7 @@ export const AutomationsPage: React.FC = () => { try { await request({ - url: `/api/chat/playground/${executionModal.workflowId}/stop`, + url: `/api/workflows/${executionModal.workflowId}/stop`, method: 'post', }); diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index 1598aea..ba20467 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -11,6 +11,7 @@ import { useSearchParams } from 'react-router-dom'; import { useDashboardInputForm } from '../../hooks/usePlayground'; import { useResizablePanels } from '../../hooks/useResizablePanels'; import { usePrompts } from '../../hooks/usePrompts'; +import { useCurrentInstance } from '../../hooks/useCurrentInstance'; import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import { useVoiceLanguage, VoiceLanguageSelect, Messages } from '../../components/UiComponents'; @@ -23,8 +24,12 @@ export const PlaygroundPage: React.FC = () => { const [searchParams] = useSearchParams(); const urlWorkflowId = searchParams.get('workflowId'); + // Get feature instance context + const { instance } = useCurrentInstance(); + const instanceId = instance?.id || ''; + // Main hook for input form and data - const hookData = useDashboardInputForm(); + const hookData = useDashboardInputForm(instanceId); const { inputValue, onInputChange, diff --git a/src/types/mandate.ts b/src/types/mandate.ts index fee57e1..8c5fa5b 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -240,6 +240,25 @@ export const FEATURE_REGISTRY: Record = { { code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true }, ] }, + chatplayground: { + code: 'chatplayground', + label: { de: 'Chat Playground', en: 'Chat Playground' }, + icon: 'message', + views: [ + { code: 'playground', label: { de: 'Playground', en: 'Playground' }, path: 'playground' }, + { code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' }, + ] + }, + automation: { + code: 'automation', + label: { de: 'Automatisierung', en: 'Automation' }, + icon: 'settings', + views: [ + { code: 'definitions', label: { de: 'Definitionen', en: 'Definitions' }, path: 'definitions' }, + { code: 'templates', label: { de: 'Vorlagen', en: 'Templates' }, path: 'templates' }, + { code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' }, + ] + }, }; // ============================================================================= From 919f6e4b7da61082757e59c9fdb9a2421ba7f254 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 4 Feb 2026 21:51:20 +0100 Subject: [PATCH 02/14] billing initial --- src/App.tsx | 12 + src/api/automationApi.ts | 1 + src/api/billingApi.ts | 254 +++++++++ .../AutomationEditor/AutomationEditor.tsx | 20 +- .../ProviderSelector.module.css | 161 ++++++ .../ProviderSelector/ProviderSelector.tsx | 221 ++++++++ src/components/ProviderSelector/index.ts | 10 + src/config/pageRegistry.tsx | 7 +- src/hooks/useBilling.ts | 265 +++++++++ src/pages/billing/Billing.module.css | 518 ++++++++++++++++++ src/pages/billing/BillingAdmin.tsx | 419 ++++++++++++++ src/pages/billing/BillingDashboard.tsx | 247 +++++++++ src/pages/billing/BillingTransactions.tsx | 142 +++++ src/pages/billing/index.ts | 7 + src/pages/workflows/PlaygroundPage.tsx | 9 + 15 files changed, 2290 insertions(+), 3 deletions(-) create mode 100644 src/api/billingApi.ts create mode 100644 src/components/ProviderSelector/ProviderSelector.module.css create mode 100644 src/components/ProviderSelector/ProviderSelector.tsx create mode 100644 src/components/ProviderSelector/index.ts create mode 100644 src/hooks/useBilling.ts create mode 100644 src/pages/billing/Billing.module.css create mode 100644 src/pages/billing/BillingAdmin.tsx create mode 100644 src/pages/billing/BillingDashboard.tsx create mode 100644 src/pages/billing/BillingTransactions.tsx create mode 100644 src/pages/billing/index.ts diff --git a/src/App.tsx b/src/App.tsx index 0a7591b..41c6028 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,9 @@ import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandat // Basedata Pages (global) import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; +// Billing Pages +import { BillingDashboard, BillingTransactions, BillingAdmin } from './pages/billing'; + function App() { // Load saved theme preference and set app name on app mount useEffect(() => { @@ -110,6 +113,14 @@ function App() { } /> + {/* ============================================== */} + {/* BILLING ROUTES */} + {/* ============================================== */} + + } /> + } /> + + {/* ============================================== */} {/* FEATURE-INSTANZ ROUTES */} {/* /mandates/:mandateId/:featureCode/:instanceId */} @@ -165,6 +176,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts index b6f0628..296933b 100644 --- a/src/api/automationApi.ts +++ b/src/api/automationApi.ts @@ -17,6 +17,7 @@ export interface Automation { lastExecution?: number; nextExecution?: number; executionLogs?: AutomationLog[]; + allowedProviders?: string[]; _createdAt?: number; _updatedAt?: number; _createdByUserName?: string; diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts new file mode 100644 index 0000000..c43c40f --- /dev/null +++ b/src/api/billingApi.ts @@ -0,0 +1,254 @@ +import { ApiRequestOptions } from '../hooks/useApi'; + +// ============================================================================ +// TYPES & INTERFACES +// ============================================================================ + +export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER' | 'CREDIT_POSTPAY' | 'UNLIMITED'; +export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT'; +export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM'; + +export interface BillingAddress { + company: string; + street: string; + zip: string; + city: string; + country: string; + vatNumber?: string; +} + +export interface BillingBalance { + mandateId: string; + mandateName: string; + billingModel: BillingModel; + balance: number; + currency: string; + warningThreshold: number; + isWarning: boolean; + creditLimit?: number; +} + +export interface BillingTransaction { + id: string; + accountId: string; + transactionType: TransactionType; + amount: number; + description: string; + referenceType?: ReferenceType; + workflowId?: string; + featureCode?: string; + aicoreProvider?: string; + createdAt?: string; +} + +export interface BillingSettings { + id: string; + mandateId: string; + billingModel: BillingModel; + defaultUserCredit: number; + warningThresholdPercent: number; + blockOnZeroBalance: boolean; + notifyOnWarning: boolean; + notifyEmails: string[]; + billingAddress?: BillingAddress; +} + +export interface BillingSettingsUpdate { + billingModel?: BillingModel; + defaultUserCredit?: number; + warningThresholdPercent?: number; + blockOnZeroBalance?: boolean; + notifyOnWarning?: boolean; + notifyEmails?: string[]; + billingAddress?: BillingAddress; +} + +export interface UsageReport { + period: string; + totalCost: number; + transactionCount: number; + costByProvider: Record; + costByFeature: Record; +} + +export interface AccountSummary { + id: string; + mandateId: string; + userId?: string; + accountType: string; + balance: number; + creditLimit?: number; + warningThreshold: number; + enabled: boolean; +} + +export interface CreditAddRequest { + userId?: string; + amount: number; + description?: string; +} + +// Type for the request function passed to API functions +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +// ============================================================================ +// USER API FUNCTIONS +// ============================================================================ + +/** + * Fetch billing balances for all mandates the user belongs to + * Endpoint: GET /api/billing/balance + */ +export async function fetchBalances( + request: ApiRequestFunction +): Promise { + return await request({ + url: '/api/billing/balance', + method: 'get' + }); +} + +/** + * Fetch billing balance for a specific mandate + * Endpoint: GET /api/billing/balance/{mandateId} + */ +export async function fetchBalanceForMandate( + request: ApiRequestFunction, + mandateId: string +): Promise { + return await request({ + url: `/api/billing/balance/${mandateId}`, + method: 'get' + }); +} + +/** + * Fetch transaction history + * Endpoint: GET /api/billing/transactions + */ +export async function fetchTransactions( + request: ApiRequestFunction, + limit: number = 50, + offset: number = 0 +): Promise { + return await request({ + url: '/api/billing/transactions', + method: 'get', + params: { limit, offset } + }); +} + +/** + * Fetch usage statistics + * Endpoint: GET /api/billing/statistics/{period} + */ +export async function fetchStatistics( + request: ApiRequestFunction, + period: 'day' | 'month' | 'year', + year: number, + month?: number +): Promise { + const params: Record = { year }; + if (month !== undefined) { + params.month = month; + } + + return await request({ + url: `/api/billing/statistics/${period}`, + method: 'get', + params + }); +} + +/** + * Fetch allowed AICore providers + * Endpoint: GET /api/billing/providers + */ +export async function fetchAllowedProviders( + request: ApiRequestFunction +): Promise { + return await request({ + url: '/api/billing/providers', + method: 'get' + }); +} + +// ============================================================================ +// ADMIN API FUNCTIONS +// ============================================================================ + +/** + * Fetch billing settings for a mandate (Admin) + * Endpoint: GET /api/billing/admin/settings/{mandateId} + */ +export async function fetchSettingsAdmin( + request: ApiRequestFunction, + mandateId: string +): Promise { + return await request({ + url: `/api/billing/admin/settings/${mandateId}`, + method: 'get' + }); +} + +/** + * Create or update billing settings (Admin) + * Endpoint: POST /api/billing/admin/settings/{mandateId} + */ +export async function updateSettingsAdmin( + request: ApiRequestFunction, + mandateId: string, + settings: BillingSettingsUpdate +): Promise { + return await request({ + url: `/api/billing/admin/settings/${mandateId}`, + method: 'post', + data: settings + }); +} + +/** + * Add credit to an account (Admin) + * Endpoint: POST /api/billing/admin/credit/{mandateId} + */ +export async function addCreditAdmin( + request: ApiRequestFunction, + mandateId: string, + creditRequest: CreditAddRequest +): Promise { + return await request({ + url: `/api/billing/admin/credit/${mandateId}`, + method: 'post', + data: creditRequest + }); +} + +/** + * Fetch all accounts for a mandate (Admin) + * Endpoint: GET /api/billing/admin/accounts/{mandateId} + */ +export async function fetchAccountsAdmin( + request: ApiRequestFunction, + mandateId: string +): Promise { + return await request({ + url: `/api/billing/admin/accounts/${mandateId}`, + method: 'get' + }); +} + +/** + * Fetch all transactions for a mandate (Admin) + * Endpoint: GET /api/billing/admin/transactions/{mandateId} + */ +export async function fetchTransactionsAdmin( + request: ApiRequestFunction, + mandateId: string, + limit: number = 100 +): Promise { + return await request({ + url: `/api/billing/admin/transactions/${mandateId}`, + method: 'get', + params: { limit } + }); +} diff --git a/src/components/AutomationEditor/AutomationEditor.tsx b/src/components/AutomationEditor/AutomationEditor.tsx index 677daea..255e404 100644 --- a/src/components/AutomationEditor/AutomationEditor.tsx +++ b/src/components/AutomationEditor/AutomationEditor.tsx @@ -13,6 +13,7 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { FaTimes, FaSave, FaChevronLeft, FaChevronRight, FaRocket, FaFileAlt, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa'; import { ActionsPanel } from '../ActionsPanel'; +import { ProviderMultiSelect } from '../ProviderSelector'; import { useToast } from '../../contexts/ToastContext'; import { useLanguage } from '../../providers/language/LanguageContext'; import { useWorkflowActions } from '../../hooks/useAutomations'; @@ -368,6 +369,7 @@ export const AutomationEditor: React.FC = ({ const [label, setLabel] = useState(''); const [schedule, setSchedule] = useState('0 22 * * *'); const [active, setActive] = useState(false); + const [allowedProviders, setAllowedProviders] = useState([]); // Template multilingual fields const [labelMulti, setLabelMulti] = useState({ en: '', de: '' }); @@ -530,6 +532,7 @@ export const AutomationEditor: React.FC = ({ setLabel(def.label || ''); setSchedule(def.schedule || '0 22 * * *'); setActive(def.active ?? false); + setAllowedProviders(def.allowedProviders || []); } // Extract template JSON @@ -684,7 +687,8 @@ export const AutomationEditor: React.FC = ({ schedule, active, template: templateJson, - placeholders + placeholders, + allowedProviders }; } @@ -700,7 +704,7 @@ export const AutomationEditor: React.FC = ({ } finally { setIsSaving(false); } - }, [label, schedule, active, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]); + }, [label, schedule, active, allowedProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]); // Computed values const editorTitle = title || (mode === 'template' @@ -831,6 +835,18 @@ export const AutomationEditor: React.FC = ({ Automatisierung ist aktiv und wird planmässig ausgeführt

+ + {/* Allowed AI Providers */} +
+ +

+ Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt. +

+
)} diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css new file mode 100644 index 0000000..465ed1e --- /dev/null +++ b/src/components/ProviderSelector/ProviderSelector.module.css @@ -0,0 +1,161 @@ +/* Provider Selector Component Styles */ + +/* ============================================================================ + SINGLE SELECT + ============================================================================ */ + +.providerSelect { + display: flex; + flex-direction: column; + gap: var(--spacing-xs, 4px); +} + +.label { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + color: var(--color-text-secondary); +} + +.select { + padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md, 6px); + background: var(--color-bg-input); + color: var(--color-text-primary); + font-size: var(--font-size-sm, 0.875rem); + cursor: pointer; + min-width: 150px; +} + +.select:focus { + outline: none; + border-color: var(--color-primary); +} + +.select:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ============================================================================ + MULTI SELECT + ============================================================================ */ + +.providerMultiSelect { + display: flex; + flex-direction: column; + gap: var(--spacing-sm, 8px); +} + +.selectActions { + display: flex; + gap: var(--spacing-xs, 4px); +} + +.actionButton { + padding: 2px 8px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm, 4px); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + font-size: var(--font-size-xs, 0.75rem); + cursor: pointer; + transition: all 0.2s ease; +} + +.actionButton:hover:not(:disabled) { + background: var(--color-bg-hover); + border-color: var(--color-primary); +} + +.actionButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.checkboxList { + display: flex; + flex-direction: column; + gap: var(--spacing-xs, 4px); + padding: var(--spacing-sm, 8px); + background: var(--color-bg-secondary); + border-radius: var(--border-radius-md, 6px); +} + +.checkboxItem { + display: flex; + align-items: center; + gap: var(--spacing-sm, 8px); + padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px); + border-radius: var(--border-radius-sm, 4px); + cursor: pointer; + transition: background 0.2s ease; +} + +.checkboxItem:hover { + background: var(--color-bg-hover); +} + +.checkboxItem.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.checkboxItem input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: inherit; +} + +.icon { + font-size: 1.1em; +} + +.providerName { + font-size: var(--font-size-sm, 0.875rem); + color: var(--color-text-primary); +} + +.hint { + font-size: var(--font-size-xs, 0.75rem); + color: var(--color-text-tertiary); + font-style: italic; + padding: var(--spacing-xs, 4px) 0; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md, 16px); + color: var(--color-text-secondary); + font-size: var(--font-size-sm, 0.875rem); +} + +/* ============================================================================ + PROVIDER BADGES + ============================================================================ */ + +.providerBadges { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs, 4px); +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm, 4px); + font-size: var(--font-size-xs, 0.75rem); + color: var(--color-text-primary); +} + +.allProviders { + font-size: var(--font-size-xs, 0.75rem); + color: var(--color-text-secondary); + font-style: italic; +} diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx new file mode 100644 index 0000000..fb36ea5 --- /dev/null +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -0,0 +1,221 @@ +/** + * ProviderSelector Component + * + * Wiederverwendbare Komponente zur Auswahl von AICore-Providern. + * Kann im Chat Playground und Automation Editor verwendet werden. + * + * Features: + * - Dropdown für Einzelauswahl + * - Checkbox-Liste für Mehrfachauswahl + * - Lädt verfügbare Provider aus dem Billing-System + */ + +import React, { useEffect, useMemo } from 'react'; +import { useBilling } from '../../hooks/useBilling'; +import styles from './ProviderSelector.module.css'; + +// Provider display names +const PROVIDER_LABELS: Record = { + anthropic: 'Anthropic (Claude)', + openai: 'OpenAI (GPT)', + perplexity: 'Perplexity', + tavily: 'Tavily (Web Search)', + internal: 'Internal', +}; + +// Provider icons (emojis for simplicity) +const PROVIDER_ICONS: Record = { + anthropic: '🤖', + openai: '💬', + perplexity: '🔍', + tavily: '🌐', + internal: '🏠', +}; + +// ============================================================================ +// SINGLE SELECT COMPONENT +// ============================================================================ + +interface ProviderSelectProps { + value: string; + onChange: (provider: string) => void; + disabled?: boolean; + className?: string; + label?: string; + showLabel?: boolean; +} + +export const ProviderSelect: React.FC = ({ + value, + onChange, + disabled = false, + className, + label = 'AI-Provider', + showLabel = true, +}) => { + const { allowedProviders, loadAllowedProviders, loading } = useBilling(); + + useEffect(() => { + if (allowedProviders.length === 0 && !loading) { + loadAllowedProviders(); + } + }, []); + + const providerOptions = useMemo(() => { + return allowedProviders.map((provider) => ({ + value: provider, + label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`, + })); + }, [allowedProviders]); + + return ( +
+ {showLabel && } + +
+ ); +}; + +// ============================================================================ +// MULTI SELECT COMPONENT (Checkbox List) +// ============================================================================ + +interface ProviderMultiSelectProps { + selectedProviders: string[]; + onChange: (providers: string[]) => void; + disabled?: boolean; + className?: string; + label?: string; + showLabel?: boolean; +} + +export const ProviderMultiSelect: React.FC = ({ + selectedProviders, + onChange, + disabled = false, + className, + label = 'Erlaubte AI-Provider', + showLabel = true, +}) => { + const { allowedProviders, loadAllowedProviders, loading } = useBilling(); + + useEffect(() => { + if (allowedProviders.length === 0 && !loading) { + loadAllowedProviders(); + } + }, []); + + const handleToggle = (provider: string) => { + if (selectedProviders.includes(provider)) { + onChange(selectedProviders.filter((p) => p !== provider)); + } else { + onChange([...selectedProviders, provider]); + } + }; + + const handleSelectAll = () => { + onChange(allowedProviders); + }; + + const handleSelectNone = () => { + onChange([]); + }; + + return ( +
+ {showLabel && } + +
+ + +
+ + {loading ? ( +
Lade Provider...
+ ) : ( +
+ {allowedProviders.map((provider) => ( + + ))} +
+ )} + + {selectedProviders.length === 0 && !loading && ( +
+ Wenn keine Provider ausgewählt sind, werden alle erlaubten Provider verwendet. +
+ )} +
+ ); +}; + +// ============================================================================ +// COMPACT PROVIDER BADGE LIST +// ============================================================================ + +interface ProviderBadgesProps { + providers: string[]; + className?: string; +} + +export const ProviderBadges: React.FC = ({ + providers, + className, +}) => { + if (providers.length === 0) { + return Alle Provider; + } + + return ( +
+ {providers.map((provider) => ( + + {PROVIDER_ICONS[provider] || '🔌'} {PROVIDER_LABELS[provider] || provider} + + ))} +
+ ); +}; + +// Default export +export default ProviderSelect; diff --git a/src/components/ProviderSelector/index.ts b/src/components/ProviderSelector/index.ts new file mode 100644 index 0000000..afe1b42 --- /dev/null +++ b/src/components/ProviderSelector/index.ts @@ -0,0 +1,10 @@ +/** + * Provider Selector Component Exports + */ + +export { + ProviderSelect, + ProviderMultiSelect, + ProviderBadges +} from './ProviderSelector'; +export { default } from './ProviderSelector'; diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 62f8828..35785cc 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -20,7 +20,7 @@ import { FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt, FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone, FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase, - FaProjectDiagram, FaMapMarkedAlt + FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt } from 'react-icons/fa'; // ============================================================================= @@ -47,6 +47,10 @@ export const PAGE_ICONS: Record = { 'page.system.pek': , 'page.system.speech': , + // Billing pages + 'page.billing.dashboard': , + 'page.billing.transactions': , + // Admin pages 'page.admin.access': , 'page.admin.users': , @@ -59,6 +63,7 @@ export const PAGE_ICONS: Record = { 'page.admin.feature-instances': , 'page.admin.feature-users': , 'page.admin.user-access-overview': , + 'page.admin.billing': , // Feature pages - Trustee 'page.feature.trustee.dashboard': , diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts new file mode 100644 index 0000000..70298aa --- /dev/null +++ b/src/hooks/useBilling.ts @@ -0,0 +1,265 @@ +/** + * useBilling Hook + * + * Hook für die Verwaltung von Billing-Daten. + * Bietet Zugriff auf Guthaben, Transaktionen, Statistiken und Admin-Funktionen. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { + fetchBalances, + fetchBalanceForMandate, + fetchTransactions, + fetchStatistics, + fetchAllowedProviders, + fetchSettingsAdmin, + updateSettingsAdmin, + addCreditAdmin, + fetchAccountsAdmin, + fetchTransactionsAdmin, + type BillingBalance, + type BillingTransaction, + type BillingSettings, + type BillingSettingsUpdate, + type UsageReport, + type AccountSummary, + type CreditAddRequest, +} from '../api/billingApi'; + +// Re-export types +export type { + BillingBalance, + BillingTransaction, + BillingSettings, + BillingSettingsUpdate, + UsageReport, + AccountSummary, + CreditAddRequest, +}; + +export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi'; + +/** + * Hook for user billing operations + */ +export function useBilling() { + const [balances, setBalances] = useState([]); + const [transactions, setTransactions] = useState([]); + const [statistics, setStatistics] = useState(null); + const [allowedProviders, setAllowedProviders] = useState([]); + const { request, isLoading: loading, error } = useApiRequest(); + + // Fetch all balances for the user + const loadBalances = useCallback(async () => { + try { + const data = await fetchBalances(request); + setBalances(Array.isArray(data) ? data : []); + return data; + } catch (err) { + console.error('Error loading balances:', err); + setBalances([]); + return []; + } + }, [request]); + + // Fetch balance for a specific mandate + const loadBalanceForMandate = useCallback(async (mandateId: string) => { + try { + return await fetchBalanceForMandate(request, mandateId); + } catch (err) { + console.error('Error loading balance for mandate:', err); + return null; + } + }, [request]); + + // Fetch transactions + const loadTransactions = useCallback(async (limit: number = 50, offset: number = 0) => { + try { + const data = await fetchTransactions(request, limit, offset); + setTransactions(Array.isArray(data) ? data : []); + return data; + } catch (err) { + console.error('Error loading transactions:', err); + setTransactions([]); + return []; + } + }, [request]); + + // Fetch statistics + const loadStatistics = useCallback(async ( + period: 'day' | 'month' | 'year', + year: number, + month?: number + ) => { + try { + const data = await fetchStatistics(request, period, year, month); + setStatistics(data); + return data; + } catch (err) { + console.error('Error loading statistics:', err); + setStatistics(null); + return null; + } + }, [request]); + + // Fetch allowed providers + const loadAllowedProviders = useCallback(async () => { + try { + const data = await fetchAllowedProviders(request); + setAllowedProviders(Array.isArray(data) ? data : []); + return data; + } catch (err) { + console.error('Error loading allowed providers:', err); + setAllowedProviders([]); + return []; + } + }, [request]); + + // Initial load + useEffect(() => { + loadBalances(); + loadAllowedProviders(); + }, []); + + return { + balances, + transactions, + statistics, + allowedProviders, + loading, + error, + loadBalances, + loadBalanceForMandate, + loadTransactions, + loadStatistics, + loadAllowedProviders, + refetch: loadBalances, + }; +} + +/** + * Hook for admin billing operations + */ +export function useBillingAdmin(mandateId?: string) { + const [settings, setSettings] = useState(null); + const [accounts, setAccounts] = useState([]); + const [transactions, setTransactions] = useState([]); + const { request, isLoading: loading, error } = useApiRequest(); + + // Fetch settings for a mandate + const loadSettings = useCallback(async (targetMandateId?: string) => { + const mId = targetMandateId || mandateId; + if (!mId) return null; + + try { + const data = await fetchSettingsAdmin(request, mId); + setSettings(data); + return data; + } catch (err) { + console.error('Error loading billing settings:', err); + setSettings(null); + return null; + } + }, [request, mandateId]); + + // Update settings + const saveSettings = useCallback(async ( + settingsUpdate: BillingSettingsUpdate, + targetMandateId?: string + ) => { + const mId = targetMandateId || mandateId; + if (!mId) return null; + + try { + const data = await updateSettingsAdmin(request, mId, settingsUpdate); + setSettings(data); + return data; + } catch (err) { + console.error('Error saving billing settings:', err); + throw err; + } + }, [request, mandateId]); + + // Add credit + const addCredit = useCallback(async ( + creditRequest: CreditAddRequest, + targetMandateId?: string + ) => { + const mId = targetMandateId || mandateId; + if (!mId) return null; + + try { + const result = await addCreditAdmin(request, mId, creditRequest); + // Reload accounts after adding credit + await loadAccounts(mId); + return result; + } catch (err) { + console.error('Error adding credit:', err); + throw err; + } + }, [request, mandateId]); + + // Fetch accounts for a mandate + const loadAccounts = useCallback(async (targetMandateId?: string) => { + const mId = targetMandateId || mandateId; + if (!mId) return []; + + try { + const data = await fetchAccountsAdmin(request, mId); + setAccounts(Array.isArray(data) ? data : []); + return data; + } catch (err) { + console.error('Error loading accounts:', err); + setAccounts([]); + return []; + } + }, [request, mandateId]); + + // Fetch transactions for a mandate + const loadTransactions = useCallback(async (targetMandateId?: string, limit: number = 100) => { + const mId = targetMandateId || mandateId; + if (!mId) return []; + + try { + const data = await fetchTransactionsAdmin(request, mId, limit); + setTransactions(Array.isArray(data) ? data : []); + return data; + } catch (err) { + console.error('Error loading transactions:', err); + setTransactions([]); + return []; + } + }, [request, mandateId]); + + // Load data when mandateId changes + useEffect(() => { + if (mandateId) { + loadSettings(); + loadAccounts(); + loadTransactions(); + } + }, [mandateId]); + + return { + settings, + accounts, + transactions, + loading, + error, + loadSettings, + saveSettings, + addCredit, + loadAccounts, + loadTransactions, + refetch: () => { + if (mandateId) { + loadSettings(); + loadAccounts(); + loadTransactions(); + } + }, + }; +} + +export default useBilling; diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css new file mode 100644 index 0000000..f7c7304 --- /dev/null +++ b/src/pages/billing/Billing.module.css @@ -0,0 +1,518 @@ +/* Billing Pages Styles */ + +/* ============================================================================ + PAGE LAYOUT + ============================================================================ */ + +.billingDashboard { + padding: var(--spacing-lg); + max-width: 1200px; + margin: 0 auto; +} + +.pageHeader { + margin-bottom: var(--spacing-xl); +} + +.pageHeader h1 { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-xs) 0; +} + +.subtitle { + font-size: var(--font-size-base); + color: var(--color-text-secondary); + margin: 0; +} + +/* ============================================================================ + SECTIONS + ============================================================================ */ + +.section { + margin-bottom: var(--spacing-xl); +} + +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.sectionTitle { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-md) 0; +} + +.sectionHeader .sectionTitle { + margin-bottom: 0; +} + +/* ============================================================================ + BALANCE CARDS + ============================================================================ */ + +.balanceGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--spacing-md); +} + +.balanceCard { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + padding: var(--spacing-lg); + cursor: pointer; + transition: all 0.2s ease; +} + +.balanceCard:hover { + border-color: var(--color-primary); + box-shadow: var(--shadow-md); +} + +.balanceCard.warning { + border-color: var(--color-warning); + background: var(--color-warning-bg, rgba(255, 193, 7, 0.1)); +} + +.balanceHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--spacing-md); +} + +.mandateName { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0; +} + +.billingModel { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + background: var(--color-bg-secondary); + padding: 2px 8px; + border-radius: var(--border-radius-sm); +} + +.balanceAmount { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin-bottom: var(--spacing-sm); +} + +.warningBadge { + display: inline-block; + font-size: var(--font-size-xs); + color: var(--color-warning-text, #856404); + background: var(--color-warning-badge-bg, rgba(255, 193, 7, 0.3)); + padding: 4px 8px; + border-radius: var(--border-radius-sm); + font-weight: var(--font-weight-medium); +} + +/* ============================================================================ + STATISTICS + ============================================================================ */ + +.periodSelector { + display: flex; + gap: var(--spacing-sm); + align-items: center; +} + +.select { + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + background: var(--color-bg-input); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + cursor: pointer; +} + +.select:focus { + outline: none; + border-color: var(--color-primary); +} + +.statisticsChart { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + padding: var(--spacing-lg); +} + +.totalCost { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--spacing-lg); + background: var(--color-bg-secondary); + border-radius: var(--border-radius-md); + margin-bottom: var(--spacing-lg); +} + +.totalLabel { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin-bottom: var(--spacing-xs); +} + +.totalAmount { + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +.chartSection { + margin-bottom: var(--spacing-lg); +} + +.chartSection:last-child { + margin-bottom: 0; +} + +.chartSection h4 { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + margin: 0 0 var(--spacing-md) 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.barChart { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.barRow { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.barLabel { + width: 100px; + font-size: var(--font-size-sm); + color: var(--color-text-primary); + text-transform: capitalize; +} + +.barContainer { + flex: 1; + height: 24px; + background: var(--color-bg-secondary); + border-radius: var(--border-radius-sm); + overflow: hidden; +} + +.bar { + height: 100%; + background: var(--color-primary); + border-radius: var(--border-radius-sm); + transition: width 0.3s ease; + min-width: 4px; +} + +.barValue { + width: 100px; + text-align: right; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + font-family: var(--font-mono); +} + +.featureList { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.featureRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-sm); + background: var(--color-bg-secondary); + border-radius: var(--border-radius-sm); +} + +.featureLabel { + font-size: var(--font-size-sm); + color: var(--color-text-primary); + text-transform: capitalize; +} + +.featureValue { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + font-family: var(--font-mono); +} + +/* ============================================================================ + TRANSACTIONS + ============================================================================ */ + +.transactionsTable { + width: 100%; + border-collapse: collapse; +} + +.transactionsTable th, +.transactionsTable td { + padding: var(--spacing-sm) var(--spacing-md); + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.transactionsTable th { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + background: var(--color-bg-secondary); +} + +.transactionsTable td { + font-size: var(--font-size-sm); + color: var(--color-text-primary); +} + +.transactionType { + display: inline-block; + padding: 2px 8px; + border-radius: var(--border-radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); +} + +.transactionType.credit { + background: var(--color-success-bg, rgba(40, 167, 69, 0.1)); + color: var(--color-success, #28a745); +} + +.transactionType.debit { + background: var(--color-error-bg, rgba(220, 53, 69, 0.1)); + color: var(--color-error, #dc3545); +} + +.transactionType.adjustment { + background: var(--color-info-bg, rgba(23, 162, 184, 0.1)); + color: var(--color-info, #17a2b8); +} + +/* ============================================================================ + ADMIN STYLES + ============================================================================ */ + +.adminSection { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + padding: var(--spacing-lg); + margin-bottom: var(--spacing-lg); +} + +.adminSection h3 { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-md) 0; +} + +.formRow { + display: flex; + gap: var(--spacing-md); + margin-bottom: var(--spacing-md); +} + +.formGroup { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.formGroup label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + +.input { + padding: var(--spacing-sm); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + background: var(--color-bg-input); + color: var(--color-text-primary); + font-size: var(--font-size-base); +} + +.input:focus { + outline: none; + border-color: var(--color-primary); +} + +.accountsGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: var(--spacing-md); +} + +.accountCard { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + padding: var(--spacing-md); +} + +.accountCard h4 { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-sm) 0; +} + +.accountInfo { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + font-size: var(--font-size-sm); +} + +.accountInfo span { + color: var(--color-text-secondary); +} + +.accountInfo strong { + color: var(--color-text-primary); +} + +/* ============================================================================ + BUTTONS + ============================================================================ */ + +.button { + padding: var(--spacing-sm) var(--spacing-md); + border: none; + border-radius: var(--border-radius-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all 0.2s ease; +} + +.buttonPrimary { + background: var(--color-primary); + color: white; +} + +.buttonPrimary:hover { + background: var(--color-primary-dark); +} + +.buttonPrimary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.buttonSecondary { + background: var(--color-bg-secondary); + color: var(--color-text-primary); + border: 1px solid var(--color-border); +} + +.buttonSecondary:hover { + background: var(--color-bg-hover); +} + +/* ============================================================================ + UTILITY CLASSES + ============================================================================ */ + +.loadingPlaceholder { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.noData { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-lg); + color: var(--color-text-tertiary); + font-size: var(--font-size-sm); + font-style: italic; +} + +.errorMessage { + background: var(--color-error-bg, rgba(220, 53, 69, 0.1)); + color: var(--color-error, #dc3545); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--border-radius-md); + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-md); +} + +.successMessage { + background: var(--color-success-bg, rgba(40, 167, 69, 0.1)); + color: var(--color-success, #28a745); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--border-radius-md); + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-md); +} + +/* ============================================================================ + RESPONSIVE + ============================================================================ */ + +@media (max-width: 768px) { + .billingDashboard { + padding: var(--spacing-md); + } + + .balanceGrid { + grid-template-columns: 1fr; + } + + .sectionHeader { + flex-direction: column; + align-items: flex-start; + } + + .periodSelector { + width: 100%; + flex-wrap: wrap; + } + + .formRow { + flex-direction: column; + } + + .barLabel, + .barValue { + width: 80px; + font-size: var(--font-size-xs); + } +} diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx new file mode 100644 index 0000000..fc16ac7 --- /dev/null +++ b/src/pages/billing/BillingAdmin.tsx @@ -0,0 +1,419 @@ +/** + * Billing Admin Page + * + * Admin-Seite für Billing-Verwaltung (SysAdmin only). + * - Settings verwalten + * - Guthaben aufladen + * - Konten übersicht + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { useBillingAdmin, type BillingSettings, type AccountSummary } from '../../hooks/useBilling'; +import { useAdminMandates } from '../../hooks/useMandates'; +import styles from './Billing.module.css'; + +// ============================================================================ +// MANDATE SELECTOR +// ============================================================================ + +interface MandateSelectorProps { + selectedMandateId: string | null; + onSelect: (mandateId: string) => void; +} + +const MandateSelector: React.FC = ({ selectedMandateId, onSelect }) => { + const { mandates, loading } = useAdminMandates(); + + return ( +
+ + +
+ ); +}; + +// ============================================================================ +// SETTINGS EDITOR +// ============================================================================ + +interface SettingsEditorProps { + settings: BillingSettings | null; + onSave: (settings: Partial) => Promise; + loading: boolean; +} + +const SettingsEditor: React.FC = ({ settings, onSave, loading }) => { + const [formData, setFormData] = useState({ + billingModel: settings?.billingModel || 'UNLIMITED', + defaultUserCredit: settings?.defaultUserCredit || 10, + warningThresholdPercent: settings?.warningThresholdPercent || 10, + blockOnZeroBalance: settings?.blockOnZeroBalance ?? true, + notifyOnWarning: settings?.notifyOnWarning ?? true, + }); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + useEffect(() => { + if (settings) { + setFormData({ + billingModel: settings.billingModel, + defaultUserCredit: settings.defaultUserCredit, + warningThresholdPercent: settings.warningThresholdPercent, + blockOnZeroBalance: settings.blockOnZeroBalance, + notifyOnWarning: settings.notifyOnWarning, + }); + } + }, [settings]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setMessage(null); + + try { + await onSave(formData); + setMessage({ type: 'success', text: 'Einstellungen gespeichert!' }); + } catch (err: any) { + setMessage({ type: 'error', text: err.message || 'Fehler beim Speichern' }); + } finally { + setSaving(false); + } + }; + + return ( +
+

Billing-Einstellungen

+ + {message && ( +
+ {message.text} +
+ )} + +
+
+
+ + +
+ +
+ + setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))} + min="0" + step="0.01" + /> +
+
+ +
+
+ + setFormData(prev => ({ ...prev, warningThresholdPercent: Number(e.target.value) }))} + min="0" + max="100" + step="1" + /> +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ ); +}; + +// ============================================================================ +// CREDIT ADDER +// ============================================================================ + +interface CreditAdderProps { + settings: BillingSettings | null; + accounts: AccountSummary[]; + onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; +} + +const CreditAdder: React.FC = ({ settings, accounts, onAddCredit }) => { + const [selectedUserId, setSelectedUserId] = useState(''); + const [amount, setAmount] = useState(10); + const [description, setDescription] = useState('Manuelles Aufladen'); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (amount <= 0) { + setMessage({ type: 'error', text: 'Betrag muss positiv sein' }); + return; + } + + setSaving(true); + setMessage(null); + + try { + await onAddCredit(isPrepayUser ? selectedUserId : undefined, amount, description); + setMessage({ type: 'success', text: `${amount} CHF erfolgreich gutgeschrieben!` }); + setAmount(10); + setDescription('Manuelles Aufladen'); + } catch (err: any) { + setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' }); + } finally { + setSaving(false); + } + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + return ( +
+

Guthaben aufladen

+ + {message && ( +
+ {message.text} +
+ )} + +
+ {isPrepayUser && ( +
+
+ + +
+
+ )} + +
+
+ + setAmount(Number(e.target.value))} + min="0.01" + step="0.01" + required + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Grund für Gutschrift" + /> +
+
+ + +
+
+ ); +}; + +// ============================================================================ +// ACCOUNTS OVERVIEW +// ============================================================================ + +interface AccountsOverviewProps { + accounts: AccountSummary[]; + loading: boolean; +} + +const AccountsOverview: React.FC = ({ accounts, loading }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + if (loading) { + return
Lade Konten...
; + } + + if (accounts.length === 0) { + return
Keine Konten vorhanden
; + } + + return ( +
+

Konten

+
+ {accounts.map((account) => ( +
+

{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}

+
+ {account.userId && User: {account.userId}} + Guthaben: {formatCurrency(account.balance)} + {account.creditLimit && Limit: {formatCurrency(account.creditLimit)}} + Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'} +
+
+ ))} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingAdmin: React.FC = () => { + const [selectedMandateId, setSelectedMandateId] = useState(null); + const { settings, accounts, loading, loadSettings, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); + + const handleMandateSelect = (mandateId: string) => { + setSelectedMandateId(mandateId || null); + }; + + const handleSaveSettings = useCallback(async (settingsUpdate: Partial) => { + if (!selectedMandateId) return; + await saveSettings(settingsUpdate); + }, [selectedMandateId, saveSettings]); + + const handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => { + if (!selectedMandateId) return; + await addCredit({ userId, amount, description }); + await loadAccounts(); + }, [selectedMandateId, addCredit, loadAccounts]); + + return ( +
+
+

Billing Administration

+

Verwaltung von Abrechnungseinstellungen und Guthaben

+
+ +
+ +
+ + {selectedMandateId && ( + <> + + + + + + + )} + + {!selectedMandateId && ( +
+ Bitte wählen Sie einen Mandanten aus. +
+ )} +
+ ); +}; + +export default BillingAdmin; diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx new file mode 100644 index 0000000..2576818 --- /dev/null +++ b/src/pages/billing/BillingDashboard.tsx @@ -0,0 +1,247 @@ +/** + * Billing Dashboard Page + * + * Zeigt Guthaben, Statistiken und Transaktionen für den Benutzer. + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling'; +import styles from './Billing.module.css'; + +// ============================================================================ +// BALANCE CARD COMPONENT +// ============================================================================ + +interface BalanceCardProps { + balance: BillingBalance; + onClick?: () => void; +} + +const BalanceCard: React.FC = ({ balance, onClick }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + const getBillingModelLabel = (model: string) => { + switch (model) { + case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; + case 'PREPAY_USER': return 'Prepaid (Benutzer)'; + case 'CREDIT_POSTPAY': return 'Kredit'; + case 'UNLIMITED': return 'Unlimited'; + default: return model; + } + }; + + return ( +
+
+

{balance.mandateName}

+ {getBillingModelLabel(balance.billingModel)} +
+
+ {formatCurrency(balance.balance)} +
+ {balance.isWarning && ( +
+ Niedriges Guthaben +
+ )} +
+ ); +}; + +// ============================================================================ +// STATISTICS CHART COMPONENT +// ============================================================================ + +interface StatisticsChartProps { + statistics: UsageReport | null; + loading?: boolean; +} + +const StatisticsChart: React.FC = ({ statistics, loading }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + if (loading) { + return
Lade Statistiken...
; + } + + if (!statistics) { + return
Keine Statistiken verfügbar
; + } + + // Calculate max cost for bar scaling + const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1); + + return ( +
+
+ Gesamtkosten + {formatCurrency(statistics.totalCost)} +
+ +
+

Kosten nach Anbieter

+ {Object.entries(statistics.costByProvider).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByProvider).map(([provider, cost]) => ( +
+ {provider} +
+
+
+ {formatCurrency(cost)} +
+ ))} +
+ )} +
+ +
+

Kosten nach Feature

+ {Object.entries(statistics.costByFeature).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByFeature).map(([feature, cost]) => ( +
+ {feature} + {formatCurrency(cost)} +
+ ))} +
+ )} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingDashboard: React.FC = () => { + const { + balances, + statistics, + loading, + loadBalances, + loadStatistics + } = useBilling(); + + const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month'); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1); + + // Load statistics when period changes + useEffect(() => { + if (selectedPeriod === 'month') { + loadStatistics('month', selectedYear); + } else { + loadStatistics('year', selectedYear); + } + }, [selectedPeriod, selectedYear, loadStatistics]); + + // Available years (current and last 2 years) + const availableYears = useMemo(() => { + const current = new Date().getFullYear(); + return [current, current - 1, current - 2]; + }, []); + + // Available months + const availableMonths = [ + { value: 1, label: 'Januar' }, + { value: 2, label: 'Februar' }, + { value: 3, label: 'März' }, + { value: 4, label: 'April' }, + { value: 5, label: 'Mai' }, + { value: 6, label: 'Juni' }, + { value: 7, label: 'Juli' }, + { value: 8, label: 'August' }, + { value: 9, label: 'September' }, + { value: 10, label: 'Oktober' }, + { value: 11, label: 'November' }, + { value: 12, label: 'Dezember' }, + ]; + + return ( +
+
+

Billing

+

Übersicht über Guthaben und Nutzung

+
+ + {/* Balance Cards */} +
+

Guthaben

+ {loading ? ( +
Lade Guthaben...
+ ) : balances.length === 0 ? ( +
Keine Abrechnungskonten vorhanden
+ ) : ( +
+ {balances.map((balance) => ( + + ))} +
+ )} +
+ + {/* Statistics */} +
+
+

Nutzungsstatistik

+
+ + + {selectedPeriod === 'month' && ( + + )} +
+
+ +
+
+ ); +}; + +export default BillingDashboard; diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx new file mode 100644 index 0000000..f584bce --- /dev/null +++ b/src/pages/billing/BillingTransactions.tsx @@ -0,0 +1,142 @@ +/** + * Billing Transactions Page + * + * Zeigt die Transaktionshistorie für den Benutzer. + */ + +import React, { useEffect, useState } from 'react'; +import { useBilling, type BillingTransaction } from '../../hooks/useBilling'; +import styles from './Billing.module.css'; + +// ============================================================================ +// TRANSACTION ROW COMPONENT +// ============================================================================ + +interface TransactionRowProps { + transaction: BillingTransaction; +} + +const TransactionRow: React.FC = ({ transaction }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return '-'; + return new Date(dateString).toLocaleString('de-CH', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const getTypeClass = (type: string) => { + switch (type) { + case 'CREDIT': return styles.credit; + case 'DEBIT': return styles.debit; + case 'ADJUSTMENT': return styles.adjustment; + default: return ''; + } + }; + + const getTypeLabel = (type: string) => { + switch (type) { + case 'CREDIT': return 'Gutschrift'; + case 'DEBIT': return 'Belastung'; + case 'ADJUSTMENT': return 'Korrektur'; + default: return type; + } + }; + + return ( + + {formatDate(transaction.createdAt)} + + + {getTypeLabel(transaction.transactionType)} + + + {transaction.description} + {transaction.aicoreProvider || '-'} + {transaction.featureCode || '-'} + + {transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)} + + + ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingTransactions: React.FC = () => { + const { transactions, loading, loadTransactions } = useBilling(); + const [limit, setLimit] = useState(50); + + useEffect(() => { + loadTransactions(limit); + }, [limit, loadTransactions]); + + const handleLoadMore = () => { + setLimit(prev => prev + 50); + }; + + return ( +
+
+

Transaktionen

+

Übersicht aller Kontobewegungen

+
+ +
+ {loading && transactions.length === 0 ? ( +
Lade Transaktionen...
+ ) : transactions.length === 0 ? ( +
Keine Transaktionen vorhanden
+ ) : ( + <> +
+ + + + + + + + + + + + + {transactions.map((transaction) => ( + + ))} + +
DatumTypBeschreibungAnbieterFeatureBetrag
+
+ + {transactions.length >= limit && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default BillingTransactions; diff --git a/src/pages/billing/index.ts b/src/pages/billing/index.ts new file mode 100644 index 0000000..eb4f6c7 --- /dev/null +++ b/src/pages/billing/index.ts @@ -0,0 +1,7 @@ +/** + * Billing Pages Exports + */ + +export { BillingDashboard } from './BillingDashboard'; +export { BillingTransactions } from './BillingTransactions'; +export { BillingAdmin } from './BillingAdmin'; diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index ba20467..f85a71b 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -15,6 +15,7 @@ import { useCurrentInstance } from '../../hooks/useCurrentInstance'; import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import { useVoiceLanguage, VoiceLanguageSelect, Messages } from '../../components/UiComponents'; +import { ProviderSelect } from '../../components/ProviderSelector'; import type { Message } from '../../components/UiComponents/Messages/MessagesTypes'; import api from '../../api'; import styles from './PlaygroundPage.module.css'; @@ -92,6 +93,9 @@ export const PlaygroundPage: React.FC = () => { // Prompts dropdown state const [selectedPromptId, setSelectedPromptId] = useState(''); + // AI Provider selection state + const [selectedProvider, setSelectedProvider] = useState(''); + // Load prompts on mount useEffect(() => { refetchPrompts(); @@ -782,6 +786,11 @@ export const PlaygroundPage: React.FC = () => { > + Date: Wed, 4 Feb 2026 22:10:45 +0100 Subject: [PATCH 03/14] billing integration into ai workflow --- src/hooks/playground/useDashboardInputForm.ts | 11 ++++++++--- src/pages/workflows/PlaygroundPage.tsx | 7 +++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index 84517fa..8650fdf 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -30,6 +30,7 @@ export function useDashboardInputForm(instanceId: string) { const [optimisticMessage, setOptimisticMessage] = useState(null); const [selectedPromptId, setSelectedPromptId] = useState(null); const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null); + const [selectedProvider, setSelectedProvider] = useState(''); // AI provider selection const { checkPermission, canView } = usePermissions(); const [playgroundUIPermission, setPlaygroundUIPermission] = useState(false); @@ -594,7 +595,8 @@ export function useDashboardInputForm(instanceId: string) { const requestBody = { prompt: trimmedInput, listFileId: fileIdsToSend.length > 0 ? fileIdsToSend : undefined, - userLanguage: 'en' + userLanguage: 'en', + preferredProvider: selectedProvider || undefined // AI provider selection }; const result = await startWorkflow(requestBody, workflowOptions); @@ -636,7 +638,7 @@ export function useDashboardInputForm(instanceId: string) { setWorkflowStatusOptimistic('idle'); } } - }, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, setWorkflowStatusOptimistic]); + }, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, selectedProvider, setWorkflowStatusOptimistic]); useEffect(() => { const handleWorkflowCleared = () => { @@ -820,7 +822,10 @@ export function useDashboardInputForm(instanceId: string) { allUserFiles: fileContext.files || [], handleFileAttach, handleFileUploadAndAttach, - latestStats + latestStats, + // AI Provider selection + selectedProvider, + onProviderSelect: setSelectedProvider }; } diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index f85a71b..4cf2b8a 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -58,6 +58,8 @@ export const PlaygroundPage: React.FC = () => { deletingFiles, previewingFiles, downloadingFiles, + selectedProvider, + onProviderSelect, } = hookData; const { prompts, refetch: refetchPrompts } = usePrompts(); @@ -93,9 +95,6 @@ export const PlaygroundPage: React.FC = () => { // Prompts dropdown state const [selectedPromptId, setSelectedPromptId] = useState(''); - // AI Provider selection state - const [selectedProvider, setSelectedProvider] = useState(''); - // Load prompts on mount useEffect(() => { refetchPrompts(); @@ -788,7 +787,7 @@ export const PlaygroundPage: React.FC = () => { Date: Fri, 6 Feb 2026 16:18:44 +0100 Subject: [PATCH 04/14] billing fixes --- src/api/billingApi.ts | 25 ++ src/components/Navigation/UserSection.tsx | 13 + .../ProviderSelector.module.css | 105 +++++- .../ProviderSelector/ProviderSelector.tsx | 137 ++++--- src/hooks/playground/useDashboardInputForm.ts | 12 +- src/hooks/playground/useWorkflowLifecycle.ts | 33 +- src/hooks/useBilling.ts | 24 ++ src/pages/billing/Billing.module.css | 341 +++++++++--------- src/pages/billing/BillingAdmin.tsx | 34 +- src/pages/workflows/PlaygroundPage.tsx | 15 +- 10 files changed, 482 insertions(+), 257 deletions(-) diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index c43c40f..800adf1 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -252,3 +252,28 @@ export async function fetchTransactionsAdmin( params: { limit } }); } + +/** + * User summary for billing admin + */ +export interface MandateUserSummary { + id: string; + email?: string; + firstName?: string; + lastName?: string; + displayName?: string; +} + +/** + * Fetch all users for a mandate (Admin) + * Endpoint: GET /api/billing/admin/users/{mandateId} + */ +export async function fetchUsersForMandateAdmin( + request: ApiRequestFunction, + mandateId: string +): Promise { + return await request({ + url: `/api/billing/admin/users/${mandateId}`, + method: 'get' + }); +} diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index b78b874..1d7ba38 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -34,6 +34,11 @@ export const UserSection: React.FC = () => { setShowMenu(false); }; + const handleBilling = () => { + navigate('/billing'); + setShowMenu(false); + }; + const handleLegal = () => { setShowLegalModal(true); setShowMenu(false); @@ -72,6 +77,14 @@ export const UserSection: React.FC = () => { {showMenu && (
+ + -
- - -
- - {loading ? ( -
Lade Provider...
- ) : ( -
- {allowedProviders.map((provider) => ( -
)}
diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index 8650fdf..b41c1ea 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -30,7 +30,7 @@ export function useDashboardInputForm(instanceId: string) { const [optimisticMessage, setOptimisticMessage] = useState(null); const [selectedPromptId, setSelectedPromptId] = useState(null); const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null); - const [selectedProvider, setSelectedProvider] = useState(''); // AI provider selection + const [selectedProviders, setSelectedProviders] = useState([]); // AI provider selection (multiselect) const { checkPermission, canView } = usePermissions(); const [playgroundUIPermission, setPlaygroundUIPermission] = useState(false); @@ -596,7 +596,7 @@ export function useDashboardInputForm(instanceId: string) { prompt: trimmedInput, listFileId: fileIdsToSend.length > 0 ? fileIdsToSend : undefined, userLanguage: 'en', - preferredProvider: selectedProvider || undefined // AI provider selection + preferredProviders: selectedProviders.length > 0 ? selectedProviders : undefined // AI provider selection (multiselect) }; const result = await startWorkflow(requestBody, workflowOptions); @@ -638,7 +638,7 @@ export function useDashboardInputForm(instanceId: string) { setWorkflowStatusOptimistic('idle'); } } - }, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, selectedProvider, setWorkflowStatusOptimistic]); + }, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, selectedProviders, setWorkflowStatusOptimistic]); useEffect(() => { const handleWorkflowCleared = () => { @@ -823,9 +823,9 @@ export function useDashboardInputForm(instanceId: string) { handleFileAttach, handleFileUploadAndAttach, latestStats, - // AI Provider selection - selectedProvider, - onProviderSelect: setSelectedProvider + // AI Provider selection (multiselect) + selectedProviders, + onProvidersChange: setSelectedProviders }; } diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index c06b601..039864f 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -479,25 +479,36 @@ export function useWorkflowLifecycle(instanceId: string) { // Continue polling if: // 1. Workflow is currently running, OR - // 2. Workflow just completed (within last 10 seconds) - grace period to catch final messages + // 2. Workflow just completed (within last 5 seconds) - grace period to catch final messages // Stop polling for failed or stopped workflows immediately - // Use ref for statusChangedFromRunningAt to get latest value (state updates are async) const changedAtRef = statusChangedFromRunningAtRef.current; - const shouldPoll = workflowStatus === 'running' || - (workflowStatus === 'completed' && changedAtRef !== null && Date.now() - changedAtRef < 10000); + const gracePeriodMs = 5000; // 5 seconds grace period + const timeSinceCompletion = changedAtRef !== null ? Date.now() - changedAtRef : Infinity; + const isInGracePeriod = workflowStatus === 'completed' && changedAtRef !== null && timeSinceCompletion < gracePeriodMs; + const shouldPoll = workflowStatus === 'running' || isInGracePeriod; if (shouldPoll) { - // Reset lastRenderedTimestamp for first poll (fetch all historical data) - if (lastRenderedTimestampRef.current === null) { - lastRenderedTimestampRef.current = null; // null means fetch all - } - // Start polling pollingControllerRef.current.startPolling(workflowId, pollWorkflowData); + + // If in grace period, set a timer to stop polling after grace period expires + if (isInGracePeriod) { + const remainingGraceTime = gracePeriodMs - timeSinceCompletion; + const graceTimer = setTimeout(() => { + pollingControllerRef.current.stopPolling(); + setStatusChangedFromRunningAt(null); + statusChangedFromRunningAtRef.current = null; + }, remainingGraceTime + 100); // Small buffer + + return () => { + clearTimeout(graceTimer); + pollingControllerRef.current.stopPolling(); + }; + } } else { // Stop polling for failed, stopped, or completed (after grace period) workflows pollingControllerRef.current.stopPolling(); - // Clear the status change timestamp when we stop polling (only if not already null) + // Clear the status change timestamp when we stop polling if (statusChangedFromRunningAt !== null) { setStatusChangedFromRunningAt(null); statusChangedFromRunningAtRef.current = null; @@ -507,7 +518,7 @@ export function useWorkflowLifecycle(instanceId: string) { return () => { pollingControllerRef.current.stopPolling(); }; - }, [workflowStatus, workflowId, pollWorkflowData]); + }, [workflowStatus, workflowId, pollWorkflowData, statusChangedFromRunningAt]); const handleStartWorkflow = useCallback(async ( workflowData: StartWorkflowRequest, diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts index 70298aa..8b79f77 100644 --- a/src/hooks/useBilling.ts +++ b/src/hooks/useBilling.ts @@ -18,6 +18,7 @@ import { addCreditAdmin, fetchAccountsAdmin, fetchTransactionsAdmin, + fetchUsersForMandateAdmin, type BillingBalance, type BillingTransaction, type BillingSettings, @@ -25,6 +26,7 @@ import { type UsageReport, type AccountSummary, type CreditAddRequest, + type MandateUserSummary, } from '../api/billingApi'; // Re-export types @@ -36,6 +38,7 @@ export type { UsageReport, AccountSummary, CreditAddRequest, + MandateUserSummary, }; export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi'; @@ -145,6 +148,7 @@ export function useBillingAdmin(mandateId?: string) { const [settings, setSettings] = useState(null); const [accounts, setAccounts] = useState([]); const [transactions, setTransactions] = useState([]); + const [users, setUsers] = useState([]); const { request, isLoading: loading, error } = useApiRequest(); // Fetch settings for a mandate @@ -232,12 +236,29 @@ export function useBillingAdmin(mandateId?: string) { } }, [request, mandateId]); + // Fetch users for a mandate + const loadUsers = useCallback(async (targetMandateId?: string) => { + const mId = targetMandateId || mandateId; + if (!mId) return []; + + try { + const data = await fetchUsersForMandateAdmin(request, mId); + setUsers(Array.isArray(data) ? data : []); + return data; + } catch (err) { + console.error('Error loading users:', err); + setUsers([]); + return []; + } + }, [request, mandateId]); + // Load data when mandateId changes useEffect(() => { if (mandateId) { loadSettings(); loadAccounts(); loadTransactions(); + loadUsers(); } }, [mandateId]); @@ -245,6 +266,7 @@ export function useBillingAdmin(mandateId?: string) { settings, accounts, transactions, + users, loading, error, loadSettings, @@ -252,11 +274,13 @@ export function useBillingAdmin(mandateId?: string) { addCredit, loadAccounts, loadTransactions, + loadUsers, refetch: () => { if (mandateId) { loadSettings(); loadAccounts(); loadTransactions(); + loadUsers(); } }, }; diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css index f7c7304..35ce33a 100644 --- a/src/pages/billing/Billing.module.css +++ b/src/pages/billing/Billing.module.css @@ -5,25 +5,26 @@ ============================================================================ */ .billingDashboard { - padding: var(--spacing-lg); + padding: 1.5rem; max-width: 1200px; margin: 0 auto; + min-height: 100%; } .pageHeader { - margin-bottom: var(--spacing-xl); + margin-bottom: 2rem; } .pageHeader h1 { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin: 0 0 var(--spacing-xs) 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin: 0 0 0.25rem 0; } .subtitle { - font-size: var(--font-size-base); - color: var(--color-text-secondary); + font-size: 0.875rem; + color: var(--text-secondary, #888); margin: 0; } @@ -32,23 +33,23 @@ ============================================================================ */ .section { - margin-bottom: var(--spacing-xl); + margin-bottom: 2rem; } .sectionHeader { display: flex; justify-content: space-between; align-items: center; - margin-bottom: var(--spacing-md); + margin-bottom: 1rem; flex-wrap: wrap; - gap: var(--spacing-sm); + gap: 0.5rem; } .sectionTitle { - font-size: var(--font-size-lg); - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); - margin: 0 0 var(--spacing-md) 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin: 0 0 1rem 0; } .sectionHeader .sectionTitle { @@ -62,65 +63,65 @@ .balanceGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: var(--spacing-md); + gap: 1rem; } .balanceCard { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-lg); - padding: var(--spacing-lg); + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 1.5rem; cursor: pointer; transition: all 0.2s ease; } .balanceCard:hover { - border-color: var(--color-primary); - box-shadow: var(--shadow-md); + border-color: var(--primary-color, #f25843); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } .balanceCard.warning { - border-color: var(--color-warning); - background: var(--color-warning-bg, rgba(255, 193, 7, 0.1)); + border-color: #ffc107; + background: rgba(255, 193, 7, 0.1); } .balanceHeader { display: flex; justify-content: space-between; align-items: flex-start; - margin-bottom: var(--spacing-md); + margin-bottom: 1rem; } .mandateName { - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); margin: 0; } .billingModel { - font-size: var(--font-size-xs); - color: var(--color-text-secondary); - background: var(--color-bg-secondary); + font-size: 0.75rem; + color: var(--text-secondary, #888); + background: var(--bg-secondary, #2a2a2a); padding: 2px 8px; - border-radius: var(--border-radius-sm); + border-radius: 4px; } .balanceAmount { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin-bottom: var(--spacing-sm); + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary, #e0e0e0); + margin-bottom: 0.5rem; } .warningBadge { display: inline-block; - font-size: var(--font-size-xs); - color: var(--color-warning-text, #856404); - background: var(--color-warning-badge-bg, rgba(255, 193, 7, 0.3)); + font-size: 0.75rem; + color: #856404; + background: rgba(255, 193, 7, 0.3); padding: 4px 8px; - border-radius: var(--border-radius-sm); - font-weight: var(--font-weight-medium); + border-radius: 4px; + font-weight: 500; } /* ============================================================================ @@ -129,56 +130,56 @@ .periodSelector { display: flex; - gap: var(--spacing-sm); + gap: 0.5rem; align-items: center; } .select { - padding: var(--spacing-xs) var(--spacing-sm); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-md); - background: var(--color-bg-input); - color: var(--color-text-primary); - font-size: var(--font-size-sm); + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--surface-color, #1e1e1e); + color: var(--text-primary, #e0e0e0); + font-size: 0.875rem; cursor: pointer; } .select:focus { outline: none; - border-color: var(--color-primary); + border-color: var(--primary-color, #f25843); } .statisticsChart { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-lg); - padding: var(--spacing-lg); + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 1.5rem; } .totalCost { display: flex; flex-direction: column; align-items: center; - padding: var(--spacing-lg); - background: var(--color-bg-secondary); - border-radius: var(--border-radius-md); - margin-bottom: var(--spacing-lg); + padding: 1.5rem; + background: var(--bg-secondary, #2a2a2a); + border-radius: 8px; + margin-bottom: 1.5rem; } .totalLabel { - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - margin-bottom: var(--spacing-xs); + font-size: 0.875rem; + color: var(--text-secondary, #888); + margin-bottom: 0.25rem; } .totalAmount { - font-size: var(--font-size-3xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); + font-size: 2rem; + font-weight: 700; + color: var(--text-primary, #e0e0e0); } .chartSection { - margin-bottom: var(--spacing-lg); + margin-bottom: 1.5rem; } .chartSection:last-child { @@ -186,10 +187,10 @@ } .chartSection h4 { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); - margin: 0 0 var(--spacing-md) 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary, #888); + margin: 0 0 1rem 0; text-transform: uppercase; letter-spacing: 0.5px; } @@ -197,34 +198,34 @@ .barChart { display: flex; flex-direction: column; - gap: var(--spacing-sm); + gap: 0.5rem; } .barRow { display: flex; align-items: center; - gap: var(--spacing-sm); + gap: 0.5rem; } .barLabel { width: 100px; - font-size: var(--font-size-sm); - color: var(--color-text-primary); + font-size: 0.875rem; + color: var(--text-primary, #e0e0e0); text-transform: capitalize; } .barContainer { flex: 1; height: 24px; - background: var(--color-bg-secondary); - border-radius: var(--border-radius-sm); + background: var(--bg-secondary, #2a2a2a); + border-radius: 4px; overflow: hidden; } .bar { height: 100%; - background: var(--color-primary); - border-radius: var(--border-radius-sm); + background: var(--primary-color, #f25843); + border-radius: 4px; transition: width 0.3s ease; min-width: 4px; } @@ -232,36 +233,36 @@ .barValue { width: 100px; text-align: right; - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - font-family: var(--font-mono); + font-size: 0.875rem; + color: var(--text-secondary, #888); + font-family: monospace; } .featureList { display: flex; flex-direction: column; - gap: var(--spacing-xs); + gap: 0.25rem; } .featureRow { display: flex; justify-content: space-between; align-items: center; - padding: var(--spacing-sm); - background: var(--color-bg-secondary); - border-radius: var(--border-radius-sm); + padding: 0.5rem; + background: var(--bg-secondary, #2a2a2a); + border-radius: 4px; } .featureLabel { - font-size: var(--font-size-sm); - color: var(--color-text-primary); + font-size: 0.875rem; + color: var(--text-primary, #e0e0e0); text-transform: capitalize; } .featureValue { - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - font-family: var(--font-mono); + font-size: 0.875rem; + color: var(--text-secondary, #888); + font-family: monospace; } /* ============================================================================ @@ -275,46 +276,46 @@ .transactionsTable th, .transactionsTable td { - padding: var(--spacing-sm) var(--spacing-md); + padding: 0.75rem 1rem; text-align: left; - border-bottom: 1px solid var(--color-border); + border-bottom: 1px solid var(--border-color, #333); } .transactionsTable th { - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #888); text-transform: uppercase; letter-spacing: 0.5px; - background: var(--color-bg-secondary); + background: var(--bg-secondary, #2a2a2a); } .transactionsTable td { - font-size: var(--font-size-sm); - color: var(--color-text-primary); + font-size: 0.875rem; + color: var(--text-primary, #e0e0e0); } .transactionType { display: inline-block; padding: 2px 8px; - border-radius: var(--border-radius-sm); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-medium); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; } .transactionType.credit { - background: var(--color-success-bg, rgba(40, 167, 69, 0.1)); - color: var(--color-success, #28a745); + background: rgba(40, 167, 69, 0.1); + color: #28a745; } .transactionType.debit { - background: var(--color-error-bg, rgba(220, 53, 69, 0.1)); - color: var(--color-error, #dc3545); + background: rgba(220, 53, 69, 0.1); + color: #dc3545; } .transactionType.adjustment { - background: var(--color-info-bg, rgba(23, 162, 184, 0.1)); - color: var(--color-info, #17a2b8); + background: rgba(23, 162, 184, 0.1); + color: #17a2b8; } /* ============================================================================ @@ -322,86 +323,86 @@ ============================================================================ */ .adminSection { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-lg); - padding: var(--spacing-lg); - margin-bottom: var(--spacing-lg); + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; } .adminSection h3 { - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); - margin: 0 0 var(--spacing-md) 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin: 0 0 1rem 0; } .formRow { display: flex; - gap: var(--spacing-md); - margin-bottom: var(--spacing-md); + gap: 1rem; + margin-bottom: 1rem; } .formGroup { flex: 1; display: flex; flex-direction: column; - gap: var(--spacing-xs); + gap: 0.25rem; } .formGroup label { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary, #888); } .input { - padding: var(--spacing-sm); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-md); - background: var(--color-bg-input); - color: var(--color-text-primary); - font-size: var(--font-size-base); + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--surface-color, #1e1e1e); + color: var(--text-primary, #e0e0e0); + font-size: 0.875rem; } .input:focus { outline: none; - border-color: var(--color-primary); + border-color: var(--primary-color, #f25843); } .accountsGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: var(--spacing-md); + gap: 1rem; } .accountCard { - background: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-md); - padding: var(--spacing-md); + background: var(--bg-secondary, #2a2a2a); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + padding: 1rem; } .accountCard h4 { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); - margin: 0 0 var(--spacing-sm) 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin: 0 0 0.5rem 0; } .accountInfo { display: flex; flex-direction: column; - gap: var(--spacing-xs); - font-size: var(--font-size-sm); + gap: 0.25rem; + font-size: 0.875rem; } .accountInfo span { - color: var(--color-text-secondary); + color: var(--text-secondary, #888); } .accountInfo strong { - color: var(--color-text-primary); + color: var(--text-primary, #e0e0e0); } /* ============================================================================ @@ -409,22 +410,22 @@ ============================================================================ */ .button { - padding: var(--spacing-sm) var(--spacing-md); + padding: 0.5rem 1rem; border: none; - border-radius: var(--border-radius-md); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; cursor: pointer; transition: all 0.2s ease; } .buttonPrimary { - background: var(--color-primary); + background: var(--primary-color, #f25843); color: white; } .buttonPrimary:hover { - background: var(--color-primary-dark); + background: var(--primary-dark, #d94d3a); } .buttonPrimary:disabled { @@ -433,13 +434,13 @@ } .buttonSecondary { - background: var(--color-bg-secondary); - color: var(--color-text-primary); - border: 1px solid var(--color-border); + background: var(--bg-secondary, #2a2a2a); + color: var(--text-primary, #e0e0e0); + border: 1px solid var(--border-color, #333); } .buttonSecondary:hover { - background: var(--color-bg-hover); + background: var(--surface-color, #1e1e1e); } /* ============================================================================ @@ -450,37 +451,37 @@ display: flex; align-items: center; justify-content: center; - padding: var(--spacing-xl); - color: var(--color-text-secondary); - font-size: var(--font-size-sm); + padding: 2rem; + color: var(--text-secondary, #888); + font-size: 0.875rem; } .noData { display: flex; align-items: center; justify-content: center; - padding: var(--spacing-lg); - color: var(--color-text-tertiary); - font-size: var(--font-size-sm); + padding: 1.5rem; + color: var(--text-tertiary, #666); + font-size: 0.875rem; font-style: italic; } .errorMessage { - background: var(--color-error-bg, rgba(220, 53, 69, 0.1)); - color: var(--color-error, #dc3545); - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--border-radius-md); - font-size: var(--font-size-sm); - margin-bottom: var(--spacing-md); + background: rgba(220, 53, 69, 0.1); + color: #dc3545; + padding: 0.75rem 1rem; + border-radius: 6px; + font-size: 0.875rem; + margin-bottom: 1rem; } .successMessage { - background: var(--color-success-bg, rgba(40, 167, 69, 0.1)); - color: var(--color-success, #28a745); - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--border-radius-md); - font-size: var(--font-size-sm); - margin-bottom: var(--spacing-md); + background: rgba(40, 167, 69, 0.1); + color: #28a745; + padding: 0.75rem 1rem; + border-radius: 6px; + font-size: 0.875rem; + margin-bottom: 1rem; } /* ============================================================================ @@ -489,7 +490,7 @@ @media (max-width: 768px) { .billingDashboard { - padding: var(--spacing-md); + padding: 1rem; } .balanceGrid { @@ -513,6 +514,6 @@ .barLabel, .barValue { width: 80px; - font-size: var(--font-size-xs); + font-size: 0.75rem; } } diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index fc16ac7..1cddd88 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -8,7 +8,7 @@ */ import React, { useState, useEffect, useCallback } from 'react'; -import { useBillingAdmin, type BillingSettings, type AccountSummary } from '../../hooks/useBilling'; +import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import { useAdminMandates } from '../../hooks/useMandates'; import styles from './Billing.module.css'; @@ -191,10 +191,11 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi interface CreditAdderProps { settings: BillingSettings | null; accounts: AccountSummary[]; + users: MandateUserSummary[]; onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; } -const CreditAdder: React.FC = ({ settings, accounts, onAddCredit }) => { +const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { const [selectedUserId, setSelectedUserId] = useState(''); const [amount, setAmount] = useState(10); const [description, setDescription] = useState('Manuelles Aufladen'); @@ -203,6 +204,14 @@ const CreditAdder: React.FC = ({ settings, accounts, onAddCred const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; + // Map accounts by userId for balance lookup + const accountsByUserId = accounts + .filter(acc => acc.accountType === 'USER') + .reduce((map, acc) => { + if (acc.userId) map[acc.userId] = acc; + return map; + }, {} as Record); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (amount <= 0) { @@ -246,21 +255,23 @@ const CreditAdder: React.FC = ({ settings, accounts, onAddCred {isPrepayUser && (
- +
@@ -355,7 +366,7 @@ const AccountsOverview: React.FC = ({ accounts, loading } export const BillingAdmin: React.FC = () => { const [selectedMandateId, setSelectedMandateId] = useState(null); - const { settings, accounts, loading, loadSettings, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); + const { settings, accounts, users, loading, loadSettings, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); const handleMandateSelect = (mandateId: string) => { setSelectedMandateId(mandateId || null); @@ -397,6 +408,7 @@ export const BillingAdmin: React.FC = () => { diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index 4cf2b8a..0733329 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -15,7 +15,7 @@ import { useCurrentInstance } from '../../hooks/useCurrentInstance'; import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import { useVoiceLanguage, VoiceLanguageSelect, Messages } from '../../components/UiComponents'; -import { ProviderSelect } from '../../components/ProviderSelector'; +import { ProviderMultiSelect } from '../../components/ProviderSelector'; import type { Message } from '../../components/UiComponents/Messages/MessagesTypes'; import api from '../../api'; import styles from './PlaygroundPage.module.css'; @@ -58,8 +58,8 @@ export const PlaygroundPage: React.FC = () => { deletingFiles, previewingFiles, downloadingFiles, - selectedProvider, - onProviderSelect, + selectedProviders, + onProvidersChange, } = hookData; const { prompts, refetch: refetchPrompts } = usePrompts(); @@ -544,9 +544,6 @@ export const PlaygroundPage: React.FC = () => { ); }; - // Debug: Log permission status - console.log('🔐 PlaygroundPage permission check:', { playgroundUIPermission }); - // Permission check - also show while loading if (playgroundUIPermission === false) { return ( @@ -785,9 +782,9 @@ export const PlaygroundPage: React.FC = () => { > - Date: Sun, 8 Feb 2026 00:25:53 +0100 Subject: [PATCH 05/14] revised state machine for workflow backend and ui --- src/App.tsx | 6 +- src/api/billingApi.ts | 94 +++ .../FormGeneratorTable.module.css | 76 ++- .../FormGeneratorTable/FormGeneratorTable.tsx | 103 ++- .../Navigation/MandateNavigation.tsx | 66 +- src/components/Navigation/UserSection.tsx | 2 +- .../ProviderSelector.module.css | 197 +++--- .../ProviderSelector/ProviderSelector.tsx | 89 ++- .../Messages/ChatMessages/ChatMessage.tsx | 13 +- .../UiComponents/Messages/Messages.module.css | 23 + src/hooks/playground/useWorkflowLifecycle.ts | 589 ++++++++---------- src/layouts/FeatureLayout.module.css | 6 +- src/layouts/MainLayout.module.css | 3 +- src/pages/admin/Admin.module.css | 7 +- src/pages/billing/Billing.module.css | 5 +- src/pages/billing/BillingDashboard.tsx | 3 + src/pages/billing/BillingDataView.tsx | 443 +++++++++++++ src/pages/billing/BillingMandateView.tsx | 280 +++++++++ src/pages/billing/BillingNav.tsx | 54 ++ src/pages/billing/BillingTransactions.tsx | 5 + src/pages/billing/BillingUserView.tsx | 376 +++++++++++ src/pages/billing/index.ts | 8 +- src/pages/workflows/PlaygroundPage.module.css | 57 +- src/pages/workflows/PlaygroundPage.tsx | 52 +- src/pages/workflows/WorkflowsPage.tsx | 6 +- 25 files changed, 1939 insertions(+), 624 deletions(-) create mode 100644 src/pages/billing/BillingDataView.tsx create mode 100644 src/pages/billing/BillingMandateView.tsx create mode 100644 src/pages/billing/BillingNav.tsx create mode 100644 src/pages/billing/BillingUserView.tsx diff --git a/src/App.tsx b/src/App.tsx index 41c6028..3b82754 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,7 +47,7 @@ import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandat import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; // Billing Pages -import { BillingDashboard, BillingTransactions, BillingAdmin } from './pages/billing'; +import { BillingDashboard, BillingDataView, BillingAdmin } from './pages/billing'; function App() { // Load saved theme preference and set app name on app mount @@ -117,8 +117,8 @@ function App() { {/* BILLING ROUTES */} {/* ============================================== */} - } /> - } /> + } /> + } /> {/* ============================================== */} diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 800adf1..69ebf34 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -39,6 +39,8 @@ export interface BillingTransaction { featureCode?: string; aicoreProvider?: string; createdAt?: string; + mandateId?: string; + mandateName?: string; } export interface BillingSettings { @@ -277,3 +279,95 @@ export async function fetchUsersForMandateAdmin( method: 'get' }); } + +// ============================================================================ +// MANDATE VIEW TYPES & API FUNCTIONS +// ============================================================================ + +export interface MandateBalance { + mandateId: string; + mandateName: string; + billingModel: BillingModel; + totalBalance: number; + userCount: number; + defaultUserCredit: number; + warningThresholdPercent: number; + blockOnZeroBalance: boolean; +} + +/** + * Fetch mandate-level balances (SysAdmin only) + * Endpoint: GET /api/billing/view/mandates/balances + */ +export async function fetchMandateViewBalances( + request: ApiRequestFunction +): Promise { + return await request({ + url: '/api/billing/view/mandates/balances', + method: 'get' + }); +} + +/** + * Fetch mandate-level transactions (SysAdmin only) + * Endpoint: GET /api/billing/view/mandates/transactions + */ +export async function fetchMandateViewTransactions( + request: ApiRequestFunction, + limit: number = 100 +): Promise { + return await request({ + url: '/api/billing/view/mandates/transactions', + method: 'get', + params: { limit } + }); +} + +// ============================================================================ +// USER VIEW TYPES & API FUNCTIONS +// ============================================================================ + +export interface UserBalance { + accountId: string; + mandateId: string; + mandateName: string; + userId: string; + userName: string; + balance: number; + warningThreshold: number; + isWarning: boolean; + enabled: boolean; +} + +export interface UserTransaction extends BillingTransaction { + userId?: string; + userName?: string; +} + +/** + * Fetch user-level balances (RBAC-based) + * Endpoint: GET /api/billing/view/users/balances + */ +export async function fetchUserViewBalances( + request: ApiRequestFunction +): Promise { + return await request({ + url: '/api/billing/view/users/balances', + method: 'get' + }); +} + +/** + * Fetch user-level transactions (RBAC-based) + * Endpoint: GET /api/billing/view/users/transactions + */ +export async function fetchUserViewTransactions( + request: ApiRequestFunction, + limit: number = 100 +): Promise { + return await request({ + url: '/api/billing/view/users/transactions', + method: 'get', + params: { limit } + }); +} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index fa439f6..1195f68 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -7,7 +7,11 @@ /* Fill available space and constrain height */ min-height: 0; flex: 1; - /* No overflow - children handle their own scrolling */ + /* Prevent overflow - constrain to parent height */ + overflow: hidden; + /* Ensure container respects parent's height */ + height: 100%; + max-height: 100%; } .title { @@ -18,18 +22,46 @@ margin-bottom: 10px; } -/* Table Container - scrollable area for table data only */ -.tableContainer { - position: relative; - overflow: auto; +/* Table wrapper - contains top scrollbar and table container */ +.tableWrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + /* Constrain height to prevent growing beyond parent */ + max-height: 100%; + overflow: hidden; border: 1px solid var(--color-primary); border-radius: 25px; background: var(--color-bg); - /* Fill remaining space after controls */ - flex: 1; +} + +/* Top horizontal scrollbar - syncs with table container */ +.topScrollbar { + overflow-x: auto; + overflow-y: hidden; + flex-shrink: 0; + background: var(--color-bg); + border-bottom: 1px solid var(--color-primary); + border-radius: 25px 25px 0 0; +} + +/* Inner div that matches table width for proper scrollbar sizing */ +.topScrollbarInner { + height: 1px; /* Minimal height - just need width to activate scrollbar */ +} + +/* Table Container - scrollable area for table data only (vertical only) */ +.tableContainer { + position: relative; + overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */ + overflow-y: auto; + background: var(--color-bg); + /* Fill remaining space but constrain to available height */ + flex: 1 1 0; min-height: 0; - /* Clip content to border-radius but allow sticky to work */ - isolation: isolate; + max-height: 100%; + border-radius: 0 0 25px 25px; } /* Empty table styling - no extra space, just header */ @@ -39,6 +71,11 @@ max-height: none; } +/* Hide top scrollbar when table is empty */ +.emptyTable .topScrollbar { + display: none; +} + /* Empty state styling */ .emptyState { display: flex; @@ -734,7 +771,7 @@ tbody .actionsColumn { outline: none; } -/* Custom scrollbar for table container */ +/* Custom scrollbar for table container (vertical only) */ .tableContainer::-webkit-scrollbar { width: 8px; height: 8px; @@ -754,6 +791,25 @@ tbody .actionsColumn { background: var(--color-secondary); } +/* Custom scrollbar for top scrollbar (horizontal only) */ +.topScrollbar::-webkit-scrollbar { + height: 8px; +} + +.topScrollbar::-webkit-scrollbar-track { + background: var(--color-gray-disabled); + border-radius: 4px; +} + +.topScrollbar::-webkit-scrollbar-thumb { + background: var(--color-gray); + border-radius: 4px; +} + +.topScrollbar::-webkit-scrollbar-thumb:hover { + background: var(--color-secondary); +} + /* Loading State */ .loadingState { display: flex; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 936231f..c54cfa0 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -338,6 +338,11 @@ export function FormGeneratorTable>({ const tableRef = useRef(null); const tableContainerRef = useRef(null); + // Refs for top scrollbar synchronization + const topScrollbarRef = useRef(null); + const topScrollbarInnerRef = useRef(null); + const isScrollingSyncRef = useRef(false); // Prevent scroll sync loops + // Track container width for actions column 20% threshold const [containerWidth, setContainerWidth] = useState(0); @@ -945,6 +950,53 @@ export function FormGeneratorTable>({ }; }, []); + // Sync top scrollbar width with table width and handle scroll synchronization + useEffect(() => { + const tableContainer = tableContainerRef.current; + const topScrollbar = topScrollbarRef.current; + const topScrollbarInner = topScrollbarInnerRef.current; + const table = tableRef.current; + + if (!tableContainer || !topScrollbar || !topScrollbarInner || !table) return; + + // Update top scrollbar inner width to match table width + const updateScrollbarWidth = () => { + const tableWidth = table.scrollWidth; + topScrollbarInner.style.width = `${tableWidth}px`; + }; + + // Initial width calculation + updateScrollbarWidth(); + + // Observe table size changes + const resizeObserver = new ResizeObserver(updateScrollbarWidth); + resizeObserver.observe(table); + + // Sync scroll positions + const syncTopToContainer = () => { + if (isScrollingSyncRef.current) return; + isScrollingSyncRef.current = true; + tableContainer.scrollLeft = topScrollbar.scrollLeft; + requestAnimationFrame(() => { isScrollingSyncRef.current = false; }); + }; + + const syncContainerToTop = () => { + if (isScrollingSyncRef.current) return; + isScrollingSyncRef.current = true; + topScrollbar.scrollLeft = tableContainer.scrollLeft; + requestAnimationFrame(() => { isScrollingSyncRef.current = false; }); + }; + + topScrollbar.addEventListener('scroll', syncTopToContainer); + tableContainer.addEventListener('scroll', syncContainerToTop); + + return () => { + resizeObserver.disconnect(); + topScrollbar.removeEventListener('scroll', syncTopToContainer); + tableContainer.removeEventListener('scroll', syncContainerToTop); + }; + }, [displayData, detectedColumns, columnWidths]); // Re-run when data or columns change + // Track which cells are currently being updated (for loading state) const [updatingCells, setUpdatingCells] = useState>(new Set()); @@ -1367,26 +1419,36 @@ export function FormGeneratorTable>({ /> )} - {/* Table */} -
- {/* Loading overlay - shown while loading */} - {loading && ( -
-
-

{t('common.loading', 'Loading...')}

-
- )} + {/* Table Wrapper - contains top scrollbar and table container */} +
+ {/* Top horizontal scrollbar - syncs with table container */} +
+
+
- {/* Empty state - only shown when not loading AND no data */} - {!loading && displayData.length === 0 ? ( -
-

{emptyMessage || t('formgen.empty', 'No data available')}

-
- ) : ( - + {/* Table Container - vertical scroll only */} +
+ {/* Loading overlay - shown while loading */} + {loading && ( +
+
+

{t('common.loading', 'Loading...')}

+
+ )} + + {/* Empty state - only shown when not loading AND no data */} + {!loading && displayData.length === 0 ? ( +
+

{emptyMessage || t('formgen.empty', 'No data available')}

+
+ ) : ( +
{selectable && ( @@ -1704,7 +1766,8 @@ export function FormGeneratorTable>({ )}
- )} + )} +
); diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 85f5398..c67b0e0 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -8,16 +8,16 @@ * Backend liefert Blocks-Struktur mit Static und Dynamic Blocks. * UI mappt uiComponent zu Icons via pageRegistry. * - * Struktur (gemäss Navigation-API-Konzept): - * - SYSTEM (static block, order: 10) - * - MEINE FEATURES (dynamic block, order: 15) - * - Mandant 1 - * - Feature A - * - Instanz 1 (mit Views) - * - WORKFLOWS (static block, order: 20) - * - BASISDATEN (static block, order: 30) - * - MIGRATE TO FEATURES (static block, order: 40) - * - ADMINISTRATION (static block, order: 200) + * FLAT STRUCTURE (kompakte Darstellung): + * - SYSTEM (static block) + * - Mandant 1 + * - 🎯 Instanz 1 (Feature-Icon + Instanz-Name) + * - 💼 Instanz 2 (Feature-Icon + Instanz-Name) + * - BASISDATEN (static block) + * - ADMINISTRATION (static block) + * + * Jede Instanz zeigt das Icon des zugehörigen Features. + * Keine Gruppierung nach Features - direkt Instanzen unter Mandant. */ import React, { useMemo } from 'react'; @@ -75,58 +75,52 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem { } /** - * Convert a FeatureInstance to TreeNodeItem - * Instance node gets path to first view so clicking the instance name (e.g. PEK) navigates to dashboard. + * Convert a FeatureInstance to TreeNodeItem (with feature icon) + * Instance node gets path to first view so clicking the instance name navigates to dashboard. + * Shows the feature icon next to the instance name for visual distinction. */ -function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem { +function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem { const children = instance.views.map(featureViewToTreeNode); return { id: instance.id, label: instance.uiLabel, + icon: getPageIcon(featureUiComponent), // Use feature icon for instance path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, children, defaultExpanded: false, }; } -/** - * Convert a MandateFeature to TreeNodeItem - */ -function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null { - if (feature.instances.length === 0) { - return null; - } - - return { - id: feature.uiComponent, - label: feature.uiLabel, - icon: getPageIcon(feature.uiComponent), - badge: feature.instances.length, - children: feature.instances.map(featureInstanceToTreeNode), - defaultExpanded: false, - }; -} - /** * Convert a NavigationMandate to TreeNodeItem + * + * FLAT STRUCTURE: Instances are listed directly under mandate (no feature grouping). + * Each instance shows the feature's icon for visual distinction. + * + * Before: Mandate → Feature → Instance → Views + * Now: Mandate → Instance (with feature icon) → Views */ function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null { if (mandate.features.length === 0) { return null; } - const children = mandate.features - .map(mandateFeatureToTreeNode) - .filter((node): node is TreeNodeItem => node !== null); + // Flatten: collect all instances from all features directly under mandate + const instanceNodes: TreeNodeItem[] = []; + for (const feature of mandate.features) { + for (const instance of feature.instances) { + instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent)); + } + } - if (children.length === 0) { + if (instanceNodes.length === 0) { return null; } return { id: mandate.id, label: mandate.uiLabel, - children, + children: instanceNodes, defaultExpanded: true, }; } diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index 1d7ba38..e19cd0e 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -35,7 +35,7 @@ export const UserSection: React.FC = () => { }; const handleBilling = () => { - navigate('/billing'); + navigate('/billing/transactions'); setShowMenu(false); }; diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css index 242df68..ddfa9c9 100644 --- a/src/components/ProviderSelector/ProviderSelector.module.css +++ b/src/components/ProviderSelector/ProviderSelector.module.css @@ -42,133 +42,90 @@ ============================================================================ */ .providerMultiSelect { - display: flex; - flex-direction: column; - gap: var(--spacing-xs, 4px); position: relative; + display: inline-block; } -.providerMultiSelect.collapsed { - /* Collapsed state styles */ -} - -.providerMultiSelect.expanded { - /* Expanded state styles */ -} - -/* Collapsible Header Button */ -.collapseHeader { +/* Trigger Button - matches iconButton style from PlaygroundPage */ +.triggerButton { display: flex; align-items: center; - gap: var(--spacing-xs, 4px); - padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-md, 6px); - background: var(--color-bg-input); - color: var(--color-text-primary); - font-size: var(--font-size-sm, 0.875rem); + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--border-color, #3a3a3a); + border-radius: 6px; + background: var(--surface-color, #2d2d2d); + color: var(--text-secondary, #888); cursor: pointer; - transition: all 0.2s ease; - min-width: 140px; + transition: all 0.2s; } -.collapseHeader:hover:not(:disabled) { - border-color: var(--color-primary); - background: var(--color-bg-hover); +.triggerButton:hover:not(:disabled) { + background: var(--bg-secondary, #3a3a3a); + color: var(--text-primary, #fff); } -.collapseHeader:disabled { - opacity: 0.6; +.triggerButton:disabled { + opacity: 0.5; cursor: not-allowed; } -.summaryIcons { - font-size: 0.9em; +.buttonIcon { + font-size: 1.1rem; } -.summaryText { - flex: 1; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.expandIcon { - font-size: 0.7em; - color: var(--color-text-secondary); -} - -/* Expandable Content - opens upward */ -.expandableContent { +/* Dropdown Content - opens upward */ +.dropdownContent { position: absolute; - bottom: 100%; - left: 0; - right: 0; - z-index: 100; - margin-bottom: var(--spacing-xs, 4px); - padding: var(--spacing-sm, 8px); - background: #2d2d2d; - color: #e0e0e0; - border: 1px solid #444; - border-radius: var(--border-radius-md, 6px); - box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4); - min-width: 200px; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + z-index: 1000; + padding: 8px; + background: var(--surface-color, #2d2d2d); + border: 1px solid var(--border-color, #3a3a3a); + border-radius: 6px; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5); + min-width: 220px; } -.expandableContent .label { - color: #b0b0b0; -} - -.expandableContent .checkboxList { - background: #252525; -} - -.expandableContent .checkboxItem { - color: #e0e0e0; -} - -.expandableContent .checkboxItem:hover { - background: #3a3a3a; -} - -.expandableContent .providerName { - color: #e0e0e0; -} - -.expandableContent .hint { - color: #888; -} - -.expandableContent .actionButton { - background: #3a3a3a; - color: #e0e0e0; - border-color: #555; -} - -.expandableContent .actionButton:hover:not(:disabled) { - background: #4a4a4a; +.dropdownHeader { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary, #888); + padding: 4px 8px; + margin-bottom: 4px; + border-bottom: 1px solid var(--border-color, #3a3a3a); } .selectActions { display: flex; - gap: var(--spacing-xs, 4px); + gap: 4px; + margin-bottom: 8px; } .actionButton { - padding: 2px 8px; - border: 1px solid var(--color-border); - border-radius: var(--border-radius-sm, 4px); - background: var(--color-bg-secondary); - color: var(--color-text-secondary); - font-size: var(--font-size-xs, 0.75rem); + flex: 1; + padding: 4px 8px; + border: 1px solid var(--border-color, #3a3a3a); + border-radius: 4px; + background: var(--bg-secondary, #252525); + color: var(--text-secondary, #888); + font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; } .actionButton:hover:not(:disabled) { - background: var(--color-bg-hover); - border-color: var(--color-primary); + background: var(--bg-hover, #3a3a3a); + color: var(--text-primary, #fff); +} + +.actionButton.active { + background: var(--primary-color, #f25843); + border-color: var(--primary-color, #f25843); + color: #fff; } .actionButton:disabled { @@ -179,24 +136,27 @@ .checkboxList { display: flex; flex-direction: column; - gap: var(--spacing-xs, 4px); - padding: var(--spacing-sm, 8px); - background: var(--color-bg-secondary); - border-radius: var(--border-radius-md, 6px); + gap: 2px; + padding: 4px; + background: var(--bg-secondary, #252525); + border-radius: 4px; + max-height: 200px; + overflow-y: auto; } .checkboxItem { display: flex; align-items: center; - gap: var(--spacing-sm, 8px); - padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px); - border-radius: var(--border-radius-sm, 4px); + gap: 8px; + padding: 6px 8px; + border-radius: 4px; cursor: pointer; - transition: background 0.2s ease; + transition: background 0.15s ease; + color: var(--text-primary, #e0e0e0); } .checkboxItem:hover { - background: var(--color-bg-hover); + background: var(--bg-hover, #3a3a3a); } .checkboxItem.disabled { @@ -205,34 +165,35 @@ } .checkboxItem input[type="checkbox"] { - width: 16px; - height: 16px; + width: 14px; + height: 14px; cursor: inherit; + accent-color: var(--primary-color, #f25843); } .icon { - font-size: 1.1em; + font-size: 1rem; } .providerName { - font-size: var(--font-size-sm, 0.875rem); - color: var(--color-text-primary); + font-size: 0.8rem; + color: var(--text-primary, #e0e0e0); } .hint { - font-size: var(--font-size-xs, 0.75rem); - color: var(--color-text-tertiary); - font-style: italic; - padding: var(--spacing-xs, 4px) 0; + font-size: 0.7rem; + color: var(--text-tertiary, #666); + text-align: center; + padding: 4px 0; } .loading { display: flex; align-items: center; justify-content: center; - padding: var(--spacing-md, 16px); - color: var(--color-text-secondary); - font-size: var(--font-size-sm, 0.875rem); + padding: 12px; + color: var(--text-secondary, #888); + font-size: 0.8rem; } /* ============================================================================ diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx index 73f0184..55596a8 100644 --- a/src/components/ProviderSelector/ProviderSelector.tsx +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -10,7 +10,7 @@ * - Lädt verfügbare Provider aus dem Billing-System */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { useBilling } from '../../hooks/useBilling'; import styles from './ProviderSelector.module.css'; @@ -20,6 +20,7 @@ const PROVIDER_LABELS: Record = { openai: 'OpenAI (GPT)', perplexity: 'Perplexity', tavily: 'Tavily (Web Search)', + privatellm: 'Private LLM', internal: 'Internal', }; @@ -29,6 +30,7 @@ const PROVIDER_ICONS: Record = { openai: '💬', perplexity: '🔍', tavily: '🌐', + privatellm: '🔒', internal: '🏠', }; @@ -112,6 +114,7 @@ export const ProviderMultiSelect: React.FC = ({ defaultExpanded = false, }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const containerRef = useRef(null); const { allowedProviders, loadAllowedProviders, loading } = useBilling(); useEffect(() => { @@ -120,66 +123,84 @@ export const ProviderMultiSelect: React.FC = ({ } }, []); + // Click outside handler + const handleClickOutside = useCallback((event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsExpanded(false); + } + }, []); + + useEffect(() => { + if (isExpanded) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isExpanded, handleClickOutside]); + + // Check if all providers are selected (or none selected = all used) + const isAllSelected = selectedProviders.length === 0 || + (allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length); + + // For checkbox display: if none selected, show all as checked (since all are used) + const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; + const handleToggle = (provider: string) => { - if (selectedProviders.includes(provider)) { - onChange(selectedProviders.filter((p) => p !== provider)); + // Use effectiveSelection for toggle logic (handles empty = all case) + if (effectiveSelection.includes(provider)) { + // Deactivate: remove from effective selection + onChange(effectiveSelection.filter((p) => p !== provider)); } else { - onChange([...selectedProviders, provider]); + // Activate: add to effective selection + onChange([...effectiveSelection, provider]); } }; const handleSelectAll = () => { - onChange(allowedProviders); + onChange([...allowedProviders]); }; const handleSelectNone = () => { onChange([]); }; - // Summary text for collapsed view - const summaryText = useMemo(() => { - if (selectedProviders.length === 0) { - return 'Alle Provider'; - } - if (selectedProviders.length === 1) { - return PROVIDER_LABELS[selectedProviders[0]] || selectedProviders[0]; - } - return `${selectedProviders.length} Provider`; - }, [selectedProviders]); - - // Summary icons for collapsed view - const summaryIcons = useMemo(() => { - if (selectedProviders.length === 0) { + // Summary icon for button + const summaryIcon = useMemo(() => { + if (selectedProviders.length === 0 || selectedProviders.length === allowedProviders.length) { return '🤖'; } - return selectedProviders.slice(0, 3).map(p => PROVIDER_ICONS[p] || '🔌').join(''); - }, [selectedProviders]); + if (selectedProviders.length === 1) { + return PROVIDER_ICONS[selectedProviders[0]] || '🔌'; + } + return '🤖'; + }, [selectedProviders, allowedProviders]); return ( -
- {/* Collapsible Header */} +
+ {/* Trigger Button - styled like iconButton */} - {/* Expandable Content */} + {/* Dropdown Content */} {isExpanded && ( -
- {showLabel && } +
+ {showLabel &&
{label}
}
@@ -194,7 +215,7 @@ export const ProviderMultiSelect: React.FC = ({
{loading ? ( -
Lade Provider...
+
Lade...
) : (
{allowedProviders.map((provider) => ( @@ -204,7 +225,7 @@ export const ProviderMultiSelect: React.FC = ({ > handleToggle(provider)} disabled={disabled} /> @@ -219,7 +240,7 @@ export const ProviderMultiSelect: React.FC = ({ {selectedProviders.length === 0 && !loading && (
- Wenn keine Provider ausgewählt sind, werden alle erlaubten Provider verwendet. + Alle Provider aktiv
)}
diff --git a/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx b/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx index 169bd72..aff1272 100644 --- a/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx +++ b/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { FaExclamationTriangle } from 'react-icons/fa'; import { Message } from '../MessagesTypes'; import { formatTimestamp } from '../MessageUtils'; import { DocumentItem, ActionInfo } from '../MessageParts'; @@ -40,7 +41,9 @@ export const ChatMessage: React.FC = ({ workflowId }) => { const isUser = message.role?.toLowerCase() === 'user'; + const isError = message.actionProgress === 'fail' || message.actionProgress === 'error'; const messageClass = isUser ? styles.messageUser : styles.messageAssistant; + const errorClass = isError ? styles.messageError : ''; // Debug: Log documents if in dev mode if (import.meta.env.DEV && message.documents) { @@ -48,8 +51,16 @@ export const ChatMessage: React.FC = ({ } return ( -
+
+ {/* Error indicator for failed actions */} + {isError && ( +
+ + Aktion fehlgeschlagen +
+ )} + {/* Message content */} {message.message && (
diff --git a/src/components/UiComponents/Messages/Messages.module.css b/src/components/UiComponents/Messages/Messages.module.css index e5741d5..620e4ac 100644 --- a/src/components/UiComponents/Messages/Messages.module.css +++ b/src/components/UiComponents/Messages/Messages.module.css @@ -64,6 +64,29 @@ border-bottom-left-radius: 4px; } +/* Error/Failed Messages */ +.messageError .messageBubble { + background-color: var(--danger-bg, #fee2e2); + border: 1px solid var(--danger-color, #dc2626); +} + +.errorIndicator { + display: flex; + align-items: center; + gap: 6px; + color: var(--danger-color, #dc2626); + font-size: 12px; + font-weight: 500; + padding: 4px 8px; + background-color: rgba(220, 38, 38, 0.1); + border-radius: 4px; + margin-bottom: 4px; +} + +.errorIcon { + font-size: 14px; +} + /* Message Metadata */ .messageMetadata { display: flex; diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index 039864f..e553a68 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -18,7 +18,54 @@ interface UnifiedChatDataItem { createdAt: number; } +/** + * ============================================================================= + * WORKFLOW LIFECYCLE STATE MACHINE + * ============================================================================= + * + * WORKFLOW STATUS (from Backend): + * • idle - No workflow + * • running - Workflow is processing + * • completed - Round finished (Backend processed "last" message) + * • stopped - User stopped the workflow + * • failed - Error occurred + * + * UI FLAG: + * • hasRenderedLastMessage: boolean + * - true: "last" message was rendered in UI + * - false: "last" message not yet in UI + * + * POLLING LOGIC: + * POLL ACTIVE when: + * status === 'running' + * OR (status === 'completed' AND !hasRenderedLastMessage) + * + * POLL STOPS when: + * status === 'stopped' + * OR status === 'failed' + * OR hasRenderedLastMessage === true + * + * TRANSITIONS: + * [Send Button] (from any status): + * → hasRenderedLastMessage = false (new round starts) + * → afterTimestamp = now + * → Start polling + * + * [Load Workflow]: + * → Load all data + * → Check if last message has status="last" + * → If yes: hasRenderedLastMessage = true, no polling + * → If no AND status=running: Start polling + * + * [Message with status="last" rendered]: + * → hasRenderedLastMessage = true + * → Stop polling + * + * ============================================================================= + */ + export function useWorkflowLifecycle(instanceId: string) { + // === STATE === const [workflowId, setWorkflowId] = useState(null); const [workflowStatus, setWorkflowStatus] = useState('idle'); const [currentRound, setCurrentRound] = useState(undefined); @@ -26,48 +73,35 @@ export function useWorkflowLifecycle(instanceId: string) { const [logs, setLogs] = useState([]); const [dashboardLogs, setDashboardLogs] = useState([]); const [unifiedContentLogs, setUnifiedContentLogs] = useState([]); - const [statusChangedFromRunningAt, setStatusChangedFromRunningAt] = useState(null); const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null); - const prevStatusRef = useRef('idle'); + + // === REFS FOR SYNC ACCESS === const statusRef = useRef('idle'); - const statusChangedFromRunningAtRef = useRef(null); const lastRenderedTimestampRef = useRef(null); - // Track processed stat IDs to avoid double-counting const processedStatIdsRef = useRef>(new Set()); - // Track cumulative stats const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }); + + // === KEY STATE MACHINE FLAG === + // This flag tracks if the UI has rendered a message with status="last" + // Polling continues until this is true (even if backend status is "completed") + const hasRenderedLastMessageRef = useRef(false); + const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState(false); + + // === HOOKS === const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations(); const { request } = useApiRequest(); const pollingController = useWorkflowPolling(); - - // Store polling controller methods in refs to avoid dependency issues const pollingControllerRef = useRef(pollingController); pollingControllerRef.current = pollingController; - - // Helper to update status and track transitions + + // === HELPER: Update workflow status === const updateWorkflowStatus = useCallback((newStatus: string) => { - const prevStatus = prevStatusRef.current; - prevStatusRef.current = newStatus; statusRef.current = newStatus; setWorkflowStatus(newStatus); - - // Track when status changes from 'running' to something else - if (prevStatus === 'running' && newStatus !== 'running') { - const timestamp = Date.now(); - setStatusChangedFromRunningAt(timestamp); - statusChangedFromRunningAtRef.current = timestamp; - } else if (newStatus === 'running') { - setStatusChangedFromRunningAt(null); - statusChangedFromRunningAtRef.current = null; - } + console.log('📍 Status updated to:', newStatus); }, []); - - // Expose setWorkflowStatus for optimistic updates - const setWorkflowStatusOptimistic = useCallback((status: string) => { - updateWorkflowStatus(status); - }, [updateWorkflowStatus]); - // Convert backend log format to frontend format + // === HELPER: Convert backend log format to frontend format === const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => { return { id: log.id, @@ -83,15 +117,15 @@ export function useWorkflowLifecycle(instanceId: string) { }; }, [workflowId]); - // Process unified chat data chronologically + // === CORE: Process unified chat data === const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => { - console.log('🔄 Processing unified chat data:', { + console.log('🔄 Processing chat data:', { messages: chatData.messages?.length || 0, logs: chatData.logs?.length || 0, stats: chatData.stats?.length || 0 }); - // Build unified timeline of all items + // Build unified timeline const timeline: UnifiedChatDataItem[] = []; // Add messages @@ -112,154 +146,132 @@ export function useWorkflowLifecycle(instanceId: string) { }); }); - // Add stats (if needed) - (chatData.stats || []).forEach((stat: any) => { + // Add stats + const rawStats = chatData.stats || []; + rawStats.forEach((stat: any) => { timeline.push({ type: 'stat', item: stat, - createdAt: stat.timestamp || stat.createdAt || Date.now() + createdAt: stat._createdAt || stat.createdAt || Date.now() }); }); - console.log('📋 Timeline created with', timeline.length, 'items'); - // Sort chronologically timeline.sort((a, b) => a.createdAt - b.createdAt); - // Process items sequentially to maintain chronological order - // Update lastRenderedTimestamp after processing all items (use latest timestamp) + // Update lastRenderedTimestamp if (timeline.length > 0) { - const latestTimestamp = timeline[timeline.length - 1].createdAt; - lastRenderedTimestampRef.current = latestTimestamp; + lastRenderedTimestampRef.current = timeline[timeline.length - 1].createdAt; } - // Use functional updates to avoid dependency on current state + // === CHECK FOR "LAST" MESSAGE === + // This is the key state machine logic: detect when a "last" message arrives + let foundLastMessage = false; + + timeline.forEach((item) => { + if (item.type === 'message') { + const message = item.item as WorkflowMessage; + if ((message as any).status === 'last') { + foundLastMessage = true; + console.log('🏁 Found "last" message:', message.id); + } + } + }); + + // === STATE MACHINE: Handle "last" message === + if (foundLastMessage && !hasRenderedLastMessageRef.current) { + console.log('🛑 "last" message detected - stopping polling'); + hasRenderedLastMessageRef.current = true; + setHasRenderedLastMessage(true); + pollingControllerRef.current.stopPolling(); + } + + // === UPDATE MESSAGES STATE === setMessages(prevMessages => { const newMessages: WorkflowMessage[] = [...prevMessages]; let hasChanges = false; - let messagesAdded = 0; - let messagesUpdated = 0; timeline.forEach((item) => { if (item.type === 'message') { const message = item.item as WorkflowMessage; + if (!message || !message.id) return; - if (!message || !message.id) { - console.warn('⚠️ Invalid message in timeline:', message); - return; - } - - // Check if message already exists const existingIndex = newMessages.findIndex(m => m.id === message.id); if (existingIndex >= 0) { - // Always update existing message (don't compare, just update) newMessages[existingIndex] = message; hasChanges = true; - messagesUpdated++; } else { newMessages.push(message); hasChanges = true; - messagesAdded++; } } }); - console.log(`📨 Messages: ${messagesAdded} added, ${messagesUpdated} updated, total: ${newMessages.length}`); - if (messagesAdded > 0 || messagesUpdated > 0) { - console.log('📨 Sample messages:', newMessages.slice(0, 3).map(m => ({ id: m.id, message: m.message?.substring(0, 50) }))); - } - - // Always return sorted array if we processed any messages if (hasChanges || timeline.some(item => item.type === 'message')) { - const sorted = [...newMessages].sort(sortMessages); - console.log(`✅ Returning ${sorted.length} sorted messages`); - return sorted; + return [...newMessages].sort(sortMessages); } - - console.log('⚠️ No changes detected, returning previous messages'); return prevMessages; }); - setDashboardLogs(prevDashboardLogs => { - const newDashboardLogs: WorkflowLog[] = [...prevDashboardLogs]; + // === UPDATE DASHBOARD LOGS (with operationId) === + setDashboardLogs(prevLogs => { + const newLogs: WorkflowLog[] = [...prevLogs]; let hasChanges = false; timeline.forEach((item) => { if (item.type === 'log') { - const backendLog = item.item as any; - const frontendLog = convertLogToFrontendFormat(backendLog); - - // Route logs based on operationId + const frontendLog = convertLogToFrontendFormat(item.item); if (frontendLog.operationId) { - // Logs WITH operationId → Dashboard - const existingIndex = newDashboardLogs.findIndex(l => l.id === frontendLog.id); + const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id); if (existingIndex >= 0) { - // Check if log actually changed - const existingLog = newDashboardLogs[existingIndex]; - if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) { - newDashboardLogs[existingIndex] = frontendLog; + if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) { + newLogs[existingIndex] = frontendLog; hasChanges = true; } } else { - newDashboardLogs.push(frontendLog); + newLogs.push(frontendLog); hasChanges = true; } } } }); - // Only return new array if there are changes - if (!hasChanges) { - return prevDashboardLogs; - } - - return [...newDashboardLogs].sort(sortLogs); + return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs; }); - setUnifiedContentLogs(prevUnifiedContentLogs => { - const newUnifiedContentLogs: WorkflowLog[] = [...prevUnifiedContentLogs]; + // === UPDATE UNIFIED CONTENT LOGS (without operationId) === + setUnifiedContentLogs(prevLogs => { + const newLogs: WorkflowLog[] = [...prevLogs]; let hasChanges = false; timeline.forEach((item) => { if (item.type === 'log') { - const backendLog = item.item as any; - const frontendLog = convertLogToFrontendFormat(backendLog); - - // Route logs based on operationId + const frontendLog = convertLogToFrontendFormat(item.item); if (!frontendLog.operationId) { - // Logs WITHOUT operationId → Unified Content Area - const existingIndex = newUnifiedContentLogs.findIndex(l => l.id === frontendLog.id); + const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id); if (existingIndex >= 0) { - // Check if log actually changed - const existingLog = newUnifiedContentLogs[existingIndex]; - if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) { - newUnifiedContentLogs[existingIndex] = frontendLog; + if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) { + newLogs[existingIndex] = frontendLog; hasChanges = true; } } else { - newUnifiedContentLogs.push(frontendLog); + newLogs.push(frontendLog); hasChanges = true; } } } }); - // Only return new array if there are changes - if (!hasChanges) { - return prevUnifiedContentLogs; - } - - return [...newUnifiedContentLogs].sort(sortLogs); + return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs; }); - // Update combined logs for backward compatibility (using functional update) + // === UPDATE COMBINED LOGS === setLogs(prevLogs => { const allLogs: WorkflowLog[] = [...prevLogs]; timeline.forEach((item) => { if (item.type === 'log') { - const backendLog = item.item as any; - const frontendLog = convertLogToFrontendFormat(backendLog); + const frontendLog = convertLogToFrontendFormat(item.item); const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id); if (existingIndex >= 0) { allLogs[existingIndex] = frontendLog; @@ -272,47 +284,36 @@ export function useWorkflowLifecycle(instanceId: string) { return [...allLogs].sort(sortLogs); }); - // Process stats - aggregate only NEW stat entries (avoid double-counting) + // === PROCESS STATS === const statsItems = timeline.filter(item => item.type === 'stat'); + if (statsItems.length > 0) { let hasNewStats = false; statsItems.forEach(statItem => { - const statData = statItem.item || statItem; - const statId = statData?.id || (statItem as any).id; + const statData = statItem.item; + const statId = statData?.id; - // Skip if already processed if (statId && processedStatIdsRef.current.has(statId)) { - return; + return; // Skip already processed } if (statData) { hasNewStats = true; - - // Mark as processed if (statId) { processedStatIdsRef.current.add(statId); } - // Add to cumulative stats - if (statData.priceUsd !== undefined && statData.priceUsd !== null) { - cumulativeStatsRef.current.priceUsd += statData.priceUsd; - } - if (statData.processingTime !== undefined && statData.processingTime !== null) { - cumulativeStatsRef.current.processingTime += statData.processingTime; - } - if (statData.bytesSent !== undefined && statData.bytesSent !== null) { - cumulativeStatsRef.current.bytesSent += statData.bytesSent; - } - if (statData.bytesReceived !== undefined && statData.bytesReceived !== null) { - cumulativeStatsRef.current.bytesReceived += statData.bytesReceived; - } + // Accumulate stats + const price = statData.priceCHF ?? statData.priceUsd ?? 0; + if (price > 0) cumulativeStatsRef.current.priceUsd += price; + if (statData.processingTime) cumulativeStatsRef.current.processingTime += statData.processingTime; + if (statData.bytesSent) cumulativeStatsRef.current.bytesSent += statData.bytesSent; + if (statData.bytesReceived) cumulativeStatsRef.current.bytesReceived += statData.bytesReceived; } }); - // Update state with cumulative totals - if (hasNewStats || (cumulativeStatsRef.current.bytesSent > 0 || cumulativeStatsRef.current.bytesReceived > 0 || - cumulativeStatsRef.current.processingTime > 0 || cumulativeStatsRef.current.priceUsd > 0)) { + if (hasNewStats) { setLatestStats({ priceUsd: cumulativeStatsRef.current.priceUsd, processingTime: cumulativeStatsRef.current.processingTime, @@ -323,10 +324,9 @@ export function useWorkflowLifecycle(instanceId: string) { } }, [convertLogToFrontendFormat]); - // Poll workflow data using unified chat data endpoint + // === POLLING FUNCTION === const pollWorkflowData = useCallback(async (id: string) => { try { - // Determine afterTimestamp for incremental polling const afterTimestamp = lastRenderedTimestampRef.current || undefined; // Fetch workflow status @@ -334,192 +334,73 @@ export function useWorkflowLifecycle(instanceId: string) { if (workflowData) { const status = workflowData.status || 'idle'; - const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined; + const round = workflowData.currentRound; updateWorkflowStatus(status); - setCurrentRound(round); + if (round !== undefined) setCurrentRound(round); + + // === STATE MACHINE: Check if polling should stop based on status === + if (status === 'stopped' || status === 'failed') { + console.log(`🛑 Workflow ${status} - stopping polling immediately`); + pollingControllerRef.current.stopPolling(); + return; + } } - // Fetch unified chat data + // Fetch chat data const chatData = await fetchChatData(request, instanceId, id, afterTimestamp); - console.log('📊 Processed chat data:', { - messagesCount: chatData.messages?.length || 0, - logsCount: chatData.logs?.length || 0, - statsCount: chatData.stats?.length || 0, - afterTimestamp: afterTimestamp + console.log('📊 Polled chat data:', { + messages: chatData.messages?.length || 0, + logs: chatData.logs?.length || 0, + stats: chatData.stats?.length || 0, + afterTimestamp }); - // If we got empty results and we're using afterTimestamp, the backend might be filtering incorrectly - // Reset timestamp to null so next poll fetches all items (but only if we have existing data) - const hasNoNewData = (chatData.messages?.length || 0) === 0 && - (chatData.logs?.length || 0) === 0 && - (chatData.stats?.length || 0) === 0; - - // Only reset if we're using afterTimestamp and got empty results - // This handles cases where backend filtering might miss items due to timestamp precision issues - if (hasNoNewData && afterTimestamp !== undefined && lastRenderedTimestampRef.current !== null) { - console.warn('⚠️ Got empty results with afterTimestamp, resetting timestamp for next poll'); - // Don't reset immediately - let this poll complete, next poll will fetch all - lastRenderedTimestampRef.current = null; - } - - // Process unified chat data + // Process data (this will detect "last" message and stop polling if found) processUnifiedChatData(chatData); - - // Determine if polling should continue - const currentStatus = statusRef.current; - // Stop polling immediately for failed or stopped workflows - // For completed workflows, allow grace period (handled by useEffect) - if (currentStatus === 'failed' || currentStatus === 'stopped') { - pollingControllerRef.current.stopPolling(); - return; - } - - // Continue polling for 'running' status - // For 'completed' status, continue if within grace period (handled by useEffect) - // Polling will be stopped by the useEffect when grace period expires or status changes to failed/stopped - } catch (error: any) { - // Handle rate limiting (429 errors) - if (error?.status === 429 || error?.response?.status === 429) { - throw error; // Let polling controller handle rate limit backoff - } - console.error('Error polling workflow data:', error); - // Don't throw for other errors - allow polling to continue with backoff - } - }, [request, updateWorkflowStatus, processUnifiedChatData]); - - // Load initial workflow data (non-polling) - const _loadWorkflowData = useCallback(async (id: string) => { - try { - const workflowData = await fetchWorkflowApi(request, id).catch(() => null); - - if (!workflowData) { - setMessages([]); - setLogs([]); - setDashboardLogs([]); - setUnifiedContentLogs([]); - setLatestStats(null); - // Reset stats tracking - processedStatIdsRef.current.clear(); - cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; - return; - } - - const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : []; - const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : []; - const status = workflowData.status || 'idle'; - const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined; - - updateWorkflowStatus(status); - setCurrentRound(round); - - // Always fetch unified chat data to get all messages and logs - // Reset lastRenderedTimestamp to fetch all historical data - lastRenderedTimestampRef.current = null; - try { - const chatData = await fetchChatData(request, instanceId, id, undefined); - console.log('📥 loadWorkflowData: Fetched unified chat data:', { - messagesCount: chatData.messages?.length || 0, - logsCount: chatData.logs?.length || 0 - }); - processUnifiedChatData(chatData); - } catch (error) { - console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error); - // Fallback to workflowData if unified chat data fails - if (messagesData.length > 0) { - setMessages([...messagesData].sort(sortMessages)); - } - - // Process logs and separate by operationId - const dashboardLogsList: WorkflowLog[] = []; - const unifiedContentLogsList: WorkflowLog[] = []; - - logsData.forEach((log: any) => { - const frontendLog = convertLogToFrontendFormat(log); - if (frontendLog.operationId) { - dashboardLogsList.push(frontendLog); - } else { - unifiedContentLogsList.push(frontendLog); - } - }); - - setDashboardLogs(dashboardLogsList.sort(sortLogs)); - setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs)); - setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs)); - } } catch (error) { - console.error('Error loading workflow data:', error); + console.error('❌ Polling error:', error); } - }, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]); - void _loadWorkflowData; // Intentionally unused, reserved for future use + }, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]); - // Set up polling when workflow is running + // === POLLING CONTROL EFFECT === useEffect(() => { if (!workflowId) { - // Only clear state if not already cleared to avoid unnecessary updates - setMessages(prev => prev.length > 0 ? [] : prev); - setLogs(prev => prev.length > 0 ? [] : prev); - setDashboardLogs(prev => prev.length > 0 ? [] : prev); - setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev); - setLatestStats(null); - // Reset stats tracking - processedStatIdsRef.current.clear(); - cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; - setCurrentRound(prev => prev !== undefined ? undefined : prev); - if (statusChangedFromRunningAt !== null) { - setStatusChangedFromRunningAt(null); - statusChangedFromRunningAtRef.current = null; - } - lastRenderedTimestampRef.current = null; pollingControllerRef.current.stopPolling(); return; } - // Continue polling if: - // 1. Workflow is currently running, OR - // 2. Workflow just completed (within last 5 seconds) - grace period to catch final messages - // Stop polling for failed or stopped workflows immediately - const changedAtRef = statusChangedFromRunningAtRef.current; - const gracePeriodMs = 5000; // 5 seconds grace period - const timeSinceCompletion = changedAtRef !== null ? Date.now() - changedAtRef : Infinity; - const isInGracePeriod = workflowStatus === 'completed' && changedAtRef !== null && timeSinceCompletion < gracePeriodMs; - const shouldPoll = workflowStatus === 'running' || isInGracePeriod; + // === STATE MACHINE: Determine if polling should be active === + const shouldPoll = + workflowStatus === 'running' || + (workflowStatus === 'completed' && !hasRenderedLastMessage); + + const shouldStopImmediately = + workflowStatus === 'stopped' || + workflowStatus === 'failed' || + hasRenderedLastMessage; + + console.log('📍 Polling decision:', { + workflowStatus, + hasRenderedLastMessage, + shouldPoll, + shouldStopImmediately + }); if (shouldPoll) { - // Start polling pollingControllerRef.current.startPolling(workflowId, pollWorkflowData); - - // If in grace period, set a timer to stop polling after grace period expires - if (isInGracePeriod) { - const remainingGraceTime = gracePeriodMs - timeSinceCompletion; - const graceTimer = setTimeout(() => { - pollingControllerRef.current.stopPolling(); - setStatusChangedFromRunningAt(null); - statusChangedFromRunningAtRef.current = null; - }, remainingGraceTime + 100); // Small buffer - - return () => { - clearTimeout(graceTimer); - pollingControllerRef.current.stopPolling(); - }; - } - } else { - // Stop polling for failed, stopped, or completed (after grace period) workflows + } else if (shouldStopImmediately) { pollingControllerRef.current.stopPolling(); - // Clear the status change timestamp when we stop polling - if (statusChangedFromRunningAt !== null) { - setStatusChangedFromRunningAt(null); - statusChangedFromRunningAtRef.current = null; - } } return () => { pollingControllerRef.current.stopPolling(); }; - }, [workflowStatus, workflowId, pollWorkflowData, statusChangedFromRunningAt]); - + }, [workflowStatus, workflowId, hasRenderedLastMessage, pollWorkflowData]); + + // === START WORKFLOW (Send Button) === const handleStartWorkflow = useCallback(async ( workflowData: StartWorkflowRequest, options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' } @@ -529,10 +410,24 @@ export function useWorkflowLifecycle(instanceId: string) { if (result.success && result.data) { const workflow = result.data as Workflow; + + // === STATE MACHINE: New round starts === + console.log('🚀 Starting workflow:', workflow.id); + + // Reset state for new round setWorkflowId(workflow.id); + hasRenderedLastMessageRef.current = false; + setHasRenderedLastMessage(false); + + // Set afterTimestamp to NOW - only poll for new data + lastRenderedTimestampRef.current = Date.now(); + + // Start polling immediately + pollingControllerRef.current.startPolling(workflow.id, pollWorkflowData); + + // Update status updateWorkflowStatus(workflow.status || 'running'); - // Reset lastRenderedTimestamp for new workflow - lastRenderedTimestampRef.current = null; + return { success: true, data: result.data }; } else { return { success: false, error: result.error || 'Failed to start workflow' }; @@ -540,8 +435,9 @@ export function useWorkflowLifecycle(instanceId: string) { } catch (error: any) { return { success: false, error: error.message || 'Failed to start workflow' }; } - }, [instanceId, startWorkflow, updateWorkflowStatus]); - + }, [instanceId, startWorkflow, updateWorkflowStatus, pollWorkflowData]); + + // === STOP WORKFLOW === const handleStopWorkflow = useCallback(async () => { if (!workflowId) { return { success: false, error: 'No workflow to stop' }; @@ -552,6 +448,7 @@ export function useWorkflowLifecycle(instanceId: string) { if (result.success) { updateWorkflowStatus('stopped'); + pollingControllerRef.current.stopPolling(); return { success: true }; } else { return { success: false, error: result.error || 'Failed to stop workflow' }; @@ -560,31 +457,44 @@ export function useWorkflowLifecycle(instanceId: string) { return { success: false, error: error.message || 'Failed to stop workflow' }; } }, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]); - + + // === RESET WORKFLOW === const resetWorkflow = useCallback(() => { + console.log('🔄 Resetting workflow state'); + setWorkflowId(null); - prevStatusRef.current = 'idle'; - statusRef.current = 'idle'; updateWorkflowStatus('idle'); setCurrentRound(undefined); + setMessages([]); + setLogs([]); + setDashboardLogs([]); + setUnifiedContentLogs([]); setLatestStats(null); - // Reset stats tracking + + // Reset refs + lastRenderedTimestampRef.current = null; processedStatIdsRef.current.clear(); cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; - setStatusChangedFromRunningAt(null); - statusChangedFromRunningAtRef.current = null; - lastRenderedTimestampRef.current = null; + hasRenderedLastMessageRef.current = false; + setHasRenderedLastMessage(false); + pollingControllerRef.current.stopPolling(); }, [updateWorkflowStatus]); - + + // === SELECT/LOAD WORKFLOW === const selectWorkflow = useCallback(async (workflowIdToSelect: string) => { try { + console.log('📥 Loading workflow:', workflowIdToSelect); + + // Reset state setWorkflowId(workflowIdToSelect); - // Reset lastRenderedTimestamp and stats for new workflow selection lastRenderedTimestampRef.current = null; processedStatIdsRef.current.clear(); cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; + hasRenderedLastMessageRef.current = false; + setHasRenderedLastMessage(false); + // Fetch workflow data const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null); if (!workflowData) { @@ -597,58 +507,66 @@ export function useWorkflowLifecycle(instanceId: string) { return; } - const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : []; - const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : []; const status = workflowData.status || 'idle'; - const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined; + const round = workflowData.currentRound; updateWorkflowStatus(status); - setCurrentRound(round); + if (round !== undefined) setCurrentRound(round); - // Always fetch unified chat data to get all messages and logs (regardless of status) - // This ensures completed workflows also show their logs + // Fetch all chat data (no afterTimestamp = get everything) try { const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined); - console.log('📥 selectWorkflow: Fetched unified chat data:', { - messagesCount: chatData.messages?.length || 0, - logsCount: chatData.logs?.length || 0, - status + console.log('📥 Loaded chat data:', { + messages: chatData.messages?.length || 0, + logs: chatData.logs?.length || 0, + stats: chatData.stats?.length || 0 }); - processUnifiedChatData(chatData); - } catch (error) { - console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error); - // Fallback to workflowData if unified chat data fails - if (messagesData.length > 0) { - setMessages([...messagesData].sort(sortMessages)); + + // === STATE MACHINE: Check if last message has status="last" === + const allMessages = chatData.messages || []; + const sortedMessages = [...allMessages].sort((a, b) => { + const aTime = a.publishedAt || a.timestamp || 0; + const bTime = b.publishedAt || b.timestamp || 0; + return bTime - aTime; // Sort descending (newest first) + }); + + const lastMessage = sortedMessages[0]; + const lastMessageStatus = lastMessage ? (lastMessage as any).status : null; + + console.log('📍 Last message status:', lastMessageStatus); + + if (lastMessageStatus === 'last') { + // Round is complete - don't start polling + hasRenderedLastMessageRef.current = true; + setHasRenderedLastMessage(true); + console.log('✅ Workflow round complete - no polling needed'); + } else if (status === 'running') { + // Workflow is running - polling will start via useEffect + console.log('🔄 Workflow is running - polling will start'); } - // Process logs and separate by operationId - const dashboardLogsList: WorkflowLog[] = []; - const unifiedContentLogsList: WorkflowLog[] = []; + // Process the data + processUnifiedChatData(chatData); - logsData.forEach((log: any) => { - const frontendLog = convertLogToFrontendFormat(log); - if (frontendLog.operationId) { - dashboardLogsList.push(frontendLog); - } else { - unifiedContentLogsList.push(frontendLog); - } - }); - - setDashboardLogs(dashboardLogsList.sort(sortLogs)); - setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs)); - setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs)); + } catch (error) { + console.warn('⚠️ Failed to fetch chat data:', error); + updateWorkflowStatus('idle'); } - // If workflow is running, polling will start automatically via useEffect } catch (error) { - console.error('Error selecting workflow:', error); + console.error('❌ Error selecting workflow:', error); } - }, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]); - + }, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]); + + // === EXPOSE STATUS SETTER FOR OPTIMISTIC UPDATES === + const setWorkflowStatusOptimistic = useCallback((status: string) => { + updateWorkflowStatus(status); + }, [updateWorkflowStatus]); + + // === COMPUTED VALUES === const isRunning = workflowStatus === 'running'; const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false; - + return { workflowId, workflowStatus, @@ -661,6 +579,7 @@ export function useWorkflowLifecycle(instanceId: string) { dashboardLogs, unifiedContentLogs, latestStats, + hasRenderedLastMessage, startWorkflow: handleStartWorkflow, stopWorkflow: handleStopWorkflow, resetWorkflow, diff --git a/src/layouts/FeatureLayout.module.css b/src/layouts/FeatureLayout.module.css index 5cce883..aeca795 100644 --- a/src/layouts/FeatureLayout.module.css +++ b/src/layouts/FeatureLayout.module.css @@ -138,8 +138,12 @@ /* Feature Content */ .featureContent { flex: 1; - overflow: auto; + /* Let child components handle their own scrolling for sticky headers */ + overflow: hidden; padding: 1.5rem; + /* Maintain flex chain for child components */ + display: flex; + flex-direction: column; } /* Dark Theme */ diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css index f245160..ac71c8b 100644 --- a/src/layouts/MainLayout.module.css +++ b/src/layouts/MainLayout.module.css @@ -81,7 +81,8 @@ /* Content */ .content { flex: 1; - overflow: auto; + /* Let child components handle their own scrolling for sticky headers */ + overflow: hidden; background: var(--bg-primary, #ffffff); } diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css index 50e63ef..6e99486 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -6,7 +6,12 @@ .adminPage { padding: 1.5rem; - min-height: 100%; + /* Fill parent height and enable flex layout for sticky table headers */ + height: 100%; + max-height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; } .pageHeader { diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css index 35ce33a..f09b398 100644 --- a/src/pages/billing/Billing.module.css +++ b/src/pages/billing/Billing.module.css @@ -6,9 +6,8 @@ .billingDashboard { padding: 1.5rem; - max-width: 1200px; - margin: 0 auto; min-height: 100%; + width: 100%; } .pageHeader { @@ -490,7 +489,7 @@ @media (max-width: 768px) { .billingDashboard { - padding: 1rem; + padding: 0.75rem; } .balanceGrid { diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx index 2576818..4e3f963 100644 --- a/src/pages/billing/BillingDashboard.tsx +++ b/src/pages/billing/BillingDashboard.tsx @@ -6,6 +6,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling'; +import { BillingNav } from './BillingNav'; import styles from './Billing.module.css'; // ============================================================================ @@ -187,6 +188,8 @@ export const BillingDashboard: React.FC = () => {

Übersicht über Guthaben und Nutzung

+ + {/* Balance Cards */}

Guthaben

diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx new file mode 100644 index 0000000..a8ceecc --- /dev/null +++ b/src/pages/billing/BillingDataView.tsx @@ -0,0 +1,443 @@ +/** + * BillingDataView + * + * Unified billing page with internal tabs: + * - Tab "Übersicht": Balance cards + Statistics (from BillingDashboard) + * - Tab "Transaktionen": Transaction table with FormGeneratorTable + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; +import api from '../../api'; +import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling'; +import { UserTransaction } from '../../api/billingApi'; +import styles from './Billing.module.css'; + +// ============================================================================ +// BALANCE CARD COMPONENT +// ============================================================================ + +interface BalanceCardProps { + balance: BillingBalance; +} + +const BalanceCard: React.FC = ({ balance }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + const getBillingModelLabel = (model: string) => { + switch (model) { + case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; + case 'PREPAY_USER': return 'Prepaid (Benutzer)'; + case 'CREDIT_POSTPAY': return 'Kredit'; + case 'UNLIMITED': return 'Unlimited'; + default: return model; + } + }; + + return ( +
+
+

{balance.mandateName}

+ {getBillingModelLabel(balance.billingModel)} +
+
+ {formatCurrency(balance.balance)} +
+ {balance.isWarning && ( +
+ Niedriges Guthaben +
+ )} +
+ ); +}; + +// ============================================================================ +// STATISTICS CHART COMPONENT +// ============================================================================ + +interface StatisticsChartProps { + statistics: UsageReport | null; + loading?: boolean; +} + +const StatisticsChart: React.FC = ({ statistics, loading }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + if (loading) { + return
Lade Statistiken...
; + } + + if (!statistics) { + return
Keine Statistiken verfügbar
; + } + + const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1); + + return ( +
+
+ Gesamtkosten + {formatCurrency(statistics.totalCost)} +
+ +
+

Kosten nach Anbieter

+ {Object.entries(statistics.costByProvider).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByProvider).map(([provider, cost]) => ( +
+ {provider} +
+
+
+ {formatCurrency(cost)} +
+ ))} +
+ )} +
+ +
+

Kosten nach Feature

+ {Object.entries(statistics.costByFeature).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByFeature).map(([feature, cost]) => ( +
+ {feature} + {formatCurrency(cost)} +
+ ))} +
+ )} +
+
+ ); +}; + +// ============================================================================ +// TAB NAVIGATION COMPONENT +// ============================================================================ + +type TabType = 'overview' | 'transactions'; + +interface TabNavProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +const TabNav: React.FC = ({ activeTab, onTabChange }) => { + const navLinkStyle = (isActive: boolean) => ({ + padding: '8px 16px', + textDecoration: 'none', + borderRadius: '4px', + backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent', + color: isActive ? 'white' : 'var(--color-text, #e0e0e0)', + fontWeight: isActive ? 600 : 400, + cursor: 'pointer', + border: 'none', + fontSize: '14px', + }); + + return ( + + ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingDataView: React.FC = () => { + const [activeTab, setActiveTab] = useState('overview'); + + // Dashboard state (for Overview tab) + const { + balances, + statistics, + loading: dashboardLoading, + loadStatistics + } = useBilling(); + const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month'); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1); + + // Transactions state (for Transactions tab) + const [transactions, setTransactions] = useState([]); + const [transactionsLoading, setTransactionsLoading] = useState(false); + const [transactionsError, setTransactionsError] = useState(null); + + // Load statistics when period changes + useEffect(() => { + if (selectedPeriod === 'month') { + loadStatistics('month', selectedYear); + } else { + loadStatistics('year', selectedYear); + } + }, [selectedPeriod, selectedYear, loadStatistics]); + + // Load transactions + const loadTransactions = useCallback(async () => { + try { + setTransactionsLoading(true); + setTransactionsError(null); + const response = await api.get('/api/billing/view/users/transactions', { + params: { limit: 500 } + }); + setTransactions(response.data || []); + } catch (err: any) { + console.error('Failed to load transactions:', err); + setTransactionsError(err.response?.data?.detail || err.message || 'Fehler beim Laden der Transaktionen'); + } finally { + setTransactionsLoading(false); + } + }, []); + + // Load transactions when switching to transactions tab + useEffect(() => { + if (activeTab === 'transactions' && transactions.length === 0) { + loadTransactions(); + } + }, [activeTab, transactions.length, loadTransactions]); + + // Available years + const availableYears = useMemo(() => { + const current = new Date().getFullYear(); + return [current, current - 1, current - 2]; + }, []); + + // Available months + const availableMonths = [ + { value: 1, label: 'Januar' }, + { value: 2, label: 'Februar' }, + { value: 3, label: 'März' }, + { value: 4, label: 'April' }, + { value: 5, label: 'Mai' }, + { value: 6, label: 'Juni' }, + { value: 7, label: 'Juli' }, + { value: 8, label: 'August' }, + { value: 9, label: 'September' }, + { value: 10, label: 'Oktober' }, + { value: 11, label: 'November' }, + { value: 12, label: 'Dezember' }, + ]; + + // Transform transactions for table display + const tableData = useMemo(() => { + return transactions.map((t, index) => ({ + _uniqueId: `${t.id}-${t.mandateId}-${index}`, + id: t.id, + createdAt: t.createdAt, + mandateId: t.mandateId, + mandateName: t.mandateName || '-', + userId: t.userId, + userName: t.userName || '-', + transactionType: t.transactionType, + description: t.description || '-', + aicoreProvider: t.aicoreProvider || '-', + featureCode: t.featureCode || '-', + amount: t.transactionType === 'DEBIT' ? -t.amount : t.amount, + })); + }, [transactions]); + + // Table column definitions + const columns: ColumnConfig[] = useMemo(() => [ + { + key: 'createdAt', + label: 'Datum', + type: 'datetime', + sortable: true, + width: 160, + }, + { + key: 'mandateName', + label: 'Mandant', + type: 'text', + sortable: true, + filterable: true, + searchable: true, + width: 150, + }, + { + key: 'userName', + label: 'Benutzer', + type: 'text', + sortable: true, + filterable: true, + searchable: true, + width: 150, + }, + { + key: 'transactionType', + label: 'Typ', + type: 'text', + sortable: true, + filterable: true, + filterOptions: ['CREDIT', 'DEBIT', 'ADJUSTMENT'], + width: 100, + }, + { + key: 'description', + label: 'Beschreibung', + type: 'text', + searchable: true, + width: 250, + }, + { + key: 'aicoreProvider', + label: 'Anbieter', + type: 'text', + sortable: true, + filterable: true, + width: 120, + }, + { + key: 'featureCode', + label: 'Feature', + type: 'text', + sortable: true, + filterable: true, + width: 120, + }, + { + key: 'amount', + label: 'Betrag (CHF)', + type: 'number', + sortable: true, + width: 120, + }, + ], []); + + return ( +
+
+

Billing

+

Guthaben, Statistiken und Transaktionen

+
+ + + + {/* Overview Tab */} + {activeTab === 'overview' && ( + <> + {/* Balance Cards */} +
+

Guthaben

+ {dashboardLoading ? ( +
Lade Guthaben...
+ ) : balances.length === 0 ? ( +
Keine Abrechnungskonten vorhanden
+ ) : ( +
+ {balances.map((balance) => ( + + ))} +
+ )} +
+ + {/* Statistics */} +
+
+

Nutzungsstatistik

+
+ + + {selectedPeriod === 'month' && ( + + )} +
+
+ +
+ + )} + + {/* Transactions Tab */} + {activeTab === 'transactions' && ( + <> + {transactionsError && ( +
+ {transactionsError} +
+ )} + + + + )} +
+ ); +}; + +export default BillingDataView; diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx new file mode 100644 index 0000000..82d022f --- /dev/null +++ b/src/pages/billing/BillingMandateView.tsx @@ -0,0 +1,280 @@ +/** + * Billing Mandate View Page + * + * Shows mandate-level balances and transactions for SysAdmins. + * Includes filtering by mandate. + */ + +import React, { useEffect, useState, useMemo } from 'react'; +import { useApiRequest } from '../../hooks/useApi'; +import { + fetchMandateViewBalances, + fetchMandateViewTransactions, + type MandateBalance, + type BillingTransaction +} from '../../api/billingApi'; +import { BillingNav } from './BillingNav'; +import styles from './Billing.module.css'; + +// ============================================================================ +// MANDATE BALANCE TABLE +// ============================================================================ + +interface MandateBalanceTableProps { + balances: MandateBalance[]; + selectedMandateId: string | null; + onSelectMandate: (mandateId: string | null) => void; +} + +const MandateBalanceTable: React.FC = ({ + balances, + selectedMandateId, + onSelectMandate +}) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + const getBillingModelLabel = (model: string) => { + switch (model) { + case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; + case 'PREPAY_USER': return 'Prepaid (Benutzer)'; + case 'CREDIT_POSTPAY': return 'Kredit'; + case 'UNLIMITED': return 'Unlimited'; + default: return model; + } + }; + + return ( +
+ + + + + + + + + + + + + {balances.map((balance) => ( + + + + + + + + + ))} + +
MandantBilling-ModellAnzahl BenutzerStandard-GuthabenGesamtguthabenAktion
{balance.mandateName || balance.mandateId}{getBillingModelLabel(balance.billingModel)}{balance.userCount}{formatCurrency(balance.defaultUserCredit)}{formatCurrency(balance.totalBalance)} + +
+
+ ); +}; + +// ============================================================================ +// TRANSACTION TABLE +// ============================================================================ + +interface TransactionTableProps { + transactions: BillingTransaction[]; +} + +const TransactionTable: React.FC = ({ transactions }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return '-'; + return new Date(dateString).toLocaleString('de-CH', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const getTypeClass = (type: string) => { + switch (type) { + case 'CREDIT': return styles.credit; + case 'DEBIT': return styles.debit; + case 'ADJUSTMENT': return styles.adjustment; + default: return ''; + } + }; + + const getTypeLabel = (type: string) => { + switch (type) { + case 'CREDIT': return 'Gutschrift'; + case 'DEBIT': return 'Belastung'; + case 'ADJUSTMENT': return 'Korrektur'; + default: return type; + } + }; + + return ( +
+ + + + + + + + + + + + + + {transactions.map((t) => ( + + + + + + + + + + ))} + +
DatumMandantTypBeschreibungAnbieterFeatureBetrag
{formatDate(t.createdAt)}{t.mandateName || '-'} + + {getTypeLabel(t.transactionType)} + + {t.description}{t.aicoreProvider || '-'}{t.featureCode || '-'} + {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingMandateView: React.FC = () => { + const { request, isLoading: loading } = useApiRequest(); + const [balances, setBalances] = useState([]); + const [transactions, setTransactions] = useState([]); + const [selectedMandateId, setSelectedMandateId] = useState(null); + const [limit, setLimit] = useState(100); + + // Load data + useEffect(() => { + const loadData = async () => { + try { + const [balanceData, transactionData] = await Promise.all([ + fetchMandateViewBalances(request), + fetchMandateViewTransactions(request, limit) + ]); + setBalances(Array.isArray(balanceData) ? balanceData : []); + setTransactions(Array.isArray(transactionData) ? transactionData : []); + } catch (err) { + console.error('Error loading mandate view data:', err); + setBalances([]); + setTransactions([]); + } + }; + loadData(); + }, [request, limit]); + + // Filter transactions by selected mandate + const filteredTransactions = useMemo(() => { + if (!selectedMandateId) return transactions; + return transactions.filter(t => t.mandateId === selectedMandateId); + }, [transactions, selectedMandateId]); + + const handleLoadMore = () => { + setLimit(prev => prev + 100); + }; + + return ( +
+
+

Mandanten-Billing

+

Guthaben und Transaktionen pro Mandant

+
+ + + + {/* Mandate Balances */} +
+

Mandanten-Guthaben

+ {loading && balances.length === 0 ? ( +
Lade Daten...
+ ) : balances.length === 0 ? ( +
Keine Mandanten mit Billing-Settings vorhanden
+ ) : ( + + )} +
+ + {/* Transactions */} +
+
+

+ Transaktionen + {selectedMandateId && ( + + (gefiltert nach {balances.find(b => b.mandateId === selectedMandateId)?.mandateName || selectedMandateId}) + + )} +

+
+ {loading && transactions.length === 0 ? ( +
Lade Transaktionen...
+ ) : filteredTransactions.length === 0 ? ( +
Keine Transaktionen vorhanden
+ ) : ( + <> + + + {transactions.length >= limit && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default BillingMandateView; diff --git a/src/pages/billing/BillingNav.tsx b/src/pages/billing/BillingNav.tsx new file mode 100644 index 0000000..0b4c918 --- /dev/null +++ b/src/pages/billing/BillingNav.tsx @@ -0,0 +1,54 @@ +/** + * Billing Navigation Component + * + * Provides navigation between billing views. + * Simplified: Übersicht (Dashboard) + Daten (FormGeneratorTable view) + */ + +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import styles from './Billing.module.css'; + +export const BillingNav: React.FC = () => { + const navLinkStyle = (isActive: boolean) => ({ + padding: '8px 16px', + textDecoration: 'none', + borderRadius: '4px', + backgroundColor: isActive ? 'var(--color-primary)' : 'transparent', + color: isActive ? 'white' : 'var(--color-text)', + fontWeight: isActive ? 600 : 400, + }); + + return ( + + ); +}; + +export default BillingNav; diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx index f584bce..ddd7f2c 100644 --- a/src/pages/billing/BillingTransactions.tsx +++ b/src/pages/billing/BillingTransactions.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState } from 'react'; import { useBilling, type BillingTransaction } from '../../hooks/useBilling'; +import { BillingNav } from './BillingNav'; import styles from './Billing.module.css'; // ============================================================================ @@ -56,6 +57,7 @@ const TransactionRow: React.FC = ({ transaction }) => { return ( {formatDate(transaction.createdAt)} + {transaction.mandateName || '-'} {getTypeLabel(transaction.transactionType)} @@ -94,6 +96,8 @@ export const BillingTransactions: React.FC = () => {

Übersicht aller Kontobewegungen

+ +
{loading && transactions.length === 0 ? (
Lade Transaktionen...
@@ -106,6 +110,7 @@ export const BillingTransactions: React.FC = () => { Datum + Mandant Typ Beschreibung Anbieter diff --git a/src/pages/billing/BillingUserView.tsx b/src/pages/billing/BillingUserView.tsx new file mode 100644 index 0000000..194be7d --- /dev/null +++ b/src/pages/billing/BillingUserView.tsx @@ -0,0 +1,376 @@ +/** + * Billing User View Page + * + * Shows user-level balances and transactions. + * RBAC-based: Users see only their own data, Admins see all. + * Includes filtering by mandate and user. + */ + +import React, { useEffect, useState, useMemo } from 'react'; +import { useApiRequest } from '../../hooks/useApi'; +import { + fetchUserViewBalances, + fetchUserViewTransactions, + type UserBalance, + type UserTransaction +} from '../../api/billingApi'; +import { BillingNav } from './BillingNav'; +import styles from './Billing.module.css'; + +// ============================================================================ +// USER BALANCE TABLE +// ============================================================================ + +interface UserBalanceTableProps { + balances: UserBalance[]; + selectedMandateId: string | null; + selectedUserId: string | null; + onSelectMandate: (mandateId: string | null) => void; + onSelectUser: (userId: string | null) => void; +} + +const UserBalanceTable: React.FC = ({ + balances, + selectedMandateId, + selectedUserId, + onSelectMandate, + onSelectUser +}) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + // Get unique mandates and users for filter dropdowns + const uniqueMandates = useMemo(() => { + const mandates = new Map(); + balances.forEach(b => { + if (b.mandateId && !mandates.has(b.mandateId)) { + mandates.set(b.mandateId, b.mandateName || b.mandateId); + } + }); + return Array.from(mandates.entries()); + }, [balances]); + + const uniqueUsers = useMemo(() => { + const users = new Map(); + balances.forEach(b => { + if (b.userId && !users.has(b.userId)) { + users.set(b.userId, b.userName || b.userId); + } + }); + return Array.from(users.entries()); + }, [balances]); + + // Apply filters + const filteredBalances = useMemo(() => { + let result = balances; + if (selectedMandateId) { + result = result.filter(b => b.mandateId === selectedMandateId); + } + if (selectedUserId) { + result = result.filter(b => b.userId === selectedUserId); + } + return result; + }, [balances, selectedMandateId, selectedUserId]); + + return ( + <> + {/* Filter Controls */} +
+
+ + +
+
+ + +
+
+ + {/* Table */} +
+ + + + + + + + + + + + {filteredBalances.map((balance) => ( + + + + + + + + ))} + +
MandantBenutzerGuthabenWarnschwelleStatus
{balance.mandateName || balance.mandateId}{balance.userName || balance.userId}{formatCurrency(balance.balance)}{formatCurrency(balance.warningThreshold)} + {balance.isWarning ? ( + + Niedrig + + ) : balance.enabled ? ( + Aktiv + ) : ( + Deaktiviert + )} +
+
+ + ); +}; + +// ============================================================================ +// USER TRANSACTION TABLE +// ============================================================================ + +interface UserTransactionTableProps { + transactions: UserTransaction[]; + selectedMandateId: string | null; + selectedUserId: string | null; +} + +const UserTransactionTable: React.FC = ({ + transactions, + selectedMandateId, + selectedUserId +}) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return '-'; + return new Date(dateString).toLocaleString('de-CH', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const getTypeClass = (type: string) => { + switch (type) { + case 'CREDIT': return styles.credit; + case 'DEBIT': return styles.debit; + case 'ADJUSTMENT': return styles.adjustment; + default: return ''; + } + }; + + const getTypeLabel = (type: string) => { + switch (type) { + case 'CREDIT': return 'Gutschrift'; + case 'DEBIT': return 'Belastung'; + case 'ADJUSTMENT': return 'Korrektur'; + default: return type; + } + }; + + // Apply filters + const filteredTransactions = useMemo(() => { + let result = transactions; + if (selectedMandateId) { + result = result.filter(t => t.mandateId === selectedMandateId); + } + if (selectedUserId) { + result = result.filter(t => t.userId === selectedUserId); + } + return result; + }, [transactions, selectedMandateId, selectedUserId]); + + return ( +
+ + + + + + + + + + + + + + + {filteredTransactions.map((t) => ( + + + + + + + + + + + ))} + +
DatumMandantBenutzerTypBeschreibungAnbieterFeatureBetrag
{formatDate(t.createdAt)}{t.mandateName || '-'}{t.userName || '-'} + + {getTypeLabel(t.transactionType)} + + {t.description}{t.aicoreProvider || '-'}{t.featureCode || '-'} + {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingUserView: React.FC = () => { + const { request, isLoading: loading } = useApiRequest(); + const [balances, setBalances] = useState([]); + const [transactions, setTransactions] = useState([]); + const [selectedMandateId, setSelectedMandateId] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(null); + const [limit, setLimit] = useState(100); + + // Load data + useEffect(() => { + const loadData = async () => { + try { + const [balanceData, transactionData] = await Promise.all([ + fetchUserViewBalances(request), + fetchUserViewTransactions(request, limit) + ]); + setBalances(Array.isArray(balanceData) ? balanceData : []); + setTransactions(Array.isArray(transactionData) ? transactionData : []); + } catch (err) { + console.error('Error loading user view data:', err); + setBalances([]); + setTransactions([]); + } + }; + loadData(); + }, [request, limit]); + + const handleLoadMore = () => { + setLimit(prev => prev + 100); + }; + + // Count filtered transactions for display + const filteredTransactionCount = useMemo(() => { + let result = transactions; + if (selectedMandateId) { + result = result.filter(t => t.mandateId === selectedMandateId); + } + if (selectedUserId) { + result = result.filter(t => t.userId === selectedUserId); + } + return result.length; + }, [transactions, selectedMandateId, selectedUserId]); + + return ( +
+
+

Benutzer-Billing

+

Guthaben und Transaktionen pro Benutzer

+
+ + + + {/* User Balances */} +
+

Benutzer-Guthaben

+ {loading && balances.length === 0 ? ( +
Lade Daten...
+ ) : balances.length === 0 ? ( +
Keine Benutzer-Konten vorhanden
+ ) : ( + + )} +
+ + {/* Transactions */} +
+
+

+ Transaktionen + {(selectedMandateId || selectedUserId) && ( + + ({filteredTransactionCount} gefiltert) + + )} +

+
+ {loading && transactions.length === 0 ? ( +
Lade Transaktionen...
+ ) : transactions.length === 0 ? ( +
Keine Transaktionen vorhanden
+ ) : ( + <> + + + {transactions.length >= limit && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default BillingUserView; diff --git a/src/pages/billing/index.ts b/src/pages/billing/index.ts index eb4f6c7..86d7563 100644 --- a/src/pages/billing/index.ts +++ b/src/pages/billing/index.ts @@ -3,5 +3,11 @@ */ export { BillingDashboard } from './BillingDashboard'; -export { BillingTransactions } from './BillingTransactions'; +export { BillingDataView } from './BillingDataView'; export { BillingAdmin } from './BillingAdmin'; +export { BillingNav } from './BillingNav'; + +// Legacy exports (can be removed after migration) +export { BillingTransactions } from './BillingTransactions'; +export { BillingMandateView } from './BillingMandateView'; +export { BillingUserView } from './BillingUserView'; diff --git a/src/pages/workflows/PlaygroundPage.module.css b/src/pages/workflows/PlaygroundPage.module.css index cbb271b..32e8025 100644 --- a/src/pages/workflows/PlaygroundPage.module.css +++ b/src/pages/workflows/PlaygroundPage.module.css @@ -20,11 +20,24 @@ flex-shrink: 0; display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-color); } +.headerLeft { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.headerTitleRow { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + .pageTitle { font-size: 1.5rem; font-weight: 600; @@ -32,6 +45,24 @@ margin: 0; } +.headerStats { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.75rem; + color: var(--text-secondary); + background-color: var(--bg-secondary); + padding: 0.25rem 0.75rem; + border-radius: 12px; +} + +.headerStatItem { + display: flex; + align-items: center; + gap: 0.25rem; + white-space: nowrap; +} + .pageSubtitle { font-size: 0.875rem; color: var(--text-secondary); @@ -372,30 +403,6 @@ border-color: var(--primary-color, #f25843); } -/* Statistics bar */ -.statsBar { - display: flex; - gap: 1.5rem; - padding: 0.5rem 0; - font-size: 0.75rem; - color: var(--text-secondary); -} - -.statItem { - display: flex; - align-items: center; - gap: 0.25rem; -} - -.statLabel { - color: var(--text-tertiary); -} - -.statValue { - font-weight: 500; - color: var(--text-secondary); -} - /* Pending files */ .pendingFiles { display: flex; diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index 0733329..9dc6af7 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -582,8 +582,26 @@ export const PlaygroundPage: React.FC = () => { {/* Page Header */}
-
-

Chat Playground

+
+
+

Chat Playground

+ {/* Stats display in header */} +
+ + ↑ {formatBytes(latestStats?.bytesSent || 0)} / ↓ {formatBytes(latestStats?.bytesReceived || 0)} + + {(latestStats?.processingTime ?? 0) > 0 && ( + + ⏱️ {formatDuration(latestStats?.processingTime || 0)} + + )} + {(latestStats?.priceUsd ?? 0) > 0 && ( + + 💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)} + + )} +
+

Workflow-Ausführung und Chat-Interaktion

@@ -711,36 +729,6 @@ export const PlaygroundPage: React.FC = () => {
)} - {/* Stats bar */} - {latestStats && (latestStats.bytesSent || latestStats.bytesReceived || latestStats.processingTime || latestStats.priceUsd) && ( -
- {(latestStats.bytesSent !== undefined || latestStats.bytesReceived !== undefined) && ( -
- Daten: - - {formatBytes(latestStats.bytesSent || 0)} / {formatBytes(latestStats.bytesReceived || 0)} - -
- )} - {latestStats.processingTime !== undefined && latestStats.processingTime > 0 && ( -
- Zeit: - - {formatDuration(latestStats.processingTime)} - -
- )} - {latestStats.priceUsd !== undefined && latestStats.priceUsd > 0 && ( -
- Kosten: - - ${latestStats.priceUsd.toFixed(4)} - -
- )} -
- )} - {/* Input row */}
diff --git a/src/pages/workflows/WorkflowsPage.tsx b/src/pages/workflows/WorkflowsPage.tsx index 2816cc0..a17060c 100644 --- a/src/pages/workflows/WorkflowsPage.tsx +++ b/src/pages/workflows/WorkflowsPage.tsx @@ -82,9 +82,11 @@ export const WorkflowsPage: React.FC = () => { } }; - // Handle continue workflow - navigate to playground + // Handle continue workflow - navigate to playground within same feature instance + // Uses relative navigation since WorkflowsPage is rendered under same instance route as playground const handleContinueWorkflow = (workflow: Workflow) => { - navigate(`/workflows/playground?workflowId=${workflow.id}`); + // Navigate relatively to playground (sibling route under same instance) + navigate(`../playground?workflowId=${workflow.id}`); }; // Handle edit submit From 3d75880d13b7292b81ac6c4b5c1e2533a76fafff Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 8 Feb 2026 01:44:48 +0100 Subject: [PATCH 06/14] fixed stats and billing sync --- src/api/workflowApi.ts | 4 +- .../Messages/ChatMessages/ChatMessage.tsx | 5 --- src/hooks/playground/useDashboardInputForm.ts | 5 ++- src/hooks/playground/useWorkflowLifecycle.ts | 38 +++++++++++++++---- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 2d5110f..a6191b4 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -47,6 +47,7 @@ export interface StartWorkflowRequest { listFileId?: string[]; // Array of file ID strings (files must be uploaded first via /api/files/upload) userLanguage?: string; // Optional, defaults to "en" metadata?: Record; + allowedProviders?: string[]; // Optional: Restrict AI calls to these providers (empty = all RBAC-permitted) } export interface StartWorkflowResponse extends Workflow { @@ -343,7 +344,8 @@ export async function startWorkflowApi( prompt: workflowData.prompt, ...(workflowData.listFileId && workflowData.listFileId.length > 0 && { listFileId: workflowData.listFileId }), ...(workflowData.userLanguage && { userLanguage: workflowData.userLanguage }), - ...(workflowData.metadata && { metadata: workflowData.metadata }) + ...(workflowData.metadata && { metadata: workflowData.metadata }), + ...(workflowData.allowedProviders && workflowData.allowedProviders.length > 0 && { allowedProviders: workflowData.allowedProviders }) }; const requestConfig = { diff --git a/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx b/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx index aff1272..f62aef4 100644 --- a/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx +++ b/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx @@ -45,11 +45,6 @@ export const ChatMessage: React.FC = ({ const messageClass = isUser ? styles.messageUser : styles.messageAssistant; const errorClass = isError ? styles.messageError : ''; - // Debug: Log documents if in dev mode - if (import.meta.env.DEV && message.documents) { - console.log('ChatMessage documents:', message.id, message.documents); - } - return (
diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index b41c1ea..ec0267d 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -596,9 +596,12 @@ export function useDashboardInputForm(instanceId: string) { prompt: trimmedInput, listFileId: fileIdsToSend.length > 0 ? fileIdsToSend : undefined, userLanguage: 'en', - preferredProviders: selectedProviders.length > 0 ? selectedProviders : undefined // AI provider selection (multiselect) + allowedProviders: selectedProviders.length > 0 ? selectedProviders : undefined // AI provider filter (multiselect) }; + // Debug: Log provider selection + console.log('🤖 Provider selection:', { selectedProviders, sentProviders: requestBody.allowedProviders }); + const result = await startWorkflow(requestBody, workflowOptions); if (result.success) { diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index e553a68..3fb301e 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -87,6 +87,9 @@ export function useWorkflowLifecycle(instanceId: string) { const hasRenderedLastMessageRef = useRef(false); const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState(false); + // Flag to prevent useEffect from stopping polling during active workflow start + const isStartingWorkflowRef = useRef(false); + // === HOOKS === const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations(); const { request } = useApiRequest(); @@ -372,19 +375,28 @@ export function useWorkflowLifecycle(instanceId: string) { return; } + // Skip if we're actively starting a workflow - handleStartWorkflow manages polling + if (isStartingWorkflowRef.current) { + console.log('📍 Polling decision: Skipping - workflow start in progress'); + return; + } + // === STATE MACHINE: Determine if polling should be active === + // Use ref for immediate value (state may be stale) + const hasLastMessage = hasRenderedLastMessageRef.current; + const shouldPoll = workflowStatus === 'running' || - (workflowStatus === 'completed' && !hasRenderedLastMessage); + (workflowStatus === 'completed' && !hasLastMessage); const shouldStopImmediately = workflowStatus === 'stopped' || workflowStatus === 'failed' || - hasRenderedLastMessage; + hasLastMessage; console.log('📍 Polling decision:', { workflowStatus, - hasRenderedLastMessage, + hasRenderedLastMessage: hasLastMessage, shouldPoll, shouldStopImmediately }); @@ -406,6 +418,9 @@ export function useWorkflowLifecycle(instanceId: string) { options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' } ) => { try { + // Set flag to prevent useEffect from interfering during start + isStartingWorkflowRef.current = true; + const result = await startWorkflow(instanceId, workflowData, options); if (result.success && result.data) { @@ -414,25 +429,32 @@ export function useWorkflowLifecycle(instanceId: string) { // === STATE MACHINE: New round starts === console.log('🚀 Starting workflow:', workflow.id); - // Reset state for new round - setWorkflowId(workflow.id); + // Reset state for new round - MUST update refs BEFORE state hasRenderedLastMessageRef.current = false; - setHasRenderedLastMessage(false); // Set afterTimestamp to NOW - only poll for new data lastRenderedTimestampRef.current = Date.now(); - // Start polling immediately + // Start polling immediately (before state updates trigger useEffect) pollingControllerRef.current.startPolling(workflow.id, pollWorkflowData); - // Update status + // Now update state (will trigger re-renders) + setWorkflowId(workflow.id); + setHasRenderedLastMessage(false); updateWorkflowStatus(workflow.status || 'running'); + // Clear the starting flag after a short delay to allow React to settle + setTimeout(() => { + isStartingWorkflowRef.current = false; + }, 100); + return { success: true, data: result.data }; } else { + isStartingWorkflowRef.current = false; return { success: false, error: result.error || 'Failed to start workflow' }; } } catch (error: any) { + isStartingWorkflowRef.current = false; return { success: false, error: error.message || 'Failed to start workflow' }; } }, [instanceId, startWorkflow, updateWorkflowStatus, pollWorkflowData]); From fcb85001047fbea24fc4edf6542f1fe48342e370 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 8 Feb 2026 13:15:23 +0100 Subject: [PATCH 07/14] fixed billing transactions mapping and added reporting --- package-lock.json | 346 ++++++++ package.json | 1 + .../FormGeneratorReport.module.css | 406 +++++++++ .../FormGeneratorReport.tsx | 773 ++++++++++++++++++ .../FormGeneratorReportTypes.ts | 255 ++++++ .../FormGeneratorReport/index.ts | 24 + src/components/FormGenerator/index.ts | 1 + src/pages/billing/Billing.module.css | 135 +++ src/pages/billing/BillingDataView.tsx | 561 +++++++------ 9 files changed, 2238 insertions(+), 264 deletions(-) create mode 100644 src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css create mode 100644 src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx create mode 100644 src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts create mode 100644 src/components/FormGenerator/FormGeneratorReport/index.ts diff --git a/package-lock.json b/package-lock.json index 34db8a7..7792924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "react-leaflet": "^5.0.0", "react-markdown": "^9.1.0", "react-router-dom": "^7.7.1", + "recharts": "^3.7.0", "remark-gfm": "^4.0.1", "xstate": "^5.20.1" }, @@ -1091,6 +1092,40 @@ "react-dom": "^19.0.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1392,6 +1427,16 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1437,6 +1482,60 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1549,6 +1648,11 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.38.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", @@ -2274,6 +2378,14 @@ "node": ">= 10.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2451,6 +2563,116 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2468,6 +2690,11 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -2746,6 +2973,11 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -3018,6 +3250,11 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -3682,6 +3919,15 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3721,6 +3967,14 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5813,6 +6067,28 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5870,6 +6146,45 @@ "node": ">=18" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", @@ -5946,6 +6261,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6386,6 +6706,11 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6734,6 +7059,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.19", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", diff --git a/package.json b/package.json index e2f4809..ca11c58 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "react-leaflet": "^5.0.0", "react-markdown": "^9.1.0", "react-router-dom": "^7.7.1", + "recharts": "^3.7.0", "remark-gfm": "^4.0.1", "xstate": "^5.20.1" }, diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css new file mode 100644 index 0000000..b1d0f13 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css @@ -0,0 +1,406 @@ +/* ============================================================================= + FormGeneratorReport - Generic Reporting Component + ============================================================================= */ + +/* --- Container --- */ + +.reportContainer { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; +} + +.reportHeader { + margin-bottom: 0.5rem; +} + +.reportTitle { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin: 0 0 0.25rem 0; +} + +.reportSubtitle { + font-size: 0.875rem; + color: var(--text-secondary, #888); + margin: 0; +} + +/* --- Toolbar (Filters + Period) --- */ + +.toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.75rem 1rem; + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 10px; +} + +.toolbarGroup { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.toolbarLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.toolbarSeparator { + width: 1px; + height: 24px; + background: var(--border-color, #333); + margin: 0 0.25rem; +} + +.select { + padding: 0.375rem 0.625rem; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--bg-secondary, #2a2a2a); + color: var(--text-primary, #e0e0e0); + font-size: 0.8125rem; + cursor: pointer; + min-width: 80px; +} + +.select:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +.dateInput { + padding: 0.375rem 0.625rem; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--bg-secondary, #2a2a2a); + color: var(--text-primary, #e0e0e0); + font-size: 0.8125rem; + cursor: pointer; +} + +.dateInput:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +.textInput { + padding: 0.375rem 0.625rem; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--bg-secondary, #2a2a2a); + color: var(--text-primary, #e0e0e0); + font-size: 0.8125rem; + min-width: 120px; +} + +.textInput:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +/* --- Sections Grid --- */ + +.sectionsGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.sectionFull { + grid-column: 1 / -1; +} + +.sectionHalf { + grid-column: span 1; +} + +/* --- Section Card --- */ + +.sectionCard { + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 1.25rem; + display: flex; + flex-direction: column; +} + +.sectionTitle { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 0.75rem 0; +} + +.sectionDescription { + font-size: 0.8125rem; + color: var(--text-tertiary, #666); + margin: -0.5rem 0 0.75rem 0; +} + +/* --- KPI Grid --- */ + +.kpiGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; +} + +.kpiCard { + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.kpiLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.kpiValue { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary, #e0e0e0); +} + +.kpiSubtitle { + font-size: 0.75rem; + color: var(--text-tertiary, #666); +} + +/* --- Charts (recharts wrappers) --- */ + +.chartWrapper { + width: 100%; + height: 280px; + min-height: 280px; + min-width: 0; +} + +.chartWrapperSmall { + width: 100%; + height: 220px; + min-height: 220px; + min-width: 0; +} + +/* --- Horizontal Bar Chart --- */ + +.horizontalBarList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.horizontalBarRow { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.horizontalBarLabel { + width: 120px; + font-size: 0.8125rem; + color: var(--text-primary, #e0e0e0); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex-shrink: 0; +} + +.horizontalBarTrack { + flex: 1; + height: 22px; + background: var(--bg-secondary, #2a2a2a); + border-radius: 4px; + overflow: hidden; +} + +.horizontalBarFill { + height: 100%; + background: var(--primary-color, #f25843); + border-radius: 4px; + transition: width 0.3s ease; + min-width: 4px; +} + +.horizontalBarValue { + width: 90px; + text-align: right; + font-size: 0.8125rem; + color: var(--text-secondary, #888); + font-family: monospace; + flex-shrink: 0; +} + +/* --- Table --- */ + +.reportTable { + width: 100%; + border-collapse: collapse; +} + +.reportTable th { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0.625rem 0.75rem; + text-align: left; + border-bottom: 2px solid var(--border-color, #333); +} + +.reportTable td { + font-size: 0.8125rem; + color: var(--text-primary, #e0e0e0); + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-color, #333); +} + +.reportTable tr:last-child td { + border-bottom: none; +} + +.reportTable tr:hover td { + background: var(--bg-secondary, #2a2a2a); +} + +.alignRight { + text-align: right; +} + +.alignCenter { + text-align: center; +} + +.monoValue { + font-family: monospace; +} + +.showMoreRow { + text-align: center; + padding: 0.5rem; +} + +.showMoreButton { + background: none; + border: none; + color: var(--primary-color, #f25843); + font-size: 0.8125rem; + cursor: pointer; + padding: 0.25rem 0.5rem; +} + +.showMoreButton:hover { + text-decoration: underline; +} + +/* --- Loading / No Data --- */ + +.loadingContainer { + display: flex; + align-items: center; + justify-content: center; + padding: 3rem; + color: var(--text-secondary, #888); + font-size: 0.875rem; +} + +.noData { + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + color: var(--text-tertiary, #666); + font-size: 0.8125rem; + font-style: italic; +} + +/* --- Recharts Custom Tooltip --- */ + +.customTooltip { + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + padding: 0.75rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.tooltipLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin-bottom: 0.25rem; +} + +.tooltipValue { + font-size: 0.8125rem; + color: var(--text-secondary, #888); +} + +.tooltipValue span { + color: var(--text-primary, #e0e0e0); + font-weight: 600; +} + +/* --- Responsive --- */ + +@media (max-width: 768px) { + .sectionsGrid { + grid-template-columns: 1fr; + } + + .sectionHalf { + grid-column: span 1; + } + + .toolbar { + flex-direction: column; + align-items: flex-start; + } + + .toolbarSeparator { + width: 100%; + height: 1px; + margin: 0.25rem 0; + } + + .kpiGrid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } + + .horizontalBarLabel { + width: 80px; + font-size: 0.75rem; + } + + .horizontalBarValue { + width: 70px; + font-size: 0.75rem; + } + + .chartWrapper { + height: 220px; + } +} diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx new file mode 100644 index 0000000..2082edd --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx @@ -0,0 +1,773 @@ +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + LineChart, Line, AreaChart, Area, + PieChart, Pie, Cell, Legend +} from 'recharts'; +import styles from './FormGeneratorReport.module.css'; +import type { + FormGeneratorReportProps, + ReportSection, + ReportSectionKpi, + ReportSectionBarChart, + ReportSectionHorizontalBar, + ReportSectionLineChart, + ReportSectionPieChart, + ReportSectionTable, + ReportSectionAreaChart, + ReportFilterState, + ReportPeriod, + ReportFilterConfig, + ReportTableColumn +} from './FormGeneratorReportTypes'; + +// ============================================================================= +// CHART COLORS +// ============================================================================= + +const CHART_COLORS = [ + 'var(--primary-color, #f25843)', + '#4e79a7', + '#59a14f', + '#f28e2b', + '#b07aa1', + '#76b7b2', + '#e15759', + '#edc948', + '#9c755f', + '#bab0ac' +]; + +const MONTH_LABELS: Record = { + '01': 'Jan', '02': 'Feb', '03': 'Mär', '04': 'Apr', + '05': 'Mai', '06': 'Jun', '07': 'Jul', '08': 'Aug', + '09': 'Sep', '10': 'Okt', '11': 'Nov', '12': 'Dez' +}; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +function _defaultFormatCurrency(value: number, currencyCode: string): string { + return `${currencyCode} ${value.toFixed(2)}`; +} + +function _formatDateLabel(dateStr: string): string { + const parts = dateStr.split('-'); + if (parts.length === 3) { + return `${parseInt(parts[2], 10)}.`; + } + if (parts.length === 2) { + return MONTH_LABELS[parts[1]] || parts[1]; + } + return dateStr; +} + +// ============================================================================= +// CUSTOM TOOLTIP +// ============================================================================= + +interface CustomTooltipProps { + active?: boolean; + payload?: any[]; + label?: string; + formatValue?: (value: number) => string; +} + +const _CustomTooltip: React.FC = ({ active, payload, label, formatValue }) => { + if (!active || !payload?.length) return null; + + const displayLabel = label ? _formatDateLabel(String(label)) : ''; + + return ( +
+ {displayLabel &&
{displayLabel}
} + {payload.map((entry: any, i: number) => ( +
+ {entry.name}: {formatValue ? formatValue(entry.value) : entry.value} +
+ ))} +
+ ); +}; + +// ============================================================================= +// SECTION RENDERERS +// ============================================================================= + +// --- KPI Grid --- + +const _renderKpiGrid = (section: ReportSectionKpi): React.ReactNode => { + return ( +
+ {section.items.map((item, i) => ( +
+ {item.label} + {item.value} + {item.subtitle && {item.subtitle}} +
+ ))} +
+ ); +}; + +// --- Bar Chart (vertical) --- + +const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const chartData = section.data.map(d => ({ + name: _formatDateLabel(d.key), + value: d.value, + rawKey: d.key + })); + + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + + return ( +
+ + + + + formatter(v)} + width={70} + /> + } /> + + + +
+ ); +}; + +// --- Horizontal Bar Chart --- + +const _renderHorizontalBar = (section: ReportSectionHorizontalBar, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const maxValue = Math.max(...section.data.map(d => d.value), 0.01); + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + + return ( +
+ {section.data.map((item, i) => ( +
+ {item.key} +
+
+
+ {formatter(item.value)} +
+ ))} +
+ ); +}; + +// --- Line Chart --- + +const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + + return ( +
+ + + + + + } /> + {section.series.map((s, i) => ( + + ))} + {section.series.length > 1 && } + + +
+ ); +}; + +// --- Area Chart --- + +const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + + return ( +
+ + + + + + } /> + {section.series.map((s, i) => ( + + ))} + {section.series.length > 1 && } + + +
+ ); +}; + +// --- Pie Chart --- + +const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + const total = section.data.reduce((sum, d) => sum + d.value, 0); + + const chartData = section.data.map((d, i) => ({ + name: d.key, + value: d.value, + color: d.color || CHART_COLORS[i % CHART_COLORS.length] + })); + + const _renderLabel = ({ name, percent }: any) => { + if (percent < 0.05) return null; + return `${name} (${(percent * 100).toFixed(0)}%)`; + }; + + return ( +
+ + + + {chartData.map((entry, i) => ( + + ))} + + [formatter(value), name]} + /> + { + const item = chartData.find(d => d.name === value); + return item ? `${value} (${((item.value / total) * 100).toFixed(1)}%)` : value; + }} + /> + + +
+ ); +}; + +// --- Table (proper component because it uses useState) --- + +interface ReportTableSectionProps { + section: ReportSectionTable; + currencyCode: string; +} + +const _ReportTableSection: React.FC = ({ section, currencyCode }) => { + const [showAll, setShowAll] = useState(false); + + if (!section.rows?.length) { + return
Keine Daten
; + } + + const maxRows = section.maxRows || 0; + const displayRows = maxRows > 0 && !showAll + ? section.rows.slice(0, maxRows) + : section.rows; + const hasMore = maxRows > 0 && section.rows.length > maxRows; + + const _formatCellValue = (col: ReportTableColumn, value: any, row: Record): string => { + if (col.formatValue) return col.formatValue(value, row); + if (value == null) return '—'; + + switch (col.format) { + case 'currency': + return _defaultFormatCurrency(Number(value), currencyCode); + case 'number': + return Number(value).toLocaleString('de-CH'); + case 'percent': + return `${(Number(value) * 100).toFixed(1)}%`; + case 'date': + return new Date(value).toLocaleDateString('de-CH'); + default: + return String(value); + } + }; + + return ( + <> + + + + {section.columns.map(col => ( + + ))} + + + + {displayRows.map((row, rowIdx) => ( + + {section.columns.map(col => ( + + ))} + + ))} + +
+ {col.label} +
+ {_formatCellValue(col, row[col.key], row)} +
+ {hasMore && !showAll && ( +
+ +
+ )} + + ); +}; + +// ============================================================================= +// SECTION WRAPPER +// ============================================================================= + +interface SectionWrapperProps { + section: ReportSection; + currencyCode: string; +} + +const _SectionWrapper: React.FC = ({ section, currencyCode }) => { + const spanClass = section.type === 'kpiGrid' || section.span === 'full' + ? styles.sectionFull + : section.span === 'half' + ? styles.sectionHalf + : styles.sectionFull; + + // KPI grid renders without card wrapper + if (section.type === 'kpiGrid') { + return ( +
+ {section.title &&

{section.title}

} + {_renderKpiGrid(section)} +
+ ); + } + + const _renderContent = (): React.ReactNode => { + switch (section.type) { + case 'barChart': + return _renderBarChart(section, currencyCode); + case 'horizontalBar': + return _renderHorizontalBar(section, currencyCode); + case 'lineChart': + return _renderLineChart(section, currencyCode); + case 'areaChart': + return _renderAreaChart(section, currencyCode); + case 'pieChart': + return _renderPieChart(section, currencyCode); + case 'table': + return <_ReportTableSection section={section} currencyCode={currencyCode} />; + default: + return
Unbekannter Sektionstyp
; + } + }; + + return ( +
+ {section.title &&

{section.title}

} + {section.description &&

{section.description}

} + {_renderContent()} +
+ ); +}; + +// ============================================================================= +// TOOLBAR (FILTERS + PERIOD SELECTOR) +// ============================================================================= + +interface ToolbarProps { + periodSelector?: FormGeneratorReportProps['periodSelector']; + dateRangeSelector?: FormGeneratorReportProps['dateRangeSelector']; + filters?: ReportFilterConfig[]; + filterState: ReportFilterState; + onFilterStateChange: (state: ReportFilterState) => void; +} + +const _Toolbar: React.FC = ({ + periodSelector, dateRangeSelector, filters, filterState, onFilterStateChange +}) => { + const hasPeriod = !!periodSelector; + const hasDateRange = dateRangeSelector?.enabled; + const hasFilters = filters && filters.length > 0; + + if (!hasPeriod && !hasDateRange && !hasFilters) return null; + + const _handlePeriodChange = (period: ReportPeriod) => { + onFilterStateChange({ ...filterState, period }); + }; + + const _handleYearChange = (year: number) => { + onFilterStateChange({ ...filterState, year }); + }; + + const _handleMonthChange = (month: number) => { + onFilterStateChange({ ...filterState, month }); + }; + + const _handleFilterChange = (key: string, value: string | string[]) => { + onFilterStateChange({ + ...filterState, + filters: { ...filterState.filters, [key]: value } + }); + }; + + const _handleDateRangeChange = (field: 'from' | 'to', dateStr: string) => { + const dateRange = filterState.dateRange || { from: new Date(), to: new Date() }; + onFilterStateChange({ + ...filterState, + dateRange: { ...dateRange, [field]: new Date(dateStr) } + }); + }; + + const currentYear = new Date().getFullYear(); + const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i); + + const monthOptions = [ + { value: 1, label: 'Januar' }, { value: 2, label: 'Februar' }, + { value: 3, label: 'März' }, { value: 4, label: 'April' }, + { value: 5, label: 'Mai' }, { value: 6, label: 'Juni' }, + { value: 7, label: 'Juli' }, { value: 8, label: 'August' }, + { value: 9, label: 'September' }, { value: 10, label: 'Oktober' }, + { value: 11, label: 'November' }, { value: 12, label: 'Dezember' } + ]; + + const _renderPeriodLabel = (p: ReportPeriod): string => { + const labels: Record = { + day: 'Tagesansicht', + week: 'Wochenansicht', + month: 'Monatsansicht', + quarter: 'Quartalsansicht', + year: 'Jahresansicht' + }; + return labels[p] || p; + }; + + return ( +
+ {/* Period Selector */} + {hasPeriod && ( +
+ Zeitraum + + + {periodSelector!.showYear !== false && ( + + )} + + {periodSelector!.showMonth !== false && filterState.period === 'day' && ( + + )} +
+ )} + + {/* Separator */} + {hasPeriod && (hasDateRange || hasFilters) && ( +
+ )} + + {/* Date Range */} + {hasDateRange && ( +
+ Von + _handleDateRangeChange('from', e.target.value)} + /> + Bis + _handleDateRangeChange('to', e.target.value)} + /> +
+ )} + + {/* Separator */} + {hasDateRange && hasFilters && ( +
+ )} + + {/* Custom Filters */} + {hasFilters && filters!.map(filter => ( +
+ {filter.label} + {filter.type === 'text' ? ( + _handleFilterChange(filter.key, e.target.value)} + /> + ) : ( + + )} +
+ ))} +
+ ); +}; + +// ============================================================================= +// MAIN COMPONENT +// ============================================================================= + +export const FormGeneratorReport: React.FC = ({ + title, + subtitle, + sections, + loading = false, + noDataMessage = 'Keine Daten verfügbar', + periodSelector, + dateRangeSelector, + filters, + onFilterChange, + currencyCode = 'CHF', + className +}) => { + // Build initial filter state + const initialFilterState = useMemo((): ReportFilterState => { + const state: ReportFilterState = { filters: {} }; + + if (periodSelector) { + state.period = periodSelector.defaultPeriod; + state.year = periodSelector.defaultYear || new Date().getFullYear(); + state.month = periodSelector.defaultMonth || new Date().getMonth() + 1; + } + + if (dateRangeSelector?.enabled) { + state.dateRange = { + from: dateRangeSelector.defaultFrom || new Date(new Date().getFullYear(), 0, 1), + to: dateRangeSelector.defaultTo || new Date() + }; + } + + if (filters) { + for (const f of filters) { + if (f.defaultValue !== undefined) { + state.filters[f.key] = f.defaultValue; + } + } + } + + return state; + }, []); // intentionally empty - only compute once + + const [filterState, setFilterState] = useState(initialFilterState); + + // Notify parent when filters change + const _handleFilterStateChange = useCallback((newState: ReportFilterState) => { + setFilterState(newState); + onFilterChange?.(newState); + }, [onFilterChange]); + + // Initial load: notify parent of default filter state + useEffect(() => { + onFilterChange?.(initialFilterState); + }, []); // intentionally once on mount + + // Loading state + if (loading) { + return ( +
+ {title && ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ )} + <_Toolbar + periodSelector={periodSelector} + dateRangeSelector={dateRangeSelector} + filters={filters} + filterState={filterState} + onFilterStateChange={_handleFilterStateChange} + /> +
Lade Daten...
+
+ ); + } + + // No sections + if (!sections || sections.length === 0) { + return ( +
+ {title && ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ )} + <_Toolbar + periodSelector={periodSelector} + dateRangeSelector={dateRangeSelector} + filters={filters} + filterState={filterState} + onFilterStateChange={_handleFilterStateChange} + /> +
{noDataMessage}
+
+ ); + } + + return ( +
+ {title && ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ )} + + <_Toolbar + periodSelector={periodSelector} + dateRangeSelector={dateRangeSelector} + filters={filters} + filterState={filterState} + onFilterStateChange={_handleFilterStateChange} + /> + +
+ {sections.map((section, i) => ( + <_SectionWrapper key={i} section={section} currencyCode={currencyCode} /> + ))} +
+
+ ); +}; + +export default FormGeneratorReport; diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts new file mode 100644 index 0000000..3340569 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts @@ -0,0 +1,255 @@ +// ============================================================================= +// FormGeneratorReport - Types +// Generic reporting component with charts, KPIs, tables, and filters +// ============================================================================= + +// ============================================================================= +// FILTER TYPES +// ============================================================================= + +/** Period granularity for time-based reports */ +export type ReportPeriod = 'day' | 'week' | 'month' | 'quarter' | 'year'; + +/** Date range with from/to */ +export interface ReportDateRange { + from: Date; + to: Date; +} + +/** Filter option for select/multiselect filters */ +export interface ReportFilterOption { + value: string; + label: string; +} + +/** A single filter definition */ +export interface ReportFilterConfig { + /** Unique key for this filter */ + key: string; + /** Display label */ + label: string; + /** Filter type */ + type: 'select' | 'multiselect' | 'text'; + /** Available options (for select/multiselect) */ + options?: ReportFilterOption[]; + /** Default value */ + defaultValue?: string | string[]; + /** Placeholder text */ + placeholder?: string; +} + +/** Period selector configuration */ +export interface ReportPeriodSelectorConfig { + /** Available periods */ + periods: ReportPeriod[]; + /** Default period */ + defaultPeriod: ReportPeriod; + /** Whether to show year selector */ + showYear?: boolean; + /** Whether to show month selector (when period is 'day') */ + showMonth?: boolean; + /** Default year */ + defaultYear?: number; + /** Default month (1-12) */ + defaultMonth?: number; +} + +/** Date range selector configuration */ +export interface ReportDateRangeSelectorConfig { + /** Whether the date range selector is enabled */ + enabled: boolean; + /** Default from date */ + defaultFrom?: Date; + /** Default to date */ + defaultTo?: Date; +} + +/** Combined filter state passed to the data callback */ +export interface ReportFilterState { + /** Selected period */ + period?: ReportPeriod; + /** Selected year */ + year?: number; + /** Selected month (1-12) */ + month?: number; + /** Date range */ + dateRange?: ReportDateRange; + /** Custom filter values: key -> value(s) */ + filters: Record; +} + +// ============================================================================= +// SECTION TYPES +// ============================================================================= + +/** KPI item for kpiGrid section */ +export interface ReportKpiItem { + label: string; + value: string | number; + subtitle?: string; + /** Optional color (CSS variable or hex) */ + color?: string; +} + +/** Column definition for table sections */ +export interface ReportTableColumn { + key: string; + label: string; + /** How to format the value */ + format?: 'text' | 'number' | 'currency' | 'percent' | 'date'; + /** Text alignment */ + align?: 'left' | 'center' | 'right'; + /** Custom formatter function */ + formatValue?: (value: any, row: Record) => string; +} + +/** Data point for chart sections */ +export interface ReportChartDataPoint { + /** Key/label for the data point (x-axis or category) */ + key: string; + /** Numeric value */ + value: number; + /** Optional secondary value */ + value2?: number; + /** Optional color override */ + color?: string; +} + +/** Time series data point */ +export interface ReportTimeSeriesPoint { + /** Date string (ISO format: "2026-02-08" or "2026-02") */ + date: string; + /** Numeric values, keyed by series name */ + [seriesKey: string]: string | number; +} + +/** Series definition for multi-series charts */ +export interface ReportChartSeries { + key: string; + label: string; + color?: string; +} + +// ============================================================================= +// SECTION DEFINITIONS +// ============================================================================= + +interface ReportSectionBase { + /** Optional section title */ + title?: string; + /** Optional description text */ + description?: string; + /** Grid span: 'full' takes full width, 'half' takes 50% */ + span?: 'full' | 'half'; +} + +/** KPI grid: display metric cards */ +export interface ReportSectionKpi extends ReportSectionBase { + type: 'kpiGrid'; + items: ReportKpiItem[]; +} + +/** Vertical bar chart */ +export interface ReportSectionBarChart extends ReportSectionBase { + type: 'barChart'; + data: ReportChartDataPoint[]; + /** Value format for tooltips/labels */ + formatValue?: (value: number) => string; + /** Bar color */ + color?: string; +} + +/** Horizontal bar chart (for comparisons/rankings) */ +export interface ReportSectionHorizontalBar extends ReportSectionBase { + type: 'horizontalBar'; + data: ReportChartDataPoint[]; + /** Value format for tooltips/labels */ + formatValue?: (value: number) => string; +} + +/** Line chart (trends over time) */ +export interface ReportSectionLineChart extends ReportSectionBase { + type: 'lineChart'; + data: ReportTimeSeriesPoint[]; + series: ReportChartSeries[]; + /** Value format for tooltips/labels */ + formatValue?: (value: number) => string; +} + +/** Pie/donut chart (distribution) */ +export interface ReportSectionPieChart extends ReportSectionBase { + type: 'pieChart'; + data: ReportChartDataPoint[]; + /** Show as donut (hollow center) */ + donut?: boolean; + /** Value format for tooltips/labels */ + formatValue?: (value: number) => string; +} + +/** Simple data table */ +export interface ReportSectionTable extends ReportSectionBase { + type: 'table'; + columns: ReportTableColumn[]; + rows: Record[]; + /** Maximum rows to display (default: all) */ + maxRows?: number; +} + +/** Area chart (filled line chart) */ +export interface ReportSectionAreaChart extends ReportSectionBase { + type: 'areaChart'; + data: ReportTimeSeriesPoint[]; + series: ReportChartSeries[]; + /** Value format for tooltips/labels */ + formatValue?: (value: number) => string; +} + +/** Union of all section types */ +export type ReportSection = + | ReportSectionKpi + | ReportSectionBarChart + | ReportSectionHorizontalBar + | ReportSectionLineChart + | ReportSectionPieChart + | ReportSectionTable + | ReportSectionAreaChart; + +// ============================================================================= +// MAIN COMPONENT PROPS +// ============================================================================= + +export interface FormGeneratorReportProps { + /** Report title (optional) */ + title?: string; + /** Report subtitle/description (optional) */ + subtitle?: string; + + /** Report sections to render */ + sections: ReportSection[]; + + /** Loading state */ + loading?: boolean; + + /** No data message */ + noDataMessage?: string; + + // --- Filter Configuration --- + + /** Period selector config */ + periodSelector?: ReportPeriodSelectorConfig; + + /** Date range selector config */ + dateRangeSelector?: ReportDateRangeSelectorConfig; + + /** Custom filter definitions */ + filters?: ReportFilterConfig[]; + + /** Called when any filter changes. Parent should reload data and update sections. */ + onFilterChange?: (filterState: ReportFilterState) => void; + + /** Currency code for formatting (default: 'CHF') */ + currencyCode?: string; + + /** Custom CSS class */ + className?: string; +} diff --git a/src/components/FormGenerator/FormGeneratorReport/index.ts b/src/components/FormGenerator/FormGeneratorReport/index.ts new file mode 100644 index 0000000..b4002a7 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorReport/index.ts @@ -0,0 +1,24 @@ +export { FormGeneratorReport, default } from './FormGeneratorReport'; +export type { + FormGeneratorReportProps, + ReportSection, + ReportSectionKpi, + ReportSectionBarChart, + ReportSectionHorizontalBar, + ReportSectionLineChart, + ReportSectionPieChart, + ReportSectionTable, + ReportSectionAreaChart, + ReportFilterState, + ReportFilterConfig, + ReportFilterOption, + ReportPeriod, + ReportPeriodSelectorConfig, + ReportDateRangeSelectorConfig, + ReportDateRange, + ReportKpiItem, + ReportTableColumn, + ReportChartDataPoint, + ReportTimeSeriesPoint, + ReportChartSeries +} from './FormGeneratorReportTypes'; diff --git a/src/components/FormGenerator/index.ts b/src/components/FormGenerator/index.ts index f7f6249..24ccc3f 100644 --- a/src/components/FormGenerator/index.ts +++ b/src/components/FormGenerator/index.ts @@ -3,6 +3,7 @@ export * from './FormGeneratorTable'; export * from './FormGeneratorList'; export * from './FormGeneratorForm'; export * from './FormGeneratorControls'; +export * from './FormGeneratorReport'; // Alias FormGeneratorTable as FormGenerator for backward compatibility export { FormGeneratorTable as FormGenerator, FormGeneratorTableComponent as FormGeneratorComponent } from './FormGeneratorTable'; diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css index f09b398..f5b50da 100644 --- a/src/pages/billing/Billing.module.css +++ b/src/pages/billing/Billing.module.css @@ -8,6 +8,8 @@ padding: 1.5rem; min-height: 100%; width: 100%; + display: flex; + flex-direction: column; } .pageHeader { @@ -123,6 +125,139 @@ font-weight: 500; } +/* ============================================================================ + KPI CARDS + ============================================================================ */ + +.kpiGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.kpiCard { + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.kpiLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.kpiValue { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary, #e0e0e0); +} + +.kpiSubtitle { + font-size: 0.75rem; + color: var(--text-tertiary, #666); +} + +/* ============================================================================ + CHARTS GRID + ============================================================================ */ + +.chartsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 1.5rem; +} + +/* ============================================================================ + TIME SERIES CHART + ============================================================================ */ + +.timeSeriesChart { + padding: 0.5rem 0; +} + +.timeSeriesBars { + display: flex; + align-items: flex-end; + gap: 4px; + height: 200px; + padding-bottom: 24px; + position: relative; +} + +.timeSeriesBarWrapper { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} + +.timeSeriesBarOuter { + flex: 1; + width: 100%; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.timeSeriesBar { + width: 80%; + max-width: 40px; + background: var(--primary-color, #f25843); + border-radius: 4px 4px 0 0; + min-height: 2px; + transition: height 0.3s ease; + cursor: pointer; +} + +.timeSeriesBar:hover { + opacity: 0.8; +} + +.timeSeriesLabel { + font-size: 0.6875rem; + color: var(--text-secondary, #888); + margin-top: 4px; + white-space: nowrap; +} + +/* ============================================================================ + SUMMARY TABLE + ============================================================================ */ + +.summaryTable { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.summaryRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.625rem 0.75rem; + background: var(--bg-secondary, #2a2a2a); + border-radius: 6px; + font-size: 0.875rem; +} + +.summaryRow span { + color: var(--text-secondary, #888); +} + +.summaryRow strong { + color: var(--text-primary, #e0e0e0); + font-family: monospace; +} + /* ============================================================================ STATISTICS ============================================================================ */ diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index a8ceecc..1f51613 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -2,17 +2,44 @@ * BillingDataView * * Unified billing page with internal tabs: - * - Tab "Übersicht": Balance cards + Statistics (from BillingDashboard) + * - Tab "Übersicht": Balance cards + Usage summary for the user + * - Tab "Statistik": Dashboard with time-series charts and breakdowns * - Tab "Transaktionen": Transaction table with FormGeneratorTable */ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport'; +import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport'; import api from '../../api'; -import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling'; +import { useBilling, type BillingBalance } from '../../hooks/useBilling'; import { UserTransaction } from '../../api/billingApi'; import styles from './Billing.module.css'; +// ============================================================================ +// HELPER: Currency formatter +// ============================================================================ + +const _formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); +}; + +// ============================================================================ +// TYPES +// ============================================================================ + +interface ViewStatistics { + totalCost: number; + transactionCount: number; + costByProvider: Record; + costByFeature: Record; + costByMandate: Record; + timeSeries: Array<{ date: string; cost: number; count: number }>; +} + // ============================================================================ // BALANCE CARD COMPONENT // ============================================================================ @@ -22,14 +49,7 @@ interface BalanceCardProps { } const BalanceCard: React.FC = ({ balance }) => { - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('de-CH', { - style: 'currency', - currency: 'CHF' - }).format(amount); - }; - - const getBillingModelLabel = (model: string) => { + const _getBillingModelLabel = (model: string) => { switch (model) { case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; case 'PREPAY_USER': return 'Prepaid (Benutzer)'; @@ -43,10 +63,10 @@ const BalanceCard: React.FC = ({ balance }) => {

{balance.mandateName}

- {getBillingModelLabel(balance.billingModel)} + {_getBillingModelLabel(balance.billingModel)}
- {formatCurrency(balance.balance)} + {_formatCurrency(balance.balance)}
{balance.isWarning && (
@@ -57,86 +77,11 @@ const BalanceCard: React.FC = ({ balance }) => { ); }; -// ============================================================================ -// STATISTICS CHART COMPONENT -// ============================================================================ - -interface StatisticsChartProps { - statistics: UsageReport | null; - loading?: boolean; -} - -const StatisticsChart: React.FC = ({ statistics, loading }) => { - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('de-CH', { - style: 'currency', - currency: 'CHF' - }).format(amount); - }; - - if (loading) { - return
Lade Statistiken...
; - } - - if (!statistics) { - return
Keine Statistiken verfügbar
; - } - - const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1); - - return ( -
-
- Gesamtkosten - {formatCurrency(statistics.totalCost)} -
- -
-

Kosten nach Anbieter

- {Object.entries(statistics.costByProvider).length === 0 ? ( -
Keine Daten
- ) : ( -
- {Object.entries(statistics.costByProvider).map(([provider, cost]) => ( -
- {provider} -
-
-
- {formatCurrency(cost)} -
- ))} -
- )} -
- -
-

Kosten nach Feature

- {Object.entries(statistics.costByFeature).length === 0 ? ( -
Keine Daten
- ) : ( -
- {Object.entries(statistics.costByFeature).map(([feature, cost]) => ( -
- {feature} - {formatCurrency(cost)} -
- ))} -
- )} -
-
- ); -}; - // ============================================================================ // TAB NAVIGATION COMPONENT // ============================================================================ -type TabType = 'overview' | 'transactions'; +type TabType = 'overview' | 'statistics' | 'transactions'; interface TabNavProps { activeTab: TabType; @@ -144,7 +89,7 @@ interface TabNavProps { } const TabNav: React.FC = ({ activeTab, onTabChange }) => { - const navLinkStyle = (isActive: boolean) => ({ + const _navLinkStyle = (isActive: boolean) => ({ padding: '8px 16px', textDecoration: 'none', borderRadius: '4px', @@ -164,22 +109,138 @@ const TabNav: React.FC = ({ activeTab, onTabChange }) => { borderBottom: '1px solid var(--color-border, #333)', paddingBottom: '8px' }}> - - + ); }; +// ============================================================================ +// HELPERS: Convert viewStats to ReportSection arrays +// ============================================================================ + +function _recordToChartData(record: Record): ReportChartDataPoint[] { + return Object.entries(record) + .sort((a, b) => b[1] - a[1]) + .map(([key, value]) => ({ key: key || '—', value })); +} + +function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] { + const topProvider = Object.entries(viewStats.costByProvider).sort((a, b) => b[1] - a[1])[0]; + const topFeature = Object.entries(viewStats.costByFeature).sort((a, b) => b[1] - a[1])[0]; + + return [ + { + type: 'kpiGrid', + items: [ + { + label: 'Gesamtkosten', + value: _formatCurrency(viewStats.totalCost), + subtitle: `${viewStats.transactionCount} Transaktionen` + }, + { + label: 'Anbieter', + value: Object.keys(viewStats.costByProvider).length, + subtitle: topProvider ? `Top: ${topProvider[0]}` : 'Keine Nutzung' + }, + { + label: 'Features', + value: Object.keys(viewStats.costByFeature).length, + subtitle: topFeature ? `Top: ${topFeature[0]}` : 'Keine Nutzung' + }, + { + label: 'Mandanten', + value: Object.keys(viewStats.costByMandate).length, + subtitle: 'aktiv genutzt' + } + ] + }, + { + type: 'horizontalBar', + title: 'Kosten nach Anbieter', + data: _recordToChartData(viewStats.costByProvider), + formatValue: _formatCurrency, + span: 'half' as const + }, + { + type: 'horizontalBar', + title: 'Kosten nach Feature', + data: _recordToChartData(viewStats.costByFeature), + formatValue: _formatCurrency, + span: 'half' as const + } + ]; +} + +function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { + // Convert timeSeries to barChart data + const timeSeriesData: ReportChartDataPoint[] = viewStats.timeSeries.map(ts => ({ + key: ts.date, + value: ts.cost + })); + + const avgCost = viewStats.transactionCount > 0 + ? viewStats.totalCost / viewStats.transactionCount + : 0; + + return [ + { + type: 'barChart', + title: 'Kostenentwicklung', + data: timeSeriesData, + formatValue: _formatCurrency, + span: 'full' as const + }, + { + type: 'pieChart', + title: 'Verteilung nach Anbieter', + data: _recordToChartData(viewStats.costByProvider), + formatValue: _formatCurrency, + donut: true, + span: 'half' as const + }, + { + type: 'pieChart', + title: 'Verteilung nach Feature', + data: _recordToChartData(viewStats.costByFeature), + formatValue: _formatCurrency, + donut: true, + span: 'half' as const + }, + { + type: 'horizontalBar', + title: 'Kosten nach Mandant', + data: _recordToChartData(viewStats.costByMandate), + formatValue: _formatCurrency, + span: 'half' as const + }, + { + type: 'table', + title: 'Zusammenfassung', + span: 'half' as const, + columns: [ + { key: 'metric', label: 'Kennzahl' }, + { key: 'value', label: 'Wert', align: 'right' as const } + ], + rows: [ + { metric: 'Gesamtkosten', value: _formatCurrency(viewStats.totalCost) }, + { metric: 'Transaktionen', value: String(viewStats.transactionCount) }, + { metric: 'Durchschnitt / Transaktion', value: _formatCurrency(avgCost) }, + { metric: 'Anbieter', value: String(Object.keys(viewStats.costByProvider).length) }, + { metric: 'Features', value: String(Object.keys(viewStats.costByFeature).length) }, + { metric: 'Mandanten', value: String(Object.keys(viewStats.costByMandate).length) } + ] + } + ]; +} + // ============================================================================ // MAIN COMPONENT // ============================================================================ @@ -190,37 +251,77 @@ export const BillingDataView: React.FC = () => { // Dashboard state (for Overview tab) const { balances, - statistics, loading: dashboardLoading, - loadStatistics } = useBilling(); - const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month'); - const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); - const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1); + + // Statistics state (shared by Overview and Statistics tabs) + const [viewStats, setViewStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); // Transactions state (for Transactions tab) const [transactions, setTransactions] = useState([]); const [transactionsLoading, setTransactionsLoading] = useState(false); const [transactionsError, setTransactionsError] = useState(null); + const [transactionsPagination, setTransactionsPagination] = useState(null); - // Load statistics when period changes - useEffect(() => { - if (selectedPeriod === 'month') { - loadStatistics('month', selectedYear); - } else { - loadStatistics('year', selectedYear); + // Load aggregated statistics from the view/statistics route + const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => { + try { + setStatsLoading(true); + const params: any = { period, year }; + if (period === 'day' && month) { + params.month = month; + } + const response = await api.get('/api/billing/view/statistics', { params }); + console.log('📊 View statistics response:', response.data); + setViewStats(response.data); + } catch (err: any) { + console.error('Failed to load statistics:', err); + setViewStats(null); + } finally { + setStatsLoading(false); } - }, [selectedPeriod, selectedYear, loadStatistics]); + }, []); - // Load transactions - const loadTransactions = useCallback(async () => { + // Handle filter changes from FormGeneratorReport (user changes period/year/month) + const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => { + const period = filterState.period || 'month'; + const year = filterState.year || new Date().getFullYear(); + const month = filterState.month; + _loadViewStatistics(period, year, month); + }, [_loadViewStatistics]); + + // Initial data load: load statistics when overview or statistics tab becomes active + useEffect(() => { + if (activeTab === 'overview' || activeTab === 'statistics') { + _loadViewStatistics('month', new Date().getFullYear()); + } + }, [activeTab, _loadViewStatistics]); + + // Load transactions with pagination support + const _loadTransactions = useCallback(async (paginationParams?: any) => { try { setTransactionsLoading(true); setTransactionsError(null); - const response = await api.get('/api/billing/view/users/transactions', { - params: { limit: 500 } - }); - setTransactions(response.data || []); + + const params: any = {}; + if (paginationParams) { + params.pagination = JSON.stringify(paginationParams); + } + + const response = await api.get('/api/billing/view/users/transactions', { params }); + const data = response.data; + + // Handle PaginatedResponse format: { items: [...], pagination: {...} } + if (data && typeof data === 'object' && 'items' in data) { + setTransactions(Array.isArray(data.items) ? data.items : []); + if (data.pagination) { + setTransactionsPagination(data.pagination); + } + } else { + // Backward compatibility: plain array response + setTransactions(Array.isArray(data) ? data : []); + } } catch (err: any) { console.error('Failed to load transactions:', err); setTransactionsError(err.response?.data?.detail || err.message || 'Fehler beim Laden der Transaktionen'); @@ -231,119 +332,53 @@ export const BillingDataView: React.FC = () => { // Load transactions when switching to transactions tab useEffect(() => { - if (activeTab === 'transactions' && transactions.length === 0) { - loadTransactions(); + if (activeTab === 'transactions') { + _loadTransactions(); } - }, [activeTab, transactions.length, loadTransactions]); + }, [activeTab, _loadTransactions]); - // Available years - const availableYears = useMemo(() => { - const current = new Date().getFullYear(); - return [current, current - 1, current - 2]; - }, []); - - // Available months - const availableMonths = [ - { value: 1, label: 'Januar' }, - { value: 2, label: 'Februar' }, - { value: 3, label: 'März' }, - { value: 4, label: 'April' }, - { value: 5, label: 'Mai' }, - { value: 6, label: 'Juni' }, - { value: 7, label: 'Juli' }, - { value: 8, label: 'August' }, - { value: 9, label: 'September' }, - { value: 10, label: 'Oktober' }, - { value: 11, label: 'November' }, - { value: 12, label: 'Dezember' }, - ]; - - // Transform transactions for table display - const tableData = useMemo(() => { - return transactions.map((t, index) => ({ - _uniqueId: `${t.id}-${t.mandateId}-${index}`, - id: t.id, - createdAt: t.createdAt, - mandateId: t.mandateId, - mandateName: t.mandateName || '-', - userId: t.userId, - userName: t.userName || '-', - transactionType: t.transactionType, - description: t.description || '-', - aicoreProvider: t.aicoreProvider || '-', - featureCode: t.featureCode || '-', - amount: t.transactionType === 'DEBIT' ? -t.amount : t.amount, - })); - }, [transactions]); + // hookData for FormGeneratorTable + const transactionsHookData = useMemo(() => ({ + refetch: _loadTransactions, + pagination: transactionsPagination ? { + totalPages: transactionsPagination.totalPages, + totalItems: transactionsPagination.totalItems, + } : undefined, + }), [_loadTransactions, transactionsPagination]); // Table column definitions const columns: ColumnConfig[] = useMemo(() => [ - { - key: 'createdAt', - label: 'Datum', - type: 'datetime', - sortable: true, - width: 160, - }, - { - key: 'mandateName', - label: 'Mandant', - type: 'text', - sortable: true, - filterable: true, - searchable: true, - width: 150, - }, - { - key: 'userName', - label: 'Benutzer', - type: 'text', - sortable: true, - filterable: true, - searchable: true, - width: 150, - }, - { - key: 'transactionType', - label: 'Typ', - type: 'text', - sortable: true, - filterable: true, - filterOptions: ['CREDIT', 'DEBIT', 'ADJUSTMENT'], - width: 100, - }, - { - key: 'description', - label: 'Beschreibung', - type: 'text', - searchable: true, - width: 250, - }, - { - key: 'aicoreProvider', - label: 'Anbieter', - type: 'text', - sortable: true, - filterable: true, - width: 120, - }, - { - key: 'featureCode', - label: 'Feature', - type: 'text', - sortable: true, - filterable: true, - width: 120, - }, - { - key: 'amount', - label: 'Betrag (CHF)', - type: 'number', - sortable: true, - width: 120, - }, + { key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 }, + { key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 }, + { key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 }, + { key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 }, + { key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 }, + { key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 }, + { key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 }, + { key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 }, ], []); + // Build report sections based on current data + const overviewSections = useMemo(() => { + if (!viewStats) return []; + return _buildOverviewSections(viewStats); + }, [viewStats]); + + const statisticsSections = useMemo(() => { + if (!viewStats) return []; + return _buildStatisticsSections(viewStats); + }, [viewStats]); + + // Period selector config (shared between overview and statistics) + const periodSelectorConfig = useMemo(() => ({ + periods: ['month' as const, 'day' as const], + defaultPeriod: 'month' as const, + showYear: true, + showMonth: true, + defaultYear: new Date().getFullYear(), + defaultMonth: new Date().getMonth() + 1 + }), []); + return (
@@ -353,12 +388,14 @@ export const BillingDataView: React.FC = () => { - {/* Overview Tab */} + {/* ================================================================ */} + {/* Tab: Übersicht (My Overview) */} + {/* ================================================================ */} {activeTab === 'overview' && ( <> {/* Balance Cards */}
-

Guthaben

+

Mein Guthaben

{dashboardLoading ? (
Lade Guthaben...
) : balances.length === 0 ? ( @@ -372,49 +409,44 @@ export const BillingDataView: React.FC = () => { )}
- {/* Statistics */} + {/* Usage Statistics via FormGeneratorReport */}
-
-

Nutzungsstatistik

-
- - - {selectedPeriod === 'month' && ( - - )} -
-
- +
)} - {/* Transactions Tab */} + {/* ================================================================ */} + {/* Tab: Statistik (Dashboard) */} + {/* ================================================================ */} + {activeTab === 'statistics' && ( +
+ +
+ )} + + {/* ================================================================ */} + {/* Tab: Transaktionen */} + {/* ================================================================ */} {activeTab === 'transactions' && ( - <> +
{transactionsError && (
{transactionsError} @@ -422,7 +454,7 @@ export const BillingDataView: React.FC = () => { )} { searchable={true} filterable={true} sortable={true} - idField="_uniqueId" + selectable={false} emptyMessage="Keine Transaktionen vorhanden" - onRefresh={loadTransactions} + onRefresh={_loadTransactions} + hookData={transactionsHookData} /> - +
)}
); From 2fdb09df01603185ad22a347704142b937f22730 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 8 Feb 2026 13:28:35 +0100 Subject: [PATCH 08/14] admin views fixes --- src/config/pageRegistry.tsx | 1 + src/pages/billing/BillingAdmin.tsx | 21 ++++++++++++++++++--- src/pages/billing/BillingDataView.tsx | 5 +---- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 35785cc..c71ba73 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -61,6 +61,7 @@ export const PAGE_ICONS: Record = { 'page.admin.user-mandates': , 'page.admin.feature-roles': , 'page.admin.feature-instances': , + 'page.admin.featureInstances': , 'page.admin.feature-users': , 'page.admin.user-access-overview': , 'page.admin.billing': , diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index 1cddd88..8efb3cf 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -7,7 +7,7 @@ * - Konten übersicht */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import { useAdminMandates } from '../../hooks/useMandates'; import styles from './Billing.module.css'; @@ -321,16 +321,30 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on interface AccountsOverviewProps { accounts: AccountSummary[]; + users: MandateUserSummary[]; loading: boolean; } -const AccountsOverview: React.FC = ({ accounts, loading }) => { +const AccountsOverview: React.FC = ({ accounts, users, loading }) => { const formatCurrency = (amount: number) => { return new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); }; + + // Build a lookup map: userId -> display name + const _userNameMap = useMemo(() => { + const map = new Map(); + for (const user of users) { + const displayName = user.displayName + || [user.firstName, user.lastName].filter(Boolean).join(' ') + || user.email + || user.id; + map.set(user.id, displayName); + } + return map; + }, [users]); if (loading) { return
Lade Konten...
; @@ -348,7 +362,7 @@ const AccountsOverview: React.FC = ({ accounts, loading }

{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}

- {account.userId && User: {account.userId}} + {account.userId && User: {_userNameMap.get(account.userId) || account.userId}} Guthaben: {formatCurrency(account.balance)} {account.creditLimit && Limit: {formatCurrency(account.creditLimit)}} Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'} @@ -414,6 +428,7 @@ export const BillingAdmin: React.FC = () => { diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 1f51613..f11360d 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -273,7 +273,6 @@ export const BillingDataView: React.FC = () => { params.month = month; } const response = await api.get('/api/billing/view/statistics', { params }); - console.log('📊 View statistics response:', response.data); setViewStats(response.data); } catch (err: any) { console.error('Failed to load statistics:', err); @@ -409,12 +408,10 @@ export const BillingDataView: React.FC = () => { )}
- {/* Usage Statistics via FormGeneratorReport */} + {/* Usage Statistics via FormGeneratorReport (no period selector - always full year) */}
Date: Sun, 8 Feb 2026 14:00:13 +0100 Subject: [PATCH 09/14] fixes --- src/pages/billing/Billing.module.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css index f5b50da..b0fa458 100644 --- a/src/pages/billing/Billing.module.css +++ b/src/pages/billing/Billing.module.css @@ -6,10 +6,11 @@ .billingDashboard { padding: 1.5rem; - min-height: 100%; + height: 100%; width: 100%; display: flex; flex-direction: column; + overflow-y: auto; } .pageHeader { From 8f29bdb270771fdb03bb19ff15ec09d354fe01dc Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 8 Feb 2026 14:26:06 +0100 Subject: [PATCH 10/14] fiixed feature instance role access --- .../admin/AdminMandateRolePermissionsPage.tsx | 221 +++++++++++++++++- 1 file changed, 220 insertions(+), 1 deletion(-) diff --git a/src/pages/admin/AdminMandateRolePermissionsPage.tsx b/src/pages/admin/AdminMandateRolePermissionsPage.tsx index ec94651..619e39c 100644 --- a/src/pages/admin/AdminMandateRolePermissionsPage.tsx +++ b/src/pages/admin/AdminMandateRolePermissionsPage.tsx @@ -10,12 +10,15 @@ * - Mandate-specific roles (mandateId=xyz) - editable permissions * * Each role can be expanded to show/edit its AccessRules via AccessRulesEditor. + * + * Includes a "Cleanup Duplicates" tool to find and remove duplicate AccessRules. */ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { useMandateRoles, type Role } from '../../hooks/useMandateRoles'; import { useUserMandates, type Mandate } from '../../hooks/useUserMandates'; import { AccessRulesEditor } from '../../components/AccessRules'; +import api from '../../api'; import { FaUserShield, FaShieldAlt, @@ -24,10 +27,35 @@ import { FaChevronRight, FaGlobe, FaBuilding, - FaFilter + FaFilter, + FaBroom, + FaTimes, + FaExclamationTriangle, + FaCheckCircle } from 'react-icons/fa'; import styles from './Admin.module.css'; +// Types for cleanup result +interface DuplicateGroup { + roleId: string; + context: string; + item: string; + totalCount: number; + keepId: string; + deleteCount: number; + deleteIds: string[]; +} + +interface CleanupResult { + dryRun: boolean; + totalRules: number; + uniqueSignatures: number; + duplicateGroups: number; + duplicateRulesToDelete: number; + deletedCount: number; + details: DuplicateGroup[]; +} + export const AdminMandateRolePermissionsPage: React.FC = () => { const { roles, @@ -44,6 +72,13 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all'); const [expandedRoleId, setExpandedRoleId] = useState(null); + // Cleanup state + const [showCleanupModal, setShowCleanupModal] = useState(false); + const [cleanupLoading, setCleanupLoading] = useState(false); + const [cleanupResult, setCleanupResult] = useState(null); + const [cleanupError, setCleanupError] = useState(null); + const [cleanupPhase, setCleanupPhase] = useState<'idle' | 'preview' | 'done'>('idle'); + // Load mandates on mount useEffect(() => { const loadMandates = async () => { @@ -105,6 +140,49 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { ); }; + // --- Cleanup functions --- + const _openCleanupModal = useCallback(async () => { + setShowCleanupModal(true); + setCleanupError(null); + setCleanupResult(null); + setCleanupPhase('idle'); + setCleanupLoading(true); + try { + const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=true'); + setCleanupResult(response.data); + setCleanupPhase('preview'); + } catch (err: any) { + setCleanupError(err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Duplikate'); + } finally { + setCleanupLoading(false); + } + }, []); + + const _executeCleanup = useCallback(async () => { + setCleanupLoading(true); + setCleanupError(null); + try { + const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=false'); + setCleanupResult(response.data); + setCleanupPhase('done'); + // Refresh roles after cleanup + if (selectedMandateId) { + fetchRoles(selectedMandateId, { scopeFilter }); + } + } catch (err: any) { + setCleanupError(err?.response?.data?.detail || err?.message || 'Fehler beim Bereinigen'); + } finally { + setCleanupLoading(false); + } + }, [selectedMandateId, scopeFilter, fetchRoles]); + + const _closeCleanupModal = useCallback(() => { + setShowCleanupModal(false); + setCleanupResult(null); + setCleanupError(null); + setCleanupPhase('idle'); + }, []); + // Filter options for scope const scopeOptions = useMemo(() => [ { value: 'all', label: 'Alle Rollen' }, @@ -140,6 +218,14 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {

+
)} + + {/* Cleanup Duplicates Modal */} + {showCleanupModal && ( +
+
e.stopPropagation()}> +
+

+ + Doppelte Regeln bereinigen +

+ +
+ +
+ {/* Loading */} + {cleanupLoading && ( +
+
+ {cleanupPhase === 'idle' ? 'Analysiere Duplikate...' : 'Bereinige Duplikate...'} +
+ )} + + {/* Error */} + {cleanupError && ( +
+ + {cleanupError} +
+ )} + + {/* Results */} + {cleanupResult && !cleanupLoading && ( + <> + {/* Summary Cards */} +
+
+
{cleanupResult.totalRules}
+
Regeln total
+
+
+
{cleanupResult.uniqueSignatures}
+
Eindeutige Regeln
+
+
0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateGroups > 0 ? '#fc8181' : '#9ae6b4'}` }}> +
0 ? '#c53030' : '#2f855a' }}>{cleanupResult.duplicateGroups}
+
Duplikat-Gruppen
+
+
0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateRulesToDelete > 0 ? '#fc8181' : '#9ae6b4'}` }}> +
0 ? '#c53030' : '#2f855a' }}> + {cleanupPhase === 'done' ? cleanupResult.deletedCount : cleanupResult.duplicateRulesToDelete} +
+
+ {cleanupPhase === 'done' ? 'Geloescht' : 'Zu loeschen'} +
+
+
+ + {/* Status Message */} + {cleanupPhase === 'done' && ( +
+ + {cleanupResult.deletedCount} doppelte Regeln wurden erfolgreich entfernt. +
+ )} + + {cleanupPhase === 'preview' && cleanupResult.duplicateGroups === 0 && ( +
+ + Keine Duplikate gefunden. Alles sauber! +
+ )} + + {/* Details Table */} + {cleanupResult.details.length > 0 && ( +
+

+ Duplikat-Details {cleanupResult.details.length < cleanupResult.duplicateGroups && `(${cleanupResult.details.length} von ${cleanupResult.duplicateGroups})`} +

+
+ + + + + + + + + + + {cleanupResult.details.map((group, idx) => ( + + + + + + + ))} + +
KontextItemTotalDuplikate
+ + {group.context} + + + + {group.item} + + {group.totalCount}{group.deleteCount}
+
+
+ )} + + )} +
+ +
+ + {cleanupPhase === 'preview' && cleanupResult && cleanupResult.duplicateRulesToDelete > 0 && ( + + )} +
+
+
+ )}
); }; From 15c93b3bf0164ba20d0c1b3afea1e972bd11c6b7 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 8 Feb 2026 16:14:06 +0100 Subject: [PATCH 11/14] billing rbac --- src/api/billingApi.ts | 7 ++++ .../ProviderSelector/ProviderSelector.tsx | 29 +++++++-------- src/pages/billing/BillingAdmin.tsx | 4 +-- src/pages/billing/BillingDashboard.tsx | 22 ++++++++++++ src/pages/billing/BillingDataView.tsx | 35 +++++++++++++++---- src/pages/billing/BillingMandateView.tsx | 2 ++ src/pages/billing/BillingTransactions.tsx | 2 ++ src/pages/billing/BillingUserView.tsx | 2 ++ 8 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 69ebf34..7c78496 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -36,11 +36,16 @@ export interface BillingTransaction { description: string; referenceType?: ReferenceType; workflowId?: string; + featureInstanceId?: string; featureCode?: string; aicoreProvider?: string; + aicoreModel?: string; + createdByUserId?: string; createdAt?: string; mandateId?: string; mandateName?: string; + userId?: string; + userName?: string; } export interface BillingSettings { @@ -70,6 +75,7 @@ export interface UsageReport { totalCost: number; transactionCount: number; costByProvider: Record; + costByModel: Record; costByFeature: Record; } @@ -260,6 +266,7 @@ export async function fetchTransactionsAdmin( */ export interface MandateUserSummary { id: string; + username?: string; email?: string; firstName?: string; lastName?: string; diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx index 55596a8..b9706d3 100644 --- a/src/components/ProviderSelector/ProviderSelector.tsx +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -137,21 +137,19 @@ export const ProviderMultiSelect: React.FC = ({ } }, [isExpanded, handleClickOutside]); - // Check if all providers are selected (or none selected = all used) - const isAllSelected = selectedProviders.length === 0 || - (allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length); + // Check if all providers are explicitly selected + const isAllSelected = allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length; - // For checkbox display: if none selected, show all as checked (since all are used) - const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; + // Check if no providers are selected (= no restriction, all allowed by default) + const isNoneSelected = selectedProviders.length === 0; const handleToggle = (provider: string) => { - // Use effectiveSelection for toggle logic (handles empty = all case) - if (effectiveSelection.includes(provider)) { - // Deactivate: remove from effective selection - onChange(effectiveSelection.filter((p) => p !== provider)); + if (selectedProviders.includes(provider)) { + // Deactivate: remove from selection + onChange(selectedProviders.filter((p) => p !== provider)); } else { - // Activate: add to effective selection - onChange([...effectiveSelection, provider]); + // Activate: add to selection + onChange([...selectedProviders, provider]); } }; @@ -165,14 +163,11 @@ export const ProviderMultiSelect: React.FC = ({ // Summary icon for button const summaryIcon = useMemo(() => { - if (selectedProviders.length === 0 || selectedProviders.length === allowedProviders.length) { - return '🤖'; - } if (selectedProviders.length === 1) { return PROVIDER_ICONS[selectedProviders[0]] || '🔌'; } return '🤖'; - }, [selectedProviders, allowedProviders]); + }, [selectedProviders]); return (
= ({ type="button" onClick={handleSelectNone} disabled={disabled} - className={styles.actionButton} + className={`${styles.actionButton} ${isNoneSelected ? styles.active : ''}`} > Keine @@ -225,7 +220,7 @@ export const ProviderMultiSelect: React.FC = ({ > handleToggle(provider)} disabled={disabled} /> diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index 8efb3cf..8a27eda 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -268,7 +268,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)'; return ( ); })} @@ -339,7 +339,7 @@ const AccountsOverview: React.FC = ({ accounts, users, lo for (const user of users) { const displayName = user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ') - || user.email + || user.username || user.id; map.set(user.id, displayName); } diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx index 4e3f963..8e73efb 100644 --- a/src/pages/billing/BillingDashboard.tsx +++ b/src/pages/billing/BillingDashboard.tsx @@ -114,6 +114,28 @@ const StatisticsChart: React.FC = ({ statistics, loading } )}
+
+

Kosten nach Modell

+ {Object.entries(statistics.costByModel || {}).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByModel || {}).map(([model, cost]) => ( +
+ {model} +
+
+
+ {formatCurrency(cost)} +
+ ))} +
+ )} +
+

Kosten nach Feature

{Object.entries(statistics.costByFeature).length === 0 ? ( diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index f11360d..504bedc 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -35,6 +35,7 @@ interface ViewStatistics { totalCost: number; transactionCount: number; costByProvider: Record; + costByModel: Record; costByFeature: Record; costByMandate: Record; timeSeries: Array<{ date: string; cost: number; count: number }>; @@ -134,6 +135,7 @@ function _recordToChartData(record: Record): ReportChartDataPoin function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] { const topProvider = Object.entries(viewStats.costByProvider).sort((a, b) => b[1] - a[1])[0]; + const topModel = Object.entries(viewStats.costByModel || {}).sort((a, b) => b[1] - a[1])[0]; const topFeature = Object.entries(viewStats.costByFeature).sort((a, b) => b[1] - a[1])[0]; return [ @@ -150,15 +152,15 @@ function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] { value: Object.keys(viewStats.costByProvider).length, subtitle: topProvider ? `Top: ${topProvider[0]}` : 'Keine Nutzung' }, + { + label: 'Modelle', + value: Object.keys(viewStats.costByModel || {}).length, + subtitle: topModel ? `Top: ${topModel[0]}` : 'Keine Nutzung' + }, { label: 'Features', value: Object.keys(viewStats.costByFeature).length, subtitle: topFeature ? `Top: ${topFeature[0]}` : 'Keine Nutzung' - }, - { - label: 'Mandanten', - value: Object.keys(viewStats.costByMandate).length, - subtitle: 'aktiv genutzt' } ] }, @@ -169,6 +171,13 @@ function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] { formatValue: _formatCurrency, span: 'half' as const }, + { + type: 'horizontalBar', + title: 'Kosten nach Modell', + data: _recordToChartData(viewStats.costByModel || {}), + formatValue: _formatCurrency, + span: 'half' as const + }, { type: 'horizontalBar', title: 'Kosten nach Feature', @@ -206,6 +215,14 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { donut: true, span: 'half' as const }, + { + type: 'pieChart', + title: 'Verteilung nach Modell', + data: _recordToChartData(viewStats.costByModel || {}), + formatValue: _formatCurrency, + donut: true, + span: 'half' as const + }, { type: 'pieChart', title: 'Verteilung nach Feature', @@ -234,6 +251,7 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { { metric: 'Transaktionen', value: String(viewStats.transactionCount) }, { metric: 'Durchschnitt / Transaktion', value: _formatCurrency(avgCost) }, { metric: 'Anbieter', value: String(Object.keys(viewStats.costByProvider).length) }, + { metric: 'Modelle', value: String(Object.keys(viewStats.costByModel || {}).length) }, { metric: 'Features', value: String(Object.keys(viewStats.costByFeature).length) }, { metric: 'Mandanten', value: String(Object.keys(viewStats.costByMandate).length) } ] @@ -304,8 +322,10 @@ export const BillingDataView: React.FC = () => { setTransactionsError(null); const params: any = {}; - if (paginationParams) { - params.pagination = JSON.stringify(paginationParams); + // Only serialize if it's a plain pagination object (not a React event or other non-serializable object) + if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) { + const { page, pageSize, sortBy, sortDirection, search, filters } = paginationParams; + params.pagination = JSON.stringify({ page, pageSize, sortBy, sortDirection, search, filters }); } const response = await api.get('/api/billing/view/users/transactions', { params }); @@ -353,6 +373,7 @@ export const BillingDataView: React.FC = () => { { key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 }, { key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 }, { key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 }, + { key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 }, { key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 }, { key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 }, ], []); diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx index 82d022f..f6792c6 100644 --- a/src/pages/billing/BillingMandateView.tsx +++ b/src/pages/billing/BillingMandateView.tsx @@ -146,6 +146,7 @@ const TransactionTable: React.FC = ({ transactions }) => Typ Beschreibung Anbieter + Modell Feature Betrag @@ -162,6 +163,7 @@ const TransactionTable: React.FC = ({ transactions }) => {t.description} {t.aicoreProvider || '-'} + {t.aicoreModel || '-'} {t.featureCode || '-'} {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx index ddd7f2c..d64bde7 100644 --- a/src/pages/billing/BillingTransactions.tsx +++ b/src/pages/billing/BillingTransactions.tsx @@ -65,6 +65,7 @@ const TransactionRow: React.FC = ({ transaction }) => { {transaction.description} {transaction.aicoreProvider || '-'} + {transaction.aicoreModel || '-'} {transaction.featureCode || '-'} {transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)} @@ -114,6 +115,7 @@ export const BillingTransactions: React.FC = () => { Typ Beschreibung Anbieter + Modell Feature Betrag diff --git a/src/pages/billing/BillingUserView.tsx b/src/pages/billing/BillingUserView.tsx index 194be7d..4587acc 100644 --- a/src/pages/billing/BillingUserView.tsx +++ b/src/pages/billing/BillingUserView.tsx @@ -228,6 +228,7 @@ const UserTransactionTable: React.FC = ({ Typ Beschreibung Anbieter + Modell Feature Betrag @@ -245,6 +246,7 @@ const UserTransactionTable: React.FC = ({ {t.description} {t.aicoreProvider || '-'} + {t.aicoreModel || '-'} {t.featureCode || '-'} {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} From 75125e3f58a247fa39cdee23998b818aad0883a9 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 9 Feb 2026 12:49:39 +0100 Subject: [PATCH 12/14] streamlined bootstrap and initial config --- .../FormGeneratorReport.module.css | 7 +- .../FormGeneratorReport.tsx | 12 +-- .../ProviderSelector/ProviderSelector.tsx | 78 +++++++++++------ src/hooks/playground/useDashboardInputForm.ts | 85 +++++-------------- src/hooks/useChatbot.ts | 24 ++---- src/hooks/useInvitations.ts | 8 +- src/hooks/useUserMandates.ts | 16 ++-- src/pages/InvitePage.tsx | 84 +++++++++++++----- .../admin/AdminMandateRolePermissionsPage.tsx | 37 +++++--- src/pages/admin/AdminMandateRolesPage.tsx | 26 +++--- src/pages/admin/AdminMandatesPage.tsx | 26 +++++- src/pages/workflows/PlaygroundPage.tsx | 1 + 12 files changed, 231 insertions(+), 173 deletions(-) diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css index b1d0f13..cb5afca 100644 --- a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css @@ -143,6 +143,9 @@ text-transform: uppercase; letter-spacing: 0.5px; margin: 0 0 0.75rem 0; + position: relative; + z-index: 1; + flex-shrink: 0; } .sectionDescription { @@ -199,8 +202,8 @@ .chartWrapperSmall { width: 100%; - height: 220px; - min-height: 220px; + height: 250px; + min-height: 250px; min-width: 0; } diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx index 2082edd..0240eed 100644 --- a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx @@ -129,7 +129,7 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): return (
- + - + - + - + = ({ @@ -112,8 +113,10 @@ export const ProviderMultiSelect: React.FC = ({ label = 'AI-Provider', showLabel = true, defaultExpanded = false, + excludeByDefault = [], }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [initialExcludeApplied, setInitialExcludeApplied] = useState(false); const containerRef = useRef(null); const { allowedProviders, loadAllowedProviders, loading } = useBilling(); @@ -123,6 +126,25 @@ export const ProviderMultiSelect: React.FC = ({ } }, []); + // Apply default exclusions when providers first load + useEffect(() => { + if ( + !initialExcludeApplied && + allowedProviders.length > 0 && + excludeByDefault.length > 0 && + selectedProviders.length === 0 + ) { + const initialSelection = allowedProviders.filter( + (p) => !excludeByDefault.includes(p) + ); + // Only apply if there's actually something to exclude + if (initialSelection.length < allowedProviders.length) { + onChange(initialSelection); + } + setInitialExcludeApplied(true); + } + }, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]); + // Click outside handler const handleClickOutside = useCallback((event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { @@ -137,37 +159,49 @@ export const ProviderMultiSelect: React.FC = ({ } }, [isExpanded, handleClickOutside]); - // Check if all providers are explicitly selected - const isAllSelected = allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length; + // Effective selection: empty array = all providers active (no restriction) + const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; - // Check if no providers are selected (= no restriction, all allowed by default) - const isNoneSelected = selectedProviders.length === 0; + // "Alle" is active when no restriction is set (empty array) OR all explicitly selected + const isAllSelected = selectedProviders.length === 0 || + (allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length); const handleToggle = (provider: string) => { - if (selectedProviders.includes(provider)) { + if (selectedProviders.length === 0) { + // Currently "all active" (no restriction) -> make explicit: all except the toggled one + onChange(allowedProviders.filter((p) => p !== provider)); + } else if (selectedProviders.includes(provider)) { // Deactivate: remove from selection - onChange(selectedProviders.filter((p) => p !== provider)); + const remaining = selectedProviders.filter((p) => p !== provider); + // If removing leaves all others selected, reset to [] (= all, no restriction) + if (remaining.length === allowedProviders.length) { + onChange([]); + } else { + onChange(remaining); + } } else { // Activate: add to selection - onChange([...selectedProviders, provider]); + const updated = [...selectedProviders, provider]; + // If all are now selected, reset to [] (= all, no restriction) + if (updated.length === allowedProviders.length) { + onChange([]); + } else { + onChange(updated); + } } }; const handleSelectAll = () => { - onChange([...allowedProviders]); - }; - - const handleSelectNone = () => { - onChange([]); + onChange([]); // Empty = all active, no restriction }; // Summary icon for button const summaryIcon = useMemo(() => { - if (selectedProviders.length === 1) { - return PROVIDER_ICONS[selectedProviders[0]] || '🔌'; + if (effectiveSelection.length === 1) { + return PROVIDER_ICONS[effectiveSelection[0]] || '🔌'; } return '🤖'; - }, [selectedProviders]); + }, [effectiveSelection]); return (
= ({ > Alle -
{loading ? ( @@ -220,7 +246,7 @@ export const ProviderMultiSelect: React.FC = ({ > handleToggle(provider)} disabled={disabled} /> @@ -233,9 +259,9 @@ export const ProviderMultiSelect: React.FC = ({
)} - {selectedProviders.length === 0 && !loading && ( + {isAllSelected && !loading && (
- Alle Provider aktiv + Alle Provider aktiv (kein Filter)
)}
diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index ec0267d..e39494a 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -10,7 +10,7 @@ import type { Workflow, WorkflowMessage } from '../../api/workflowApi'; import { useWorkflowLifecycle } from './useWorkflowLifecycle'; import { useWorkflows } from './useWorkflows'; import { useDashboardLogTree } from './useDashboardLogTree'; -import { extractFileIdsFromMessage, convertFilesToDocuments, sortMessages } from './playgroundUtils'; +import { convertFilesToDocuments, sortMessages } from './playgroundUtils'; import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes'; export interface WorkflowFile { @@ -279,44 +279,21 @@ export function useDashboardInputForm(instanceId: string) { useEffect(() => { if (!messages || messages.length === 0) return; + if (!optimisticMessage) return; - const messageTexts = new Set(); - messages.forEach((message: WorkflowMessage) => { - if (message.message) { - messageTexts.add(message.message.trim()); - } - }); + // Clear optimistic message when backend's "first" user message arrives via polling. + // The backend message contains the normalizedRequest (which differs from the original prompt), + // so we match by status="first" instead of content comparison. + const hasFirstMessage = messages.some((msg: WorkflowMessage) => + (msg as any).status === 'first' && msg.role?.toLowerCase() === 'user' + ); - if (optimisticMessage && optimisticMessage.message) { - const optimisticText = optimisticMessage.message.trim(); - const optimisticFileIds = extractFileIdsFromMessage(optimisticMessage); - - const matchingMessage = Array.from(messages).find((msg: WorkflowMessage) => - msg.message && msg.message.trim() === optimisticText - ); - - if (matchingMessage) { - const matchingFileIds = extractFileIdsFromMessage(matchingMessage); - - if (optimisticFileIds.size > 0) { - const allFilesConfirmed = Array.from(optimisticFileIds).every(fileId => - matchingFileIds.has(fileId) - ); - if (allFilesConfirmed && matchingFileIds.size > 0) { - setOptimisticMessage(null); - } - } else { - if (messageTexts.has(optimisticText)) { - setOptimisticMessage(null); - } - } - } + if (hasFirstMessage) { + setOptimisticMessage(null); } }, [messages, optimisticMessage]); const displayMessages = useMemo(() => { - const optimisticText = optimisticMessage?.message?.trim(); - const processedMessages = (messages || []).map((message: WorkflowMessage) => { const files = (message as any).files as any[] | undefined; const documents = (message as any).documents as MessageDocument[] | undefined; @@ -331,37 +308,19 @@ export function useDashboardInputForm(instanceId: string) { return message; }); - let replacedMessageTimestamp: number | undefined; - const filteredMessages = processedMessages.filter((message: WorkflowMessage) => { - const isUserMessage = message.role?.toLowerCase() === 'user'; - const messageText = message.message?.trim(); - - if (optimisticMessage && optimisticText && isUserMessage && messageText === optimisticText) { - const documents = (message as any).documents as MessageDocument[] | undefined; - const files = (message as any).files as any[] | undefined; - const hasDocuments = documents && Array.isArray(documents) && documents.length > 0; - const hasFiles = files && Array.isArray(files) && files.length > 0; - - if (hasDocuments || hasFiles) { - return true; - } - - if (message.publishedAt !== undefined) { - replacedMessageTimestamp = message.publishedAt; - } - - return false; - } - - return true; - }); - - const allMessages = [...filteredMessages]; + // If optimistic message is still active (backend "first" message not yet polled), + // show the optimistic message instead of any backend user messages to avoid duplicates. + const allMessages = [...processedMessages]; if (optimisticMessage) { - const optimisticWithTimestamp = replacedMessageTimestamp !== undefined - ? { ...optimisticMessage, publishedAt: replacedMessageTimestamp } - : optimisticMessage; - allMessages.push(optimisticWithTimestamp); + // Find backend "first" user message to inherit its timestamp for correct ordering + const firstBackendMsg = processedMessages.find((msg: WorkflowMessage) => + (msg as any).status === 'first' && msg.role?.toLowerCase() === 'user' + ); + if (!firstBackendMsg) { + // Backend "first" message not yet arrived - show optimistic message + allMessages.push(optimisticMessage); + } + // If firstBackendMsg exists, the useEffect above will clear optimistic on next render } return allMessages.sort(sortMessages); diff --git a/src/hooks/useChatbot.ts b/src/hooks/useChatbot.ts index c4db9f8..6fb79c0 100644 --- a/src/hooks/useChatbot.ts +++ b/src/hooks/useChatbot.ts @@ -256,26 +256,12 @@ export function useChatbot(): ChatbotHookReturn { return prev; } - // For user messages, check if we already have a temporary one with same content - // Only replace if it's the temporary message we just created (by ID match) - if (message.role === 'user' && message.message === inputMessageContent) { - // Check if we have the exact temporary message we created - const hasTempMessage = prev.some(m => m.id === tempUserMessageId); - if (hasTempMessage) { - // Replace the temporary message with the real one from backend - return prev.map(m => - m.id === tempUserMessageId ? message : m - ); - } - // If no temp message found, check if this is a duplicate of an existing real message - const isDuplicate = prev.some(m => - m.role === 'user' && - m.message === inputMessageContent && - !m.id.startsWith('temp-') + // Backend sends the "first" message with the transformed/normalized user prompt + // Replace the temporary optimistic message with it + if (message.status === 'first') { + return prev.map(m => + m.id === tempUserMessageId ? message : m ); - if (isDuplicate) { - return prev; // Don't add duplicate - } } // For other messages, check for duplicates by role and content (more lenient check) diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts index bd2514b..e8dbd79 100644 --- a/src/hooks/useInvitations.ts +++ b/src/hooks/useInvitations.ts @@ -51,6 +51,7 @@ export interface InvitationCreate { email?: string; roleIds: string[]; featureInstanceId?: string; + frontendUrl?: string; expiresInHours?: number; maxUses?: number; } @@ -117,6 +118,7 @@ export function useInvitations() { try { const params = new URLSearchParams(); + params.append('frontendUrl', window.location.origin); if (fetchOptions?.includeUsed) params.append('includeUsed', 'true'); if (fetchOptions?.includeExpired) params.append('includeExpired', 'true'); if (Object.keys(paginationParams).length > 0) { @@ -160,7 +162,11 @@ export function useInvitations() { setLoading(true); setError(null); try { - const response = await api.post('/api/invitations/', data, { + const payload = { + ...data, + frontendUrl: data.frontendUrl || window.location.origin, + }; + const response = await api.post('/api/invitations/', payload, { headers: { 'X-Mandate-Id': mandateId } }); return { success: true, data: response.data }; diff --git a/src/hooks/useUserMandates.ts b/src/hooks/useUserMandates.ts index 8c4ec4f..c7c128c 100644 --- a/src/hooks/useUserMandates.ts +++ b/src/hooks/useUserMandates.ts @@ -62,6 +62,7 @@ export interface Mandate { name: string | { [key: string]: string }; code?: string; language?: string; + isSystem?: boolean; } /** @@ -226,7 +227,10 @@ export function useUserMandates() { }, []); /** - * Fetch all available roles (global and mandate-specific, excluding feature-instance roles) + * Fetch available roles for a mandate (mandate-instance roles only). + * Each mandate has its own instances of system roles (admin, user, viewer) + * copied from templates during mandate creation. Only these mandate-bound + * roles should be offered for user assignment - NOT global templates. */ const fetchRoles = useCallback(async (mandateId?: string): Promise => { try { @@ -238,15 +242,15 @@ export function useUserMandates() { roles = response.data; } - // Filter to global roles and mandate-specific roles only - // Exclude feature-instance roles (they have featureInstanceId set) + // Only mandate-instance roles (mandateId matches, no featureInstanceId) + // Global templates (mandateId=null) are NOT assignable to users if (mandateId) { return roles.filter(r => - !r.featureInstanceId && (!r.mandateId || r.mandateId === mandateId) + !r.featureInstanceId && r.mandateId === mandateId ); } - // Without mandateId, return only global roles (no mandateId and no featureInstanceId) - return roles.filter(r => !r.mandateId && !r.featureInstanceId); + // Without mandateId, no roles available (roles are always mandate-specific) + return []; } catch (err: any) { console.error('Error fetching roles:', err); return []; diff --git a/src/pages/InvitePage.tsx b/src/pages/InvitePage.tsx index 1f9257a..f5cd0ab 100644 --- a/src/pages/InvitePage.tsx +++ b/src/pages/InvitePage.tsx @@ -44,6 +44,7 @@ export const InvitePage: React.FC = () => { const [accepting, setAccepting] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(null); + const [userExists, setUserExists] = useState(null); // Validate token on mount useEffect(() => { @@ -56,7 +57,6 @@ export const InvitePage: React.FC = () => { const result = await validateInvitation(token); setValidation(result); - setValidating(false); // If invitation is valid but user is not authenticated, // store the token for later use after login/registration @@ -64,7 +64,24 @@ export const InvitePage: React.FC = () => { // (e.g., when user opens password reset email in a new tab) if (result.valid && !isAuthenticated) { localStorage.setItem(PENDING_INVITATION_KEY, token); + + // Check if the target username already has an account + if (result.targetUsername) { + try { + const resp = await fetch(`/api/local/available?username=${encodeURIComponent(result.targetUsername)}`); + if (resp.ok) { + const data = await resp.json(); + // available=true means username is free -> user does NOT exist + setUserExists(!data.available); + } + } catch { + // On error, default to showing both options + setUserExists(null); + } + } } + + setValidating(false); }; validate(); @@ -222,7 +239,7 @@ export const InvitePage: React.FC = () => { ); } - // Not authenticated - show login/register options (NO inline registration form) + // Not authenticated - show appropriate options based on whether user account exists return (
@@ -254,9 +271,11 @@ export const InvitePage: React.FC = () => {

- {validation.targetUsername + {userExists === true ? `Bitte melden Sie sich als "${validation.targetUsername}" an, um die Einladung anzunehmen.` - : 'Bitte melden Sie sich an, um die Einladung anzunehmen.'} + : userExists === false + ? 'Bitte erstellen Sie ein Konto, um die Einladung anzunehmen.' + : 'Bitte melden Sie sich an oder erstellen Sie ein Konto, um die Einladung anzunehmen.'}

@@ -267,29 +286,48 @@ export const InvitePage: React.FC = () => { )}
- - -
- oder -
- - + {userExists === true ? ( + + ) : userExists === false ? ( + + ) : ( + <> + +
+ oder +
+ + + )}

- Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen. - Die Einladung wird automatisch nach der Anmeldung akzeptiert. + {userExists === true + ? 'Melden Sie sich mit Ihrem bestehenden Konto an. Die Einladung wird automatisch nach der Anmeldung akzeptiert.' + : userExists === false + ? 'Erstellen Sie ein neues Konto. Die Einladung wird automatisch nach der Registrierung akzeptiert.' + : 'Die Einladung wird automatisch nach der Anmeldung akzeptiert.'}

diff --git a/src/pages/admin/AdminMandateRolePermissionsPage.tsx b/src/pages/admin/AdminMandateRolePermissionsPage.tsx index 619e39c..b54ebc1 100644 --- a/src/pages/admin/AdminMandateRolePermissionsPage.tsx +++ b/src/pages/admin/AdminMandateRolePermissionsPage.tsx @@ -69,7 +69,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { // State const [mandates, setMandates] = useState([]); const [selectedMandateId, setSelectedMandateId] = useState(''); - const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all'); + const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate'); const [expandedRoleId, setExpandedRoleId] = useState(null); // Cleanup state @@ -117,19 +117,24 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { setExpandedRoleId(prev => prev === roleId ? null : roleId); }; + // Check if a role is a template (not bound to a specific mandate) + const _isTemplateRole = (role: Role): boolean => { + return !!role.isSystemRole || !role.mandateId; + }; + // Get scope badge const getScopeBadge = (role: Role) => { if (role.isSystemRole) { return ( - System + System-Template ); } if (!role.mandateId) { return ( - Global + Template ); } @@ -185,9 +190,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { // Filter options for scope const scopeOptions = useMemo(() => [ - { value: 'all', label: 'Alle Rollen' }, - { value: 'mandate', label: 'Nur Mandanten-Rollen' }, - { value: 'global', label: 'Nur globale Rollen' }, + { value: 'mandate', label: 'Mandanten-Rollen' }, + { value: 'all', label: 'Alle (inkl. Templates)' }, + { value: 'global', label: 'Nur Templates' }, ], []); if (error) { @@ -274,7 +279,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten. - Alle Rollen-Berechtigungen sind bearbeitbar (System-Rollen-Namen sind geschützt). + Template-Rollen sind schreibgeschützt - Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus. + Mandanten-Rollen sind direkt bearbeitbar.
@@ -293,9 +299,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {

Keine Rollen gefunden

{scopeFilter === 'mandate' - ? 'Es gibt noch keine mandantenspezifischen Rollen.' + ? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.' : scopeFilter === 'global' - ? 'Es gibt noch keine globalen Rollen.' + ? 'Es gibt noch keine Rollen-Templates.' : 'Es gibt noch keine Rollen für diesen Mandanten.'}

@@ -328,11 +334,20 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { {/* Expanded Content - AccessRulesEditor */} {expandedRoleId === role.id && (
+ {_isTemplateRole(role) && ( +
+ + + Dies ist eine Template-Rolle. Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus. + Bestehende Mandanten-Instanzen werden nicht aktualisiert. + +
+ )} diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index a6fc149..67bb828 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -46,7 +46,7 @@ export const AdminMandateRolesPage: React.FC = () => { const [showCreateModal, setShowCreateModal] = useState(false); const [editingRole, setEditingRole] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all'); + const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate'); const [backendAttributes, setBackendAttributes] = useState([]); // Store current filter state for refetch @@ -126,14 +126,14 @@ export const AdminMandateRolesPage: React.FC = () => { if (value === 'system') { return ( - System + System-Template ); } if (value === 'global') { return ( - Global + Template ); } @@ -164,7 +164,7 @@ export const AdminMandateRolesPage: React.FC = () => { default: 'mandate', options: [ { value: 'mandate', label: 'Nur dieser Mandant' }, - { value: 'global', label: 'Global (alle Mandanten)' }, + { value: 'global', label: 'Template (wird bei neuen Mandanten kopiert)' }, ] }); } @@ -359,9 +359,9 @@ export const AdminMandateRolesPage: React.FC = () => { onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')} style={{ minWidth: 150 }} > - - - + + +
@@ -389,9 +389,9 @@ export const AdminMandateRolesPage: React.FC = () => {
- System-Rollen (admin, user, viewer) können nicht bearbeitet oder gelöscht werden. - Globale Rollen gelten für alle Mandanten. - Mandanten-Rollen gelten nur für den ausgewählten Mandanten. + System-Templates (admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert. + Templates selbst können nicht gelöscht werden. + Mandanten-Rollen gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.
)} @@ -416,9 +416,9 @@ export const AdminMandateRolesPage: React.FC = () => {

Keine Rollen

{scopeFilter === 'mandate' - ? 'Es gibt noch keine mandantenspezifischen Rollen.' + ? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.' : scopeFilter === 'global' - ? 'Es gibt noch keine globalen Rollen.' + ? 'Es gibt noch keine Rollen-Templates.' : 'Es gibt noch keine Rollen für diesen Mandanten.'}

- {formAttributes.length === 0 ? ( + {createFormAttributes.length === 0 ? (
Lade Formular...
) : ( setShowCreateModal(false)} @@ -233,6 +245,14 @@ export const AdminMandatesPage: React.FC = () => {
+ {editingMandate.isSystem && ( +
+ + + Dies ist ein System-Mandant. Er kann nicht gelöscht werden und der Name sollte nicht geändert werden. + +
+ )} {formAttributes.length === 0 ? (
diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index 9dc6af7..abd0569 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -774,6 +774,7 @@ export const PlaygroundPage: React.FC = () => { selectedProviders={selectedProviders} onChange={onProvidersChange} showLabel={false} + excludeByDefault={['privatellm']} /> Date: Mon, 9 Feb 2026 23:45:05 +0100 Subject: [PATCH 13/14] logical fixes --- src/api/automationApi.ts | 1 + .../FormGeneratorControls.module.css | 37 +++++ .../FormGeneratorControls.tsx | 20 ++- .../FormGeneratorTable.module.css | 47 +++--- .../FormGeneratorTable/FormGeneratorTable.tsx | 144 +++++++++++++++- src/hooks/useAutomations.ts | 4 +- src/hooks/usePrompts.ts | 13 +- src/pages/Dashboard.tsx | 154 ++++++++++-------- src/pages/FeatureView.tsx | 39 ++--- src/pages/admin/AdminFeatureAccessPage.tsx | 1 + .../admin/AdminFeatureInstanceUsersPage.tsx | 1 + src/pages/admin/AdminFeatureRolesPage.tsx | 1 + src/pages/admin/AdminInvitationsPage.tsx | 1 + src/pages/admin/AdminMandateRolesPage.tsx | 1 + src/pages/admin/AdminMandatesPage.tsx | 1 + .../admin/AdminUserAccessOverviewPage.tsx | 9 +- src/pages/admin/AdminUserMandatesPage.tsx | 1 + src/pages/admin/AdminUsersPage.tsx | 1 + src/pages/basedata/ConnectionsPage.tsx | 42 +++-- src/pages/basedata/FilesPage.tsx | 46 ++++-- src/pages/basedata/PromptsPage.tsx | 26 ++- src/pages/billing/BillingDataView.tsx | 1 + .../realestate/RealEstateParcelsView.tsx | 1 + .../realestate/RealEstateProjectsView.tsx | 1 + .../views/trustee/TrusteeDocumentsView.tsx | 1 + .../trustee/TrusteePositionDocumentsView.tsx | 1 + .../views/trustee/TrusteePositionsView.tsx | 1 + .../workflows/AutomationTemplatesPage.tsx | 1 + src/pages/workflows/AutomationsPage.tsx | 21 ++- src/pages/workflows/WorkflowsPage.tsx | 1 + 30 files changed, 449 insertions(+), 170 deletions(-) diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts index 296933b..38d560c 100644 --- a/src/api/automationApi.ts +++ b/src/api/automationApi.ts @@ -22,6 +22,7 @@ export interface Automation { _updatedAt?: number; _createdByUserName?: string; mandateName?: string; + featureInstanceName?: string; [key: string]: any; } diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css index d9ed884..2aa4da0 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css @@ -37,6 +37,43 @@ white-space: nowrap; } +/* CSV Export Button */ +.csvExportButton { + display: inline-flex; + align-items: center; + gap: 5px; + height: 40px; + padding: 0 14px; + border: 1px solid var(--color-primary); + border-radius: 25px; + background: var(--color-bg); + color: var(--color-text); + font-size: 12px; + font-family: var(--font-family); + font-weight: 400; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + flex-shrink: 0; +} + +.csvExportButton:hover:not(:disabled) { + background: var(--color-secondary); + color: var(--color-bg); + border-color: var(--color-secondary); +} + +.csvExportButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.csvExportIcon { + font-size: 13px; + display: flex; + align-items: center; +} + .refreshButton { display: flex; align-items: center; diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index 04b233f..719be78 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -3,7 +3,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; import styles from './FormGeneratorControls.module.css'; import { Button } from '../../UiComponents/Button'; import { IoIosRefresh } from "react-icons/io"; -import { FaTrash } from "react-icons/fa"; +import { FaTrash, FaDownload } from "react-icons/fa"; import type { AttributeType } from '../../../utils/attributeTypeMapper'; // Generic field/column config interface @@ -62,6 +62,9 @@ export interface FormGeneratorControlsProps { onPageSizeChange?: (pageSize: number) => void; supportsBackendPagination?: boolean; hookData?: any; + // CSV Export + onCsvExport?: () => void; + csvExporting?: boolean; } export function FormGeneratorControls({ @@ -87,7 +90,9 @@ export function FormGeneratorControls({ onPageChange, onPageSizeChange, supportsBackendPagination = false, - hookData: _hookData // Reserved for future use + hookData: _hookData, // Reserved for future use + onCsvExport, + csvExporting = false }: FormGeneratorControlsProps) { void _hookData; // Suppress unused variable warning const { t } = useLanguage(); @@ -147,6 +152,17 @@ export function FormGeneratorControls({ {activeFiltersCount} {t('formgen.filter.active', 'filter(s)')} )} + {onCsvExport && ( + + )} {onRefresh && ( diff --git a/src/hooks/useUserMandates.ts b/src/hooks/useUserMandates.ts index c7c128c..f52b6f3 100644 --- a/src/hooks/useUserMandates.ts +++ b/src/hooks/useUserMandates.ts @@ -60,6 +60,7 @@ export interface Role { export interface Mandate { id: string; name: string | { [key: string]: string }; + label?: string; code?: string; language?: string; isSystem?: boolean; diff --git a/src/pages/admin/AccessManagementHub.tsx b/src/pages/admin/AccessManagementHub.tsx index 5a64905..d522ea7 100644 --- a/src/pages/admin/AccessManagementHub.tsx +++ b/src/pages/admin/AccessManagementHub.tsx @@ -24,6 +24,7 @@ import { FeatureInstanceWizard } from './FeatureInstanceWizard'; import { InstanceHierarchyView } from './InstanceHierarchyView'; function getMandateName(mandate: Mandate): string { + if (mandate.label) return mandate.label; if (typeof mandate.name === 'object') { return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; } diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index b266af9..fb0b92e 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -310,6 +310,7 @@ export const AdminFeatureAccessPage: React.FC = () => { // Get mandate name const getMandateName = (mandate: Mandate) => { + if (mandate.label) return mandate.label; if (typeof mandate.name === 'object') { return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; } diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx index 554ea1c..c7250af 100644 --- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx +++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx @@ -86,7 +86,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { allOptions.push({ mandateId: mandate.id, instanceId: inst.id, - mandateName: typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id), + mandateName: mandate.label || (typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id)), instanceLabel: inst.label || inst.id, featureCode: inst.featureCode, combinedKey: `${mandate.id}:${inst.id}`, diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 037afb8..febc527 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -226,6 +226,7 @@ export const AdminInvitationsPage: React.FC = () => { // Get mandate name const getMandateName = (mandate: Mandate) => { + if (mandate.label) return mandate.label; if (typeof mandate.name === 'object') { return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; } diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index 2e9cadb..19ddff4 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -285,6 +285,7 @@ export const AdminMandateRolesPage: React.FC = () => { // Get mandate name const getMandateName = (mandate: Mandate) => { + if (mandate.label) return mandate.label; if (typeof mandate.name === 'object') { return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; } diff --git a/src/pages/admin/AdminUserMandatesPage.tsx b/src/pages/admin/AdminUserMandatesPage.tsx index 8a0644c..72cd2fd 100644 --- a/src/pages/admin/AdminUserMandatesPage.tsx +++ b/src/pages/admin/AdminUserMandatesPage.tsx @@ -248,6 +248,7 @@ export const AdminUserMandatesPage: React.FC = () => { // Get mandate name const getMandateName = (mandate: Mandate) => { + if (mandate.label) return mandate.label; if (typeof mandate.name === 'object') { return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; } diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 8c5fa5b..30c1903 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -116,7 +116,8 @@ export interface MandateFeature { */ export interface Mandate { id: string; // mandateId - name: string; // Anzeige-Name + name: string; // Technischer Identifier + label?: string; // Anzeige-Label (fuer FK-Referenzen und UI) code?: string; // Optionaler Code features: MandateFeature[]; }