Merge pull request #79 from valueonag/feat/demo-system-readieness
abo enterprise, ai agent fixes
This commit is contained in:
commit
618574bc76
9 changed files with 628 additions and 92 deletions
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
271
src/pages/billing/EnterpriseDialog.tsx
Normal file
271
src/pages/billing/EnterpriseDialog.tsx
Normal 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;
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue