From 439fc3676ffd4904764a82c4bbb275a3c992ab54 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 22 Mar 2026 17:23:47 +0100 Subject: [PATCH 1/5] subscription base logic --- src/App.tsx | 3 +- src/api/subscriptionApi.ts | 143 ++++++ src/hooks/useConfirm.tsx | 133 +++++ src/hooks/useSubscription.ts | 161 ++++++ src/pages/admin/PermissionMatrix.tsx | 23 +- src/pages/billing/AdminSubscriptionsPage.tsx | 117 +++++ src/pages/billing/BillingAdmin.tsx | 214 ++++++-- src/pages/billing/BillingDataView.tsx | 1 + src/pages/billing/BillingMandateView.tsx | 22 +- src/pages/billing/SubscriptionTab.tsx | 481 ++++++++++++++++++ src/pages/billing/index.ts | 1 + .../chatbot/ChatbotConversationsView.tsx | 29 +- .../trustee/TrusteeAccountingSettingsView.tsx | 10 +- src/pages/views/workspace/useWorkspace.ts | 13 +- 14 files changed, 1283 insertions(+), 68 deletions(-) create mode 100644 src/api/subscriptionApi.ts create mode 100644 src/hooks/useConfirm.tsx create mode 100644 src/hooks/useSubscription.ts create mode 100644 src/pages/billing/AdminSubscriptionsPage.tsx create mode 100644 src/pages/billing/SubscriptionTab.tsx diff --git a/src/App.tsx b/src/App.tsx index 531ac7f..595379d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,7 +41,7 @@ import { FeatureViewPage } from './pages/FeatureView'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; -import { BillingDataView, BillingAdmin, BillingMandateView } from './pages/billing'; +import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; function App() { // Load saved theme preference and set app name on app mount useEffect(() => { @@ -188,6 +188,7 @@ function App() { } /> } /> + } /> } /> } /> diff --git a/src/api/subscriptionApi.ts b/src/api/subscriptionApi.ts new file mode 100644 index 0000000..9fefe9f --- /dev/null +++ b/src/api/subscriptionApi.ts @@ -0,0 +1,143 @@ +import { ApiRequestOptions } from '../hooks/useApi'; + +// ============================================================================ +// TYPES — aligned with State Machine (wiki/concepts/Subscription-State-Machine.md) +// ============================================================================ + +export type SubscriptionStatus = 'PENDING' | 'SCHEDULED' | 'TRIALING' | 'ACTIVE' | 'PAST_DUE' | 'EXPIRED'; +export type BillingPeriod = 'MONTHLY' | 'YEARLY' | 'NONE'; + +export interface SubscriptionPlan { + planKey: string; + selectableByUser: boolean; + title: Record; + description: Record; + currency: string; + billingPeriod: BillingPeriod; + pricePerUserCHF: number; + pricePerFeatureInstanceCHF: number; + autoRenew: boolean; + maxUsers: number | null; + maxFeatureInstances: number | null; + trialDays: number | null; + successorPlanKey: string | null; +} + +export interface MandateSubscription { + id: string; + mandateId: string; + planKey: string; + status: SubscriptionStatus; + recurring: boolean; + startedAt: string; + effectiveFrom: string | null; + endedAt: string | null; + currentPeriodStart: string | null; + currentPeriodEnd: string | null; + trialEndsAt: string | null; + snapshotPricePerUserCHF: number; + snapshotPricePerInstanceCHF: number; + stripeSubscriptionId: string | null; +} + +export interface SubscriptionStatusResponse { + active: boolean; + subscription: MandateSubscription | null; + plan: SubscriptionPlan | null; + scheduled: MandateSubscription | null; +} + +export interface ActivatePlanResponse { + redirectUrl?: string; + [key: string]: unknown; +} + +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +// ============================================================================ +// Helpers +// ============================================================================ + +function _mandateConfig(mandateId?: string): Record { + if (!mandateId) return {}; + return { headers: { 'X-Mandate-Id': mandateId } }; +} + +// ============================================================================ +// API FUNCTIONS +// ============================================================================ + +export async function fetchSelectablePlans( + request: ApiRequestFunction, + mandateId?: string, +): Promise { + return await request({ + url: '/api/subscription/plans', + method: 'get', + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function fetchSubscriptionStatus( + request: ApiRequestFunction, + mandateId?: string, +): Promise { + return await request({ + url: '/api/subscription/status', + method: 'get', + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function activatePlan( + request: ApiRequestFunction, + planKey: string, + mandateId?: string, + returnUrl?: string, +): Promise { + return await request({ + url: '/api/subscription/activate', + method: 'post', + data: { planKey, returnUrl: returnUrl || '' }, + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function cancelSubscription( + request: ApiRequestFunction, + subscriptionId: string, + mandateId?: string, +): Promise> { + return await request({ + url: '/api/subscription/cancel', + method: 'post', + data: { subscriptionId }, + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function reactivateSubscription( + request: ApiRequestFunction, + subscriptionId: string, + mandateId?: string, +): Promise> { + return await request({ + url: '/api/subscription/reactivate', + method: 'post', + data: { subscriptionId }, + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function verifyCheckout( + request: ApiRequestFunction, + sessionId: string, + mandateId?: string, +): Promise<{ status: string; message: string }> { + return await request({ + url: '/api/subscription/checkout/verify', + method: 'post', + data: { sessionId }, + additionalConfig: _mandateConfig(mandateId), + }); +} diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx new file mode 100644 index 0000000..bb9fbab --- /dev/null +++ b/src/hooks/useConfirm.tsx @@ -0,0 +1,133 @@ +/** + * useConfirm — application-level confirm dialog replacing native browser confirm(). + * + * Usage: + * const { confirm, ConfirmDialog } = useConfirm(); + * const ok = await confirm('Wirklich löschen?', { confirmLabel: 'Löschen', variant: 'danger' }); + * // Render once in the component tree. + */ + +import React, { useState, useCallback, useRef } from 'react'; + +export interface ConfirmOptions { + title?: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'primary' | 'danger'; +} + +interface ConfirmState { + message: string; + options: Required; + resolve: (value: boolean) => void; +} + +const _defaults: Required = { + title: 'Bestätigung', + confirmLabel: 'Bestätigen', + cancelLabel: 'Abbrechen', + variant: 'primary', +}; + +export function useConfirm() { + const [state, setState] = useState(null); + const resolveRef = useRef<((v: boolean) => void) | null>(null); + + const confirm = useCallback((message: string, options?: ConfirmOptions): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setState({ + message, + options: { ..._defaults, ...options }, + resolve, + }); + }); + }, []); + + const _handleConfirm = useCallback(() => { + resolveRef.current?.(true); + resolveRef.current = null; + setState(null); + }, []); + + const _handleCancel = useCallback(() => { + resolveRef.current?.(false); + resolveRef.current = null; + setState(null); + }, []); + + const ConfirmDialog: React.FC = useCallback(() => { + if (!state) return null; + + const { message, options } = state; + const isDanger = options.variant === 'danger'; + + return ( +
+
e.stopPropagation()} + style={{ + background: 'var(--surface-color, #1a1a2e)', + border: '1px solid var(--color-border, #333)', + borderRadius: '12px', + padding: '1.5rem', + minWidth: 340, maxWidth: 480, + boxShadow: '0 8px 32px rgba(0,0,0,0.4)', + display: 'flex', flexDirection: 'column', gap: '1.25rem', + }} + > +

+ {options.title} +

+ +

+ {message} +

