subscription base logic
This commit is contained in:
parent
196f76f95e
commit
439fc3676f
14 changed files with 1283 additions and 68 deletions
|
|
@ -41,7 +41,7 @@ import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
|
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
|
||||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
function App() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -188,6 +188,7 @@ function App() {
|
||||||
<Route path="billing">
|
<Route path="billing">
|
||||||
<Route index element={<BillingAdmin />} />
|
<Route index element={<BillingAdmin />} />
|
||||||
<Route path="mandates" element={<BillingMandateView />} />
|
<Route path="mandates" element={<BillingMandateView />} />
|
||||||
|
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
|
|
|
||||||
143
src/api/subscriptionApi.ts
Normal file
143
src/api/subscriptionApi.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES — aligned with State Machine (wiki/concepts/Subscription-State-Machine.md)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SubscriptionStatus = 'PENDING' | 'SCHEDULED' | 'TRIALING' | 'ACTIVE' | 'PAST_DUE' | 'EXPIRED';
|
||||||
|
export type BillingPeriod = 'MONTHLY' | 'YEARLY' | 'NONE';
|
||||||
|
|
||||||
|
export interface SubscriptionPlan {
|
||||||
|
planKey: string;
|
||||||
|
selectableByUser: boolean;
|
||||||
|
title: Record<string, string>;
|
||||||
|
description: Record<string, string>;
|
||||||
|
currency: string;
|
||||||
|
billingPeriod: BillingPeriod;
|
||||||
|
pricePerUserCHF: number;
|
||||||
|
pricePerFeatureInstanceCHF: number;
|
||||||
|
autoRenew: boolean;
|
||||||
|
maxUsers: number | null;
|
||||||
|
maxFeatureInstances: number | null;
|
||||||
|
trialDays: number | null;
|
||||||
|
successorPlanKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MandateSubscription {
|
||||||
|
id: string;
|
||||||
|
mandateId: string;
|
||||||
|
planKey: string;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
recurring: boolean;
|
||||||
|
startedAt: string;
|
||||||
|
effectiveFrom: string | null;
|
||||||
|
endedAt: string | null;
|
||||||
|
currentPeriodStart: string | null;
|
||||||
|
currentPeriodEnd: string | null;
|
||||||
|
trialEndsAt: string | null;
|
||||||
|
snapshotPricePerUserCHF: number;
|
||||||
|
snapshotPricePerInstanceCHF: number;
|
||||||
|
stripeSubscriptionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionStatusResponse {
|
||||||
|
active: boolean;
|
||||||
|
subscription: MandateSubscription | null;
|
||||||
|
plan: SubscriptionPlan | null;
|
||||||
|
scheduled: MandateSubscription | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivatePlanResponse {
|
||||||
|
redirectUrl?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function _mandateConfig(mandateId?: string): Record<string, any> {
|
||||||
|
if (!mandateId) return {};
|
||||||
|
return { headers: { 'X-Mandate-Id': mandateId } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function fetchSelectablePlans(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
mandateId?: string,
|
||||||
|
): Promise<SubscriptionPlan[]> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/plans',
|
||||||
|
method: 'get',
|
||||||
|
additionalConfig: _mandateConfig(mandateId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscriptionStatus(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
mandateId?: string,
|
||||||
|
): Promise<SubscriptionStatusResponse> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/status',
|
||||||
|
method: 'get',
|
||||||
|
additionalConfig: _mandateConfig(mandateId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activatePlan(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
planKey: string,
|
||||||
|
mandateId?: string,
|
||||||
|
returnUrl?: string,
|
||||||
|
): Promise<ActivatePlanResponse> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/activate',
|
||||||
|
method: 'post',
|
||||||
|
data: { planKey, returnUrl: returnUrl || '' },
|
||||||
|
additionalConfig: _mandateConfig(mandateId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelSubscription(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
subscriptionId: string,
|
||||||
|
mandateId?: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/cancel',
|
||||||
|
method: 'post',
|
||||||
|
data: { subscriptionId },
|
||||||
|
additionalConfig: _mandateConfig(mandateId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reactivateSubscription(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
subscriptionId: string,
|
||||||
|
mandateId?: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/reactivate',
|
||||||
|
method: 'post',
|
||||||
|
data: { subscriptionId },
|
||||||
|
additionalConfig: _mandateConfig(mandateId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCheckout(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
sessionId: string,
|
||||||
|
mandateId?: string,
|
||||||
|
): Promise<{ status: string; message: string }> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/checkout/verify',
|
||||||
|
method: 'post',
|
||||||
|
data: { sessionId },
|
||||||
|
additionalConfig: _mandateConfig(mandateId),
|
||||||
|
});
|
||||||
|
}
|
||||||
133
src/hooks/useConfirm.tsx
Normal file
133
src/hooks/useConfirm.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
/**
|
||||||
|
* useConfirm — application-level confirm dialog replacing native browser confirm().
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
* const ok = await confirm('Wirklich löschen?', { confirmLabel: 'Löschen', variant: 'danger' });
|
||||||
|
* // Render <ConfirmDialog /> once in the component tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface ConfirmOptions {
|
||||||
|
title?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
variant?: 'primary' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmState {
|
||||||
|
message: string;
|
||||||
|
options: Required<ConfirmOptions>;
|
||||||
|
resolve: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _defaults: Required<ConfirmOptions> = {
|
||||||
|
title: 'Bestätigung',
|
||||||
|
confirmLabel: 'Bestätigen',
|
||||||
|
cancelLabel: 'Abbrechen',
|
||||||
|
variant: 'primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useConfirm() {
|
||||||
|
const [state, setState] = useState<ConfirmState | null>(null);
|
||||||
|
const resolveRef = useRef<((v: boolean) => void) | null>(null);
|
||||||
|
|
||||||
|
const confirm = useCallback((message: string, options?: ConfirmOptions): Promise<boolean> => {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
resolveRef.current = resolve;
|
||||||
|
setState({
|
||||||
|
message,
|
||||||
|
options: { ..._defaults, ...options },
|
||||||
|
resolve,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleConfirm = useCallback(() => {
|
||||||
|
resolveRef.current?.(true);
|
||||||
|
resolveRef.current = null;
|
||||||
|
setState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleCancel = useCallback(() => {
|
||||||
|
resolveRef.current?.(false);
|
||||||
|
resolveRef.current = null;
|
||||||
|
setState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ConfirmDialog: React.FC = useCallback(() => {
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
const { message, options } = state;
|
||||||
|
const isDanger = options.variant === 'danger';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={_handleCancel}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 10000,
|
||||||
|
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-color, #1a1a2e)',
|
||||||
|
border: '1px solid var(--color-border, #333)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
minWidth: 340, maxWidth: 480,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0, fontSize: '1.05rem', fontWeight: 600,
|
||||||
|
color: 'var(--text-primary, #e0e0e0)',
|
||||||
|
}}>
|
||||||
|
{options.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
margin: 0, fontSize: '0.9rem', lineHeight: 1.5,
|
||||||
|
color: 'var(--text-secondary, #999)',
|
||||||
|
}}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={_handleCancel}
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
||||||
|
border: '1px solid var(--color-border, #444)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--text-secondary, #aaa)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={_handleConfirm}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
|
||||||
|
border: 'none',
|
||||||
|
background: isDanger ? '#ef4444' : 'var(--color-primary, #3b82f6)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [state, _handleConfirm, _handleCancel]);
|
||||||
|
|
||||||
|
return { confirm, ConfirmDialog };
|
||||||
|
}
|
||||||
161
src/hooks/useSubscription.ts
Normal file
161
src/hooks/useSubscription.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
/**
|
||||||
|
* useSubscription Hook — state-machine-aligned subscription management.
|
||||||
|
*
|
||||||
|
* Exposes the operative subscription, any scheduled successor, available plans,
|
||||||
|
* and ID-based mutation functions (activate, cancel, reactivate).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import {
|
||||||
|
fetchSelectablePlans,
|
||||||
|
fetchSubscriptionStatus,
|
||||||
|
activatePlan as activatePlanApi,
|
||||||
|
cancelSubscription as cancelSubscriptionApi,
|
||||||
|
reactivateSubscription as reactivateSubscriptionApi,
|
||||||
|
verifyCheckout as verifyCheckoutApi,
|
||||||
|
type SubscriptionPlan,
|
||||||
|
type MandateSubscription,
|
||||||
|
type SubscriptionStatusResponse,
|
||||||
|
} from '../api/subscriptionApi';
|
||||||
|
|
||||||
|
export interface UseSubscriptionReturn {
|
||||||
|
plans: SubscriptionPlan[];
|
||||||
|
subscription: MandateSubscription | null;
|
||||||
|
plan: SubscriptionPlan | null;
|
||||||
|
scheduled: MandateSubscription | null;
|
||||||
|
active: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
loadPlans: () => Promise<void>;
|
||||||
|
loadStatus: () => Promise<void>;
|
||||||
|
activatePlan: (planKey: string) => Promise<void>;
|
||||||
|
cancelSubscription: (subscriptionId: string) => Promise<void>;
|
||||||
|
reactivateSubscription: (subscriptionId: string) => Promise<void>;
|
||||||
|
verifyCheckout: (sessionId: string) => Promise<{ status: string; message: string }>;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubscription(mandateId?: string): UseSubscriptionReturn {
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
|
const [subscription, setSubscription] = useState<MandateSubscription | null>(null);
|
||||||
|
const [plan, setPlan] = useState<SubscriptionPlan | null>(null);
|
||||||
|
const [scheduled, setScheduled] = useState<MandateSubscription | null>(null);
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const { request, isLoading: loading, error: apiError } = useApiRequest();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadPlans = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchSelectablePlans(request, mandateId);
|
||||||
|
setPlans(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading plans:', err);
|
||||||
|
setPlans([]);
|
||||||
|
}
|
||||||
|
}, [request, mandateId]);
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId);
|
||||||
|
setActive(data.active);
|
||||||
|
setSubscription(data.subscription ?? null);
|
||||||
|
setPlan(data.plan ?? null);
|
||||||
|
setScheduled(data.scheduled ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading subscription status:', err);
|
||||||
|
setActive(false);
|
||||||
|
setSubscription(null);
|
||||||
|
setPlan(null);
|
||||||
|
setScheduled(null);
|
||||||
|
}
|
||||||
|
}, [request, mandateId]);
|
||||||
|
|
||||||
|
const activatePlan = useCallback(async (planKey: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
currentUrl.searchParams.delete('success');
|
||||||
|
currentUrl.searchParams.delete('canceled');
|
||||||
|
currentUrl.searchParams.delete('session_id');
|
||||||
|
currentUrl.searchParams.set('tab', 'subscription');
|
||||||
|
if (mandateId) currentUrl.searchParams.set('mandate', mandateId);
|
||||||
|
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
|
||||||
|
|
||||||
|
const result = await activatePlanApi(request, planKey, mandateId, returnUrl);
|
||||||
|
if (result?.redirectUrl) {
|
||||||
|
window.location.href = result.redirectUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.detail || err.message || 'Fehler beim Aktivieren';
|
||||||
|
setError(msg);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [request, mandateId, loadStatus]);
|
||||||
|
|
||||||
|
const cancelSub = useCallback(async (subscriptionId: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await cancelSubscriptionApi(request, subscriptionId, mandateId);
|
||||||
|
await loadStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.detail || err.message || 'Fehler beim Kündigen';
|
||||||
|
setError(msg);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [request, mandateId, loadStatus]);
|
||||||
|
|
||||||
|
const reactivateSub = useCallback(async (subscriptionId: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await reactivateSubscriptionApi(request, subscriptionId, mandateId);
|
||||||
|
await loadStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.detail || err.message || 'Fehler beim Reaktivieren';
|
||||||
|
setError(msg);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [request, mandateId, loadStatus]);
|
||||||
|
|
||||||
|
const verifyCheckout = useCallback(async (sessionId: string) => {
|
||||||
|
const result = await verifyCheckoutApi(request, sessionId, mandateId);
|
||||||
|
await loadStatus();
|
||||||
|
return result;
|
||||||
|
}, [request, mandateId, loadStatus]);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
await Promise.all([loadPlans(), loadStatus()]);
|
||||||
|
}, [loadPlans, loadStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mandateId) {
|
||||||
|
loadPlans();
|
||||||
|
loadStatus();
|
||||||
|
} else {
|
||||||
|
setPlans([]);
|
||||||
|
setSubscription(null);
|
||||||
|
setPlan(null);
|
||||||
|
setScheduled(null);
|
||||||
|
setActive(false);
|
||||||
|
}
|
||||||
|
}, [mandateId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
plans,
|
||||||
|
subscription,
|
||||||
|
plan,
|
||||||
|
scheduled,
|
||||||
|
active,
|
||||||
|
loading,
|
||||||
|
error: error || (apiError ? String(apiError) : null),
|
||||||
|
loadPlans,
|
||||||
|
loadStatus,
|
||||||
|
activatePlan,
|
||||||
|
cancelSubscription: cancelSub,
|
||||||
|
reactivateSubscription: reactivateSub,
|
||||||
|
verifyCheckout,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
* User × Role matrix with inline toggles and edit/remove actions.
|
* User × Role matrix with inline toggles and edit/remove actions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { FaEdit, FaTrash } from 'react-icons/fa';
|
import { FaEdit, FaTrash } from 'react-icons/fa';
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
|
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
|
||||||
import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess';
|
import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
@ -29,15 +30,20 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
const handleRemove = (user: FeatureAccessUser) => {
|
const handleRemove = useCallback(async (user: FeatureAccessUser) => {
|
||||||
if (removingId) return;
|
if (removingId) return;
|
||||||
if (window.confirm(`"${user.username}" aus dieser Instanz entfernen?`)) {
|
const ok = await confirm(`"${user.username}" aus dieser Instanz entfernen?`, {
|
||||||
setRemovingId(user.userId);
|
title: 'Benutzer entfernen',
|
||||||
onRemoveUser(user);
|
confirmLabel: 'Entfernen',
|
||||||
setRemovingId(null);
|
variant: 'danger',
|
||||||
}
|
});
|
||||||
};
|
if (!ok) return;
|
||||||
|
setRemovingId(user.userId);
|
||||||
|
onRemoveUser(user);
|
||||||
|
setRemovingId(null);
|
||||||
|
}, [removingId, confirm, onRemoveUser]);
|
||||||
|
|
||||||
if (roles.length === 0) {
|
if (roles.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -135,6 +141,7 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
|
||||||
+ Benutzer hinzufügen
|
+ Benutzer hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
117
src/pages/billing/AdminSubscriptionsPage.tsx
Normal file
117
src/pages/billing/AdminSubscriptionsPage.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
import api from '../../api';
|
||||||
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
|
const _TERMINAL_STATUSES = new Set(['EXPIRED']);
|
||||||
|
|
||||||
|
const _STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: 'Ausstehend',
|
||||||
|
SCHEDULED: 'Geplant',
|
||||||
|
TRIALING: 'Testphase',
|
||||||
|
ACTIVE: 'Aktiv',
|
||||||
|
PAST_DUE: 'Überfällig',
|
||||||
|
EXPIRED: 'Abgelaufen',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _COLUMNS: ColumnConfig[] = [
|
||||||
|
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, width: 180 },
|
||||||
|
{ key: 'planTitle', label: 'Plan', type: 'text' as any, sortable: true, filterable: true, width: 180 },
|
||||||
|
{ key: 'status', label: 'Status', type: 'text' as any, sortable: true, filterable: true, width: 110 },
|
||||||
|
{ key: 'recurring', label: 'Wiederkehrend', type: 'boolean' as any, sortable: true, filterable: true, width: 120 },
|
||||||
|
{ key: 'activeUsers', label: 'User', type: 'number' as any, sortable: true, width: 70 },
|
||||||
|
{ key: 'activeInstances', label: 'Instanzen', type: 'number' as any, sortable: true, width: 90 },
|
||||||
|
{ key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number' as any, sortable: true, width: 140 },
|
||||||
|
{ key: 'startedAt', label: 'Gestartet', type: 'date' as any, sortable: true, width: 130 },
|
||||||
|
{ key: 'currentPeriodEnd', label: 'Periodenende', type: 'date' as any, sortable: true, width: 130 },
|
||||||
|
{ key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number' as any, sortable: true, width: 100 },
|
||||||
|
{ key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number' as any, sortable: true, width: 110 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AdminSubscriptionsPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
const [subscriptions, setSubscriptions] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const _loadSubscriptions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request({ url: '/api/subscription/admin/all', method: 'get' });
|
||||||
|
const rows = (Array.isArray(data) ? data : []).map((row: any) => ({
|
||||||
|
...row,
|
||||||
|
status: _STATUS_LABELS[row.status] || row.status,
|
||||||
|
_rawStatus: row.status,
|
||||||
|
}));
|
||||||
|
setSubscriptions(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load subscriptions:', err);
|
||||||
|
setSubscriptions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadSubscriptions(); }, [_loadSubscriptions]);
|
||||||
|
|
||||||
|
const _handleForceCancel = useCallback(async (row: any) => {
|
||||||
|
const ok = await confirm(
|
||||||
|
`Subscription «${row.planTitle}» für Mandant «${row.mandateName}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.`,
|
||||||
|
{ confirmLabel: 'Sofort kündigen', cancelLabel: 'Abbrechen', variant: 'danger' },
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/subscription/force-cancel', { subscriptionId: row.id });
|
||||||
|
await _loadSubscriptions();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Force cancel failed:', err);
|
||||||
|
}
|
||||||
|
}, [confirm, _loadSubscriptions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h1>Subscription-Übersicht</h1>
|
||||||
|
<p className={styles.subtitle}>Alle Abonnements aller Mandanten</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.button}
|
||||||
|
onClick={() => navigate('/admin/billing')}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
← Zurück zur Billing-Verwaltung
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.noData}>Lade Subscriptions…</div>
|
||||||
|
) : (
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={subscriptions}
|
||||||
|
columns={_COLUMNS}
|
||||||
|
apiEndpoint="/api/subscription/admin/all"
|
||||||
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'forceCancel',
|
||||||
|
label: 'Sofort kündigen',
|
||||||
|
icon: '✕',
|
||||||
|
variant: 'danger' as any,
|
||||||
|
onClick: (_id: string, row: any) => _handleForceCancel(row),
|
||||||
|
isVisible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyMessage="Keine Subscriptions vorhanden."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminSubscriptionsPage;
|
||||||
|
|
@ -8,15 +8,20 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { useSearchParams, Link } from 'react-router-dom';
|
||||||
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
||||||
import type { CheckoutCreateRequest } from '../../api/billingApi';
|
import type { CheckoutCreateRequest } from '../../api/billingApi';
|
||||||
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
|
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
|
||||||
import { useCurrentUser } from '../../hooks/useUsers';
|
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 api from '../../api';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
|
type AdminTabType = 'settings' | 'credit' | 'subscription' | 'transactions';
|
||||||
|
|
||||||
const _formatCurrency = (amount: number) => {
|
const _formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat('de-CH', {
|
return new Intl.NumberFormat('de-CH', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
|
|
@ -206,7 +211,7 @@ interface CreditAdderProps {
|
||||||
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||||
const [amount, setAmount] = useState<string>('');
|
const [amount, setAmount] = useState<string>('');
|
||||||
const [description, setDescription] = useState<string>('Manuelles Aufladen durch Admin');
|
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
|
@ -222,8 +227,8 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
const _handleSubmit = async (e: React.FormEvent) => {
|
const _handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const numAmount = parseFloat(amount);
|
const numAmount = parseFloat(amount);
|
||||||
if (!numAmount || numAmount <= 0) {
|
if (!numAmount || numAmount === 0) {
|
||||||
setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
|
setMessage({ type: 'error', text: 'Betrag darf nicht null sein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,10 +237,13 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
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('');
|
setAmount('');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
|
setMessage({ type: 'error', text: err.message || 'Fehler bei der Buchung' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +251,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminSection}>
|
<div className={styles.adminSection}>
|
||||||
<h3>Guthaben manuell aufladen</h3>
|
<h3>Guthaben manuell verwalten</h3>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
||||||
|
|
@ -285,8 +293,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
placeholder="z.B. 50"
|
placeholder="z.B. 50 oder -20"
|
||||||
min="0.01"
|
|
||||||
step="0.01"
|
step="0.01"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
@ -308,7 +315,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
||||||
>
|
>
|
||||||
{saving ? 'Wird gutgeschrieben...' : 'Manuell aufladen'}
|
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -456,6 +463,78 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ 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<MandateTransactionsTabProps> = ({ mandateId }) => {
|
||||||
|
const { request, isLoading: loading } = useApiRequest();
|
||||||
|
const [transactions, setTransactions] = useState<any[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _loadTransactions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/billing/admin/transactions/${mandateId}`,
|
||||||
|
method: 'get',
|
||||||
|
params: { limit: 500 },
|
||||||
|
});
|
||||||
|
setTransactions(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden');
|
||||||
|
setTransactions([]);
|
||||||
|
}
|
||||||
|
}, [request, mandateId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_loadTransactions();
|
||||||
|
}, [_loadTransactions]);
|
||||||
|
|
||||||
|
const hookData = useMemo(() => ({
|
||||||
|
refetch: _loadTransactions,
|
||||||
|
}), [_loadTransactions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '400px' }}>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 1rem 0' }}>
|
||||||
|
AI-Verbrauch und Guthaben-Transaktionen. Subscription-Gebühren werden separat über Stripe abgerechnet.
|
||||||
|
</p>
|
||||||
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={transactions}
|
||||||
|
columns={_mandateTxColumns}
|
||||||
|
apiEndpoint={`/api/billing/admin/transactions/${mandateId}`}
|
||||||
|
loading={loading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={25}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
sortable={true}
|
||||||
|
selectable={false}
|
||||||
|
emptyMessage="Keine Transaktionen für diesen Mandanten"
|
||||||
|
onRefresh={_loadTransactions}
|
||||||
|
hookData={hookData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -465,7 +544,9 @@ export const BillingAdmin: React.FC = () => {
|
||||||
const { user: currentUser } = useCurrentUser();
|
const { user: currentUser } = useCurrentUser();
|
||||||
const isSysAdmin = currentUser?.isSysAdmin === true;
|
const isSysAdmin = currentUser?.isSysAdmin === true;
|
||||||
|
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(
|
||||||
|
searchParams.get('mandate') || null
|
||||||
|
);
|
||||||
const [mandateList, setMandateList] = useState<UserMandateRow[]>([]);
|
const [mandateList, setMandateList] = useState<UserMandateRow[]>([]);
|
||||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||||
|
|
||||||
|
|
@ -530,7 +611,14 @@ export const BillingAdmin: React.FC = () => {
|
||||||
const canceledParam = searchParams.get('canceled');
|
const canceledParam = searchParams.get('canceled');
|
||||||
const sessionIdParam = searchParams.get('session_id');
|
const sessionIdParam = searchParams.get('session_id');
|
||||||
|
|
||||||
|
const _initialAdminTab = (searchParams.get('tab') as AdminTabType) || 'settings';
|
||||||
|
const [adminTab, setAdminTab] = useState<AdminTabType>(
|
||||||
|
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const _confirmCheckoutIfNeeded = async () => {
|
const _confirmCheckoutIfNeeded = async () => {
|
||||||
|
|
@ -580,34 +668,51 @@ export const BillingAdmin: React.FC = () => {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
|
}, [adminTab, successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
|
||||||
|
|
||||||
const _clearStripeParams = useCallback(() => {
|
const _clearStripeParams = useCallback(() => {
|
||||||
searchParams.delete('success');
|
searchParams.delete('success');
|
||||||
searchParams.delete('canceled');
|
searchParams.delete('canceled');
|
||||||
searchParams.delete('session_id');
|
searchParams.delete('session_id');
|
||||||
|
searchParams.delete('mandate');
|
||||||
setSearchParams(searchParams, { replace: true });
|
setSearchParams(searchParams, { replace: true });
|
||||||
setStripeReturnMessage(null);
|
setStripeReturnMessage(null);
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings;
|
const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings;
|
||||||
|
|
||||||
|
const _tabStyle = (isActive: boolean) => ({
|
||||||
|
padding: '8px 16px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
|
||||||
|
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '14px',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard}>
|
<div className={styles.billingDashboard}>
|
||||||
<header className={styles.pageHeader}>
|
<header className={styles.pageHeader}>
|
||||||
<h1>Billing Administration</h1>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<p className={styles.subtitle}>
|
<div>
|
||||||
{isSysAdmin
|
<h1>Billing-Verwaltung</h1>
|
||||||
? 'Verwaltung von Abrechnungseinstellungen und Guthaben'
|
<p className={styles.subtitle}>
|
||||||
: 'Guthaben und Konten für Ihre Mandanten'}
|
Abrechnungseinstellungen, Guthaben und Abonnement pro Mandant
|
||||||
</p>
|
</p>
|
||||||
{isSysAdmin && (
|
</div>
|
||||||
<p style={{ marginTop: '8px' }}>
|
{isSysAdmin && (
|
||||||
<Link to="/admin/billing/mandates" style={{ color: 'var(--color-primary)' }}>
|
<Link
|
||||||
Mandanten-Übersicht (Balances & Transaktionen)
|
to="/admin/billing/subscriptions"
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ whiteSpace: 'nowrap', marginTop: 4 }}
|
||||||
|
>
|
||||||
|
Alle Abonnements →
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
)}
|
||||||
)}
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{stripeReturnMessage && (
|
{stripeReturnMessage && (
|
||||||
|
|
@ -635,9 +740,30 @@ export const BillingAdmin: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{selectedMandateId && (
|
{selectedMandateId ? (
|
||||||
<>
|
<>
|
||||||
{isSysAdmin && (
|
<nav style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
borderBottom: '1px solid var(--color-border, #333)',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
}}>
|
||||||
|
<button onClick={() => setAdminTab('settings')} style={_tabStyle(adminTab === 'settings')}>
|
||||||
|
Einstellungen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
|
||||||
|
Guthaben
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setAdminTab('subscription')} style={_tabStyle(adminTab === 'subscription')}>
|
||||||
|
Abonnement
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
|
||||||
|
Transaktionen
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{adminTab === 'settings' && (
|
||||||
<SettingsEditor
|
<SettingsEditor
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onSave={handleSaveSettings}
|
onSave={handleSaveSettings}
|
||||||
|
|
@ -645,24 +771,34 @@ export const BillingAdmin: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSysAdmin && (
|
{adminTab === 'credit' && (
|
||||||
<CreditAdder
|
<>
|
||||||
settings={settings}
|
{isSysAdmin && (
|
||||||
accounts={accounts}
|
<CreditAdder
|
||||||
users={users}
|
settings={settings}
|
||||||
onAddCredit={_handleAddCredit}
|
accounts={accounts}
|
||||||
/>
|
users={users}
|
||||||
|
onAddCredit={_handleAddCredit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showStripeForMandateAdmin && (
|
||||||
|
<MandateStripeTopUp mandateId={selectedMandateId} createCheckout={createCheckout} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AccountsOverview accounts={accounts} users={users} loading={loading} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStripeForMandateAdmin && (
|
{adminTab === 'subscription' && (
|
||||||
<MandateStripeTopUp mandateId={selectedMandateId} createCheckout={createCheckout} />
|
<SubscriptionTab mandateId={selectedMandateId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AccountsOverview accounts={accounts} users={users} loading={loading} />
|
{adminTab === 'transactions' && (
|
||||||
|
<MandateTransactionsTab mandateId={selectedMandateId} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{!selectedMandateId && (
|
|
||||||
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
|
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -741,6 +741,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,11 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const BillingMandateView: React.FC = () => {
|
interface BillingMandateViewProps {
|
||||||
|
embedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BillingMandateView: React.FC<BillingMandateViewProps> = ({ embedded = false }) => {
|
||||||
const { request, isLoading: loading } = useApiRequest();
|
const { request, isLoading: loading } = useApiRequest();
|
||||||
const [balances, setBalances] = useState<MandateBalance[]>([]);
|
const [balances, setBalances] = useState<MandateBalance[]>([]);
|
||||||
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
||||||
|
|
@ -212,13 +216,17 @@ export const BillingMandateView: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard}>
|
<div className={embedded ? '' : styles.billingDashboard}>
|
||||||
<header className={styles.pageHeader}>
|
{!embedded && (
|
||||||
<h1>Mandanten-Billing</h1>
|
<>
|
||||||
<p className={styles.subtitle}>Guthaben und Transaktionen pro Mandant</p>
|
<header className={styles.pageHeader}>
|
||||||
</header>
|
<h1>Mandanten-Billing</h1>
|
||||||
|
<p className={styles.subtitle}>Guthaben und Transaktionen pro Mandant</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<BillingNav />
|
<BillingNav />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mandate Balances */}
|
{/* Mandate Balances */}
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
|
|
|
||||||
481
src/pages/billing/SubscriptionTab.tsx
Normal file
481
src/pages/billing/SubscriptionTab.tsx
Normal file
|
|
@ -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<string, string> | 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<string, { label: string; color: string }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<PlanCardProps> = ({ plan, isCurrent, onActivate, activatingPlanKey }) => {
|
||||||
|
const activating = activatingPlanKey === plan.planKey;
|
||||||
|
const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: isCurrent ? '2px solid var(--color-primary, #3b82f6)' : '1px solid var(--color-border, #333)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.75rem',
|
||||||
|
background: isCurrent ? 'rgba(59,130,246,0.06)' : 'var(--color-surface, #1a1a2e)',
|
||||||
|
minWidth: 220,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<strong style={{ fontSize: '1rem' }}>{_t(plan.title)}</strong>
|
||||||
|
{isCurrent && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
|
||||||
|
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
|
||||||
|
}}>Aktuell</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: 0 }}>
|
||||||
|
{_t(plan.description)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!isFreePlan && (
|
||||||
|
<div style={{ fontSize: '0.85rem' }}>
|
||||||
|
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
|
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFreePlan && plan.trialDays && (
|
||||||
|
<div style={{ fontSize: '0.85rem' }}>
|
||||||
|
{plan.trialDays} Tage kostenlos
|
||||||
|
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
||||||
|
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCurrent && (
|
||||||
|
<button
|
||||||
|
onClick={() => 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'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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<SubInfoProps> = ({ 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 (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid var(--color-border, #333)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
background: 'var(--color-surface, #1a1a2e)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<strong style={{ fontSize: '1.1rem' }}>{plan ? _t(plan.title) : sub.planKey}</strong>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
{isActive && !sub.recurring && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
|
||||||
|
background: '#ef4444', color: '#fff', fontWeight: 600,
|
||||||
|
}}>Gekündigt</span>
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem', padding: '2px 10px', borderRadius: '4px',
|
||||||
|
background: statusInfo.color, color: '#fff', fontWeight: 600,
|
||||||
|
}}>{statusInfo.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.6rem 0.8rem', borderRadius: '6px',
|
||||||
|
background: justPaid ? 'rgba(34,197,94,0.1)' : 'rgba(245,158,11,0.1)',
|
||||||
|
border: `1px solid ${justPaid ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
|
||||||
|
color: justPaid ? '#22c55e' : '#f59e0b', fontSize: '0.85rem',
|
||||||
|
}}>
|
||||||
|
{justPaid
|
||||||
|
? 'Zahlung erfolgreich. Abonnement wird aktiviert — bitte warten...'
|
||||||
|
: 'Die Zahlung wurde noch nicht abgeschlossen. Sie können den Checkout abbrechen oder erneut starten.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isScheduled && sub.effectiveFrom && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.6rem 0.8rem', borderRadius: '6px',
|
||||||
|
background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)',
|
||||||
|
color: '#8b5cf6', fontSize: '0.85rem',
|
||||||
|
}}>
|
||||||
|
Dieses Abonnement wird am {_formatDate(sub.effectiveFrom)} aktiv, wenn das aktuelle Abonnement ausläuft.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPending && !isScheduled && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.85rem', color: 'var(--text-secondary, #888)',
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem 1rem',
|
||||||
|
}}>
|
||||||
|
<span>Gestartet: {_formatDate(sub.startedAt)}</span>
|
||||||
|
{plan && <span>Periode: {_periodLabel[plan.billingPeriod] || '—'}</span>}
|
||||||
|
{sub.currentPeriodEnd && <span>Periodenende: {_formatDate(sub.currentPeriodEnd)}</span>}
|
||||||
|
{sub.trialEndsAt && <span>Trial endet: {_formatDate(sub.trialEndsAt)}</span>}
|
||||||
|
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
||||||
|
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
|
||||||
|
{isActive && !sub.recurring && onReactivate && (
|
||||||
|
<button
|
||||||
|
onClick={() => 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'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && sub.recurring && onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={() => 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'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isPending || isScheduled) && onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={() => 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'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Subscription Tab
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SubscriptionTabProps {
|
||||||
|
mandateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) => {
|
||||||
|
const {
|
||||||
|
plans,
|
||||||
|
subscription,
|
||||||
|
plan: currentPlan,
|
||||||
|
scheduled,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
activatePlan,
|
||||||
|
cancelSubscription,
|
||||||
|
reactivateSubscription,
|
||||||
|
verifyCheckout,
|
||||||
|
} = useSubscription(mandateId);
|
||||||
|
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
const [activatingPlanKey, setActivatingPlanKey] = useState<string | null>(null);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const [reactivating, setReactivating] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(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 <div className={styles.loadingPlaceholder}>Lade Abonnement-Daten...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Checkout feedback */}
|
||||||
|
{checkoutMessage && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px',
|
||||||
|
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'rgba(59,130,246,0.12)',
|
||||||
|
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6'}`,
|
||||||
|
color: checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}>
|
||||||
|
{checkoutMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{(error || actionError) && (
|
||||||
|
<div className={styles.errorMessage} style={{ marginBottom: '1rem' }}>
|
||||||
|
{actionError || error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current subscription */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Aktuelles Abonnement</h2>
|
||||||
|
{subscription ? (
|
||||||
|
<SubInfoCard
|
||||||
|
sub={subscription}
|
||||||
|
plan={currentPlan}
|
||||||
|
label={subscription.status === 'PENDING'
|
||||||
|
? (justPaid ? 'Zahlung wird verarbeitet' : 'Checkout in Bearbeitung')
|
||||||
|
: 'Operatives Abonnement'}
|
||||||
|
onCancel={_handleCancel}
|
||||||
|
onReactivate={_handleReactivate}
|
||||||
|
cancelling={cancelling}
|
||||||
|
reactivating={reactivating}
|
||||||
|
justPaid={justPaid}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.noData}>
|
||||||
|
Kein aktives Abonnement. Wählen Sie unten einen Plan.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Scheduled successor */}
|
||||||
|
{scheduled && (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Geplanter Nachfolger</h2>
|
||||||
|
<SubInfoCard
|
||||||
|
sub={scheduled}
|
||||||
|
plan={null}
|
||||||
|
label="Startet nach Ablauf des aktuellen Abonnements"
|
||||||
|
onCancel={_handleCancel}
|
||||||
|
cancelling={cancelling}
|
||||||
|
reactivating={false}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available plans */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Verfügbare Pläne</h2>
|
||||||
|
{plans.length === 0 ? (
|
||||||
|
<div className={styles.noData}>Keine Pläne verfügbar</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}>
|
||||||
|
{plans.map((p) => (
|
||||||
|
<PlanCard
|
||||||
|
key={p.planKey}
|
||||||
|
plan={p}
|
||||||
|
isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'}
|
||||||
|
onActivate={_handleActivate}
|
||||||
|
activatingPlanKey={activatingPlanKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmDialog />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -11,3 +11,4 @@ export { BillingNav } from './BillingNav';
|
||||||
export { BillingTransactions } from './BillingTransactions';
|
export { BillingTransactions } from './BillingTransactions';
|
||||||
export { BillingMandateView } from './BillingMandateView';
|
export { BillingMandateView } from './BillingMandateView';
|
||||||
export { BillingUserView } from './BillingUserView';
|
export { BillingUserView } from './BillingUserView';
|
||||||
|
export { default as AdminSubscriptionsPage } from './AdminSubscriptionsPage';
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
* Similar to trustee views but hardcoded for chatbot feature.
|
* 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 { useChatbot } from '../../../hooks/useChatbot';
|
||||||
|
import { useConfirm } from '../../../hooks/useConfirm';
|
||||||
import { TextField } from '../../../components/UiComponents/TextField';
|
import { TextField } from '../../../components/UiComponents/TextField';
|
||||||
import { Button } from '../../../components/UiComponents/Button';
|
import { Button } from '../../../components/UiComponents/Button';
|
||||||
import { AutoScroll } from '../../../components/UiComponents/AutoScroll';
|
import { AutoScroll } from '../../../components/UiComponents/AutoScroll';
|
||||||
|
|
@ -40,7 +41,8 @@ export const ChatbotConversationsView: React.FC = () => {
|
||||||
} = useChatbot();
|
} = useChatbot();
|
||||||
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!inputValue.trim() || isStreaming) return;
|
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();
|
e.stopPropagation();
|
||||||
if (window.confirm('Möchten Sie diese Konversation wirklich löschen?')) {
|
const ok = await confirm('Möchten Sie diese Konversation wirklich löschen?', {
|
||||||
setDeletingId(workflowId);
|
title: 'Konversation löschen',
|
||||||
try {
|
confirmLabel: 'Löschen',
|
||||||
await deleteThread(workflowId);
|
variant: 'danger',
|
||||||
} finally {
|
});
|
||||||
setDeletingId(null);
|
if (!ok) return;
|
||||||
}
|
setDeletingId(workflowId);
|
||||||
|
try {
|
||||||
|
await deleteThread(workflowId);
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
};
|
}, [confirm, deleteThread]);
|
||||||
|
|
||||||
const formatDate = (timestamp?: number) => {
|
const formatDate = (timestamp?: number) => {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
|
|
@ -269,6 +275,7 @@ export const ChatbotConversationsView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { useConfirm } from '../../../hooks/useConfirm';
|
||||||
import {
|
import {
|
||||||
fetchAccountingConnectors,
|
fetchAccountingConnectors,
|
||||||
fetchAccountingConfig,
|
fetchAccountingConfig,
|
||||||
|
|
@ -42,6 +43,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!importDone) return;
|
if (!importDone) return;
|
||||||
|
|
@ -145,7 +147,12 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
|
|
||||||
const handleRemove = async () => {
|
const handleRemove = async () => {
|
||||||
if (!instanceId) return;
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await deleteAccountingConfig(request, instanceId);
|
await deleteAccountingConfig(request, instanceId);
|
||||||
|
|
@ -421,6 +428,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,18 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
const item = event.item as Record<string, unknown> | undefined;
|
const item = event.item as Record<string, unknown> | undefined;
|
||||||
let msg = event.content || 'Unknown error';
|
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 =
|
const preferDe =
|
||||||
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de');
|
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de');
|
||||||
const de = typeof item.messageDe === 'string' ? item.messageDe : '';
|
const de = typeof item.messageDe === 'string' ? item.messageDe : '';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue