diff --git a/src/api/subscriptionApi.ts b/src/api/subscriptionApi.ts index 70379c3..119c348 100644 --- a/src/api/subscriptionApi.ts +++ b/src/api/subscriptionApi.ts @@ -42,6 +42,53 @@ export interface MandateSubscription { snapshotPricePerUserCHF: number; snapshotPricePerInstanceCHF: number; stripeSubscriptionId: string | null; + isEnterprise?: boolean; + enterpriseFlatPriceCHF?: number | null; + enterpriseMaxUsers?: number | null; + enterpriseMaxFeatureInstances?: number | null; + enterpriseMaxDataVolumeMB?: number | null; + enterpriseBudgetAiCHF?: number | null; + enterpriseNote?: string | null; +} + +// ============================================================================ +// Enterprise Types +// ============================================================================ + +export interface EnterpriseCreateParams { + mandateId: string; + startDate: number; + endDate: number; + autoRenew: boolean; + flatPriceCHF: number; + maxUsers?: number | null; + maxFeatureInstances?: number | null; + maxDataVolumeMB?: number | null; + budgetAiCHF?: number | null; + note?: string | null; +} + +export interface EnterpriseRenewParams { + subscriptionId: string; + newEndDate: number; + autoRenew?: boolean; + flatPriceCHF?: number; + maxUsers?: number | null; + maxFeatureInstances?: number | null; + maxDataVolumeMB?: number | null; + budgetAiCHF?: number | null; + note?: string | null; +} + +export interface EnterpriseUpdateParams { + subscriptionId: string; + enterpriseFlatPriceCHF?: number; + enterpriseMaxUsers?: number | null; + enterpriseMaxFeatureInstances?: number | null; + enterpriseMaxDataVolumeMB?: number | null; + enterpriseBudgetAiCHF?: number | null; + enterpriseNote?: string | null; + recurring?: boolean; } export interface SubscriptionUsage { @@ -154,3 +201,40 @@ export async function verifyCheckout( additionalConfig: _mandateConfig(mandateId), }); } + +// ============================================================================ +// Enterprise API +// ============================================================================ + +export async function createEnterprise( + request: ApiRequestFunction, + params: EnterpriseCreateParams, +): Promise> { + return await request({ + url: '/api/subscription/enterprise/create', + method: 'post', + data: params, + }); +} + +export async function renewEnterprise( + request: ApiRequestFunction, + params: EnterpriseRenewParams, +): Promise> { + return await request({ + url: '/api/subscription/enterprise/renew', + method: 'post', + data: params, + }); +} + +export async function updateEnterprise( + request: ApiRequestFunction, + params: EnterpriseUpdateParams, +): Promise> { + return await request({ + url: '/api/subscription/enterprise/update', + method: 'put', + data: params, + }); +} diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index f3c18d8..d8b84c1 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -121,7 +121,6 @@ export const PAGE_ICONS: Record = { // Feature pages - CommCoach 'page.feature.commcoach.dashboard': , 'page.feature.commcoach.coaching': , - 'page.feature.commcoach.dossier': , 'page.feature.commcoach.settings': , // Feature icons (for feature grouping in navigation) diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 8497fee..826b195 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -48,7 +48,7 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView'; import { NeutralizationView } from './views/neutralization'; // CommCoach Views -import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, CommcoachSessionView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach'; +import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, CommcoachSessionView, CommcoachSettingsView } from './views/commcoach'; // Redmine Views import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine'; @@ -174,7 +174,6 @@ const VIEW_COMPONENTS: Record> = { assistant: CommcoachAssistantView, modules: CommcoachModulesView, session: CommcoachSessionView, - dossier: CommcoachDossierView, settings: CommcoachSettingsView, }, redmine: { diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx index f6d7403..ea657f6 100644 --- a/src/pages/billing/AdminSubscriptionsPage.tsx +++ b/src/pages/billing/AdminSubscriptionsPage.tsx @@ -6,8 +6,16 @@ import { useApiRequest } from '../../hooks/useApi'; import { fetchAttributes } from '../../api/attributesApi'; import type { AttributeDefinition } from '../../api/attributesApi'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import { + createEnterprise, + renewEnterprise, + updateEnterprise, +} from '../../api/subscriptionApi'; +import { fetchMandates } from '../../api/mandateApi'; +import type { Mandate } from '../../api/mandateApi'; import api from '../../api'; import styles from './Billing.module.css'; +import EnterpriseDialog, { type EnterpriseDialogMode, type EnterpriseDialogData } from './EnterpriseDialog'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -21,15 +29,101 @@ const AdminSubscriptionsPage: React.FC = () => { const { confirm, ConfirmDialog } = useConfirm(); const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions(); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogMode, setDialogMode] = useState('create'); + const [dialogData, setDialogData] = useState({}); + const [mandateOptions, setMandateOptions] = useState<{ id: string; label: string }[]>([]); + const [mandatesLoading, setMandatesLoading] = useState(false); + useEffect(() => { fetchAttributes(request, 'MandateSubscriptionView') .then(setBackendAttributes) .catch(() => setBackendAttributes([])); }, [request]); + const _loadMandates = useCallback(async () => { + setMandatesLoading(true); + try { + const data = await fetchMandates(request); + const items: Mandate[] = Array.isArray(data) ? data : (data as any).items ?? []; + setMandateOptions(items.map((m) => ({ id: m.id, label: m.label || m.name }))); + } catch { + setMandateOptions([]); + } finally { + setMandatesLoading(false); + } + }, [request]); + + const _openCreate = useCallback(() => { + setDialogMode('create'); + setDialogData({}); + setDialogOpen(true); + _loadMandates(); + }, [_loadMandates]); + + const _openRenew = useCallback((row: any) => { + setDialogMode('renew'); + setDialogData({ + subscriptionId: row.id, + mandateName: row.mandateName, + endDate: row.currentPeriodEnd, + autoRenew: row.recurring === true || row.recurring === 'Ja', + flatPriceCHF: row.enterpriseFlatPriceCHF, + maxUsers: row.enterpriseMaxUsers, + maxFeatureInstances: row.enterpriseMaxFeatureInstances, + maxDataVolumeMB: row.enterpriseMaxDataVolumeMB, + budgetAiCHF: row.enterpriseBudgetAiCHF, + note: row.enterpriseNote, + }); + setDialogOpen(true); + }, []); + + const _openUpdate = useCallback((row: any) => { + setDialogMode('update'); + setDialogData({ + subscriptionId: row.id, + mandateName: row.mandateName, + autoRenew: row.recurring === true || row.recurring === 'Ja', + flatPriceCHF: row.enterpriseFlatPriceCHF, + maxUsers: row.enterpriseMaxUsers, + maxFeatureInstances: row.enterpriseMaxFeatureInstances, + maxDataVolumeMB: row.enterpriseMaxDataVolumeMB, + budgetAiCHF: row.enterpriseBudgetAiCHF, + note: row.enterpriseNote, + }); + setDialogOpen(true); + }, []); + + const _handleDialogSubmit = useCallback(async (mode: EnterpriseDialogMode, values: Record) => { + if (mode === 'create') { + await createEnterprise(request, values as any); + } else if (mode === 'renew') { + await renewEnterprise(request, values as any); + } else if (mode === 'update') { + await updateEnterprise(request, values as any); + } + await refetch(); + }, [request, refetch]); + const _rawColumns: ColumnConfig[] = useMemo(() => [ { key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 }, - { key: 'planTitle', label: t('Plan'), sortable: true, filterable: true, width: 180 }, + { key: 'planTitle', label: t('Plan'), sortable: true, filterable: true, width: 180, + render: (value: any, row: any) => { + const isEnt = row.isEnterprise || row.planKey === 'ENTERPRISE'; + return ( + + {value} + {isEnt && ( + Enterprise + )} + + ); + }, + }, { key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 }, { key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 }, { key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 }, @@ -61,11 +155,48 @@ const AdminSubscriptionsPage: React.FC = () => { } }, [confirm, refetch, t]); + const _isEnterprise = (row: any) => row.isEnterprise || row.planKey === 'ENTERPRISE'; + + const customActions = useMemo(() => [ + { + id: 'enterpriseUpdate', + title: t('Enterprise anpassen'), + icon: '✎', + onClick: (row: any) => _openUpdate(row), + visible: (row: any) => _isEnterprise(row) && !_TERMINAL_STATUSES.has(row._rawStatus), + }, + { + id: 'enterpriseRenew', + title: t('Enterprise erneuern'), + icon: '↻', + onClick: (row: any) => _openRenew(row), + visible: (row: any) => _isEnterprise(row) && !_TERMINAL_STATUSES.has(row._rawStatus), + }, + { + id: 'forceCancel', + title: t('Sofort stornieren'), + icon: '✕', + onClick: (row: any) => _handleForceCancel(row), + visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus), + }, + ], [_openUpdate, _openRenew, _handleForceCancel, t]); + return (
-

{t('Abonnementübersicht')}

-

{t('Alle Abonnements aller Mandanten')}

+
+
+

{t('Abonnementübersicht')}

+

{t('Alle Abonnements aller Mandanten')}

+
+ +
@@ -78,19 +209,21 @@ const AdminSubscriptionsPage: React.FC = () => { pageSize={50} selectable={false} hookData={{ refetch, pagination }} - customActions={[ - { - id: 'forceCancel', - title: t('Sofort stornieren'), - icon: '✕', - onClick: (row: any) => _handleForceCancel(row), - visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus), - }, - ]} + customActions={customActions} emptyMessage={t('Keine Abonnements vorhanden')} />
+ setDialogOpen(false)} + onSubmit={_handleDialogSubmit} + /> +
); diff --git a/src/pages/billing/EnterpriseDialog.tsx b/src/pages/billing/EnterpriseDialog.tsx new file mode 100644 index 0000000..0ce6543 --- /dev/null +++ b/src/pages/billing/EnterpriseDialog.tsx @@ -0,0 +1,271 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Modal } from '../../components/UiComponents/Modal'; +import { useLanguage } from '../../providers/language/LanguageContext'; +import styles from './Billing.module.css'; + +export type EnterpriseDialogMode = 'create' | 'renew' | 'update'; + +export interface EnterpriseDialogData { + mandateId?: string; + subscriptionId?: string; + mandateName?: string; + startDate?: string; + endDate?: string; + autoRenew?: boolean; + flatPriceCHF?: number; + maxUsers?: number | null; + maxFeatureInstances?: number | null; + maxDataVolumeMB?: number | null; + budgetAiCHF?: number | null; + note?: string | null; +} + +interface MandateOption { + id: string; + label: string; +} + +interface EnterpriseDialogProps { + open: boolean; + mode: EnterpriseDialogMode; + data: EnterpriseDialogData; + mandates?: MandateOption[]; + loading?: boolean; + onClose: () => void; + onSubmit: (mode: EnterpriseDialogMode, values: Record) => Promise; +} + +const _formatDateForInput = (iso?: string): string => { + if (!iso) return ''; + try { + return new Date(iso).toISOString().slice(0, 10); + } catch { + return ''; + } +}; + +const _todayStr = (): string => new Date().toISOString().slice(0, 10); + +const _oneYearLaterStr = (): string => { + const d = new Date(); + d.setFullYear(d.getFullYear() + 1); + return d.toISOString().slice(0, 10); +}; + +const EnterpriseDialog: React.FC = ({ + open, mode, data, mandates, loading, onClose, onSubmit, +}) => { + const { t } = useLanguage(); + + const [mandateId, setMandateId] = useState(''); + const [startDate, setStartDate] = useState(_todayStr()); + const [endDate, setEndDate] = useState(_oneYearLaterStr()); + const [autoRenew, setAutoRenew] = useState(true); + const [flatPrice, setFlatPrice] = useState(''); + const [maxUsers, setMaxUsers] = useState(''); + const [maxFeatureInstances, setMaxFeatureInstances] = useState(''); + const [maxDataVolumeMB, setMaxDataVolumeMB] = useState(''); + const [budgetAiCHF, setBudgetAiCHF] = useState(''); + const [note, setNote] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + if (!open) return; + setMandateId(data.mandateId || ''); + setStartDate(data.startDate ? _formatDateForInput(data.startDate) : _todayStr()); + setEndDate(data.endDate ? _formatDateForInput(data.endDate) : _oneYearLaterStr()); + setAutoRenew(data.autoRenew ?? true); + setFlatPrice(data.flatPriceCHF != null ? String(data.flatPriceCHF) : ''); + setMaxUsers(data.maxUsers != null ? String(data.maxUsers) : ''); + setMaxFeatureInstances(data.maxFeatureInstances != null ? String(data.maxFeatureInstances) : ''); + setMaxDataVolumeMB(data.maxDataVolumeMB != null ? String(data.maxDataVolumeMB) : ''); + setBudgetAiCHF(data.budgetAiCHF != null ? String(data.budgetAiCHF) : ''); + setNote(data.note ?? ''); + setErrorMsg(null); + }, [open, data]); + + const _handleSubmit = useCallback(async () => { + setErrorMsg(null); + setSubmitting(true); + try { + const values: Record = {}; + + if (mode === 'create') { + if (!mandateId) { setErrorMsg(t('Mandant ist erforderlich')); setSubmitting(false); return; } + if (!flatPrice) { setErrorMsg(t('Pauschalpreis ist erforderlich')); setSubmitting(false); return; } + values.mandateId = mandateId; + values.startDate = Math.floor(new Date(startDate).getTime() / 1000); + values.endDate = Math.floor(new Date(endDate).getTime() / 1000); + values.autoRenew = autoRenew; + values.flatPriceCHF = parseFloat(flatPrice); + if (maxUsers) values.maxUsers = parseInt(maxUsers, 10); + if (maxFeatureInstances) values.maxFeatureInstances = parseInt(maxFeatureInstances, 10); + if (maxDataVolumeMB) values.maxDataVolumeMB = parseInt(maxDataVolumeMB, 10); + if (budgetAiCHF) values.budgetAiCHF = parseFloat(budgetAiCHF); + if (note.trim()) values.note = note.trim(); + } else if (mode === 'renew') { + if (!data.subscriptionId) return; + values.subscriptionId = data.subscriptionId; + values.newEndDate = Math.floor(new Date(endDate).getTime() / 1000); + values.autoRenew = autoRenew; + if (flatPrice) values.flatPriceCHF = parseFloat(flatPrice); + if (maxUsers) values.maxUsers = parseInt(maxUsers, 10); + if (maxFeatureInstances) values.maxFeatureInstances = parseInt(maxFeatureInstances, 10); + if (maxDataVolumeMB) values.maxDataVolumeMB = parseInt(maxDataVolumeMB, 10); + if (budgetAiCHF) values.budgetAiCHF = parseFloat(budgetAiCHF); + if (note.trim()) values.note = note.trim(); + } else if (mode === 'update') { + if (!data.subscriptionId) return; + values.subscriptionId = data.subscriptionId; + if (flatPrice) values.enterpriseFlatPriceCHF = parseFloat(flatPrice); + if (maxUsers) values.enterpriseMaxUsers = parseInt(maxUsers, 10); + if (maxFeatureInstances) values.enterpriseMaxFeatureInstances = parseInt(maxFeatureInstances, 10); + if (maxDataVolumeMB) values.enterpriseMaxDataVolumeMB = parseInt(maxDataVolumeMB, 10); + if (budgetAiCHF) values.enterpriseBudgetAiCHF = parseFloat(budgetAiCHF); + if (note.trim()) values.enterpriseNote = note.trim(); + values.recurring = autoRenew; + } + + await onSubmit(mode, values); + onClose(); + } catch (err: any) { + setErrorMsg(err?.response?.data?.detail || err?.message || t('Fehler')); + } finally { + setSubmitting(false); + } + }, [mode, mandateId, startDate, endDate, autoRenew, flatPrice, maxUsers, maxFeatureInstances, maxDataVolumeMB, budgetAiCHF, note, data, onSubmit, onClose, t]); + + const _title: Record = { + create: t('Enterprise-Abo erstellen'), + renew: t('Enterprise-Abo erneuern'), + update: t('Enterprise-Abo anpassen'), + }; + + const _submitLabel: Record = { + create: t('Erstellen'), + renew: t('Erneuern'), + update: t('Speichern'), + }; + + const isCreate = mode === 'create'; + const isUpdate = mode === 'update'; + + return ( + +
+ {errorMsg &&
{errorMsg}
} + + {data.mandateName && !isCreate && ( +
+ {t('Mandant:')} {data.mandateName} +
+ )} + + {isCreate && ( +
+ + +
+ )} + + {!isUpdate && ( +
+ {isCreate && ( +
+ + setStartDate(e.target.value)} /> +
+ )} +
+ + setEndDate(e.target.value)} /> +
+
+ )} + +
+
+ + setFlatPrice(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : ''} /> +
+
+ +
+ setAutoRenew(e.target.checked)} + style={{ width: 18, height: 18, accentColor: 'var(--primary-color, #F25843)' }} /> +
+
+
+ +
+
+ + setMaxUsers(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} /> +
+
+ + setMaxFeatureInstances(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} /> +
+
+ +
+
+ + setMaxDataVolumeMB(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} /> +
+
+ + setBudgetAiCHF(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : t('Kein Budget')} /> +
+
+ +
+ +