Merge pull request #79 from valueonag/feat/demo-system-readieness

abo enterprise, ai agent fixes
This commit is contained in:
Patrick Motsch 2026-05-10 22:21:09 +02:00 committed by GitHub
commit 618574bc76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 628 additions and 92 deletions

View file

@ -42,6 +42,53 @@ export interface MandateSubscription {
snapshotPricePerUserCHF: number; snapshotPricePerUserCHF: number;
snapshotPricePerInstanceCHF: number; snapshotPricePerInstanceCHF: number;
stripeSubscriptionId: string | null; 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 { export interface SubscriptionUsage {
@ -154,3 +201,40 @@ export async function verifyCheckout(
additionalConfig: _mandateConfig(mandateId), additionalConfig: _mandateConfig(mandateId),
}); });
} }
// ============================================================================
// Enterprise API
// ============================================================================
export async function createEnterprise(
request: ApiRequestFunction,
params: EnterpriseCreateParams,
): Promise<Record<string, unknown>> {
return await request({
url: '/api/subscription/enterprise/create',
method: 'post',
data: params,
});
}
export async function renewEnterprise(
request: ApiRequestFunction,
params: EnterpriseRenewParams,
): Promise<Record<string, unknown>> {
return await request({
url: '/api/subscription/enterprise/renew',
method: 'post',
data: params,
});
}
export async function updateEnterprise(
request: ApiRequestFunction,
params: EnterpriseUpdateParams,
): Promise<Record<string, unknown>> {
return await request({
url: '/api/subscription/enterprise/update',
method: 'put',
data: params,
});
}

View file

@ -121,7 +121,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
// Feature pages - CommCoach // Feature pages - CommCoach
'page.feature.commcoach.dashboard': <FaChartLine />, 'page.feature.commcoach.dashboard': <FaChartLine />,
'page.feature.commcoach.coaching': <FaComments />, 'page.feature.commcoach.coaching': <FaComments />,
'page.feature.commcoach.dossier': <FaClipboardList />,
'page.feature.commcoach.settings': <FaCog />, 'page.feature.commcoach.settings': <FaCog />,
// Feature icons (for feature grouping in navigation) // Feature icons (for feature grouping in navigation)

View file

@ -48,7 +48,7 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
import { NeutralizationView } from './views/neutralization'; import { NeutralizationView } from './views/neutralization';
// CommCoach Views // CommCoach Views
import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, CommcoachSessionView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach'; import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, CommcoachSessionView, CommcoachSettingsView } from './views/commcoach';
// Redmine Views // Redmine Views
import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine'; import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
@ -174,7 +174,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
assistant: CommcoachAssistantView, assistant: CommcoachAssistantView,
modules: CommcoachModulesView, modules: CommcoachModulesView,
session: CommcoachSessionView, session: CommcoachSessionView,
dossier: CommcoachDossierView,
settings: CommcoachSettingsView, settings: CommcoachSettingsView,
}, },
redmine: { redmine: {

View file

@ -6,8 +6,16 @@ import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi'; import { fetchAttributes } from '../../api/attributesApi';
import type { AttributeDefinition } from '../../api/attributesApi'; import type { AttributeDefinition } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver'; 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 api from '../../api';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
import EnterpriseDialog, { type EnterpriseDialogMode, type EnterpriseDialogData } from './EnterpriseDialog';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
@ -21,15 +29,101 @@ const AdminSubscriptionsPage: React.FC = () => {
const { confirm, ConfirmDialog } = useConfirm(); const { confirm, ConfirmDialog } = useConfirm();
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions(); const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<EnterpriseDialogMode>('create');
const [dialogData, setDialogData] = useState<EnterpriseDialogData>({});
const [mandateOptions, setMandateOptions] = useState<{ id: string; label: string }[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(false);
useEffect(() => { useEffect(() => {
fetchAttributes(request, 'MandateSubscriptionView') fetchAttributes(request, 'MandateSubscriptionView')
.then(setBackendAttributes) .then(setBackendAttributes)
.catch(() => setBackendAttributes([])); .catch(() => setBackendAttributes([]));
}, [request]); }, [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<string, any>) => {
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(() => [ const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 }, { 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 (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{value}
{isEnt && (
<span style={{
fontSize: '0.65rem', padding: '1px 7px', borderRadius: '8px',
background: 'rgba(139,92,246,0.15)', color: '#8b5cf6',
fontWeight: 600, letterSpacing: '0.03em', whiteSpace: 'nowrap',
}}>Enterprise</span>
)}
</span>
);
},
},
{ key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 }, { key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 },
{ key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 }, { key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 },
{ key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 }, { key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 },
@ -61,11 +155,48 @@ const AdminSubscriptionsPage: React.FC = () => {
} }
}, [confirm, refetch, t]); }, [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 ( return (
<div className={styles.billingDashboard} style={{ minHeight: 0 }}> <div className={styles.billingDashboard} style={{ minHeight: 0 }}>
<header className={styles.pageHeader} style={{ flexShrink: 0 }}> <header className={styles.pageHeader} style={{ flexShrink: 0 }}>
<h1>{t('Abonnementübersicht')}</h1> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p className={styles.subtitle}>{t('Alle Abonnements aller Mandanten')}</p> <div>
<h1>{t('Abonnementübersicht')}</h1>
<p className={styles.subtitle}>{t('Alle Abonnements aller Mandanten')}</p>
</div>
<button
className={`${styles.button} ${styles.buttonPrimary}`}
onClick={_openCreate}
style={{ height: 'fit-content' }}
>
{t('Enterprise-Abo erstellen')}
</button>
</div>
</header> </header>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}> <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
@ -78,19 +209,21 @@ const AdminSubscriptionsPage: React.FC = () => {
pageSize={50} pageSize={50}
selectable={false} selectable={false}
hookData={{ refetch, pagination }} hookData={{ refetch, pagination }}
customActions={[ customActions={customActions}
{
id: 'forceCancel',
title: t('Sofort stornieren'),
icon: '✕',
onClick: (row: any) => _handleForceCancel(row),
visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus),
},
]}
emptyMessage={t('Keine Abonnements vorhanden')} emptyMessage={t('Keine Abonnements vorhanden')}
/> />
</div> </div>
<EnterpriseDialog
open={dialogOpen}
mode={dialogMode}
data={dialogData}
mandates={mandateOptions}
loading={mandatesLoading}
onClose={() => setDialogOpen(false)}
onSubmit={_handleDialogSubmit}
/>
<ConfirmDialog /> <ConfirmDialog />
</div> </div>
); );

View file

@ -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<string, any>) => Promise<void>;
}
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<EnterpriseDialogProps> = ({
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<string>('');
const [maxUsers, setMaxUsers] = useState<string>('');
const [maxFeatureInstances, setMaxFeatureInstances] = useState<string>('');
const [maxDataVolumeMB, setMaxDataVolumeMB] = useState<string>('');
const [budgetAiCHF, setBudgetAiCHF] = useState<string>('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(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<string, any> = {};
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<EnterpriseDialogMode, string> = {
create: t('Enterprise-Abo erstellen'),
renew: t('Enterprise-Abo erneuern'),
update: t('Enterprise-Abo anpassen'),
};
const _submitLabel: Record<EnterpriseDialogMode, string> = {
create: t('Erstellen'),
renew: t('Erneuern'),
update: t('Speichern'),
};
const isCreate = mode === 'create';
const isUpdate = mode === 'update';
return (
<Modal open={open} onClose={onClose} title={_title[mode]} size="lg" closeOnEscape>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{errorMsg && <div className={styles.errorMessage}>{errorMsg}</div>}
{data.mandateName && !isCreate && (
<div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)' }}>
{t('Mandant:')} <strong>{data.mandateName}</strong>
</div>
)}
{isCreate && (
<div className={styles.formGroup}>
<label>{t('Mandant')}</label>
<select
className={styles.input}
value={mandateId}
onChange={(e) => setMandateId(e.target.value)}
disabled={loading}
>
<option value="">{t('— Mandant wählen —')}</option>
{(mandates || []).map((m) => (
<option key={m.id} value={m.id}>{m.label}</option>
))}
</select>
</div>
)}
{!isUpdate && (
<div className={styles.formRow}>
{isCreate && (
<div className={styles.formGroup}>
<label>{t('Startdatum')}</label>
<input type="date" className={styles.input} value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
)}
<div className={styles.formGroup}>
<label>{isCreate ? t('Enddatum') : t('Neues Enddatum')}</label>
<input type="date" className={styles.input} value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div>
)}
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>{t('Pauschalpreis (CHF)')}</label>
<input type="number" step="0.01" min="0" className={styles.input}
value={flatPrice} onChange={(e) => setFlatPrice(e.target.value)}
placeholder={isUpdate ? t('Unverändert') : ''} />
</div>
<div className={styles.formGroup} style={{ maxWidth: 160 }}>
<label>{t('Automatisch erneuern')}</label>
<div style={{ display: 'flex', alignItems: 'center', height: '2.25rem' }}>
<input type="checkbox" checked={autoRenew} onChange={(e) => setAutoRenew(e.target.checked)}
style={{ width: 18, height: 18, accentColor: 'var(--primary-color, #F25843)' }} />
</div>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>{t('Max. Benutzer')}</label>
<input type="number" min="1" className={styles.input}
value={maxUsers} onChange={(e) => setMaxUsers(e.target.value)}
placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} />
</div>
<div className={styles.formGroup}>
<label>{t('Max. Module')}</label>
<input type="number" min="0" className={styles.input}
value={maxFeatureInstances} onChange={(e) => setMaxFeatureInstances(e.target.value)}
placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} />
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>{t('Speicher (MB)')}</label>
<input type="number" min="0" className={styles.input}
value={maxDataVolumeMB} onChange={(e) => setMaxDataVolumeMB(e.target.value)}
placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} />
</div>
<div className={styles.formGroup}>
<label>{t('AI-Budget (CHF)')}</label>
<input type="number" step="0.01" min="0" className={styles.input}
value={budgetAiCHF} onChange={(e) => setBudgetAiCHF(e.target.value)}
placeholder={isUpdate ? t('Unverändert') : t('Kein Budget')} />
</div>
</div>
<div className={styles.formGroup}>
<label>{t('Notiz')}</label>
<textarea className={styles.input} rows={2}
value={note} onChange={(e) => setNote(e.target.value)}
placeholder={t('Optionale interne Notiz')} />
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1.25rem' }}>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={onClose}
disabled={submitting}
>
{t('Abbrechen')}
</button>
<button
className={`${styles.button} ${styles.buttonPrimary}`}
onClick={_handleSubmit}
disabled={submitting}
>
{submitting ? t('Wird gespeichert…') : _submitLabel[mode]}
</button>
</div>
</Modal>
);
};
export default EnterpriseDialog;

View file

@ -53,6 +53,9 @@ function _getPeriodMap(t: (k: string) => string): Record<string, string> {
const _storageOveragePerGbMonth = 0.5; const _storageOveragePerGbMonth = 0.5;
const _isEnterpriseSub = (sub: MandateSubscription | null): boolean =>
!!sub && (sub.isEnterprise === true || sub.planKey === 'ENTERPRISE');
// ============================================================================ // ============================================================================
// Plan Card // Plan Card
// ============================================================================ // ============================================================================
@ -266,6 +269,12 @@ const _SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, usage, label, onCance
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontSize: '1.15rem' }}>{plan ? plan.title : sub.planKey}</strong> <strong style={{ fontSize: '1.15rem' }}>{plan ? plan.title : sub.planKey}</strong>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{_isEnterpriseSub(sub) && (
<span style={{
fontSize: '0.65rem', padding: '2px 10px', borderRadius: '12px',
background: 'rgba(139,92,246,0.15)', color: '#8b5cf6', fontWeight: 600,
}}>Enterprise</span>
)}
{isActive && !sub.recurring && ( {isActive && !sub.recurring && (
<span style={{ <span style={{
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px', fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
@ -320,8 +329,31 @@ const _SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, usage, label, onCance
</div> </div>
)} )}
{/* Plan details */} {/* Plan details — enterprise vs. standard */}
{plan && !isPending && !isScheduled && ( {!isPending && !isScheduled && _isEnterpriseSub(sub) && (
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem 1.5rem',
fontSize: '0.85rem', color: 'var(--text-secondary)',
paddingTop: '0.5rem', borderTop: '1px solid var(--color-border, rgba(255,255,255,0.06))',
}}>
<span>{t('Pauschalpreis:')} {_formatCurrency(sub.enterpriseFlatPriceCHF ?? 0)}</span>
<span>{t('Max. Benutzer:')} {sub.enterpriseMaxUsers != null ? sub.enterpriseMaxUsers : t('unbegrenzt')}</span>
<span>{t('Max. Module:')} {sub.enterpriseMaxFeatureInstances != null ? sub.enterpriseMaxFeatureInstances : t('unbegrenzt')}</span>
<span>
{t('Speicher:')}{' '}
{sub.enterpriseMaxDataVolumeMB != null
? formatBinaryDataSizeFromMebibytes(sub.enterpriseMaxDataVolumeMB)
: t('unbegrenzt')}
</span>
{sub.enterpriseBudgetAiCHF != null && (
<span>{t('AI-Budget:')} {_formatCurrency(sub.enterpriseBudgetAiCHF)}</span>
)}
{sub.enterpriseNote && (
<span style={{ gridColumn: '1 / -1', fontStyle: 'italic' }}>{sub.enterpriseNote}</span>
)}
</div>
)}
{!isPending && !isScheduled && !_isEnterpriseSub(sub) && plan && (
<div style={{ <div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem 1.5rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem 1.5rem',
fontSize: '0.85rem', color: 'var(--text-secondary)', fontSize: '0.85rem', color: 'var(--text-secondary)',
@ -356,43 +388,60 @@ const _SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, usage, label, onCance
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '0.5rem', gap: '0.5rem',
}}> }}>
<_UsageMetric label={t('User')} value={usage.activeUsers} max={plan?.maxUsers ?? undefined} /> <_UsageMetric
<_UsageMetric label={t('Module')} value={usage.activeInstances} max={plan?.includedModules ?? undefined} /> label={t('User')}
value={usage.activeUsers}
max={_isEnterpriseSub(sub) ? (sub.enterpriseMaxUsers ?? undefined) : (plan?.maxUsers ?? undefined)}
/>
<_UsageMetric
label={t('Module')}
value={usage.activeInstances}
max={_isEnterpriseSub(sub) ? (sub.enterpriseMaxFeatureInstances ?? undefined) : (plan?.includedModules ?? undefined)}
/>
<_UsageMetric label={t('Speicher')} value={usage.usedStorageMB} max={usage.maxStorageMB ?? undefined} formatValue={(v) => formatBinaryDataSizeFromMebibytes(v)} /> <_UsageMetric label={t('Speicher')} value={usage.usedStorageMB} max={usage.maxStorageMB ?? undefined} formatValue={(v) => formatBinaryDataSizeFromMebibytes(v)} />
</div> </div>
</div> </div>
)} )}
{/* Actions */} {/* Actions — enterprise subscriptions are sysadmin-managed, no self-service */}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.25rem' }}> {_isEnterpriseSub(sub) ? (
{isActive && !sub.recurring && onReactivate && ( <div style={{
<button onClick={() => onReactivate(sub.id)} disabled={reactivating} style={{ fontSize: '0.8rem', color: 'var(--text-secondary)',
padding: '8px 16px', borderRadius: '8px', border: 'none', fontStyle: 'italic', marginTop: '0.25rem',
background: 'var(--primary-color, #F25843)', color: '#fff', }}>
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem', {t('Dieses Abonnement wird vom Plattform-Administrator verwaltet.')}
}}> </div>
{reactivating ? t('Wird reaktiviert…') : t('Reaktivieren')} ) : (
</button> <div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.25rem' }}>
)} {isActive && !sub.recurring && onReactivate && (
{isActive && sub.recurring && onCancel && ( <button onClick={() => onReactivate(sub.id)} disabled={reactivating} style={{
<button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{ padding: '8px 16px', borderRadius: '8px', border: 'none',
padding: '8px 16px', borderRadius: '8px', background: 'var(--primary-color, #F25843)', color: '#fff',
border: '1px solid #ef4444', background: 'transparent', fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem', }}>
}}> {reactivating ? t('Wird reaktiviert…') : t('Reaktivieren')}
{cancelling ? t('Wird gekündigt…') : t('Kündigen')} </button>
</button> )}
)} {isActive && sub.recurring && onCancel && (
{(isPending || isScheduled) && onCancel && ( <button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{
<button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{ padding: '8px 16px', borderRadius: '8px',
padding: '8px 16px', borderRadius: '8px', border: '1px solid #ef4444', background: 'transparent',
border: '1px solid #ef4444', background: 'transparent', color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem', }}>
}}> {cancelling ? t('Wird gekündigt…') : t('Kündigen')}
{cancelling ? t('Wird abgebrochen…') : t('Abbrechen')} </button>
</button> )}
)} {(isPending || isScheduled) && onCancel && (
</div> <button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{
padding: '8px 16px', borderRadius: '8px',
border: '1px solid #ef4444', background: 'transparent',
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
}}>
{cancelling ? t('Wird abgebrochen…') : t('Abbrechen')}
</button>
)}
</div>
)}
</div> </div>
); );
}; };
@ -593,29 +642,31 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
</section> </section>
)} )}
{/* Available plans */} {/* Available plans — hidden for enterprise subscriptions */}
<section className={styles.section}> {!_isEnterpriseSub(subscription) && (
<h2 className={styles.sectionTitle}>{t('Verfügbare Pläne')}</h2> <section className={styles.section}>
{plans.length === 0 ? ( <h2 className={styles.sectionTitle}>{t('Verfügbare Pläne')}</h2>
<div className={styles.noData}>{t('Keine Pläne verfügbar')}</div> {plans.length === 0 ? (
) : ( <div className={styles.noData}>{t('Keine Pläne verfügbar')}</div>
<div style={{ ) : (
display: 'grid', <div style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', display: 'grid',
gap: '1.25rem', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
}}> gap: '1.25rem',
{plans.map((p) => ( }}>
<_PlanCard {plans.map((p) => (
key={p.planKey} <_PlanCard
plan={p} key={p.planKey}
isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'} plan={p}
onActivate={_handleActivate} isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'}
activatingPlanKey={activatingPlanKey} onActivate={_handleActivate}
/> activatingPlanKey={activatingPlanKey}
))} />
</div> ))}
)} </div>
</section> )}
</section>
)}
<ConfirmDialog /> <ConfirmDialog />
</div> </div>

