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..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
@@ -741,6 +744,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