Prompts
@@ -194,68 +194,45 @@ export const PromptsPage: React.FC = () => {
- {loading && (!prompts || prompts.length === 0) ? (
-
- ) : !prompts || prompts.length === 0 ? (
-
-
-
Keine Prompts vorhanden
-
- Erstellen Sie einen neuen Prompt, um loszulegen.
-
- {canCreate && (
-
setShowCreateModal(true)}
- >
- Ersten Prompt erstellen
-
- )}
-
- ) : (
-
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
new file mode 100644
index 0000000..287b903
--- /dev/null
+++ b/src/pages/billing/AdminSubscriptionsPage.tsx
@@ -0,0 +1,78 @@
+import React, { useCallback } from 'react';
+import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
+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 _COLUMNS: ColumnConfig[] = [
+ { 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 { confirm, ConfirmDialog } = useConfirm();
+ const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
+
+ 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 refetch();
+ } catch (err) {
+ console.error('Force cancel failed:', err);
+ }
+ }, [confirm, refetch]);
+
+ return (
+
+
+
+
+ _handleForceCancel(row),
+ visible: (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..d317f51 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 } 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,94 @@ 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 [pagination, setPagination] = useState(null);
+ const [error, setError] = useState(null);
+
+ 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: requestParams,
+ });
+ 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]);
+
+ useEffect(() => {
+ _loadTransactions();
+ }, [_loadTransactions]);
+
+ return (
+
+
+ AI-Verbrauch und Guthaben-Transaktionen. Subscription-Gebühren werden separat über Stripe abgerechnet.
+
+ {error &&
{error}
}
+
_loadTransactions()}
+ hookData={{ refetch: _loadTransactions, pagination }}
+ />
+
+ );
+};
+
// ============================================================================
// MAIN COMPONENT
// ============================================================================
@@ -465,7 +560,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 +627,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 +684,42 @@ 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 showStripeForMandateAdmin = !!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
+
+
+
{stripeReturnMessage && (
@@ -635,9 +747,30 @@ export const BillingAdmin: React.FC = () => {
/>
- {selectedMandateId && (
+ {selectedMandateId ? (
<>
- {isSysAdmin && (
+
+ setAdminTab('settings')} style={_tabStyle(adminTab === 'settings')}>
+ Einstellungen
+
+ setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
+ Guthaben
+
+ setAdminTab('subscription')} style={_tabStyle(adminTab === 'subscription')}>
+ Abonnement
+
+ setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
+ Transaktionen
+
+
+
+ {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..feb44f1 100644
--- a/src/pages/billing/BillingDataView.tsx
+++ b/src/pages/billing/BillingDataView.tsx
@@ -65,8 +65,9 @@ const BalanceCard: React.FC = ({ balance, onCheckout, checkout
return 'Prepaid (Mandant)';
};
- const canTopUp = balance.billingModel === 'PREPAY_USER'
- || balance.billingModel === 'PREPAY_MANDATE';
+ // Stripe top-up on this page: only personal prepaid wallets. Mandate pool (PREPAY_MANDATE) is topped up by mandate admins via Administration → Billing.
+ const canStripeTopUpHere = balance.billingModel === 'PREPAY_USER';
+ const isMandatePrepaidPool = balance.billingModel === 'PREPAY_MANDATE';
return (
@@ -82,7 +83,20 @@ const BalanceCard: React.FC = ({ balance, onCheckout, checkout
Niedriges Guthaben
)}
- {canTopUp && onCheckout && (
+ {isMandatePrepaidPool && (
+
+ Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).
+
+ )}
+ {canStripeTopUpHere && onCheckout && (
{!showCheckout ? (
{
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 +546,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
@@ -741,6 +758,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 (
-
-
+
+ {!embedded && (
+ <>
+
-
+
+ >
+ )}
{/* 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 && (
+
onActivate(plan.planKey)}
+ disabled={!!activatingPlanKey}
+ style={{
+ marginTop: 'auto', padding: '8px 16px', borderRadius: '6px', border: 'none',
+ background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
+ cursor: activatingPlanKey ? 'wait' : 'pointer',
+ opacity: activatingPlanKey ? 0.6 : 1,
+ }}
+ >
+ {activating
+ ? 'Weiterleitung...'
+ : (!isFreePlan && !plan.trialDays) ? 'Kostenpflichtig abonnieren' : 'Auswählen'}
+
+ )}
+
+ );
+};
+
+// ============================================================================
+// 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 && (
+ onReactivate(sub.id)}
+ disabled={reactivating}
+ style={{
+ padding: '6px 14px', borderRadius: '6px', border: 'none',
+ background: 'var(--color-primary, #3b82f6)', color: '#fff',
+ fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
+ }}
+ >
+ {reactivating ? 'Wird reaktiviert...' : 'Reaktivieren'}
+
+ )}
+
+ {isActive && sub.recurring && onCancel && (
+ onCancel(sub.id)}
+ disabled={cancelling}
+ style={{
+ padding: '6px 14px', borderRadius: '6px',
+ border: '1px solid #ef4444', background: 'transparent',
+ color: '#ef4444', fontWeight: 500,
+ cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
+ }}
+ >
+ {cancelling ? 'Wird gekündigt...' : 'Kündigen'}
+
+ )}
+
+ {(isPending || isScheduled) && onCancel && (
+ onCancel(sub.id)}
+ disabled={cancelling}
+ style={{
+ padding: '6px 14px', borderRadius: '6px',
+ border: '1px solid #ef4444', background: 'transparent',
+ color: '#ef4444', fontWeight: 500,
+ cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
+ }}
+ >
+ {cancelling ? 'Wird abgebrochen...' : 'Abbrechen'}
+
+ )}
+
+
+ );
+};
+
+// ============================================================================
+// 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/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/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/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/automation2/Automation2Page.tsx b/src/pages/views/automation2/Automation2Page.tsx
new file mode 100644
index 0000000..36b25d6
--- /dev/null
+++ b/src/pages/views/automation2/Automation2Page.tsx
@@ -0,0 +1,38 @@
+/**
+ * Automation2Page
+ *
+ * n8n-style flow builder with backend-driven node list.
+ */
+import React from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+import { Automation2FlowEditor } from '../../../components/Automation2FlowEditor';
+import styles from '../../FeatureView.module.css';
+
+export const Automation2Page: React.FC = () => {
+ const instanceId = useInstanceId();
+ const [searchParams] = useSearchParams();
+ const workflowId = searchParams.get('workflowId');
+ const { currentLanguage } = useLanguage();
+ const language = (currentLanguage?.slice(0, 2) || 'de') as string;
+
+ if (!instanceId) {
+ return (
+
+
Automation 2
+
Keine Feature-Instanz gefunden.
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/src/pages/views/automation2/Automation2WorkflowsPage.tsx b/src/pages/views/automation2/Automation2WorkflowsPage.tsx
new file mode 100644
index 0000000..159e2b5
--- /dev/null
+++ b/src/pages/views/automation2/Automation2WorkflowsPage.tsx
@@ -0,0 +1,233 @@
+/**
+ * Automation2WorkflowsPage
+ * List of saved workflows with FormGeneratorTable.
+ * Shows: label, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
+ * Actions: Edit (navigate to editor), Delete, Execute.
+ */
+
+import React, { useState, useCallback, useEffect } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { FaPlay, FaSync } from 'react-icons/fa';
+import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import { useApiRequest } from '../../../hooks/useApi';
+import {
+ fetchWorkflows,
+ deleteWorkflow,
+ executeGraph,
+ type Automation2Workflow,
+} from '../../../api/automation2Api';
+import { useToast } from '../../../contexts/ToastContext';
+import { formatUnixTimestamp } from '../../../utils/time';
+import styles from '../../../pages/admin/Admin.module.css';
+
+function formatTs(ts?: number): string {
+ if (ts == null || ts <= 0) return '—';
+ const sec = ts < 1e12 ? ts : ts / 1000;
+ const { time } = formatUnixTimestamp(sec, undefined, {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ return time;
+}
+
+export const Automation2WorkflowsPage: React.FC = () => {
+ const instanceId = useInstanceId();
+ const { mandateId } = useParams<{ mandateId: string }>();
+ const { request } = useApiRequest();
+ const navigate = useNavigate();
+ const { showSuccess, showError } = useToast();
+
+ const [workflows, setWorkflows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [executingId, setExecutingId] = useState(null);
+
+ const load = useCallback(async () => {
+ if (!instanceId) return;
+ setLoading(true);
+ try {
+ const list = await fetchWorkflows(request, instanceId);
+ setWorkflows(list);
+ } catch (e) {
+ console.error('[Automation2] load workflows failed', e);
+ showError('Fehler beim Laden der Workflows');
+ } finally {
+ setLoading(false);
+ }
+ }, [instanceId, request, showError]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const handleDelete = useCallback(
+ async (workflowId: string): Promise => {
+ if (!instanceId) return false;
+ try {
+ await deleteWorkflow(request, instanceId, workflowId);
+ showSuccess('Workflow gelöscht');
+ await load();
+ return true;
+ } catch (e: any) {
+ showError(`Fehler: ${e?.message || 'Löschen fehlgeschlagen'}`);
+ return false;
+ }
+ },
+ [instanceId, request, showSuccess, showError, load]
+ );
+
+ const handleEdit = useCallback(
+ (row: Automation2Workflow) => {
+ if (!mandateId || !instanceId) return;
+ navigate(`/mandates/${mandateId}/automation2/${instanceId}/editor?workflowId=${row.id}`);
+ },
+ [mandateId, instanceId, navigate]
+ );
+
+ const handleExecute = useCallback(
+ async (row: Automation2Workflow) => {
+ if (!instanceId) return;
+ setExecutingId(row.id);
+ try {
+ const result = await executeGraph(request, instanceId, row.graph!, row.id);
+ if (result?.success) {
+ if (result?.paused) {
+ showSuccess('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.');
+ } else {
+ showSuccess('Workflow ausgeführt');
+ }
+ await load();
+ } else {
+ showError(result?.error || 'Ausführung fehlgeschlagen');
+ }
+ } catch (e: any) {
+ showError(`Fehler: ${e?.message || 'Ausführung fehlgeschlagen'}`);
+ } finally {
+ setExecutingId(null);
+ }
+ },
+ [instanceId, request, showSuccess, showError, load]
+ );
+
+ const columns: ColumnConfig[] = [
+ { key: 'label', label: 'Workflow', type: 'string', width: 200, sortable: true },
+ {
+ key: 'isRunning',
+ label: 'Läuft',
+ type: 'boolean',
+ width: 80,
+ formatter: (value: boolean) =>
+ value ? (
+ ✓ Ja
+ ) : (
+ Nein
+ ),
+ },
+ {
+ key: 'stuckAtNodeLabel',
+ label: 'Steht bei',
+ type: 'string',
+ width: 160,
+ formatter: (value: string, row: Automation2Workflow) =>
+ row.isRunning && (value || row.stuckAtNodeId)
+ ? value || row.stuckAtNodeId || '—'
+ : '—',
+ },
+ {
+ key: 'createdAt',
+ label: 'Erstellt',
+ type: 'number',
+ width: 140,
+ formatter: (v: number) => formatTs(v),
+ },
+ {
+ key: 'lastStartedAt',
+ label: 'Zuletzt gestartet',
+ type: 'number',
+ width: 160,
+ formatter: (v: number) => formatTs(v),
+ },
+ {
+ key: 'runCount',
+ label: 'Läufe',
+ type: 'number',
+ width: 80,
+ formatter: (v: number) => (v != null ? String(v) : '0'),
+ },
+ ];
+
+ const hookData = {
+ refetch: load,
+ handleDelete: (id: string) => handleDelete(id),
+ };
+
+ if (!instanceId) {
+ return (
+
+
Keine Feature-Instanz gefunden.
+
+ );
+ }
+
+ return (
+
+
+
+
Gespeicherte Workflows
+
+ Workflows verwalten, ausführen und bearbeiten
+
+
+
+ load()}
+ disabled={loading}
+ >
+ Aktualisieren
+
+
+
+
+
+
+ data={workflows}
+ columns={columns}
+ loading={loading}
+ pagination={true}
+ pageSize={25}
+ searchable={true}
+ filterable={true}
+ sortable={true}
+ selectable={false}
+ actionButtons={[
+ {
+ type: 'edit',
+ title: 'Bearbeiten',
+ onAction: handleEdit,
+ },
+ {
+ type: 'delete',
+ title: 'Löschen',
+ },
+ ]}
+ customActions={[
+ {
+ id: 'execute',
+ icon: ,
+ title: 'Ausführen',
+ onClick: (row) => handleExecute(row),
+ loading: (row) => executingId === row.id,
+ },
+ ]}
+ onDelete={(row) => handleDelete(row.id)}
+ hookData={hookData}
+ emptyMessage="Keine Workflows gefunden. Erstelle einen im Editor."
+ />
+
+
+ );
+};
diff --git a/src/pages/views/automation2/Automation2WorkflowsTasks.module.css b/src/pages/views/automation2/Automation2WorkflowsTasks.module.css
new file mode 100644
index 0000000..6fcb79b
--- /dev/null
+++ b/src/pages/views/automation2/Automation2WorkflowsTasks.module.css
@@ -0,0 +1,281 @@
+.container {
+ padding: 1.5rem;
+ max-width: 900px;
+}
+
+.container h2 {
+ margin: 0 0 1rem 0;
+ font-size: 1.25rem;
+}
+
+.section {
+ margin-bottom: 1.5rem;
+}
+
+.sectionTitle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0 0 0.75rem 0;
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.completedHeader {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.6rem 0;
+ text-align: left;
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary, #333);
+}
+
+.completedHeader:hover {
+ color: var(--primary-color, #007bff);
+}
+
+.completedList {
+ max-height: 360px;
+ overflow-y: auto;
+ padding-top: 0.5rem;
+}
+
+.taskMeta {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.5rem 1.25rem;
+ margin-bottom: 0.75rem;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+}
+
+.taskMetaRow {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.metaLabel {
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-secondary, #666);
+}
+
+.metaValue {
+ font-size: 0.9rem;
+ color: var(--text-primary, #333);
+}
+
+.metaValueMono {
+ font-size: 0.75rem;
+ font-family: monospace;
+ color: var(--text-secondary, #666);
+}
+
+.loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ padding: 3rem;
+ color: var(--text-secondary, #666);
+}
+
+.spinner {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.placeholder {
+ padding: 2rem;
+ text-align: center;
+ color: var(--text-secondary, #666);
+}
+
+.workflowList {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.workflowItem {
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--bg-primary, #fff);
+}
+
+.workflowHeader {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.75rem 1rem;
+ text-align: left;
+ background: var(--bg-secondary, #f8f9fa);
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+}
+
+.workflowHeader:hover {
+ background: var(--bg-hover, #e9ecef);
+}
+
+.badge {
+ margin-left: auto;
+ background: var(--primary-color, #007bff);
+ color: white;
+ padding: 0.2rem 0.5rem;
+ border-radius: 12px;
+ font-size: 0.8rem;
+}
+
+.taskList {
+ padding: 1rem;
+ border-top: 1px solid var(--border-color, #e0e0e0);
+}
+
+.empty {
+ color: var(--text-tertiary, #999);
+ font-size: 0.9rem;
+ margin: 0;
+}
+
+.taskCard {
+ padding: 1rem;
+ margin-bottom: 0.75rem;
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 6px;
+ background: var(--bg-primary, #fff);
+}
+
+.taskCard:last-child {
+ margin-bottom: 0;
+}
+
+.taskType {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-secondary, #666);
+ margin-bottom: 0.5rem;
+}
+
+.formFields {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.formFields button {
+ margin-top: 0.75rem;
+ align-self: flex-start;
+}
+
+.formFields label,
+.taskCard label {
+ display: block;
+ font-size: 0.875rem;
+ margin-top: 0.5rem;
+ margin-bottom: 0.25rem;
+}
+
+.formFields input[type='text'],
+.formFields input[type='number'],
+.formFields input[type='date'],
+.taskCard input[type='text'],
+.taskCard input[type='number'],
+.taskCard textarea {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 4px;
+}
+
+.taskCard textarea {
+ min-height: 80px;
+ margin-bottom: 0.5rem;
+}
+
+.openFormButton {
+ margin-top: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: var(--primary-color, #007bff);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.openFormButton:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.openFormButton:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.popupSubmitButton {
+ padding: 0.5rem 1.25rem;
+ background: var(--success-color, #28a745);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.popupSubmitButton:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.popupSubmitButton:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.approvalButtons {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.approvalButtons button,
+.taskCard button {
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ border: none;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+
+.approvalButtons button:first-child,
+.taskCard button[type='button'] {
+ background: var(--primary-color, #007bff);
+ color: white;
+}
+
+.approvalButtons button:last-of-type:not(:first-child) {
+ background: var(--danger-color, #dc3545);
+ color: white;
+}
+
+.approvalButtons button:disabled,
+.taskCard button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
diff --git a/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx b/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
new file mode 100644
index 0000000..3950d06
--- /dev/null
+++ b/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
@@ -0,0 +1,461 @@
+/**
+ * Automation2WorkflowsTasksPage
+ * Tasks only (no workflow grouping).
+ * Open tasks at top, completed tasks at bottom (expandable, scrollable).
+ * Each task shows workflow, created, due, step, type, and action.
+ */
+import React, { useState, useEffect, useCallback } from 'react';
+import { FaChevronDown, FaChevronRight, FaSpinner } from 'react-icons/fa';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import { useApiRequest } from '../../../hooks/useApi';
+import {
+ fetchTasks,
+ completeTask,
+ type Automation2Task,
+} from '../../../api/automation2Api';
+import { Popup } from '../../../components/UiComponents/Popup';
+import styles from './Automation2WorkflowsTasks.module.css';
+
+const NODE_TYPE_LABELS: Record = {
+ 'input.form': 'Formular',
+ 'input.approval': 'Genehmigung',
+ 'input.upload': 'Upload',
+ 'input.comment': 'Kommentar',
+ 'input.review': 'Prüfung',
+ 'input.selection': 'Auswahl',
+ 'input.confirmation': 'Bestätigung',
+};
+
+function formatTimestamp(ts?: number): string {
+ if (ts == null || ts <= 0) return '—';
+ const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts);
+ return d.toLocaleString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function getNodeStepLabel(config: Record): string {
+ const title = config?.title;
+ if (typeof title === 'string' && title.trim()) return title;
+ const label = config?.label;
+ if (typeof label === 'string' && label.trim()) return label;
+ if (typeof label === 'object' && label != null && 'de' in (label as Record)) {
+ return (label as Record).de ?? (label as Record).en ?? '';
+ }
+ return '';
+}
+
+export const Automation2WorkflowsTasksPage: React.FC = () => {
+ const instanceId = useInstanceId();
+ const { request } = useApiRequest();
+ const [tasks, setTasks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [completedExpanded, setCompletedExpanded] = useState(false);
+ const [submitting, setSubmitting] = useState(null);
+
+ const load = useCallback(async () => {
+ if (!instanceId) return;
+ setLoading(true);
+ try {
+ const taskList = await fetchTasks(request, instanceId);
+ setTasks(taskList);
+ } catch (e) {
+ console.error('[Automation2] load failed', e);
+ } finally {
+ setLoading(false);
+ }
+ }, [instanceId, request]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const handleComplete = async (taskId: string, result: Record) => {
+ if (!instanceId) return;
+ setSubmitting(taskId);
+ try {
+ await completeTask(request, instanceId, taskId, result);
+ await load();
+ } catch (e) {
+ console.error('[Automation2] complete failed', e);
+ } finally {
+ setSubmitting(null);
+ }
+ };
+
+ const openTasks = tasks.filter((t) => t.status === 'pending');
+ const completedTasks = tasks.filter((t) => t.status !== 'pending');
+
+ if (!instanceId) {
+ return (
+
+
Tasks
+
Keine Feature-Instanz gefunden.
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
Tasks
+
+ {/* Open tasks */}
+
+
+ Offene Tasks
+ {openTasks.length > 0 && {openTasks.length} }
+
+ {openTasks.length === 0 ? (
+ Keine offenen Tasks
+ ) : (
+
+ {openTasks.map((task) => (
+ handleComplete(task.id, result)}
+ submitting={submitting === task.id}
+ />
+ ))}
+
+ )}
+
+
+ {/* Completed tasks */}
+
+ setCompletedExpanded((p) => !p)}
+ >
+ {completedExpanded ? : }
+ Erledigte Tasks
+ {completedTasks.length > 0 && (
+ {completedTasks.length}
+ )}
+
+ {completedExpanded && (
+
+ {completedTasks.length === 0 ? (
+
Keine erledigten Tasks
+ ) : (
+ completedTasks.map((task) => (
+
handleComplete(task.id, result)}
+ submitting={submitting === task.id}
+ readOnly
+ />
+ ))
+ )}
+
+ )}
+
+
+ );
+};
+
+interface TaskCardProps {
+ task: Automation2Task;
+ onSubmit: (result: Record) => void;
+ submitting: boolean;
+ readOnly?: boolean;
+}
+
+const TaskCard: React.FC = ({
+ task,
+ onSubmit,
+ submitting,
+ readOnly = false,
+}) => {
+ const [formData, setFormData] = useState>({});
+ const [formPopupOpen, setFormPopupOpen] = useState(false);
+ const config = task.config ?? {};
+ const nodeType = task.nodeType;
+ const stepLabel = getNodeStepLabel(config);
+
+ const renderInput = () => {
+ if (readOnly) return null;
+ switch (nodeType) {
+ case 'input.form': {
+ const fields =
+ (config.fields as Array<{ name: string; type: string; label: string; required?: boolean }>) ??
+ [];
+ const requiredFields = fields.filter((f) => f.required);
+ const allRequiredFilled = requiredFields.every((f) => {
+ const v = formData[f.name];
+ if (f.type === 'boolean') return true;
+ return v !== undefined && v !== null && String(v).trim() !== '';
+ });
+ const formContent = (
+
+ );
+ return (
+ <>
+ setFormPopupOpen(true)}
+ disabled={submitting}
+ className={styles.openFormButton}
+ >
+ Formular bearbeiten
+
+ setFormPopupOpen(false)}
+ size="medium"
+ footerContent={
+ {
+ onSubmit(formData);
+ setFormPopupOpen(false);
+ }}
+ disabled={submitting || !allRequiredFilled}
+ className={styles.popupSubmitButton}
+ >
+ {submitting ? 'Wird gesendet…' : 'Absenden'}
+
+ }
+ >
+ {formContent}
+
+ >
+ );
+ }
+ case 'input.approval':
+ return (
+
+ {config.title != null && String(config.title) !== '' &&
{String(config.title)} }
+ {config.description != null && String(config.description) !== '' &&
{String(config.description)}
}
+
+ onSubmit({ approved: true })}
+ disabled={submitting}
+ >
+ Genehmigen
+
+ onSubmit({ approved: false })}
+ disabled={submitting}
+ >
+ Ablehnen
+
+
+
+ );
+ case 'input.comment':
+ return (
+
+
+ );
+ case 'input.selection': {
+ const options =
+ (config.options as Array<{ value: string; label: string }>) ?? [];
+ const multiple = config.multiple as boolean;
+ return (
+
+ {options.map((o) => (
+
+ {
+ if (multiple) {
+ const prev = (formData.selected as string[]) ?? [];
+ const next = e.target.checked
+ ? [...prev, o.value]
+ : prev.filter((v) => v !== o.value);
+ setFormData((p) => ({ ...p, selected: next }));
+ } else {
+ setFormData((p) => ({ ...p, selected: o.value }));
+ }
+ }}
+ />
+ {o.label || o.value}
+
+ ))}
+ onSubmit(formData)}
+ disabled={submitting}
+ >
+ Absenden
+
+
+ );
+ }
+ case 'input.confirmation':
+ return (
+
+
{(config.question as string) ?? 'Bestätigen?'}
+
+ onSubmit({ confirmed: true })}
+ disabled={submitting}
+ >
+ {typeof config.confirmLabel === 'string' ? config.confirmLabel : 'Bestätigen'}
+
+ onSubmit({ confirmed: false })}
+ disabled={submitting}
+ >
+ {typeof config.rejectLabel === 'string' ? config.rejectLabel : 'Ablehnen'}
+
+
+
+ );
+ case 'input.upload':
+ return (
+
+
Upload-Komponente – noch nicht implementiert
+
onSubmit({ uploaded: [] })}
+ disabled={submitting}
+ >
+ Platzhalter absenden
+
+
+ );
+ case 'input.review':
+ return (
+
+
Review – Content anzeigen + Feedback
+
+ );
+ default:
+ return (
+
+
Unbekannter Task-Typ: {nodeType}
+
onSubmit({})}
+ disabled={submitting}
+ >
+ Absenden
+
+
+ );
+ }
+ };
+
+ return (
+
+
+
+ Workflow
+
+ {task.workflowLabel || task.workflowId || '—'}
+
+
+
+ Erstellt
+
+ {formatTimestamp(task.createdAt)}
+
+
+
+ Fällig
+
+ {formatTimestamp(task.dueAt)}
+
+
+ {stepLabel && (
+
+ Schritt
+ {stepLabel}
+
+ )}
+
+ Typ
+
+ {NODE_TYPE_LABELS[nodeType] ?? nodeType}
+
+
+ {task.nodeId && (
+
+ Node
+ {task.nodeId}
+
+ )}
+
+ {renderInput()}
+
+ );
+};
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/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx
index 9042cb7..939af4f 100644
--- a/src/pages/views/realestate/RealEstateParcelsView.tsx
+++ b/src/pages/views/realestate/RealEstateParcelsView.tsx
@@ -14,7 +14,7 @@ import {
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
-import { FaSync, FaMapMarkerAlt } from 'react-icons/fa';
+import { FaSync } from 'react-icons/fa';
import styles from '../../admin/Admin.module.css';
export const RealEstateParcelsView: React.FC = () => {
@@ -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) ? (
-
- ) : !parcels || parcels.length === 0 ? (
-
-
-
Keine Parzellen vorhanden
-
- Erstellen Sie eine neue Parzelle, um zu beginnen.
-
- {canCreate && (
-
- + Neue Parzelle
-
- )}
-
- ) : (
-
{
}}
emptyMessage="Keine Parzellen gefunden"
/>
- )}
{(editingParcel || isCreateMode) && (
diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx
index 41124f9..63ac061 100644
--- a/src/pages/views/realestate/RealEstateProjectsView.tsx
+++ b/src/pages/views/realestate/RealEstateProjectsView.tsx
@@ -14,7 +14,7 @@ import {
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
-import { FaSync, FaBuilding } from 'react-icons/fa';
+import { FaSync } from 'react-icons/fa';
import styles from '../../admin/Admin.module.css';
export const RealEstateProjectsView: React.FC = () => {
@@ -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) ? (
-
- ) : !projects || projects.length === 0 ? (
-
-
-
Keine Projekte vorhanden
-
Erstellen Sie ein neues Projekt, um zu beginnen.
- {canCreate && (
-
- + Neues Projekt
-
- )}
-
- ) : (
-
{
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
emptyMessage="Keine Projekte gefunden"
/>
- )}
{(editingProject || isCreateMode) && (
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/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx
index fea0e85..6cac4db 100644
--- a/src/pages/views/trustee/TrusteeDocumentsView.tsx
+++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx
@@ -10,7 +10,7 @@ import { useTrusteeDocuments, useTrusteeDocumentOperations, TrusteeDocument } fr
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
-import { FaSync, FaFileAlt, FaDownload } from 'react-icons/fa';
+import { FaSync, FaDownload } from 'react-icons/fa';
import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
import styles from '../../admin/Admin.module.css';
@@ -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) ? (
-
- ) : !documents || documents.length === 0 ? (
-
-
-
Keine Dokumente vorhanden
-
- Erstellen Sie ein neues Dokument, um zu beginnen.
-
- {canCreate && (
-
- + Neues Dokument
-
- )}
-
- ) : (
-
{
}}
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..b514d48 100644
--- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx
+++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx
@@ -10,7 +10,7 @@ import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations, Trus
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
-import { FaSync, FaLink } from 'react-icons/fa';
+import { FaSync } from 'react-icons/fa';
import styles from '../../admin/Admin.module.css';
export const TrusteePositionDocumentsView: React.FC = () => {
@@ -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 && (
-
- + Neue Verknüpfung
-
- )}
-
- ) : (
-
{
}}
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..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 = [];
@@ -412,7 +400,7 @@ export const TrusteePositionsView: React.FC = () => {
}
return (
-
+
Buchungspositionen verwalten
@@ -437,29 +425,7 @@ export const TrusteePositionsView: React.FC = () => {
- {loading && (!positions || positions.length === 0) ? (
-
- ) : !positions || positions.length === 0 ? (
-
-
-
Keine Positionen vorhanden
-
- Erstellen Sie eine neue Position, um zu beginnen.
-
- {canCreate && (
-
- + Neue Position
-
- )}
-
- ) : (
-
{
}}
emptyMessage="Keine Positionen gefunden"
/>
- )}
{/* Create/Edit Modal */}
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
+
+
+
+ Max. Agenten-Runden
+
+
+ setInputValue(e.target.value)}
+ placeholder={String(maxRoundsInfo.instanceDefault)}
+ min={1}
+ max={100}
+ />
+ {hasOverride && (
+
+ Zurücksetzen
+
+ )}
+
+
+ Standard der Instanz: {maxRoundsInfo.instanceDefault}.
+ {maxRoundsInfo.userOverride != null && (
+ <> Ihr Override: {maxRoundsInfo.userOverride}.>
+ )}
+ {' '}Effektiv: {maxRoundsInfo.effective}.
+
+
+
+
+
+ {saving ? 'Speichern...' : 'Einstellungen speichern'}
+
+
+ );
+};
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/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' && (
)}
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 : '';
diff --git a/src/types/mandate.ts b/src/types/mandate.ts
index 9b38cb9..0d695b7 100644
--- a/src/types/mandate.ts
+++ b/src/types/mandate.ts
@@ -261,6 +261,16 @@ export const FEATURE_REGISTRY: Record = {
{ code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' },
]
},
+ automation2: {
+ code: 'automation2',
+ label: { de: 'Automation 2', en: 'Automation 2' },
+ icon: 'sitemap',
+ views: [
+ { code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' },
+ { code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
+ { code: 'workflows-tasks', label: { de: 'Tasks', en: 'Tasks' }, path: 'workflows-tasks' },
+ ]
+ },
neutralization: {
code: 'neutralization',
label: { de: 'Neutralisierung', en: 'Neutralization', fr: 'Neutralisation' },
@@ -290,6 +300,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' },
]
},