+ +
+ + +
+
+
+ ); + }, [state, _handleConfirm, _handleCancel]); + + return { confirm, ConfirmDialog }; +} diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts new file mode 100644 index 0000000..ea98e6c --- /dev/null +++ b/src/hooks/useSubscription.ts @@ -0,0 +1,161 @@ +/** + * useSubscription Hook — state-machine-aligned subscription management. + * + * Exposes the operative subscription, any scheduled successor, available plans, + * and ID-based mutation functions (activate, cancel, reactivate). + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { + fetchSelectablePlans, + fetchSubscriptionStatus, + activatePlan as activatePlanApi, + cancelSubscription as cancelSubscriptionApi, + reactivateSubscription as reactivateSubscriptionApi, + verifyCheckout as verifyCheckoutApi, + type SubscriptionPlan, + type MandateSubscription, + type SubscriptionStatusResponse, +} from '../api/subscriptionApi'; + +export interface UseSubscriptionReturn { + plans: SubscriptionPlan[]; + subscription: MandateSubscription | null; + plan: SubscriptionPlan | null; + scheduled: MandateSubscription | null; + active: boolean; + loading: boolean; + error: string | null; + loadPlans: () => Promise; + loadStatus: () => Promise; + activatePlan: (planKey: string) => Promise; + cancelSubscription: (subscriptionId: string) => Promise; + reactivateSubscription: (subscriptionId: string) => Promise; + verifyCheckout: (sessionId: string) => Promise<{ status: string; message: string }>; + refetch: () => Promise; +} + +export function useSubscription(mandateId?: string): UseSubscriptionReturn { + const [plans, setPlans] = useState([]); + const [subscription, setSubscription] = useState(null); + const [plan, setPlan] = useState(null); + const [scheduled, setScheduled] = useState(null); + const [active, setActive] = useState(false); + const { request, isLoading: loading, error: apiError } = useApiRequest(); + const [error, setError] = useState(null); + + const loadPlans = useCallback(async () => { + try { + const data = await fetchSelectablePlans(request, mandateId); + setPlans(Array.isArray(data) ? data : []); + } catch (err) { + console.error('Error loading plans:', err); + setPlans([]); + } + }, [request, mandateId]); + + const loadStatus = useCallback(async () => { + try { + const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId); + setActive(data.active); + setSubscription(data.subscription ?? null); + setPlan(data.plan ?? null); + setScheduled(data.scheduled ?? null); + } catch (err) { + console.error('Error loading subscription status:', err); + setActive(false); + setSubscription(null); + setPlan(null); + setScheduled(null); + } + }, [request, mandateId]); + + const activatePlan = useCallback(async (planKey: string) => { + try { + setError(null); + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete('success'); + currentUrl.searchParams.delete('canceled'); + currentUrl.searchParams.delete('session_id'); + currentUrl.searchParams.set('tab', 'subscription'); + if (mandateId) currentUrl.searchParams.set('mandate', mandateId); + const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`; + + const result = await activatePlanApi(request, planKey, mandateId, returnUrl); + if (result?.redirectUrl) { + window.location.href = result.redirectUrl; + return; + } + await loadStatus(); + } catch (err: any) { + const msg = err?.response?.data?.detail || err.message || 'Fehler beim Aktivieren'; + setError(msg); + throw err; + } + }, [request, mandateId, loadStatus]); + + const cancelSub = useCallback(async (subscriptionId: string) => { + try { + setError(null); + await cancelSubscriptionApi(request, subscriptionId, mandateId); + await loadStatus(); + } catch (err: any) { + const msg = err?.response?.data?.detail || err.message || 'Fehler beim Kündigen'; + setError(msg); + throw err; + } + }, [request, mandateId, loadStatus]); + + const reactivateSub = useCallback(async (subscriptionId: string) => { + try { + setError(null); + await reactivateSubscriptionApi(request, subscriptionId, mandateId); + await loadStatus(); + } catch (err: any) { + const msg = err?.response?.data?.detail || err.message || 'Fehler beim Reaktivieren'; + setError(msg); + throw err; + } + }, [request, mandateId, loadStatus]); + + const verifyCheckout = useCallback(async (sessionId: string) => { + const result = await verifyCheckoutApi(request, sessionId, mandateId); + await loadStatus(); + return result; + }, [request, mandateId, loadStatus]); + + const refetch = useCallback(async () => { + await Promise.all([loadPlans(), loadStatus()]); + }, [loadPlans, loadStatus]); + + useEffect(() => { + if (mandateId) { + loadPlans(); + loadStatus(); + } else { + setPlans([]); + setSubscription(null); + setPlan(null); + setScheduled(null); + setActive(false); + } + }, [mandateId]); + + return { + plans, + subscription, + plan, + scheduled, + active, + loading, + error: error || (apiError ? String(apiError) : null), + loadPlans, + loadStatus, + activatePlan, + cancelSubscription: cancelSub, + reactivateSubscription: reactivateSub, + verifyCheckout, + refetch, + }; +} diff --git a/src/pages/admin/PermissionMatrix.tsx b/src/pages/admin/PermissionMatrix.tsx index 7738610..d7a9b1b 100644 --- a/src/pages/admin/PermissionMatrix.tsx +++ b/src/pages/admin/PermissionMatrix.tsx @@ -4,8 +4,9 @@ * User × Role matrix with inline toggles and edit/remove actions. */ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { FaEdit, FaTrash } from 'react-icons/fa'; +import { useConfirm } from '../../hooks/useConfirm'; import type { FeatureAccessUser } from '../../hooks/useFeatureAccess'; import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess'; import styles from './Admin.module.css'; @@ -29,15 +30,20 @@ export const PermissionMatrix: React.FC = ({ disabled = false, }) => { const [removingId, setRemovingId] = useState(null); + const { confirm, ConfirmDialog } = useConfirm(); - const handleRemove = (user: FeatureAccessUser) => { + const handleRemove = useCallback(async (user: FeatureAccessUser) => { if (removingId) return; - if (window.confirm(`"${user.username}" aus dieser Instanz entfernen?`)) { - setRemovingId(user.userId); - onRemoveUser(user); - setRemovingId(null); - } - }; + const ok = await confirm(`"${user.username}" aus dieser Instanz entfernen?`, { + title: 'Benutzer entfernen', + confirmLabel: 'Entfernen', + variant: 'danger', + }); + if (!ok) return; + setRemovingId(user.userId); + onRemoveUser(user); + setRemovingId(null); + }, [removingId, confirm, onRemoveUser]); if (roles.length === 0) { return ( @@ -135,6 +141,7 @@ export const PermissionMatrix: React.FC = ({ + Benutzer hinzufügen + ); }; diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx new file mode 100644 index 0000000..36450d0 --- /dev/null +++ b/src/pages/billing/AdminSubscriptionsPage.tsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; +import { useApiRequest } from '../../hooks/useApi'; +import { useConfirm } from '../../hooks/useConfirm'; +import api from '../../api'; +import styles from './Billing.module.css'; + +const _TERMINAL_STATUSES = new Set(['EXPIRED']); + +const _STATUS_LABELS: Record = { + PENDING: 'Ausstehend', + SCHEDULED: 'Geplant', + TRIALING: 'Testphase', + ACTIVE: 'Aktiv', + PAST_DUE: 'Überfällig', + EXPIRED: 'Abgelaufen', +}; + +const _COLUMNS: ColumnConfig[] = [ + { key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, width: 180 }, + { key: 'planTitle', label: 'Plan', type: 'text' as any, sortable: true, filterable: true, width: 180 }, + { key: 'status', label: 'Status', type: 'text' as any, sortable: true, filterable: true, width: 110 }, + { key: 'recurring', label: 'Wiederkehrend', type: 'boolean' as any, sortable: true, filterable: true, width: 120 }, + { key: 'activeUsers', label: 'User', type: 'number' as any, sortable: true, width: 70 }, + { key: 'activeInstances', label: 'Instanzen', type: 'number' as any, sortable: true, width: 90 }, + { key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number' as any, sortable: true, width: 140 }, + { key: 'startedAt', label: 'Gestartet', type: 'date' as any, sortable: true, width: 130 }, + { key: 'currentPeriodEnd', label: 'Periodenende', type: 'date' as any, sortable: true, width: 130 }, + { key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number' as any, sortable: true, width: 100 }, + { key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number' as any, sortable: true, width: 110 }, +]; + +const AdminSubscriptionsPage: React.FC = () => { + const navigate = useNavigate(); + const { request } = useApiRequest(); + const { confirm, ConfirmDialog } = useConfirm(); + const [subscriptions, setSubscriptions] = useState([]); + const [loading, setLoading] = useState(true); + + const _loadSubscriptions = useCallback(async () => { + setLoading(true); + try { + const data = await request({ url: '/api/subscription/admin/all', method: 'get' }); + const rows = (Array.isArray(data) ? data : []).map((row: any) => ({ + ...row, + status: _STATUS_LABELS[row.status] || row.status, + _rawStatus: row.status, + })); + setSubscriptions(rows); + } catch (err) { + console.error('Failed to load subscriptions:', err); + setSubscriptions([]); + } finally { + setLoading(false); + } + }, [request]); + + useEffect(() => { _loadSubscriptions(); }, [_loadSubscriptions]); + + const _handleForceCancel = useCallback(async (row: any) => { + const ok = await confirm( + `Subscription «${row.planTitle}» für Mandant «${row.mandateName}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.`, + { confirmLabel: 'Sofort kündigen', cancelLabel: 'Abbrechen', variant: 'danger' }, + ); + if (!ok) return; + + try { + await api.post('/api/subscription/force-cancel', { subscriptionId: row.id }); + await _loadSubscriptions(); + } catch (err) { + console.error('Force cancel failed:', err); + } + }, [confirm, _loadSubscriptions]); + + return ( +
+
+

Subscription-Übersicht

+

Alle Abonnements aller Mandanten

+ +
+ + {loading ? ( +
Lade Subscriptions…
+ ) : ( + _handleForceCancel(row), + isVisible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus), + }, + ]} + emptyMessage="Keine Subscriptions vorhanden." + /> + )} + + +
+ ); +}; + +export default AdminSubscriptionsPage; diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index ce2b743..af26318 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -8,15 +8,20 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { useSearchParams, Link } from 'react-router-dom'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import type { CheckoutCreateRequest } from '../../api/billingApi'; import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates'; import { useCurrentUser } from '../../hooks/useUsers'; +import { useApiRequest } from '../../hooks/useApi'; +import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; +import { SubscriptionTab } from './SubscriptionTab'; import api from '../../api'; import { getUserDataCache } from '../../utils/userCache'; import styles from './Billing.module.css'; +type AdminTabType = 'settings' | 'credit' | 'subscription' | 'transactions'; + const _formatCurrency = (amount: number) => { return new Intl.NumberFormat('de-CH', { style: 'currency', @@ -206,7 +211,7 @@ interface CreditAdderProps { const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { const [selectedUserId, setSelectedUserId] = useState(''); const [amount, setAmount] = useState(''); - const [description, setDescription] = useState('Manuelles Aufladen durch Admin'); + const [description, setDescription] = useState('Manuelle Buchung durch Admin'); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -222,8 +227,8 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const numAmount = parseFloat(amount); - if (!numAmount || numAmount <= 0) { - setMessage({ type: 'error', text: 'Betrag muss positiv sein' }); + if (!numAmount || numAmount === 0) { + setMessage({ type: 'error', text: 'Betrag darf nicht null sein' }); return; } @@ -232,10 +237,13 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on try { await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description); - setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` }); + const label = numAmount > 0 + ? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` + : `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`; + setMessage({ type: 'success', text: label }); setAmount(''); } catch (err: any) { - setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' }); + setMessage({ type: 'error', text: err.message || 'Fehler bei der Buchung' }); } finally { setSaving(false); } @@ -243,7 +251,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on return (
-

Guthaben manuell aufladen

+

Guthaben manuell verwalten

{message && (
@@ -285,8 +293,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on className={styles.input} value={amount} onChange={(e) => setAmount(e.target.value)} - placeholder="z.B. 50" - min="0.01" + placeholder="z.B. 50 oder -20" step="0.01" required /> @@ -308,7 +315,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on className={`${styles.button} ${styles.buttonPrimary}`} disabled={saving || (isPrepayUser && !selectedUserId) || !amount} > - {saving ? 'Wird gutgeschrieben...' : 'Manuell aufladen'} + {saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
@@ -456,6 +463,78 @@ const MandateStripeTopUp: React.FC = ({ mandateId, crea ); }; +// ============================================================================ +// MANDATE TRANSACTIONS TAB (FormGeneratorTable with filters, search, export) +// ============================================================================ + +const _mandateTxColumns: ColumnConfig[] = [ + { key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 }, + { 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: '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 }, +]; + +interface MandateTransactionsTabProps { + mandateId: string; +} + +const MandateTransactionsTab: React.FC = ({ mandateId }) => { + const { request, isLoading: loading } = useApiRequest(); + const [transactions, setTransactions] = useState([]); + const [error, setError] = useState(null); + + const _loadTransactions = useCallback(async () => { + try { + setError(null); + const data = await request({ + url: `/api/billing/admin/transactions/${mandateId}`, + method: 'get', + params: { limit: 500 }, + }); + setTransactions(Array.isArray(data) ? data : []); + } catch (err: any) { + setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden'); + setTransactions([]); + } + }, [request, mandateId]); + + useEffect(() => { + _loadTransactions(); + }, [_loadTransactions]); + + const hookData = useMemo(() => ({ + refetch: _loadTransactions, + }), [_loadTransactions]); + + return ( +
+

+ AI-Verbrauch und Guthaben-Transaktionen. Subscription-Gebühren werden separat über Stripe abgerechnet. +

+ {error &&
{error}
} + +
+ ); +}; + // ============================================================================ // MAIN COMPONENT // ============================================================================ @@ -465,7 +544,9 @@ export const BillingAdmin: React.FC = () => { const { user: currentUser } = useCurrentUser(); const isSysAdmin = currentUser?.isSysAdmin === true; - const [selectedMandateId, setSelectedMandateId] = useState(null); + const [selectedMandateId, setSelectedMandateId] = useState( + searchParams.get('mandate') || null + ); const [mandateList, setMandateList] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); @@ -530,7 +611,14 @@ export const BillingAdmin: React.FC = () => { const canceledParam = searchParams.get('canceled'); const sessionIdParam = searchParams.get('session_id'); + const _initialAdminTab = (searchParams.get('tab') as AdminTabType) || 'settings'; + const [adminTab, setAdminTab] = useState( + ['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings' + ); + useEffect(() => { + if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return; + let cancelled = false; const _confirmCheckoutIfNeeded = async () => { @@ -580,34 +668,51 @@ export const BillingAdmin: React.FC = () => { return () => { cancelled = true; }; - }, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]); + }, [adminTab, successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]); const _clearStripeParams = useCallback(() => { searchParams.delete('success'); searchParams.delete('canceled'); searchParams.delete('session_id'); + searchParams.delete('mandate'); setSearchParams(searchParams, { replace: true }); setStripeReturnMessage(null); }, [searchParams, setSearchParams]); const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings; + const _tabStyle = (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 (
-

Billing Administration

-

- {isSysAdmin - ? 'Verwaltung von Abrechnungseinstellungen und Guthaben' - : 'Guthaben und Konten für Ihre Mandanten'} -

- {isSysAdmin && ( -

- - Mandanten-Übersicht (Balances & Transaktionen) +

+
+

Billing-Verwaltung

+

+ Abrechnungseinstellungen, Guthaben und Abonnement pro Mandant +

+
+ {isSysAdmin && ( + + Alle Abonnements → -

- )} + )} +
{stripeReturnMessage && ( @@ -635,9 +740,30 @@ export const BillingAdmin: React.FC = () => { /> - {selectedMandateId && ( + {selectedMandateId ? ( <> - {isSysAdmin && ( + + + {adminTab === 'settings' && ( { /> )} - {isSysAdmin && ( - + {adminTab === 'credit' && ( + <> + {isSysAdmin && ( + + )} + + {showStripeForMandateAdmin && ( + + )} + + + )} - {showStripeForMandateAdmin && ( - + {adminTab === 'subscription' && ( + )} - + {adminTab === 'transactions' && ( + + )} - )} - - {!selectedMandateId && ( + ) : (
Bitte wählen Sie einen Mandanten aus.
)}
diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 0a1dfef..5c313ff 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -741,6 +741,7 @@ export const BillingDataView: React.FC = () => { />
)} + ); }; diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx index cc84f16..3279505 100644 --- a/src/pages/billing/BillingMandateView.tsx +++ b/src/pages/billing/BillingMandateView.tsx @@ -175,7 +175,11 @@ const TransactionTable: React.FC = ({ transactions }) => // MAIN COMPONENT // ============================================================================ -export const BillingMandateView: React.FC = () => { +interface BillingMandateViewProps { + embedded?: boolean; +} + +export const BillingMandateView: React.FC = ({ embedded = false }) => { const { request, isLoading: loading } = useApiRequest(); const [balances, setBalances] = useState([]); const [transactions, setTransactions] = useState([]); @@ -212,13 +216,17 @@ export const BillingMandateView: React.FC = () => { }; return ( -
-
-

Mandanten-Billing

-

Guthaben und Transaktionen pro Mandant

-
+
+ {!embedded && ( + <> +
+

Mandanten-Billing

+

Guthaben und Transaktionen pro Mandant

+
- + + + )} {/* Mandate Balances */}
diff --git a/src/pages/billing/SubscriptionTab.tsx b/src/pages/billing/SubscriptionTab.tsx new file mode 100644 index 0000000..c06d749 --- /dev/null +++ b/src/pages/billing/SubscriptionTab.tsx @@ -0,0 +1,481 @@ +/** + * SubscriptionTab — State-machine-aligned subscription management UI. + * + * Shows: + * - Current operative subscription with status, recurring flag, and period info + * - Scheduled successor (if plan switch in progress) + * - Available plans as cards + * - ID-based actions: cancel, reactivate, activate + */ + +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { useSubscription } from '../../hooks/useSubscription'; +import { useConfirm } from '../../hooks/useConfirm'; +import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi'; +import styles from './Billing.module.css'; + +// ============================================================================ +// Helpers +// ============================================================================ + +const _lang = (): string => + typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en'; + +const _t = (dict: Record | undefined): string => { + if (!dict) return ''; + const l = _lang(); + return dict[l] || dict['en'] || dict['de'] || Object.values(dict)[0] || ''; +}; + +const _formatCurrency = (amount: number) => + new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); + +const _formatDate = (iso: string | null | undefined): string => { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } catch { + return iso; + } +}; + +const _statusLabel: Record = { + PENDING: { label: 'Zahlung ausstehend', color: '#f59e0b' }, + SCHEDULED: { label: 'Geplant', color: '#8b5cf6' }, + ACTIVE: { label: 'Aktiv', color: '#22c55e' }, + TRIALING: { label: 'Testphase', color: '#3b82f6' }, + PAST_DUE: { label: 'Zahlung ausstehend', color: '#f59e0b' }, + EXPIRED: { label: 'Abgelaufen', color: '#6b7280' }, +}; + +const _periodLabel: Record = { + MONTHLY: 'Monatlich', + YEARLY: 'Jährlich', + NONE: '—', +}; + +// ============================================================================ +// Plan Card +// ============================================================================ + +interface PlanCardProps { + plan: SubscriptionPlan; + isCurrent: boolean; + onActivate: (planKey: string) => void; + activatingPlanKey: string | null; +} + +const PlanCard: React.FC = ({ plan, isCurrent, onActivate, activatingPlanKey }) => { + const activating = activatingPlanKey === plan.planKey; + const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0; + + return ( +
+
+ {_t(plan.title)} + {isCurrent && ( + Aktuell + )} +
+ +

+ {_t(plan.description)} +

+ + {!isFreePlan && ( +
+
User: {_formatCurrency(plan.pricePerUserCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
+
Instanz: {_formatCurrency(plan.pricePerFeatureInstanceCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
+
+ )} + + {isFreePlan && plan.trialDays && ( +
+ {plan.trialDays} Tage kostenlos + {plan.maxUsers && <> · max. {plan.maxUsers} User} + {plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen} +
+ )} + + {!isCurrent && ( + + )} +
+ ); +}; + +// ============================================================================ +// Subscription Info Card +// ============================================================================ + +interface SubInfoProps { + sub: MandateSubscription; + plan: SubscriptionPlan | null; + label: string; + onCancel?: (id: string) => void; + onReactivate?: (id: string) => void; + cancelling: boolean; + reactivating: boolean; + justPaid?: boolean; +} + +const SubInfoCard: React.FC = ({ sub, plan, label, onCancel, onReactivate, cancelling, reactivating, justPaid }) => { + const statusInfo = _statusLabel[sub.status] || _statusLabel.EXPIRED; + const isActive = sub.status === 'ACTIVE'; + const isPending = sub.status === 'PENDING'; + const isScheduled = sub.status === 'SCHEDULED'; + + return ( +
+
+ {label} +
+
+ {plan ? _t(plan.title) : sub.planKey} +
+ {isActive && !sub.recurring && ( + Gekündigt + )} + {statusInfo.label} +
+
+ + {isPending && ( +
+ {justPaid + ? 'Zahlung erfolgreich. Abonnement wird aktiviert — bitte warten...' + : 'Die Zahlung wurde noch nicht abgeschlossen. Sie können den Checkout abbrechen oder erneut starten.'} +
+ )} + + {isScheduled && sub.effectiveFrom && ( +
+ Dieses Abonnement wird am {_formatDate(sub.effectiveFrom)} aktiv, wenn das aktuelle Abonnement ausläuft. +
+ )} + + {!isPending && !isScheduled && ( +
+ Gestartet: {_formatDate(sub.startedAt)} + {plan && Periode: {_periodLabel[plan.billingPeriod] || '—'}} + {sub.currentPeriodEnd && Periodenende: {_formatDate(sub.currentPeriodEnd)}} + {sub.trialEndsAt && Trial endet: {_formatDate(sub.trialEndsAt)}} + {isActive && !sub.recurring && sub.currentPeriodEnd && ( + Läuft aus am: {_formatDate(sub.currentPeriodEnd)} + )} +
+ )} + +
+ {isActive && !sub.recurring && onReactivate && ( + + )} + + {isActive && sub.recurring && onCancel && ( + + )} + + {(isPending || isScheduled) && onCancel && ( + + )} +
+
+ ); +}; + +// ============================================================================ +// Subscription Tab +// ============================================================================ + +interface SubscriptionTabProps { + mandateId: string; +} + +export const SubscriptionTab: React.FC = ({ mandateId }) => { + const { + plans, + subscription, + plan: currentPlan, + scheduled, + loading, + error, + activatePlan, + cancelSubscription, + reactivateSubscription, + verifyCheckout, + } = useSubscription(mandateId); + + const { confirm, ConfirmDialog } = useConfirm(); + const [activatingPlanKey, setActivatingPlanKey] = useState(null); + const [cancelling, setCancelling] = useState(false); + const [reactivating, setReactivating] = useState(false); + const [actionError, setActionError] = useState(null); + const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'info'; text: string } | null>(null); + const [justPaid, setJustPaid] = useState(false); + const verifyCalledRef = useRef(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('success') === 'true') { + const sessionId = params.get('session_id') || ''; + setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich — Abonnement wird aktiviert...' }); + setJustPaid(true); + + const url = new URL(window.location.href); + url.searchParams.delete('success'); + url.searchParams.delete('session_id'); + window.history.replaceState({}, '', url.toString()); + + if (sessionId && !verifyCalledRef.current) { + verifyCalledRef.current = true; + verifyCheckout(sessionId) + .then((result) => { + if (result.status === 'activated') { + setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' }); + setJustPaid(false); + } + }) + .catch(() => {}); + } + } else if (params.get('canceled') === 'true') { + setCheckoutMessage({ type: 'info', text: 'Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.' }); + const url = new URL(window.location.href); + url.searchParams.delete('canceled'); + window.history.replaceState({}, '', url.toString()); + } + }, []); + + useEffect(() => { + if (!justPaid) return; + if (subscription && subscription.status !== 'PENDING') { + setJustPaid(false); + setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' }); + } + }, [justPaid, subscription]); + + const _handleActivate = useCallback(async (planKey: string) => { + setActivatingPlanKey(planKey); + setActionError(null); + try { + await activatePlan(planKey); + } catch (err: any) { + setActionError(err?.response?.data?.detail || err.message || 'Fehler beim Aktivieren'); + } finally { + setActivatingPlanKey(null); + } + }, [activatePlan]); + + const _handleCancel = useCallback(async (subscriptionId: string) => { + const sub = subscription?.id === subscriptionId ? subscription : scheduled; + const isPendingOrScheduled = sub?.status === 'PENDING' || sub?.status === 'SCHEDULED'; + const ok = await confirm( + isPendingOrScheduled + ? 'Diesen Vorgang abbrechen?' + : 'Abonnement kündigen? Es bleibt bis zum Periodenende aktiv.', + { + title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen', + confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen', + cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined, + variant: 'danger', + }, + ); + if (!ok) return; + setCancelling(true); + setActionError(null); + try { + await cancelSubscription(subscriptionId); + setCheckoutMessage(null); + } catch (err: any) { + setActionError(err?.response?.data?.detail || err.message || 'Fehler'); + } finally { + setCancelling(false); + } + }, [cancelSubscription, subscription, scheduled]); + + const _handleReactivate = useCallback(async (subscriptionId: string) => { + setReactivating(true); + setActionError(null); + try { + await reactivateSubscription(subscriptionId); + } catch (err: any) { + setActionError(err?.response?.data?.detail || err.message || 'Fehler beim Reaktivieren'); + } finally { + setReactivating(false); + } + }, [reactivateSubscription]); + + if (loading && !subscription) { + return
Lade Abonnement-Daten...
; + } + + return ( +
+ {/* Checkout feedback */} + {checkoutMessage && ( +
+ {checkoutMessage.text} +
+ )} + + {/* Error display */} + {(error || actionError) && ( +
+ {actionError || error} +
+ )} + + {/* Current subscription */} +
+

Aktuelles Abonnement

+ {subscription ? ( + + ) : ( +
+ Kein aktives Abonnement. Wählen Sie unten einen Plan. +
+ )} +
+ + {/* Scheduled successor */} + {scheduled && ( +
+

Geplanter Nachfolger

+ +
+ )} + + {/* Available plans */} +
+

Verfügbare Pläne

+ {plans.length === 0 ? ( +
Keine Pläne verfügbar
+ ) : ( +
+ {plans.map((p) => ( + + ))} +
+ )} +
+ + +
+ ); +}; diff --git a/src/pages/billing/index.ts b/src/pages/billing/index.ts index 86d7563..a399597 100644 --- a/src/pages/billing/index.ts +++ b/src/pages/billing/index.ts @@ -11,3 +11,4 @@ export { BillingNav } from './BillingNav'; export { BillingTransactions } from './BillingTransactions'; export { BillingMandateView } from './BillingMandateView'; export { BillingUserView } from './BillingUserView'; +export { default as AdminSubscriptionsPage } from './AdminSubscriptionsPage'; diff --git a/src/pages/views/chatbot/ChatbotConversationsView.tsx b/src/pages/views/chatbot/ChatbotConversationsView.tsx index cfbfa32..db0d660 100644 --- a/src/pages/views/chatbot/ChatbotConversationsView.tsx +++ b/src/pages/views/chatbot/ChatbotConversationsView.tsx @@ -5,8 +5,9 @@ * Similar to trustee views but hardcoded for chatbot feature. */ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { useChatbot } from '../../../hooks/useChatbot'; +import { useConfirm } from '../../../hooks/useConfirm'; import { TextField } from '../../../components/UiComponents/TextField'; import { Button } from '../../../components/UiComponents/Button'; import { AutoScroll } from '../../../components/UiComponents/AutoScroll'; @@ -40,7 +41,8 @@ export const ChatbotConversationsView: React.FC = () => { } = useChatbot(); const [deletingId, setDeletingId] = useState(null); - + const { confirm, ConfirmDialog } = useConfirm(); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!inputValue.trim() || isStreaming) return; @@ -76,17 +78,21 @@ export const ChatbotConversationsView: React.FC = () => { } }; - const handleDeleteThread = async (e: React.MouseEvent, workflowId: string) => { + const handleDeleteThread = useCallback(async (e: React.MouseEvent, workflowId: string) => { e.stopPropagation(); - if (window.confirm('Möchten Sie diese Konversation wirklich löschen?')) { - setDeletingId(workflowId); - try { - await deleteThread(workflowId); - } finally { - setDeletingId(null); - } + const ok = await confirm('Möchten Sie diese Konversation wirklich löschen?', { + title: 'Konversation löschen', + confirmLabel: 'Löschen', + variant: 'danger', + }); + if (!ok) return; + setDeletingId(workflowId); + try { + await deleteThread(workflowId); + } finally { + setDeletingId(null); } - }; + }, [confirm, deleteThread]); const formatDate = (timestamp?: number) => { if (!timestamp) return ''; @@ -269,6 +275,7 @@ export const ChatbotConversationsView: React.FC = () => { )} +
); }; diff --git a/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx b/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx index b145a0d..32debd7 100644 --- a/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx +++ b/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx @@ -10,6 +10,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useApiRequest } from '../../../hooks/useApi'; import { useToast } from '../../../contexts/ToastContext'; +import { useConfirm } from '../../../hooks/useConfirm'; import { fetchAccountingConnectors, fetchAccountingConfig, @@ -42,6 +43,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => { const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const mountedRef = useRef(true); + const { confirm, ConfirmDialog } = useConfirm(); useEffect(() => { if (!importDone) return; @@ -145,7 +147,12 @@ export const TrusteeAccountingSettingsView: React.FC = () => { const handleRemove = async () => { if (!instanceId) return; - if (!window.confirm('Remove the accounting integration? This does not delete synced data.')) return; + const ok = await confirm('Remove the accounting integration? This does not delete synced data.', { + title: 'Remove Integration', + confirmLabel: 'Remove', + variant: 'danger', + }); + if (!ok) return; setSaving(true); try { await deleteAccountingConfig(request, instanceId); @@ -421,6 +428,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
)} + ); }; diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts index d8ff7c4..7da9299 100644 --- a/src/pages/views/workspace/useWorkspace.ts +++ b/src/pages/views/workspace/useWorkspace.ts @@ -358,7 +358,18 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { setIsProcessing(false); const item = event.item as Record | undefined; let msg = event.content || 'Unknown error'; - if (item && item.error === 'INSUFFICIENT_BALANCE') { + const subscriptionErrors = new Set([ + 'SUBSCRIPTION_INACTIVE', + 'SUBSCRIPTION_PAYMENT_REQUIRED', + 'SUBSCRIPTION_PAYMENT_PENDING', + 'SUBSCRIPTION_EXPIRED', + ]); + if (item && typeof item.error === 'string' && subscriptionErrors.has(item.error)) { + msg = typeof item.message === 'string' ? item.message : msg; + if (typeof item.subscriptionUiPath === 'string') { + msg += `\n\n→ ${item.subscriptionUiPath}`; + } + } else if (item && item.error === 'INSUFFICIENT_BALANCE') { const preferDe = typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de'); const de = typeof item.messageDe === 'string' ? item.messageDe : ''; From e6d28c436b3b88de58b269a5f8861dc26b68f97e Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 22 Mar 2026 21:34:58 +0100 Subject: [PATCH 2/5] streamlined formgeneratortable and sort/filter globally --- src/api/automationApi.ts | 29 +- .../FormGeneratorControls.tsx | 6 +- .../FormGeneratorTable.module.css | 1 + .../FormGeneratorTable/FormGeneratorTable.tsx | 377 +++++++++++++----- src/config/pageRegistry.tsx | 2 + src/hooks/useAdminSubscriptions.ts | 84 ++++ src/hooks/useAutomations.ts | 17 +- src/pages/admin/AdminAutomationEventsPage.tsx | 29 +- src/pages/admin/AdminFeatureRolesPage.tsx | 32 +- src/pages/admin/AdminInvitationsPage.tsx | 2 +- src/pages/basedata/FilesPage.tsx | 2 + src/pages/basedata/PromptsPage.tsx | 103 ++--- src/pages/billing/AdminSubscriptionsPage.tsx | 85 ++-- src/pages/billing/BillingAdmin.tsx | 34 +- src/pages/billing/BillingDataView.tsx | 17 +- .../automation/AutomationTemplatesView.tsx | 4 +- .../realestate/RealEstateParcelsView.tsx | 24 +- .../realestate/RealEstateProjectsView.tsx | 22 +- .../views/trustee/TrusteeDocumentsView.tsx | 27 +- .../trustee/TrusteePositionDocumentsView.tsx | 27 +- .../views/trustee/TrusteePositionsView.tsx | 27 +- 21 files changed, 563 insertions(+), 388 deletions(-) create mode 100644 src/hooks/useAdminSubscriptions.ts diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts index 38d560c..80d44cd 100644 --- a/src/api/automationApi.ts +++ b/src/api/automationApi.ts @@ -254,17 +254,26 @@ export async function fetchAutomationAttributes( * Endpoint: GET /api/automation-templates */ export async function fetchAutomationTemplates( - request: ApiRequestFunction -): Promise { - const data = await request({ - url: '/api/automation-templates', - method: 'get' - }); - - if (data?.items && Array.isArray(data.items)) { - return data.items; + request: ApiRequestFunction, + params?: any +): Promise { + const requestParams: Record = {}; + if (params && typeof params === 'object') { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } } - return Array.isArray(data) ? data : []; + return await request({ + url: '/api/automation-templates', + method: 'get', + params: requestParams, + }); } /** diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index 11f33b4..24bbbae 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -100,11 +100,10 @@ export function FormGeneratorControls({ onPageChange, onPageSizeChange, supportsBackendPagination = false, - hookData: _hookData, // Reserved for future use + hookData, onCsvExport, csvExporting = false }: FormGeneratorControlsProps) { - void _hookData; // Suppress unused variable warning const { t } = useLanguage(); // Check if all items are selected @@ -290,9 +289,8 @@ export function FormGeneratorControls({ »» - {/* Total items count - always show actual displayed data length */} - ({loading ? '...' : displayData.length.toString()} {t('formgen.pagination.items', 'items')}) + ({loading ? '...' : (hookData?.pagination?.totalItems ?? displayData.length).toString()} {t('formgen.pagination.items', 'items')}) )} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index 6e4a873..cd30431 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -56,6 +56,7 @@ position: relative; overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */ overflow-y: auto; + scrollbar-gutter: stable; background: var(--color-bg); /* Fill remaining space but constrain to available height */ flex: 1 1 0; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index f97d6d8..351a59b 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -1,3 +1,58 @@ +/** + * FormGeneratorTable — Backend-driven data table. + * + * ARCHITECTURE: + * This table does NO client-side filtering, sorting, or pagination. + * All data processing is delegated to the backend via hookData.refetch(). + * The `data` prop is rendered as-is (displayData = data). + * + * REQUIRED CONTRACT for interactive features (search, filter, sort, pagination): + * + * hookData={{ + * refetch, // (params?: PaginationParams) => Promise + * // Called on every search/filter/sort/page change. + * // Must fetch from backend with pagination query param + * // and update the data + pagination states. + * pagination, // { currentPage, pageSize, totalItems, totalPages } | null + * // Drives pagination controls. Comes from backend response. + * fetchFilterValues, // (columnKey: string) => Promise (Optional) + * // If provided, called when a filter dropdown opens. + * // If NOT provided but apiEndpoint is set, the table + * // auto-fetches from `{apiEndpoint}/filter-values?column=xxx`. + * }} + * + * Without hookData.refetch, interactive controls (sort, filter, search, + * pagination) are inert — the table renders data but actions have no effect. + * + * FILTER VALUES (autofilter): + * When a filterable column's dropdown opens, distinct values are loaded from: + * 1. column.filterOptions (static enum — used as-is, no backend call) + * 2. hookData.fetchFilterValues(columnKey) if provided + * 3. GET {apiEndpoint}/filter-values?column=xxx&pagination={currentFilters} + * Cross-filtering is supported: changing a filter invalidates the cache, + * so re-opening another column's dropdown re-fetches with current filters. + * Boolean columns render as "Ja"/"Nein"; date columns render as range picker. + * + * BACKEND RESPONSE FORMAT (for refetch): + * { items: T[], pagination: PaginationMetadata | null } + * + * BACKEND RESPONSE FORMAT (for filter-values): + * string[] + * + * EXAMPLE (minimal integration): + * + * const { data, pagination, loading, refetch } = useMyEntityHook(); + * + * + * + * See useOrgUsers / AdminUsersPage for a full reference implementation. + */ import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import type { IconType } from 'react-icons'; import { useLanguage } from '../../../providers/language/LanguageContext'; @@ -175,6 +230,67 @@ export interface FormGeneratorTableProps { onRowDragStart?: (e: React.DragEvent, row: T) => void; } +const _FILTER_PAGE_SIZE = 100; + +/** + * Renders a scrollable list of filter values with IntersectionObserver-based lazy loading. + * Shows _FILTER_PAGE_SIZE items initially, loads more as the user scrolls. + */ +function FilterValuesList({ + columnKey, + allValues, + activeFilter, + onSelect, +}: { + columnKey: string; + allValues: string[]; + activeFilter: any; + onSelect: (value: string) => void; +}) { + const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE); + const sentinelRef = useRef(null); + + useEffect(() => { + setDisplayCount(_FILTER_PAGE_SIZE); + }, [columnKey, allValues.length]); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || displayCount >= allValues.length) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setDisplayCount(prev => Math.min(prev + _FILTER_PAGE_SIZE, allValues.length)); + } + }, + { threshold: 0.1 } + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [displayCount, allValues.length]); + + const visibleValues = allValues.slice(0, displayCount); + + return ( + <> + {visibleValues.map(value => ( +
onSelect(value)} + title={value} + > + {value.length > 30 ? value.substring(0, 30) + '...' : value} +
+ ))} + {displayCount < allValues.length && ( +
+ )} + + ); +} + export function FormGeneratorTable>({ data, columns: providedColumns, @@ -294,8 +410,11 @@ export function FormGeneratorTable>({ useEffect(() => { const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 300); // 300ms debounce + setDebouncedSearchTerm(prev => { + if (prev !== searchTerm) setCurrentPage(1); + return searchTerm; + }); + }, 300); return () => clearTimeout(timer); }, [searchTerm]); @@ -718,21 +837,19 @@ export function FormGeneratorTable>({ const existingIndex = current.findIndex(sc => sc.key === key); if (existingIndex === -1) { - // Column not in sort list → add as ascending (lowest priority) return [...current, { key, direction: 'asc' }]; } const existing = current[existingIndex]; if (existing.direction === 'asc') { - // Ascending → change to descending (keep same position) const newConfigs = [...current]; newConfigs[existingIndex] = { key, direction: 'desc' }; return newConfigs; } - // Descending → remove from sort list return current.filter(sc => sc.key !== key); }); + setCurrentPage(1); }; // Get sort info for a column (returns { direction, position } or null) @@ -743,7 +860,7 @@ export function FormGeneratorTable>({ }, [sortConfigs]); // Handle filtering - const handleFilter = (key: string, value: any) => { + const handleFilter = (key: string, value: any, keepOpen = false) => { setFilters(prev => { const newFilters = { ...prev }; if (value === undefined || value === '' || value === null) { @@ -753,8 +870,10 @@ export function FormGeneratorTable>({ } return newFilters; }); - setCurrentPage(1); // Reset to first page when filtering - setOpenFilterColumn(null); // Close filter dropdown + setCurrentPage(1); + if (!keepOpen) { + setOpenFilterColumn(null); + } }; // Handle filter input focus @@ -782,22 +901,17 @@ export function FormGeneratorTable>({ }, [filters]); // Track which filter columns show all values (expanded beyond initial 100) - const [expandedFilterColumns, setExpandedFilterColumns] = useState>(new Set()); - // Async-loaded filter values per column (from backend via hookData.fetchFilterValues) const [asyncFilterValues, setAsyncFilterValues] = useState>({}); const [filterValuesLoading, setFilterValuesLoading] = useState>({}); - const _toggleFilterExpand = useCallback((columnKey: string) => { - setExpandedFilterColumns(prev => { - const next = new Set(prev); - if (next.has(columnKey)) { - next.delete(columnKey); - } else { - next.add(columnKey); - } - return next; - }); - }, []); + // Invalidate cached filter values when filters change (cross-filtering) + const filtersRef = useRef(filters); + useEffect(() => { + if (filtersRef.current !== filters) { + filtersRef.current = filters; + setAsyncFilterValues({}); + } + }, [filters]); // Load filter values on-demand when a filter dropdown is opened useEffect(() => { @@ -811,58 +925,61 @@ export function FormGeneratorTable>({ // Skip if already loaded or currently loading if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return; - // If the hook provides fetchFilterValues, use it (backend distinct query) - if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') { - setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: true })); - hookData.fetchFilterValues(openFilterColumn).then((values: string[]) => { - setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: values })); - }).catch(() => { - // On error, fall back to current page data (set empty to prevent re-fetch) - setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: [] })); - }).finally(() => { - setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: false })); - }); - } - }, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData]); + const _fetchValues = async (columnKey: string) => { + setFilterValuesLoading(prev => ({ ...prev, [columnKey]: true })); + try { + let values: string[]; + if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') { + values = await hookData.fetchFilterValues(columnKey); + } else if (apiEndpoint && supportsBackendPagination) { + const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint; + const params: Record = { column: columnKey }; + if (Object.keys(filters).length > 0) { + params.pagination = JSON.stringify({ filters }); + } + const response = await api.get(`${endpoint}/filter-values`, { params }); + values = Array.isArray(response.data) ? response.data : []; + } else { + values = []; + } + setAsyncFilterValues(prev => ({ ...prev, [columnKey]: values })); + } catch { + setAsyncFilterValues(prev => ({ ...prev, [columnKey]: [] })); + } finally { + setFilterValuesLoading(prev => ({ ...prev, [columnKey]: false })); + } + }; + + _fetchValues(openFilterColumn); + }, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData, apiEndpoint, supportsBackendPagination, filters]); // Get unique values for a column (for filter dropdown) - // Priority: 1) column.filterOptions (static enum) - // 2) asyncFilterValues (loaded from backend) - // 3) data (current page - fallback) + // Sources: 1) column.filterOptions (static enum) + // 2) asyncFilterValues (loaded from backend via hookData.fetchFilterValues) + // 3) data — ONLY when no backend pagination (data = full dataset) + // With backend pagination, data is a single page, so extracting filter + // values from it would be incomplete and misleading. const getUniqueValuesForColumn = useCallback((columnKey: string): string[] => { const column = detectedColumns.find(c => c.key === columnKey); - // Static enum options defined in the column config if (column?.filterOptions && column.filterOptions.length > 0) { return column.filterOptions; } - // Values loaded asynchronously from the backend (all data, not just page) if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) { return asyncFilterValues[columnKey]; } - // Fallback: extract from current page data - const values = new Set(); - data.forEach(row => { - const value = row[columnKey]; - if (value !== undefined && value !== null && value !== '') { - if (typeof value === 'object' && !Array.isArray(value)) { - if (isTextMultilingual(value)) { - const text = value.en || Object.values(value)[0]; - if (text) values.add(String(text)); - } else { - values.add(JSON.stringify(value)); - } - } else if (typeof value === 'boolean') { - values.add(value ? 'true' : 'false'); - } else { - values.add(String(value)); - } - } - }); - return Array.from(values).sort(); - }, [data, detectedColumns, asyncFilterValues]); + if (!apiEndpoint && !hookData?.fetchFilterValues) { + console.warn( + `FormGeneratorTable: Column "${columnKey}" is filterable ` + + `but has no filterOptions, no hookData.fetchFilterValues, and no apiEndpoint. ` + + `Filter dropdown will be empty. Provide apiEndpoint (auto-fetches /filter-values) ` + + `or add filterOptions to the column config.` + ); + } + return []; + }, [detectedColumns, asyncFilterValues, apiEndpoint, hookData]); // Close filter dropdown when clicking outside useEffect(() => { @@ -1131,7 +1248,7 @@ export function FormGeneratorTable>({ topScrollbar.removeEventListener('scroll', syncTopToContainer); tableContainer.removeEventListener('scroll', syncContainerToTop); }; - }, [displayData, detectedColumns, columnWidths]); // Re-run when data or columns change + }, [detectedColumns, columnWidths]); // ResizeObserver handles data-driven size changes // Track which cells are currently being updated (for loading state) const [updatingCells, setUpdatingCells] = useState>(new Set()); @@ -1828,54 +1945,104 @@ export function FormGeneratorTable>({ )}
- {/* "All" option to clear filter */} -
clearFilter(column.key)} - > - ({t('formgen.filter.all', 'All')}) -
- {/* Filter values - loaded from backend or static filterOptions */} - {filterValuesLoading[column.key] ? ( -
- {t('formgen.filter.loading', 'Lade Filterwerte...')} -
- ) : (() => { - const allValues = getUniqueValuesForColumn(column.key); - const isExpanded = expandedFilterColumns.has(column.key); - const displayLimit = isExpanded ? allValues.length : 100; - const visibleValues = allValues.slice(0, displayLimit); - const remaining = allValues.length - displayLimit; + {(() => { + const colType = column.type || 'text'; + const isBool = isCheckboxType(colType as AttributeType); + const isDate = isDateTimeType(colType as AttributeType); + + if (isBool) { + const currentVal = filters[column.key]; + return ( + <> +
clearFilter(column.key)} + > + ({t('formgen.filter.all', 'Alle')}) +
+
handleFilter(column.key, 'true')} + > + {t('formgen.filter.yes', 'Ja')} +
+
handleFilter(column.key, 'false')} + > + {t('formgen.filter.no', 'Nein')} +
+ + ); + } + + if (isDate) { + const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {}; + return ( +
+
clearFilter(column.key)} + > + ({t('formgen.filter.all', 'Alle')}) +
+ + { + const from = e.target.value; + const to = rangeVal.to || ''; + if (!from && !to) { + clearFilter(column.key); + } else { + handleFilter(column.key, { operator: 'between', value: { from, to } }, true); + } + }} + /> + + { + const to = e.target.value; + const from = rangeVal.from || ''; + if (!from && !to) { + clearFilter(column.key); + } else { + handleFilter(column.key, { operator: 'between', value: { from, to } }, true); + } + }} + /> +
+ ); + } return ( <> - {visibleValues.map(value => ( -
handleFilter(column.key, value)} - title={value} - > - {value.length > 30 ? value.substring(0, 30) + '...' : value} -
- ))} - {remaining > 0 && ( -
_toggleFilterExpand(column.key)} - style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }} - > - + {remaining} {t('formgen.filter.more', 'weitere anzeigen')} -
- )} - {isExpanded && allValues.length > 100 && ( -
_toggleFilterExpand(column.key)} - style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }} - > - {t('formgen.filter.less', 'Weniger anzeigen')} +
clearFilter(column.key)} + > + ({t('formgen.filter.all', 'Alle')}) +
+ {filterValuesLoading[column.key] ? ( +
+ {t('formgen.filter.loading', 'Lade Filterwerte...')}
+ ) : ( + handleFilter(column.key, value)} + /> )} ); diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 21a8b40..31922b5 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -22,6 +22,7 @@ import { FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase, FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock, FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList, + FaFileContract, } from 'react-icons/fa'; // ============================================================================= @@ -66,6 +67,7 @@ export const PAGE_ICONS: Record = { 'page.admin.user-access-overview': , 'page.admin.userAccessOverview': , 'page.admin.billing': , + 'page.admin.subscriptions': , 'page.admin.automationEvents': , 'page.admin.automation-events': , 'page.admin.logs': , diff --git a/src/hooks/useAdminSubscriptions.ts b/src/hooks/useAdminSubscriptions.ts new file mode 100644 index 0000000..02acd1f --- /dev/null +++ b/src/hooks/useAdminSubscriptions.ts @@ -0,0 +1,84 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; + +interface PaginationParams { + page?: number; + pageSize?: number; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; + filters?: Record; + search?: string; +} + +interface PaginationState { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; +} + +const _STATUS_LABELS: Record = { + PENDING: 'Ausstehend', + SCHEDULED: 'Geplant', + TRIALING: 'Testphase', + ACTIVE: 'Aktiv', + PAST_DUE: 'Überfällig', + EXPIRED: 'Abgelaufen', +}; + +export function useAdminSubscriptions() { + const [subscriptions, setSubscriptions] = useState([]); + const [pagination, setPagination] = useState(null); + const { request, isLoading: loading, error } = useApiRequest(); + + const refetch = useCallback(async (params?: PaginationParams) => { + try { + const requestParams: Record = {}; + + if (params) { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } + } + + const data = await request({ + url: '/api/subscription/admin/all', + method: 'get', + params: requestParams, + }); + + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray(data.items) ? data.items : []; + setSubscriptions(items.map(_enrichRow)); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setSubscriptions(items.map(_enrichRow)); + setPagination(null); + } + } catch { + setSubscriptions([]); + setPagination(null); + } + }, [request]); + + useEffect(() => { refetch(); }, [refetch]); + + return { data: subscriptions, pagination, loading, error, refetch }; +} + +function _enrichRow(row: any): any { + return { + ...row, + _rawStatus: row.status, + status: _STATUS_LABELS[row.status] || row.status, + }; +} diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts index 69a6cbf..28897ac 100644 --- a/src/hooks/useAutomations.ts +++ b/src/hooks/useAutomations.ts @@ -472,22 +472,30 @@ export function useAutomationOperations() { export function useAutomationTemplates() { const [templates, setTemplates] = useState([]); const [attributes, setAttributes] = useState([]); + const [pagination, setPagination] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { request } = useApiRequest(); const { checkPermission } = usePermissions(); const [permissions, setPermissions] = useState(null); - const fetchTemplates = useCallback(async () => { + const fetchTemplates = useCallback(async (params?: any) => { setLoading(true); setError(null); try { - const data = await fetchTemplatesApi(request); - setTemplates(data); + const data = await fetchTemplatesApi(request, params); + if (data && typeof data === 'object' && 'items' in data) { + setTemplates(Array.isArray(data.items) ? data.items : []); + if (data.pagination) setPagination(data.pagination); + } else { + setTemplates(Array.isArray(data) ? data : []); + setPagination(null); + } } catch (e: any) { console.error('Error fetching templates:', e); setError(e.message || 'Failed to fetch templates'); setTemplates([]); + setPagination(null); } finally { setLoading(false); } @@ -555,11 +563,12 @@ export function useAutomationTemplates() { return { templates, - data: templates, // Alias for FormGenerator compatibility + data: templates, attributes, loading, error, permissions, + pagination, refetch, fetchTemplates, fetchAttributes, diff --git a/src/pages/admin/AdminAutomationEventsPage.tsx b/src/pages/admin/AdminAutomationEventsPage.tsx index dee1558..2433199 100644 --- a/src/pages/admin/AdminAutomationEventsPage.tsx +++ b/src/pages/admin/AdminAutomationEventsPage.tsx @@ -41,18 +41,37 @@ const _formatNextRun = (nextRunTime: string | null): string => { export const AdminAutomationEventsPage: React.FC = () => { const [events, setEvents] = useState([]); + const [pagination, setPagination] = useState(null); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); const [syncResult, setSyncResult] = useState(null); - const _fetchEvents = useCallback(async () => { + const _fetchEvents = useCallback(async (params?: any) => { try { setLoading(true); setError(null); - const response = await api.get('/api/admin/automation-events'); - // Map eventId to id for FormGeneratorTable compatibility - setEvents(response.data.map((e: any) => ({ ...e, id: e.eventId }))); + const requestParams: Record = {}; + if (params && typeof params === 'object') { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } + } + const response = await api.get('/api/admin/automation-events', { params: requestParams }); + const data = response.data; + if (data && typeof data === 'object' && 'items' in data) { + setEvents((data.items || []).map((e: any) => ({ ...e, id: e.eventId }))); + if (data.pagination) setPagination(data.pagination); + } else { + setEvents((Array.isArray(data) ? data : []).map((e: any) => ({ ...e, id: e.eventId }))); + setPagination(null); + } } catch (err: any) { setError(err.response?.data?.detail || 'Fehler beim Laden der Events'); } finally { @@ -196,6 +215,7 @@ export const AdminAutomationEventsPage: React.FC = () => { { hookData={{ handleDelete: _handleDelete, refetch: _fetchEvents, + pagination, }} emptyMessage="Keine Automationen gefunden. Nutzen Sie 'Sync All', um Automationen zu synchronisieren." /> diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx index da1697d..cfd035c 100644 --- a/src/pages/admin/AdminFeatureRolesPage.tsx +++ b/src/pages/admin/AdminFeatureRolesPage.tsx @@ -73,8 +73,10 @@ export const AdminFeatureRolesPage: React.FC = () => { }, []); + const [pagination, setPagination] = useState(null); + // Load roles when feature changes - const fetchRoles = useCallback(async () => { + const fetchRoles = useCallback(async (params?: any) => { if (!selectedFeatureCode) { setRoles([]); return; @@ -83,15 +85,32 @@ export const AdminFeatureRolesPage: React.FC = () => { setLoading(true); setError(null); try { - const response = await api.get(`/api/features/templates/roles`, { - params: { featureCode: selectedFeatureCode } - }); - const roleList = response.data || []; - setRoles(Array.isArray(roleList) ? roleList : []); + const requestParams: Record = { featureCode: selectedFeatureCode }; + if (params && typeof params === 'object') { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } + } + const response = await api.get(`/api/features/templates/roles`, { params: requestParams }); + const data = response.data; + if (data && typeof data === 'object' && 'items' in data) { + setRoles(Array.isArray(data.items) ? data.items : []); + if (data.pagination) setPagination(data.pagination); + } else { + setRoles(Array.isArray(data) ? data : []); + setPagination(null); + } } catch (err: any) { console.error('Error loading feature roles:', err); setError('Fehler beim Laden der Feature-Rollen'); setRoles([]); + setPagination(null); } finally { setLoading(false); } @@ -383,6 +402,7 @@ export const AdminFeatureRolesPage: React.FC = () => { onDelete={handleDeleteRole} hookData={{ refetch: fetchRoles, + pagination, handleDelete: handleDeleteRole, }} emptyMessage="Keine Feature-Rollen gefunden" diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 6105266..8f42a62 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -379,7 +379,7 @@ export const AdminInvitationsPage: React.FC = () => { ]} hookData={{ handleDelete: handleDeleteInvitation, - refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }), + refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }), pagination, }} emptyMessage="Keine Einladungen gefunden" diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index bec6348..57eb64e 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -49,6 +49,7 @@ export const FilesPage: React.FC = () => { loading, error, refetch, + pagination, fetchFileById, updateFileOptimistically, } = useUserFiles(); @@ -479,6 +480,7 @@ export const FilesPage: React.FC = () => { onDeleteMultiple={handleDeleteMultiple} hookData={{ refetch: _tableRefetch, + pagination, permissions, handleDelete: handleFileDelete, handleInlineUpdate, diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index f3d4435..6a7e407 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -168,7 +168,7 @@ export const PromptsPage: React.FC = () => { } return ( -
+

Prompts

@@ -194,68 +194,45 @@ export const PromptsPage: React.FC = () => {
- {loading && (!prompts || prompts.length === 0) ? ( -
-
- Lade Prompts... -
- ) : !prompts || prompts.length === 0 ? ( -
- -

Keine Prompts vorhanden

-

- Erstellen Sie einen neuen Prompt, um loszulegen. -

- {canCreate && ( - - )} -
- ) : ( - deletingPrompts.has(row.id), - }] : []), - ]} - onDelete={handleDelete} - hookData={{ - refetch, - permissions, - pagination, - handleDelete: handlePromptDelete, - handleInlineUpdate, - updateOptimistically, - }} - emptyMessage="Keine Prompts gefunden" - /> - )} + deletingPrompts.has(row.id), + }] : []), + ]} + onDelete={handleDelete} + hookData={{ + refetch, + permissions, + pagination, + handleDelete: handlePromptDelete, + handleInlineUpdate, + updateOptimistically, + }} + emptyMessage="Keine Prompts gefunden" + />
{/* Create Modal */} diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx index 36450d0..7c16320 100644 --- a/src/pages/billing/AdminSubscriptionsPage.tsx +++ b/src/pages/billing/AdminSubscriptionsPage.tsx @@ -1,62 +1,31 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; -import { useApiRequest } from '../../hooks/useApi'; +import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions'; import { useConfirm } from '../../hooks/useConfirm'; import api from '../../api'; import styles from './Billing.module.css'; const _TERMINAL_STATUSES = new Set(['EXPIRED']); -const _STATUS_LABELS: Record = { - PENDING: 'Ausstehend', - SCHEDULED: 'Geplant', - TRIALING: 'Testphase', - ACTIVE: 'Aktiv', - PAST_DUE: 'Überfällig', - EXPIRED: 'Abgelaufen', -}; - const _COLUMNS: ColumnConfig[] = [ - { key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, width: 180 }, - { key: 'planTitle', label: 'Plan', type: 'text' as any, sortable: true, filterable: true, width: 180 }, - { key: 'status', label: 'Status', type: 'text' as any, sortable: true, filterable: true, width: 110 }, - { key: 'recurring', label: 'Wiederkehrend', type: 'boolean' as any, sortable: true, filterable: true, width: 120 }, - { key: 'activeUsers', label: 'User', type: 'number' as any, sortable: true, width: 70 }, - { key: 'activeInstances', label: 'Instanzen', type: 'number' as any, sortable: true, width: 90 }, - { key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number' as any, sortable: true, width: 140 }, - { key: 'startedAt', label: 'Gestartet', type: 'date' as any, sortable: true, width: 130 }, - { key: 'currentPeriodEnd', label: 'Periodenende', type: 'date' as any, sortable: true, width: 130 }, - { key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number' as any, sortable: true, width: 100 }, - { key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number' as any, sortable: true, width: 110 }, + { key: 'mandateName', label: 'Mandant', type: 'text', sortable: true, filterable: true, width: 180 }, + { key: 'planTitle', label: 'Plan', type: 'text', sortable: true, filterable: true, width: 180 }, + { key: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 110 }, + { key: 'recurring', label: 'Wiederkehrend', type: 'boolean', sortable: true, filterable: true, width: 120 }, + { key: 'activeUsers', label: 'User', type: 'number', sortable: true, width: 70 }, + { key: 'activeInstances', label: 'Instanzen', type: 'number', sortable: true, width: 90 }, + { key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number', sortable: true, width: 140 }, + { key: 'startedAt', label: 'Gestartet', type: 'date', sortable: true, filterable: true, width: 130 }, + { key: 'currentPeriodEnd', label: 'Periodenende', type: 'date', sortable: true, filterable: true, width: 130 }, + { key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number', sortable: true, width: 100 }, + { key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number', sortable: true, width: 110 }, ]; const AdminSubscriptionsPage: React.FC = () => { const navigate = useNavigate(); - const { request } = useApiRequest(); const { confirm, ConfirmDialog } = useConfirm(); - const [subscriptions, setSubscriptions] = useState([]); - const [loading, setLoading] = useState(true); - - const _loadSubscriptions = useCallback(async () => { - setLoading(true); - try { - const data = await request({ url: '/api/subscription/admin/all', method: 'get' }); - const rows = (Array.isArray(data) ? data : []).map((row: any) => ({ - ...row, - status: _STATUS_LABELS[row.status] || row.status, - _rawStatus: row.status, - })); - setSubscriptions(rows); - } catch (err) { - console.error('Failed to load subscriptions:', err); - setSubscriptions([]); - } finally { - setLoading(false); - } - }, [request]); - - useEffect(() => { _loadSubscriptions(); }, [_loadSubscriptions]); + const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions(); const _handleForceCancel = useCallback(async (row: any) => { const ok = await confirm( @@ -67,15 +36,15 @@ const AdminSubscriptionsPage: React.FC = () => { try { await api.post('/api/subscription/force-cancel', { subscriptionId: row.id }); - await _loadSubscriptions(); + await refetch(); } catch (err) { console.error('Force cancel failed:', err); } - }, [confirm, _loadSubscriptions]); + }, [confirm, refetch]); return ( -
-
+
+

Subscription-Übersicht

Alle Abonnements aller Mandanten

- {loading ? ( -
Lade Subscriptions…
- ) : ( +
_handleForceCancel(row), - isVisible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus), + onClick: (row: any) => _handleForceCancel(row), + visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus), }, ]} emptyMessage="Keine Subscriptions vorhanden." /> - )} +
diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index af26318..d16a47f 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -485,20 +485,40 @@ interface MandateTransactionsTabProps { const MandateTransactionsTab: React.FC = ({ mandateId }) => { const { request, isLoading: loading } = useApiRequest(); const [transactions, setTransactions] = useState([]); + const [pagination, setPagination] = useState(null); const [error, setError] = useState(null); - const _loadTransactions = useCallback(async () => { + const _loadTransactions = useCallback(async (params?: any) => { try { setError(null); + const requestParams: Record = {}; + if (params) { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } + } const data = await request({ url: `/api/billing/admin/transactions/${mandateId}`, method: 'get', - params: { limit: 500 }, + params: requestParams, }); - setTransactions(Array.isArray(data) ? data : []); + if (data && typeof data === 'object' && 'items' in data) { + setTransactions(Array.isArray(data.items) ? data.items : []); + if (data.pagination) setPagination(data.pagination); + } else { + setTransactions(Array.isArray(data) ? data : []); + setPagination(null); + } } catch (err: any) { setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden'); setTransactions([]); + setPagination(null); } }, [request, mandateId]); @@ -506,10 +526,6 @@ const MandateTransactionsTab: React.FC = ({ mandate _loadTransactions(); }, [_loadTransactions]); - const hookData = useMemo(() => ({ - refetch: _loadTransactions, - }), [_loadTransactions]); - return (

@@ -528,8 +544,8 @@ const MandateTransactionsTab: React.FC = ({ mandate sortable={true} selectable={false} emptyMessage="Keine Transaktionen für diesen Mandanten" - onRefresh={_loadTransactions} - hookData={hookData} + onRefresh={() => _loadTransactions()} + hookData={{ refetch: _loadTransactions, pagination }} />

); diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 5c313ff..1d2571f 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -489,10 +489,16 @@ export const BillingDataView: React.FC = () => { setTransactionsError(null); const params: any = {}; - // 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 pObj: any = {}; + if (paginationParams.page !== undefined) pObj.page = paginationParams.page; + if (paginationParams.pageSize !== undefined) pObj.pageSize = paginationParams.pageSize; + if (paginationParams.sort) pObj.sort = paginationParams.sort; + if (paginationParams.filters) pObj.filters = paginationParams.filters; + if (paginationParams.search) pObj.search = paginationParams.search; + if (Object.keys(pObj).length > 0) { + params.pagination = JSON.stringify(pObj); + } } const response = await api.get('/api/billing/view/users/transactions', { params }); @@ -526,10 +532,7 @@ export const BillingDataView: React.FC = () => { // hookData for FormGeneratorTable const transactionsHookData = useMemo(() => ({ refetch: _loadTransactions, - pagination: transactionsPagination ? { - totalPages: transactionsPagination.totalPages, - totalItems: transactionsPagination.totalItems, - } : undefined, + pagination: transactionsPagination || undefined, }), [_loadTransactions, transactionsPagination]); // Table column definitions diff --git a/src/pages/views/automation/AutomationTemplatesView.tsx b/src/pages/views/automation/AutomationTemplatesView.tsx index 43a2cdc..849a83f 100644 --- a/src/pages/views/automation/AutomationTemplatesView.tsx +++ b/src/pages/views/automation/AutomationTemplatesView.tsx @@ -23,6 +23,8 @@ export const AutomationTemplatesView: React.FC = () => { error, permissions, refetch, + fetchTemplates, + pagination, createTemplate, updateTemplate, deleteTemplate, @@ -176,7 +178,7 @@ export const AutomationTemplatesView: React.FC = () => { { type: 'delete' as const, title: 'Löschen', disabled: (row: any) => row.isSystem && !isSysAdmin ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin gelöscht werden' } : !canDelete ? { disabled: true, message: 'Keine Berechtigung' } : false }, ]} onDelete={(template) => handleDelete(template.id)} - hookData={{ refetch, handleDelete, attributes }} + hookData={{ refetch: fetchTemplates, pagination, handleDelete, attributes }} emptyMessage="Keine Vorlagen gefunden" /> )} diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index 9042cb7..f9f2929 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -141,7 +141,7 @@ export const RealEstateParcelsView: React.FC = () => { } return ( -
+

Parzellen verwalten

@@ -163,26 +163,7 @@ export const RealEstateParcelsView: React.FC = () => {
- {loading && (!parcels || parcels.length === 0) ? ( -
-
- Lade Parzellen... -
- ) : !parcels || parcels.length === 0 ? ( -
- -

Keine Parzellen vorhanden

-

- Erstellen Sie eine neue Parzelle, um zu beginnen. -

- {canCreate && ( - - )} -
- ) : ( - { }} emptyMessage="Keine Parzellen gefunden" /> - )}
{(editingParcel || isCreateMode) && ( diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx index 41124f9..143ee25 100644 --- a/src/pages/views/realestate/RealEstateProjectsView.tsx +++ b/src/pages/views/realestate/RealEstateProjectsView.tsx @@ -131,7 +131,7 @@ export const RealEstateProjectsView: React.FC = () => { } return ( -
+

Projekte verwalten

@@ -149,24 +149,7 @@ export const RealEstateProjectsView: React.FC = () => {
- {loading && (!projects || projects.length === 0) ? ( -
-
- Lade Projekte... -
- ) : !projects || projects.length === 0 ? ( -
- -

Keine Projekte vorhanden

-

Erstellen Sie ein neues Projekt, um zu beginnen.

- {canCreate && ( - - )} -
- ) : ( - { hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }} emptyMessage="Keine Projekte gefunden" /> - )}
{(editingProject || isCreateMode) && ( diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx index fea0e85..aeb4703 100644 --- a/src/pages/views/trustee/TrusteeDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx @@ -178,7 +178,7 @@ export const TrusteeDocumentsView: React.FC = () => { } return ( -
+

Belege und Dokumente verwalten

@@ -203,29 +203,7 @@ export const TrusteeDocumentsView: React.FC = () => {
- {loading && (!documents || documents.length === 0) ? ( -
-
- Lade Dokumente... -
- ) : !documents || documents.length === 0 ? ( -
- -

Keine Dokumente vorhanden

-

- Erstellen Sie ein neues Dokument, um zu beginnen. -

- {canCreate && ( - - )} -
- ) : ( - { }} emptyMessage="Keine Dokumente gefunden" /> - )}
{/* Create/Edit Modal */} diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx index 85c293b..cc008ca 100644 --- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx @@ -146,7 +146,7 @@ export const TrusteePositionDocumentsView: React.FC = () => { } return ( -
+

Belege mit Buchungspositionen verknüpfen

@@ -171,29 +171,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
- {loading && (!links || links.length === 0) ? ( -
-
- Lade Verknüpfungen... -
- ) : !links || links.length === 0 ? ( -
- -

Keine Verknüpfungen vorhanden

-

- Verknüpfen Sie Belege mit Buchungspositionen. -

- {canCreate && ( - - )} -
- ) : ( - { }} emptyMessage="Keine Verknüpfungen gefunden" /> - )}
{/* Create Modal */} diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx index 4de9f93..1c2d1a1 100644 --- a/src/pages/views/trustee/TrusteePositionsView.tsx +++ b/src/pages/views/trustee/TrusteePositionsView.tsx @@ -412,7 +412,7 @@ export const TrusteePositionsView: React.FC = () => { } return ( -
+

Buchungspositionen verwalten

@@ -437,29 +437,7 @@ export const TrusteePositionsView: React.FC = () => {
- {loading && (!positions || positions.length === 0) ? ( -
-
- Lade Positionen... -
- ) : !positions || positions.length === 0 ? ( -
- -

Keine Positionen vorhanden

-

- Erstellen Sie eine neue Position, um zu beginnen. -

- {canCreate && ( - - )} -
- ) : ( - { }} emptyMessage="Keine Positionen gefunden" /> - )}
{/* Create/Edit Modal */} From 05317e64ca2bf5cecd0b731e90e5b299f139a9c9 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 22 Mar 2026 22:19:47 +0100 Subject: [PATCH 3/5] fixed tables and forms --- src/App.tsx | 5 +- src/config/pageRegistry.tsx | 2 + src/pages/FeatureView.tsx | 3 +- src/pages/admin/AdminAutomationLogsPage.tsx | 223 ++++++++++++++++++ src/pages/admin/index.ts | 1 + src/pages/billing/BillingAdmin.tsx | 2 +- .../views/automation/AutomationLogsView.tsx | 14 -- src/pages/views/automation/index.ts | 1 - .../views/trustee/TrusteePositionsView.tsx | 16 +- 9 files changed, 233 insertions(+), 34 deletions(-) create mode 100644 src/pages/admin/AdminAutomationLogsPage.tsx delete mode 100644 src/pages/views/automation/AutomationLogsView.tsx diff --git a/src/App.tsx b/src/App.tsx index 595379d..172ae21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,7 +38,7 @@ import { SettingsPage } from './pages/Settings'; import { GDPRPage } from './pages/GDPR'; import StorePage from './pages/Store'; import { FeatureViewPage } from './pages/FeatureView'; -import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin'; +import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminAutomationLogsPage, AdminLogsPage } from './pages/admin'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; @@ -188,9 +188,10 @@ function App() { } /> } /> - } /> + } /> } /> + } /> } /> } /> } /> diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 31922b5..da41d00 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -70,6 +70,8 @@ export const PAGE_ICONS: Record = { 'page.admin.subscriptions': , 'page.admin.automationEvents': , 'page.admin.automation-events': , + 'page.admin.automationLogs': , + 'page.admin.automation-logs': , 'page.admin.logs': , 'page.admin.mandate-wizard': , 'page.admin.mandateWizard': , diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 56deae2..02a4563 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -28,7 +28,7 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; // Automation Views -import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation'; +import { AutomationDefinitionsView, AutomationTemplatesView } from './views/automation'; // Workspace Views import { WorkspacePage } from './views/workspace/WorkspacePage'; @@ -126,7 +126,6 @@ const VIEW_COMPONENTS: Record> = { automation: { definitions: AutomationDefinitionsView, templates: AutomationTemplatesView, - logs: AutomationLogsView, }, workspace: { dashboard: WorkspacePage, diff --git a/src/pages/admin/AdminAutomationLogsPage.tsx b/src/pages/admin/AdminAutomationLogsPage.tsx new file mode 100644 index 0000000..1fcb1dd --- /dev/null +++ b/src/pages/admin/AdminAutomationLogsPage.tsx @@ -0,0 +1,223 @@ +/** + * AdminAutomationLogsPage + * + * SysAdmin-only page for viewing consolidated automation execution logs + * across all mandates and feature instances. + * Uses FormGeneratorTable with backend-driven pagination. + */ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { FaSync, FaCheck, FaExclamationCircle, FaTimes } from 'react-icons/fa'; +import api from '../../api'; +import styles from './Admin.module.css'; +import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; + +interface AutomationLogEntry { + id: string; + timestamp: number; + automationId: string; + automationLabel: string; + mandateName: string; + featureInstanceName: string; + executedBy: string; + status: string; + workflowId: string; + messages: string; +} + +const _formatTimestamp = (ts: unknown): React.ReactNode => { + if (!ts || typeof ts !== 'number') return ; + return new Date(ts * 1000).toLocaleString('de-CH', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); +}; + +const _formatStatus = (value: unknown): React.ReactNode => { + const status = String(value || ''); + const map: Record = { + completed: { icon: , color: 'var(--success-color, #16a34a)', label: 'Abgeschlossen' }, + error: { icon: , color: 'var(--error-color, #dc2626)', label: 'Fehler' }, + failed: { icon: , color: 'var(--error-color, #dc2626)', label: 'Fehlgeschlagen' }, + stopped: { icon: , color: 'var(--warning-color, #d97706)', label: 'Gestoppt' }, + }; + const entry = map[status]; + if (!entry) return status || '–'; + return ( + + {entry.icon}{entry.label} + + ); +}; + +export const AdminAutomationLogsPage: React.FC = () => { + const [logs, setLogs] = useState([]); + const [pagination, setPagination] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const _fetchLogs = useCallback(async (params?: any) => { + try { + setLoading(true); + setError(null); + const requestParams: Record = {}; + if (params && typeof params === 'object') { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } + } + const response = await api.get('/api/admin/automation-logs', { params: requestParams }); + const data = response.data; + if (data && typeof data === 'object' && 'items' in data) { + setLogs(data.items || []); + if (data.pagination) setPagination(data.pagination); + } else { + setLogs(Array.isArray(data) ? data : []); + setPagination(null); + } + } catch (err: any) { + setError(err.response?.data?.detail || 'Fehler beim Laden der Ausführungsprotokolle'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { _fetchLogs(); }, [_fetchLogs]); + + const columns: ColumnConfig[] = useMemo(() => [ + { + key: 'timestamp', + label: 'Zeitpunkt', + type: 'number' as const, + sortable: true, + filterable: false, + width: 170, + minWidth: 140, + formatter: _formatTimestamp, + }, + { + key: 'automationLabel', + label: 'Automatisierung', + type: 'string' as const, + sortable: true, + filterable: true, + searchable: true, + width: 200, + minWidth: 130, + }, + { + key: 'mandateName', + label: 'Mandant', + type: 'string' as const, + sortable: true, + filterable: true, + width: 150, + minWidth: 100, + }, + { + key: 'featureInstanceName', + label: 'Feature-Instanz', + type: 'string' as const, + sortable: true, + filterable: true, + width: 150, + minWidth: 100, + }, + { + key: 'executedBy', + label: 'Ausgeführt von', + type: 'string' as const, + sortable: true, + filterable: true, + width: 140, + minWidth: 100, + }, + { + key: 'status', + label: 'Status', + type: 'string' as const, + sortable: true, + filterable: true, + width: 140, + minWidth: 100, + formatter: _formatStatus, + }, + { + key: 'workflowId', + label: 'Workflow-ID', + type: 'string' as const, + sortable: false, + filterable: false, + width: 120, + minWidth: 80, + formatter: (v: unknown) => + v ? {String(v).slice(0, 8)}… : '–', + }, + { + key: 'messages', + label: 'Meldungen', + type: 'string' as const, + sortable: false, + filterable: false, + searchable: true, + width: 300, + minWidth: 150, + maxWidth: 500, + }, + ], []); + + return ( +
+
+
+

Ausführungsprotokolle

+

+ Konsolidierte Automation-Logs über alle Mandanten +

+
+
+ +
+
+ + {error && ( +
+ ! + {error} +
+ )} + + +
+ ); +}; + +export default AdminAutomationLogsPage; diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index 34656d2..8b7cfee 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -16,4 +16,5 @@ export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage'; export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage'; export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage'; export { AdminAutomationEventsPage } from './AdminAutomationEventsPage'; +export { AdminAutomationLogsPage } from './AdminAutomationLogsPage'; export { AdminLogsPage } from './AdminLogsPage'; diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index d16a47f..67e0cc9 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -721,7 +721,7 @@ export const BillingAdmin: React.FC = () => {
{isSysAdmin && ( diff --git a/src/pages/views/automation/AutomationLogsView.tsx b/src/pages/views/automation/AutomationLogsView.tsx deleted file mode 100644 index 355fae8..0000000 --- a/src/pages/views/automation/AutomationLogsView.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/** - * AutomationLogsView - * - * Placeholder view for automation execution logs. - */ -import React from 'react'; -import styles from '../../FeatureView.module.css'; - -export const AutomationLogsView: React.FC = () => ( -
-

Execution Logs

-

Automatisierungs-Ausführungsprotokolle

-
-); diff --git a/src/pages/views/automation/index.ts b/src/pages/views/automation/index.ts index 855bc6e..e611c92 100644 --- a/src/pages/views/automation/index.ts +++ b/src/pages/views/automation/index.ts @@ -4,4 +4,3 @@ export { AutomationDefinitionsView } from './AutomationDefinitionsView'; export { AutomationTemplatesView } from './AutomationTemplatesView'; -export { AutomationLogsView } from './AutomationLogsView'; diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx index 1c2d1a1..7cf9e43 100644 --- a/src/pages/views/trustee/TrusteePositionsView.tsx +++ b/src/pages/views/trustee/TrusteePositionsView.tsx @@ -11,7 +11,7 @@ import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useApiRequest } from '../../../hooks/useApi'; import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; -import { FaSync, FaReceipt, FaDownload } from 'react-icons/fa'; +import { FaSync, FaDownload } from 'react-icons/fa'; import { useToast } from '../../../contexts/ToastContext'; import api from '../../../api'; import { fetchSyncStatus, syncPositionsToAccounting, type AccountingSyncStatus } from '../../../api/trusteeApi'; @@ -293,19 +293,7 @@ export const TrusteePositionsView: React.FC = () => { return col; }); - const createdAtCol = { - key: '_createdAt', - label: 'Erstellt am', - type: 'timestamp' as any, - sortable: true, - filterable: false, - searchable: false, - width: 150, - minWidth: 120, - maxWidth: 200, - }; - - const allColumns = [...attrColumns, belegeColumn, syncStatusColumn, createdAtCol]; + const allColumns = [...attrColumns, belegeColumn, syncStatusColumn]; const byKey = new Map(allColumns.map(c => [c.key, c])); const ordered: typeof allColumns = []; From 0367563ea8f4960a8d569a08645ec90f00b908b1 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 23 Mar 2026 00:05:24 +0100 Subject: [PATCH 4/5] tool fixes --- src/pages/billing/AdminSubscriptionsPage.tsx | 10 -- src/pages/billing/BillingAdmin.tsx | 13 +- .../workspace/WorkspaceGeneralSettings.tsx | 155 ++++++++++++++++++ .../views/workspace/WorkspaceSettingsPage.tsx | 11 +- 4 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 src/pages/views/workspace/WorkspaceGeneralSettings.tsx diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx index 7c16320..287b903 100644 --- a/src/pages/billing/AdminSubscriptionsPage.tsx +++ b/src/pages/billing/AdminSubscriptionsPage.tsx @@ -1,5 +1,4 @@ import React, { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions'; import { useConfirm } from '../../hooks/useConfirm'; @@ -23,7 +22,6 @@ const _COLUMNS: ColumnConfig[] = [ ]; const AdminSubscriptionsPage: React.FC = () => { - const navigate = useNavigate(); const { confirm, ConfirmDialog } = useConfirm(); const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions(); @@ -47,14 +45,6 @@ const AdminSubscriptionsPage: React.FC = () => {

Subscription-Übersicht

Alle Abonnements aller Mandanten

-
diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index 67e0cc9..d317f51 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -8,7 +8,7 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { useSearchParams, Link } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import type { CheckoutCreateRequest } from '../../api/billingApi'; import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates'; @@ -695,7 +695,7 @@ export const BillingAdmin: React.FC = () => { setStripeReturnMessage(null); }, [searchParams, setSearchParams]); - const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings; + const showStripeForMandateAdmin = !!selectedMandateId && !!settings; const _tabStyle = (isActive: boolean) => ({ padding: '8px 16px', @@ -719,15 +719,6 @@ export const BillingAdmin: React.FC = () => { Abrechnungseinstellungen, Guthaben und Abonnement pro Mandant

- {isSysAdmin && ( - - Alle Abonnements → - - )}
diff --git a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx new file mode 100644 index 0000000..901a8fb --- /dev/null +++ b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx @@ -0,0 +1,155 @@ +/** + * WorkspaceGeneralSettings -- Per-user workspace settings (e.g. max agent rounds). + * + * The user can override the instance default. Setting a field to null reverts to the default. + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from '../../../hooks/useApi'; +import styles from './WorkspaceSettings.module.css'; + +interface GeneralSettingsProps { + instanceId: string; +} + +interface MaxAgentRoundsInfo { + effective: number; + userOverride: number | null; + instanceDefault: number; +} + +export const WorkspaceGeneralSettings: React.FC = ({ instanceId }) => { + const { request } = useApiRequest(); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [maxRoundsInfo, setMaxRoundsInfo] = useState({ + effective: 25, + userOverride: null, + instanceDefault: 25, + }); + const [inputValue, setInputValue] = useState(''); + + const _loadSettings = useCallback(async () => { + if (!instanceId) return; + setLoading(true); + try { + const data = await request({ + url: `/api/workspace/${instanceId}/settings/general`, + method: 'get', + }); + const info = (data as any)?.maxAgentRounds; + if (info) { + setMaxRoundsInfo(info); + setInputValue(info.userOverride != null ? String(info.userOverride) : ''); + } + } catch (err: any) { + setError(err?.message || 'Fehler beim Laden der Einstellungen'); + } finally { + setLoading(false); + } + }, [instanceId, request]); + + useEffect(() => { + _loadSettings(); + }, [_loadSettings]); + + const _handleSave = async () => { + setSaving(true); + setError(null); + setSuccess(null); + try { + const val = inputValue.trim() === '' ? null : parseInt(inputValue, 10); + if (val !== null && (isNaN(val) || val < 1 || val > 100)) { + setError('Wert muss zwischen 1 und 100 liegen.'); + setSaving(false); + return; + } + const data = await request({ + url: `/api/workspace/${instanceId}/settings/general`, + method: 'put', + data: { maxAgentRounds: val }, + }); + const info = (data as any)?.maxAgentRounds; + if (info) { + setMaxRoundsInfo(info); + setInputValue(info.userOverride != null ? String(info.userOverride) : ''); + } + setSuccess('Einstellungen gespeichert.'); + setTimeout(() => setSuccess(null), 3000); + } catch (err: any) { + setError(err?.message || 'Fehler beim Speichern'); + } finally { + setSaving(false); + } + }; + + const _handleReset = () => { + setInputValue(''); + }; + + if (loading) { + return
Lade Einstellungen...
; + } + + const hasOverride = inputValue.trim() !== ''; + + return ( +
+

Generelle Einstellungen

+ + {error &&
{error}
} + {success &&
{success}
} + +
+

Agenten-Konfiguration

+ +
+ +
+ setInputValue(e.target.value)} + placeholder={String(maxRoundsInfo.instanceDefault)} + min={1} + max={100} + /> + {hasOverride && ( + + )} +
+ + Standard der Instanz: {maxRoundsInfo.instanceDefault}. + {maxRoundsInfo.userOverride != null && ( + <> Ihr Override: {maxRoundsInfo.userOverride}. + )} + {' '}Effektiv: {maxRoundsInfo.effective}. + +
+
+ + +
+ ); +}; diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx index 52ea9f7..d19e0df 100644 --- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx +++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx @@ -8,21 +8,23 @@ import React, { useState } from 'react'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { WorkspaceSettings } from './WorkspaceSettings'; +import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings'; -type SettingsTab = 'voice'; +type SettingsTab = 'general' | 'voice'; const _TABS: { key: SettingsTab; label: string }[] = [ + { key: 'general', label: 'Generelle Einstellungen' }, { key: 'voice', label: 'Sprache & Stimme' }, ]; export const WorkspaceSettingsPage: React.FC = () => { const instanceId = useInstanceId(); - const [activeTab, setActiveTab] = useState('voice'); + const [activeTab, setActiveTab] = useState('general'); if (!instanceId) { return (
- Keine Workspace-Instanz ausgewaehlt. + Keine Workspace-Instanz ausgewählt.
); } @@ -61,6 +63,9 @@ export const WorkspaceSettingsPage: React.FC = () => {
+ {activeTab === 'general' && ( + + )} {activeTab === 'voice' && ( )} From 064635ae7430fd2a68d5ce4a6311d920bb9a3ed5 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 23 Mar 2026 00:18:06 +0100 Subject: [PATCH 5/5] rag stats --- src/App.tsx | 1 + src/pages/FeatureView.tsx | 4 +- .../WorkspaceRagInsightsPage.module.css | 82 ++++++ .../workspace/WorkspaceRagInsightsPage.tsx | 273 ++++++++++++++++++ src/types/mandate.ts | 1 + 5 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 src/pages/views/workspace/WorkspaceRagInsightsPage.module.css create mode 100644 src/pages/views/workspace/WorkspaceRagInsightsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 172ae21..e834cad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -156,6 +156,7 @@ function App() { {/* Workspace Editor */} } /> + } /> {/* Teams Bot Feature Views */} } /> diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 02a4563..5ee085c 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -34,6 +34,7 @@ import { AutomationDefinitionsView, AutomationTemplatesView } from './views/auto import { WorkspacePage } from './views/workspace/WorkspacePage'; import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage'; import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage'; +import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage'; // Teamsbot Views import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; @@ -130,6 +131,7 @@ const VIEW_COMPONENTS: Record> = { workspace: { dashboard: WorkspacePage, editor: WorkspaceEditorPage, + 'rag-insights': WorkspaceRagInsightsPage, settings: WorkspaceSettingsPage, }, teamsbot: { @@ -196,7 +198,7 @@ export const FeatureViewPage: React.FC = ({ view }) => { // Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level; // other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering. - if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') { + if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') { return null; } diff --git a/src/pages/views/workspace/WorkspaceRagInsightsPage.module.css b/src/pages/views/workspace/WorkspaceRagInsightsPage.module.css new file mode 100644 index 0000000..712c04e --- /dev/null +++ b/src/pages/views/workspace/WorkspaceRagInsightsPage.module.css @@ -0,0 +1,82 @@ +.wrap { + display: flex; + flex-direction: column; + gap: 1.25rem; + max-width: 1200px; +} + +.disclaimer { + font-size: 0.85rem; + line-height: 1.45; + color: var(--text-secondary, #666); + padding: 0.75rem 1rem; + background: var(--bg-secondary, #f5f5f5); + border-radius: 8px; + border: 1px solid var(--border-color, #e8e8e8); +} + +.kpiGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.75rem; +} + +.kpiCard { + padding: 1rem; + border-radius: 8px; + background: var(--bg-primary, #fff); + border: 1px solid var(--border-color, #e0e0e0); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.kpiValue { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary, #1a1a1a); + margin: 0 0 0.25rem; +} + +.kpiLabel { + font-size: 0.8rem; + color: var(--text-secondary, #666); + margin: 0; + line-height: 1.3; +} + +.chartBlock { + padding: 1rem; + border-radius: 8px; + background: var(--bg-primary, #fff); + border: 1px solid var(--border-color, #e0e0e0); + min-height: 280px; +} + +.chartTitle { + font-size: 0.95rem; + font-weight: 600; + margin: 0 0 0.75rem; + color: var(--text-primary, #1a1a1a); +} + +.row2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +@media (max-width: 900px) { + .row2 { + grid-template-columns: 1fr; + } +} + +.meta { + font-size: 0.75rem; + color: var(--text-secondary, #888); + margin-top: 0.5rem; +} + +.error { + color: #c62828; + padding: 1rem; +} diff --git a/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx b/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx new file mode 100644 index 0000000..9256ade --- /dev/null +++ b/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx @@ -0,0 +1,273 @@ +/** + * WorkspaceRagInsightsPage — Aggregierte, nicht personenbezogene Kennzahlen zum + * Knowledge Store / RAG dieser Workspace-Instanz (Präsentationen, Monitoring). + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + BarChart, + Bar, + PieChart, + Pie, + Cell, +} from 'recharts'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { useApiRequest } from '../../../hooks/useApi'; +import styles from './WorkspaceRagInsightsPage.module.css'; + +const MIME_LABELS: Record = { + pdf: 'PDF', + office_doc: 'Office (Text)', + office_sheet: 'Office (Tabellen)', + office_slides: 'Office (Folien)', + text: 'Text', + image: 'Bild', + html: 'HTML', + other: 'Sonstige', +}; + +const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828']; + +function _formatBytes(n: number): string { + if (!Number.isFinite(n) || n <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; +} + +interface RagKpis { + indexedDocuments: number; + indexedBytesTotal: number; + contributorUsers: number; + contentChunks: number; + chunksWithEmbedding: number; + embeddingCoveragePercent: number; + workflowEntities: number; +} + +interface RagStatsResponse { + error?: string; + scope?: { + featureInstanceId?: string; + mandateScopedShared?: boolean; + workspaceFileIdsResolved?: number; + }; + kpis?: RagKpis; + indexedDocumentsByStatus?: Record; + documentsByMimeCategory?: Record; + chunksByContentType?: Record; + timelineIndexedDocuments?: Array<{ date: string; indexedDocuments: number }>; + generatedAtUtc?: string; +} + +export const WorkspaceRagInsightsPage: React.FC = () => { + const instanceId = useInstanceId(); + const { request } = useApiRequest(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [stats, setStats] = useState(null); + + const load = useCallback(async () => { + if (!instanceId) return; + setLoading(true); + setError(null); + try { + const data = (await request({ + url: `/api/workspace/${instanceId}/rag-statistics`, + method: 'get', + })) as RagStatsResponse; + if (data?.error) { + setError(String(data.error)); + setStats(null); + } else { + setStats(data ?? null); + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen'); + setStats(null); + } finally { + setLoading(false); + } + }, [instanceId, request]); + + useEffect(() => { + void load(); + }, [load]); + + if (!instanceId) { + return ( +
+ Keine Workspace-Instanz ausgewählt. +
+ ); + } + + if (loading) { + return
Lade Kennzahlen …
; + } + + if (error) { + return
{error}
; + } + + const kpis = stats?.kpis; + const timeline = stats?.timelineIndexedDocuments ?? []; + const mimeRows = Object.entries(stats?.documentsByMimeCategory ?? {}).map(([key, value]) => ({ + name: MIME_LABELS[key] ?? key, + value, + })); + const statusRows = Object.entries(stats?.indexedDocumentsByStatus ?? {}).map(([name, value]) => ({ + name, + value, + })); + const chunkTypeRows = Object.entries(stats?.chunksByContentType ?? {}).map(([name, value]) => ({ + name, + value, + })); + + return ( +
+

+ Dargestellt sind ausschliesslich aggregierte technische Masszahlen dieser Instanz (Anzahl + Dokumente, Fragmente, Speicherumfang, Verteilungen). Es werden keine Inhalte, Dateinamen + oder personenbezogene Angaben ausgewiesen. Geeignet für interne Berichte und Präsentationen. +

+ + {stats?.scope?.workspaceFileIdsResolved !== undefined && ( +

+ Zuordnung Knowledge ↔ Dateien: {stats.scope.workspaceFileIdsResolved} Datei-ID(s) mit + dieser Feature-Instanz in der Dateiverwaltung. Neu indexierte Uploads erhalten die + Instanz automatisch; ältere Einträge ohne Zuordnung erscheinen erst nach erneuter + Indexierung. +

+ )} + + {kpis && ( +
+
+

{kpis.indexedDocuments}

+

Indexierte Dokumente

+
+
+

{_formatBytes(kpis.indexedBytesTotal)}

+

Indexiertes Datenvolumen (geschätzt)

+
+
+

{kpis.contentChunks}

+

Inhalts-Fragmente (Chunks)

+
+
+

+ {kpis.embeddingCoveragePercent}% +

+

Anteil Fragmente mit Embedding

+
+
+

{kpis.contributorUsers}

+

Beitragende Benutzer (Anzahl)

+
+
+

{kpis.workflowEntities}

+

Workflow-Entitäten (Cache)

+
+
+ )} + +
+

Neu indexierte Dokumente pro Tag (letzte Wochen)

+ {timeline.length === 0 ? ( +

Keine Zeitreihen-Daten für den gewählten Zeitraum.

+ ) : ( + + + + + + + + + + )} +
+ +
+
+

Dokumente nach Format-Kategorie

+ {mimeRows.length === 0 ? ( +

Keine Daten.

+ ) : ( + + + + + + + + + + )} +
+ +
+

Index-Status

+ {statusRows.length === 0 ? ( +

Keine Daten.

+ ) : ( + + + + `${name ?? ''} ${percent != null ? (percent * 100).toFixed(0) : '0'}%`} + > + {statusRows.map((_, i) => ( + + ))} + + + + + )} +
+
+ +
+

Fragmente nach Inhaltstyp

+ {chunkTypeRows.length === 0 ? ( +

Keine Chunk-Daten.

+ ) : ( + + + + + + + + + + )} +
+ + {stats?.generatedAtUtc && ( +

Stand (UTC): {stats.generatedAtUtc}

+ )} +
+ ); +}; diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 9b38cb9..2e34a91 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -290,6 +290,7 @@ export const FEATURE_REGISTRY: Record = { views: [ { code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' }, { code: 'editor', label: { de: 'Editor', en: 'Editor', fr: 'Editeur' }, path: 'editor' }, + { code: 'rag-insights', label: { de: 'Wissens-Insights', en: 'Knowledge insights', fr: 'Aperçu des connaissances' }, path: 'rag-insights' }, { code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' }, ] },