View file

@ -31,12 +31,6 @@ export const CommcoachDashboardView: React.FC = () => {
} }
}, [mandateId, instanceId, navigate]); }, [mandateId, instanceId, navigate]);
const _handleOpenDossier = useCallback(() => {
if (mandateId && instanceId) {
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/dossier`);
}
}, [mandateId, instanceId, navigate]);
const _categoryLabel = useCallback( const _categoryLabel = useCallback(
(category: string) => { (category: string) => {
const labels: Record<string, string> = { const labels: Record<string, string> = {
@ -102,14 +96,9 @@ export const CommcoachDashboardView: React.FC = () => {
<div className={styles.section}> <div className={styles.section}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h3 className={styles.sectionTitle} style={{ margin: 0 }}>{t('Aktive Coaching-Themen')}</h3> <h3 className={styles.sectionTitle} style={{ margin: 0 }}>{t('Aktive Coaching-Themen')}</h3>
<div style={{ display: 'flex', gap: '0.5rem' }}> <button className={styles.newTopicBtn} onClick={_handleNewTopic}>
<button className={styles.newTopicBtn} onClick={_handleOpenDossier}> + {t('Neues Thema')}
{t('Dossier')} </button>
</button>
<button className={styles.newTopicBtn} onClick={_handleNewTopic}>
+ {t('Neues Thema')}
</button>
</div>
</div> </div>
{(dashboard.modules || dashboard.contexts || []).length === 0 ? ( {(dashboard.modules || dashboard.contexts || []).length === 0 ? (
<div className={styles.emptyState}> <div className={styles.emptyState}>

View file

@ -9,6 +9,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as commcoachApi from '../../../api/commcoachApi'; import * as commcoachApi from '../../../api/commcoachApi';
import { getSessionExportUrl } from '../../../api/commcoachApi';
import type { CoachingPersona } from '../../../api/commcoachApi'; import type { CoachingPersona } from '../../../api/commcoachApi';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './Commcoach.module.css'; import styles from './Commcoach.module.css';
@ -202,11 +203,21 @@ export const CommcoachModulesView: React.FC = () => {
<div className={styles.sessionList}> <div className={styles.sessionList}>
{(sessions[mod.id] || []).map((sess: any) => ( {(sessions[mod.id] || []).map((sess: any) => (
<div key={sess.id} className={styles.sessionRow}> <div key={sess.id} className={styles.sessionRow}>
<span>{sess.summary || t('Session')}</span> <div style={{ flex: 1 }}>
<span className={styles.sessionStatus}>{sess.status}</span> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span className={styles.sessionDate}> <span className={styles.sessionStatus}>{sess.status === 'completed' ? t('Abgeschlossen') : sess.status === 'active' ? t('Aktiv') : sess.status}</span>
{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'} <span className={styles.sessionDate}>
</span> {sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString('de-CH') : '-'}
</span>
{sess.competenceScore != null && <span style={{ fontSize: '0.8rem', fontWeight: 600 }}>Score: {Math.round(sess.competenceScore)}</span>}
{sess.durationSeconds != null && <span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>{Math.round(sess.durationSeconds / 60)} Min.</span>}
{sess.messageCount != null && <span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>{sess.messageCount} {t('Nachrichten')}</span>}
{instanceId && sess.status === 'completed' && (
<a style={{ fontSize: '0.8rem' }} href={getSessionExportUrl(instanceId, sess.id, 'md')} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>{t('Export')}</a>
)}
</div>
{sess.summary && <div style={{ fontSize: '0.85rem', marginTop: '0.3rem', color: 'var(--text-secondary, #666)' }}>{sess.summary}</div>}
</div>
</div> </div>
))} ))}
</div> </div>

View file

@ -2,5 +2,4 @@ export { CommcoachDashboardView } from './CommcoachDashboardView';
export { CommcoachAssistantView } from './CommcoachAssistantView'; export { CommcoachAssistantView } from './CommcoachAssistantView';
export { CommcoachModulesView } from './CommcoachModulesView'; export { CommcoachModulesView } from './CommcoachModulesView';
export { CommcoachSessionView } from './CommcoachSessionView'; export { CommcoachSessionView } from './CommcoachSessionView';
export { CommcoachDossierView } from './CommcoachDossierView';
export { CommcoachSettingsView } from './CommcoachSettingsView'; export { CommcoachSettingsView } from './CommcoachSettingsView';