This commit is contained in:
ValueOn AG 2026-04-12 14:05:01 +02:00
parent 3803ebb274
commit 4762818d3d
18 changed files with 434 additions and 378 deletions

View file

@ -10,8 +10,8 @@ export type BillingPeriod = 'MONTHLY' | 'YEARLY' | 'NONE';
export interface SubscriptionPlan {
planKey: string;
selectableByUser: boolean;
title: Record<string, string>;
description: Record<string, string>;
title: string;
description: string;
currency: string;
billingPeriod: BillingPeriod;
pricePerUserCHF: number;
@ -44,11 +44,20 @@ export interface MandateSubscription {
stripeSubscriptionId: string | null;
}
export interface SubscriptionUsage {
activeUsers: number;
activeInstances: number;
usedStorageMB: number;
maxStorageMB: number | null;
storagePercent: number | null;
}
export interface SubscriptionStatusResponse {
active: boolean;
subscription: MandateSubscription | null;
plan: SubscriptionPlan | null;
scheduled: MandateSubscription | null;
usage: SubscriptionUsage | null;
}
export interface ActivatePlanResponse {

View file

@ -654,26 +654,28 @@ export function FormGeneratorForm<T extends Record<string, any>>({
];
for (const lang of availableLanguages) {
if (lang.code === 'xx') continue;
langs.push({ code: lang.code, uiLabel: lang.code.toUpperCase(), required: false });
langs.push({ code: lang.code, uiLabel: lang.label || lang.code.toUpperCase(), required: false });
}
return langs;
}, [availableLanguages, t]);
const _handleAutoTranslate = async (attrName: string, multilingualValue: Record<string, string>) => {
const sourceLang = multilingualLangs.find(l => (multilingualValue[l.code] || '').trim())?.code;
if (!sourceLang) return;
const sourceText = (multilingualValue[sourceLang] || '').trim();
const sourceLangEntry = multilingualLangs.find(l => (multilingualValue[l.code] || '').trim());
if (!sourceLangEntry) return;
const sourceText = (multilingualValue[sourceLangEntry.code] || '').trim();
if (!sourceText) return;
const targetLangs = multilingualLangs.map(l => l.code).filter(c => c !== sourceLang);
if (!targetLangs.length) return;
const targets = multilingualLangs
.filter(l => l.code !== sourceLangEntry.code && l.code !== 'xx')
.map(l => ({ code: l.code, label: l.uiLabel }));
if (!targets.length) return;
setTranslatingField(attrName);
try {
const res = await api.post('/api/i18n/translate-field', {
sourceText,
sourceLang,
targetLangs,
sourceLang: sourceLangEntry.code,
targetLangs: targets,
});
const translations: Record<string, string> = res.data?.translations || {};
const newValue = { ...multilingualValue };

View file

@ -78,33 +78,21 @@ import api from '../../../api';
// FK Cache type: maps fkSource -> { id -> displayLabel }
type FkCacheType = Record<string, Record<string, string>>;
const isTextMultilingual = (value: any): boolean => {
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
return false;
/**
* Stringify any cell value for display.
* The backend resolves TextMultilingual to plain strings via resolveText() / get_text().
* If an unresolved object still arrives, extract xx as a safe fallback and log a warning.
*/
const _objectToDisplayString = (value: Record<string, unknown>): string => {
if ('xx' in value && typeof value.xx === 'string' && value.xx.trim()) {
console.warn('FormGeneratorTable: received unresolved TextMultilingual object — backend should call resolveText()', value);
return value.xx as string;
}
return 'en' in value && typeof value.en === 'string';
};
const formatTextMultilingual = (value: any, currentLanguage?: string): string => {
if (!isTextMultilingual(value)) {
return String(value);
for (const field of ['label', 'name', 'title', 'id', 'value', 'text']) {
const v = value[field];
if (v !== undefined && v !== null) return String(v);
}
if (currentLanguage && value[currentLanguage] && typeof value[currentLanguage] === 'string' && value[currentLanguage].trim()) {
return value[currentLanguage];
}
if (value.en && typeof value.en === 'string' && value.en.trim()) {
return value.en;
}
for (const key of Object.keys(value)) {
if (key !== 'en' && value[key] && typeof value[key] === 'string' && value[key].trim()) {
return value[key];
}
}
return '-';
try { return JSON.stringify(value); } catch { return String(value); }
};
// Types for the FormGeneratorTable
@ -575,9 +563,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
}
}).current;
// Helper function to convert any field value to display string
// Handles: string, boolean, number, TextMultilingual, objects
const convertToDisplayString = useCallback((fieldValue: any, language: string): string => {
const convertToDisplayString = useCallback((fieldValue: any, _language: string): string => {
if (fieldValue === null || fieldValue === undefined) {
return '-';
}
@ -597,18 +583,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return fieldValue;
}
// Object - check for TextMultilingual (has 'en' key)
if (typeof fieldValue === 'object' && fieldValue !== null) {
if ('en' in fieldValue) {
return formatTextMultilingual(fieldValue, language);
}
// Other objects → try to stringify
try {
return JSON.stringify(fieldValue);
} catch {
return String(fieldValue);
}
return _objectToDisplayString(fieldValue as Record<string, unknown>);
}
// Fallback
@ -1299,11 +1275,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
if (typeof val === 'boolean') {
str = val ? 'true' : 'false';
} else if (typeof val === 'object') {
if (isTextMultilingual(val)) {
str = formatTextMultilingual(val, currentLanguage);
} else {
try { str = JSON.stringify(val); } catch { str = String(val); }
}
str = _objectToDisplayString(val as Record<string, unknown>);
} else {
str = String(val);
}
@ -1554,45 +1526,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
}
}
// Handle objects/arrays (e.g., references to other entities)
// Check if value is an object (but not Date, Array, or null)
// Handle objects (e.g., references to other entities, or unresolved TextMultilingual)
if (typeof value === 'object' && value !== null && !(value instanceof Date) && !Array.isArray(value)) {
// Check if this is a TextMultilingual object first
if (isTextMultilingual(value)) {
return formatTextMultilingual(value, currentLanguage);
}
// Try to find a display field in common order: label, name, title, id
const displayFields = ['label', 'name', 'title', 'id', 'value', 'text'];
for (const field of displayFields) {
if (value[field] !== undefined && value[field] !== null) {
const displayValue = value[field];
// If the display value is itself an object, check if it's TextMultilingual
if (typeof displayValue === 'object' && displayValue !== null && !Array.isArray(displayValue) && !(displayValue instanceof Date)) {
if (isTextMultilingual(displayValue)) {
return formatTextMultilingual(displayValue, currentLanguage);
}
try {
return JSON.stringify(displayValue);
} catch {
return String(displayValue);
}
}
return String(displayValue);
}
}
// If no display field found, try to stringify the object (limited to avoid huge output)
try {
const stringified = JSON.stringify(value);
// Truncate if too long
if (stringified.length > 100) {
return stringified.substring(0, 97) + '...';
}
return stringified;
} catch {
// If stringification fails, show object type
return `[${value.constructor?.name || 'Object'}]`;
}
return _objectToDisplayString(value as Record<string, unknown>);
}
// Handle arrays (e.g., multiselect values)

View file

@ -47,12 +47,12 @@ type NavTranslateFn = (key: string, params?: Record<string, string | number>) =>
/**
* Convert a NavigationItem (from static block) to TreeNodeItem.
* Labels from the backend are German i18n keys translate via t().
* Labels are already translated by the backend via t().
*/
function navigationItemToTreeNode(item: NavigationItem, tr: NavTranslateFn): TreeNodeItem {
function _navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
return {
id: item.objectKey,
label: tr(item.uiLabel),
label: item.uiLabel,
icon: getPageIcon(item.uiComponent),
path: item.uiPath,
};
@ -66,25 +66,24 @@ function _staticItemsToTreeNode(
id: string,
label: string,
items: NavigationItem[],
tr: NavTranslateFn,
defaultExpanded: boolean = true,
): TreeNodeItem {
return {
id,
label,
children: items.map(i => navigationItemToTreeNode(i, tr)),
children: items.map(i => _navigationItemToTreeNode(i)),
defaultExpanded,
};
}
/**
* Convert a FeatureView to TreeNodeItem.
* View labels are German i18n keys translate via t().
* View labels are already translated by the backend.
*/
function featureViewToTreeNode(view: FeatureView, tr: NavTranslateFn): TreeNodeItem {
function _featureViewToTreeNode(view: FeatureView): TreeNodeItem {
return {
id: view.objectKey,
label: tr(view.uiLabel),
label: view.uiLabel,
path: view.uiPath,
};
}
@ -95,17 +94,17 @@ function featureViewToTreeNode(view: FeatureView, tr: NavTranslateFn): TreeNodeI
* Shows the feature icon next to the instance name for visual distinction.
* If user is instance admin, a rename icon appears on hover.
*/
function featureInstanceToTreeNode(
function _featureInstanceToTreeNode(
instance: FeatureInstance,
featureUiComponent: string,
onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
tr: NavTranslateFn,
t: NavTranslateFn,
): TreeNodeItem {
const children = instance.views.map(v => featureViewToTreeNode(v, tr));
const children = instance.views.map(v => _featureViewToTreeNode(v));
const renameAction = instance.isAdmin && onRename ? (
<button
className={styles.renameButton}
title={tr('Umbenennen')}
title={t('Umbenennen')}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRename(instance.id, instance.uiLabel); }}
>
<FaPen size={10} />
@ -132,10 +131,10 @@ function featureInstanceToTreeNode(
* Before: Mandate Feature Instance Views
* Now: Mandate Instance (with feature icon) Views
*/
function navigationMandateToTreeNode(
function _navigationMandateToTreeNode(
mandate: NavigationMandate,
onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
tr: NavTranslateFn,
t: NavTranslateFn,
): TreeNodeItem | null {
if (mandate.features.length === 0) {
return null;
@ -144,7 +143,7 @@ function navigationMandateToTreeNode(
const instanceNodes: TreeNodeItem[] = [];
for (const feature of mandate.features) {
for (const instance of feature.instances) {
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename, tr));
instanceNodes.push(_featureInstanceToTreeNode(instance, feature.uiComponent, onRename, t));
}
}
@ -163,13 +162,13 @@ function navigationMandateToTreeNode(
/**
* Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
*/
function dynamicBlockToTreeNodes(
function _dynamicBlockToTreeNodes(
block: DynamicBlock,
onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
tr: NavTranslateFn,
t: NavTranslateFn,
): TreeNodeItem[] {
return block.mandates
.map((m) => navigationMandateToTreeNode(m, onRename, tr))
.map((m) => _navigationMandateToTreeNode(m, onRename, t))
.filter((node): node is TreeNodeItem => node !== null);
}
@ -227,23 +226,17 @@ export const MandateNavigation: React.FC = () => {
const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = [];
let systemBlock: { items: NavigationItem[]; subgroups?: NavSubgroup[] } | null = null;
let adminItems: NavigationItem[] = [];
let adminSubgroups: NavSubgroup[] = [];
let systemBlock: { title: string; items: NavigationItem[]; subgroups?: NavSubgroup[] } | null = null;
let adminBlock: { title: string; items: NavigationItem[]; subgroups: NavSubgroup[] } | null = null;
for (const block of blocks) {
if (block.type === 'static') {
if (block.id === 'admin') {
if (block.subgroups && block.subgroups.length > 0) {
adminSubgroups = block.subgroups;
} else {
adminItems = [...block.items];
}
adminBlock = { title: block.title, items: [...block.items], subgroups: block.subgroups || [] };
} else if (block.id === 'system') {
systemBlock = { items: block.items || [], subgroups: block.subgroups };
systemBlock = { title: block.title, items: block.items || [], subgroups: block.subgroups };
} else if (block.items.length > 0) {
// Legacy: other static blocks get merged as flat items
if (!systemBlock) systemBlock = { items: [], subgroups: [] };
if (!systemBlock) systemBlock = { title: block.title, items: [], subgroups: [] };
systemBlock.items.push(...block.items);
}
}
@ -252,14 +245,14 @@ export const MandateNavigation: React.FC = () => {
if (systemBlock) {
const children: TreeNodeItem[] = [];
for (const item of systemBlock.items) {
children.push(navigationItemToTreeNode(item, t));
children.push(_navigationItemToTreeNode(item));
}
if (systemBlock.subgroups && systemBlock.subgroups.length > 0) {
for (const sg of systemBlock.subgroups) {
children.push({
id: sg.id,
label: sg.title,
children: sg.items.map(i => navigationItemToTreeNode(i, t)),
children: sg.items.map(i => _navigationItemToTreeNode(i)),
defaultExpanded: true,
});
}
@ -267,7 +260,7 @@ export const MandateNavigation: React.FC = () => {
if (children.length > 0) {
items.push({
id: 'meine-sicht',
label: t('Meine Sicht'),
label: systemBlock.title,
children,
defaultExpanded: true,
});
@ -276,7 +269,7 @@ export const MandateNavigation: React.FC = () => {
for (const block of blocks) {
if (block.type === 'dynamic') {
const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename, t);
const mandateNodes = _dynamicBlockToTreeNodes(block, _handleRename, t);
if (mandateNodes.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
items.push(...mandateNodes);
@ -284,23 +277,23 @@ export const MandateNavigation: React.FC = () => {
}
}
if (adminSubgroups.length > 0) {
if (adminBlock && adminBlock.subgroups.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
const subgroupNodes: TreeNodeItem[] = adminBlock.subgroups.map(sg => ({
id: sg.id,
label: sg.title,
children: sg.items.map(i => navigationItemToTreeNode(i, t)),
children: sg.items.map(i => _navigationItemToTreeNode(i)),
defaultExpanded: false,
}));
items.push({
id: 'administration',
label: t('Administration'),
label: adminBlock.title,
children: subgroupNodes,
defaultExpanded: false,
});
} else if (adminItems.length > 0) {
} else if (adminBlock && adminBlock.items.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
items.push(_staticItemsToTreeNode('administration', t('Administration'), adminItems, t, false));
items.push(_staticItemsToTreeNode('administration', adminBlock.title, adminBlock.items, false));
}
return items;

View file

@ -26,6 +26,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
treeFileNodes,
treeFilesLoading,
refreshTreeFiles,
updateTreeFileNode,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
@ -146,22 +147,24 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
}, [refreshFolders, refreshTreeFiles]);
const _onScopeChange = useCallback(async (fileId: string, newScope: string) => {
updateTreeFileNode(fileId, { scope: newScope });
try {
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
await refreshTreeFiles();
} catch (err) {
console.error('Failed to update scope:', err);
await refreshTreeFiles();
}
}, [refreshTreeFiles]);
}, [updateTreeFileNode, refreshTreeFiles]);
const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
updateTreeFileNode(fileId, { neutralize: newValue });
try {
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
await refreshTreeFiles();
} catch (err) {
console.error('Failed to toggle neutralize:', err);
await refreshTreeFiles();
}
}, [refreshTreeFiles]);
}, [updateTreeFileNode, refreshTreeFiles]);
if (treeFilesLoading && treeFileNodes.length === 0) {
return <div className={styles.loading}>{t('Dateien laden')}</div>;

View file

@ -16,6 +16,7 @@ interface FileContextType {
treeFilesLoading: boolean;
loadTreeFiles: (folderId: string) => Promise<void>;
refreshTreeFiles: () => Promise<void>;
updateTreeFileNode: (fileId: string, patch: Partial<FileNode>) => void;
expandedFolderIds: Set<string>;
toggleFolderExpanded: (id: string) => void;
@ -160,6 +161,24 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
);
}, [treeFilesMap, loadTreeFiles]);
const updateTreeFileNode = useCallback((fileId: string, patch: Partial<FileNode>) => {
setTreeFilesMap(prev => {
const next = new Map<string, FileNode[]>();
let found = false;
for (const [key, files] of prev) {
const updated = files.map(f => {
if (f.id === fileId) {
found = true;
return { ...f, ...patch };
}
return f;
});
next.set(key, updated);
}
return found ? next : prev;
});
}, []);
// Load root files on mount and on context change
useEffect(() => { loadTreeFiles(''); }, [loadTreeFiles, storageKey]);
@ -284,6 +303,7 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
treeFilesLoading,
loadTreeFiles,
refreshTreeFiles,
updateTreeFileNode,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,

View file

@ -26,14 +26,12 @@ import {
// Re-export types for backward compatibility
export type { Role, RoleUpdateData, AttributeDefinition, PaginationParams };
// Helper function to detect TextMultilingual objects
// TextMultilingual has structure: { en: string, ge?: string, fr?: string, it?: string }
/** TextMultilingual has structure: { xx: string (required source text), de?: string, en?: string, … } */
const isTextMultilingual = (value: any): boolean => {
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
return false;
}
// Check if it has 'en' property (required) and optionally other language codes
return 'en' in value && typeof value.en === 'string';
return 'xx' in value && typeof value.xx === 'string';
};
// Helper function to check if a field name suggests it's a multilingual field
@ -421,30 +419,23 @@ export function useRbacRoles() {
// String validation for required fields
else if (fieldType === 'string' && required) {
validator = (value: any) => {
// Check if this is a multilingual field (TextMultilingual object)
if (isMultilingualFieldName(attr.name)) {
// Handle TextMultilingual object
if (isTextMultilingual(value)) {
if (!value.en || typeof value.en !== 'string' || value.en.trim() === '') {
return `${attr.label} (English) is required`;
if (!value.xx.trim()) {
return `${attr.label} is required`;
}
return null;
}
// If it's a multilingual field but value is not yet a TextMultilingual object,
// check if it's an empty object or needs initialization
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Empty object or object without 'en' property
if (!value.en || typeof value.en !== 'string' || value.en.trim() === '') {
return `${attr.label} (English) is required`;
if (!value.xx || typeof value.xx !== 'string' || !value.xx.trim()) {
return `${attr.label} is required`;
}
return null;
}
// If it's a string, that's also valid (will be converted to TextMultilingual)
if (typeof value === 'string' && value.trim() !== '') {
return null;
}
// Empty or invalid value
return `${attr.label} (English) is required`;
return `${attr.label} is required`;
}
// Regular string validation for non-multilingual fields
if (typeof value !== 'string' || !value || value.trim() === '') {

View file

@ -12,7 +12,7 @@ import api from '../api';
export interface Role {
id: string;
roleLabel: string;
description?: string;
description?: Record<string, string>;
mandateId?: string;
featureInstanceId?: string;
featureCode?: string;
@ -24,7 +24,7 @@ export interface Role {
export interface RoleCreate {
roleLabel: string;
description?: string;
description?: Record<string, string>;
mandateId?: string;
featureInstanceId?: string;
featureCode?: string;
@ -32,7 +32,7 @@ export interface RoleCreate {
export interface RoleUpdate {
roleLabel?: string;
description?: string;
description?: Record<string, string>;
mandateId?: string | null;
}

View file

@ -17,6 +17,7 @@ import {
type SubscriptionPlan,
type MandateSubscription,
type SubscriptionStatusResponse,
type SubscriptionUsage,
} from '../api/subscriptionApi';
export interface UseSubscriptionReturn {
@ -24,6 +25,7 @@ export interface UseSubscriptionReturn {
subscription: MandateSubscription | null;
plan: SubscriptionPlan | null;
scheduled: MandateSubscription | null;
usage: SubscriptionUsage | null;
active: boolean;
loading: boolean;
error: string | null;
@ -41,6 +43,7 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
const [subscription, setSubscription] = useState<MandateSubscription | null>(null);
const [plan, setPlan] = useState<SubscriptionPlan | null>(null);
const [scheduled, setScheduled] = useState<MandateSubscription | null>(null);
const [usage, setUsage] = useState<SubscriptionUsage | null>(null);
const [active, setActive] = useState(false);
const { request, isLoading: loading, error: apiError, clearCache } = useApiRequest();
const [error, setError] = useState<string | null>(null);
@ -64,12 +67,14 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
setSubscription(data.subscription ?? null);
setPlan(data.plan ?? null);
setScheduled(data.scheduled ?? null);
setUsage(data.usage ?? null);
} catch (err) {
console.error('Error loading subscription status:', err);
setActive(false);
setSubscription(null);
setPlan(null);
setScheduled(null);
setUsage(null);
}
}, [request, mandateId, clearCache]);
@ -140,6 +145,7 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
setSubscription(null);
setPlan(null);
setScheduled(null);
setUsage(null);
setActive(false);
}
}, [mandateId]);
@ -149,6 +155,7 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
subscription,
plan,
scheduled,
usage,
active,
loading,
error: error || (apiError ? String(apiError) : null),

View file

@ -130,7 +130,7 @@ const VoiceSettingsTab: React.FC = () => {
}));
setVoiceMap(entries);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden der Voice-Einstellungen');
setError(err.message || t('Fehler beim Laden der Voice-Einstellungen'));
} finally {
setLoading(false);
}
@ -181,7 +181,7 @@ const VoiceSettingsTab: React.FC = () => {
setTimeout(() => setSuccess(null), 3000);
await _loadSettings();
} catch (err: any) {
setError(err.message || 'Fehler beim Speichern');
setError(err.message || t('Fehler beim Speichern'));
} finally {
setSaving(false);
}
@ -242,12 +242,12 @@ const VoiceSettingsTab: React.FC = () => {
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('TTS-Stimmen Sprachausgabe')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
{t('Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.')}
</p>
{voiceMap.length === 0 ? (
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
{t('Keine Stimmen konfiguriert. Die Standardstimme wird für alle Sprachen verwendet.')}
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
@ -256,10 +256,10 @@ const VoiceSettingsTab: React.FC = () => {
{voiceMap.map(entry => (
<tr key={entry.language} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
<td style={{ padding: '0.5rem' }}>{_getLanguageName(entry.language)}</td>
<td style={{ padding: '0.5rem' }}>{entry.voiceName || 'Standard'}</td>
<td style={{ padding: '0.5rem' }}>{entry.voiceName || t('Standard')}</td>
<td style={{ padding: '0.5rem' }}>
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }} onClick={() => _handleTestVoice(entry.language, entry.voiceName)} disabled={testing === entry.language}>
{testing === entry.language ? '...' : 'Test'}
{testing === entry.language ? '...' : t('Test')}
</button>
</td>
<td style={{ padding: '0.5rem' }}>
@ -291,7 +291,7 @@ const VoiceSettingsTab: React.FC = () => {
</div>
<button className={styles.button} onClick={_handleAddEntry} style={{ padding: '0.5rem 1rem' }}>{t('Zuweisen')}</button>
<button className={styles.button} onClick={() => _handleTestVoice(addLanguage, addVoiceName)} disabled={testing !== null} style={{ padding: '0.5rem 1rem' }}>
{testing === addLanguage ? '...' : 'Testen'}
{testing === addLanguage ? '...' : t('Testen')}
</button>
</div>
</section>
@ -336,7 +336,7 @@ const NeutralizationMappingsTab: React.FC = () => {
}));
setMappings(items);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden');
setError(err.message || t('Fehler beim Laden'));
} finally {
setLoading(false);
}
@ -349,7 +349,7 @@ const NeutralizationMappingsTab: React.FC = () => {
await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' });
setMappings(prev => prev.filter(m => m.id !== id));
} catch (err: any) {
setError(err.message || 'Fehler beim Loeschen');
setError(err.message || t('Fehler beim Löschen'));
}
}, [request]);
@ -378,27 +378,24 @@ const NeutralizationMappingsTab: React.FC = () => {
color: 'var(--text-primary, #1e3a5f)',
}}
>
<strong>AI-Workspace:</strong> Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
<strong>{t('Mandant AI-Workspace-Instanz Einstellungen Tab Neutralisierung')}</strong> (nicht auf dieser
Seite). Dieser Tab zeigt nur die <strong>lokale</strong> Liste über <code>/api/local/neutralization-mappings</code>.
<strong>{t('AI-Workspace:')}</strong> {t('Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter')}{' '}
<strong>{t('Mandant AI-Workspace-Instanz Einstellungen Tab Neutralisierung')}</strong> {t('(nicht auf dieser Seite). Dieser Tab zeigt nur die lokale Liste.')}
</div>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle
geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert (zentrale Pipeline ueber
den AI-Service). Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings hier einsehbar und loeschbar.
{t('Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert. Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings.')}
</p>
{mappings.length === 0 ? (
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
Keine Neutralisierungs-Mappings vorhanden.
{t('Keine Neutralisierungs-Mappings vorhanden.')}
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Platzhalter-ID</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Originaltext</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Typ</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>{t('Platzhalter-ID')}</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>{t('Originaltext')}</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>{t('Typ')}</th>
<th />
</tr>
</thead>
@ -418,7 +415,7 @@ const NeutralizationMappingsTab: React.FC = () => {
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }}
onClick={() => _handleDelete(m.id)}
>
Loeschen
{t('Löschen')}
</button>
</td>
</tr>
@ -470,12 +467,12 @@ export const SettingsPage: React.FC = () => {
if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage });
setLanguage(newLanguage);
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
} catch { setLanguageError('Sprache konnte nicht gespeichert werden'); }
} catch { setLanguageError(t('Sprache konnte nicht gespeichert werden')); }
finally { setIsSavingLanguage(false); }
}, [currentUser, updateUser, setLanguage]);
}, [currentUser, updateUser, setLanguage, t]);
const handleProfileSave = useCallback(async (formData: any) => {
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
if (!currentUser?.id || !currentUser?.username) throw new Error(t('Nicht angemeldet'));
const newLanguage = formData.language || currentUser.language || 'de';
const updatedUser = await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: formData.email || currentUser.email, fullName: formData.fullName || currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' });
const cachedUser = getUserDataCache();
@ -540,7 +537,7 @@ export const SettingsPage: React.FC = () => {
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Darstellung')}</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>Theme</label><p className={styles.settingDescription}>{t('Wählen zwischen Hell- und Dunkelmodus')}</p></div>
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('Theme')}</label><p className={styles.settingDescription}>{t('Wählen zwischen Hell- und Dunkelmodus')}</p></div>
<div className={styles.settingControl}>
<div className={styles.themeToggle}>
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>{t('Thema Hell')}</button>

View file

@ -124,8 +124,10 @@ export const AdminFeatureRolesPage: React.FC = () => {
fetchRoles();
}, [fetchRoles]);
const getTextValue = (value: string | undefined): string => {
return value || '-';
const getTextValue = (value: any): string => {
if (!value) return '-';
if (typeof value === 'string') return value;
return String(value);
};
// Table columns

View file

@ -27,7 +27,7 @@ import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminMandateRolesPage: React.FC = () => {
const { t } = useLanguage();
const { t, currentLanguage } = useLanguage();
const navigate = useNavigate();
const { showError, showWarning } = useToast();
@ -92,8 +92,13 @@ export const AdminMandateRolesPage: React.FC = () => {
});
}, [selectedMandateId, fetchRoles]);
const getDescriptionText = (desc: string | undefined) => {
return desc || '-';
const getDescriptionText = (desc: any) => {
if (!desc) return '-';
if (typeof desc === 'string') return desc;
if (typeof desc === 'object') {
return desc[currentLanguage] || desc['xx'] || Object.values(desc).find((v: any) => typeof v === 'string' && v.trim()) || '-';
}
return String(desc);
};
// Table columns - scopeType is now a backend-computed field
@ -190,7 +195,7 @@ export const AdminMandateRolesPage: React.FC = () => {
}, [backendAttributes]);
// Handle create role
const handleCreateRole = async (data: { roleLabel: string; description?: string; scope: 'mandate' | 'global' }) => {
const handleCreateRole = async (data: { roleLabel: string; description?: Record<string, string>; scope: 'mandate' | 'global' }) => {
if (!selectedMandateId) return;
setIsSubmitting(true);
try {

View file

@ -80,6 +80,7 @@ export const FilesPage: React.FC = () => {
refreshFolders,
treeFileNodes,
refreshTreeFiles,
updateTreeFileNode,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
@ -121,22 +122,26 @@ export const FilesPage: React.FC = () => {
}, [_tableRefetch, refreshTreeFiles, refreshFolders]);
const _handleScopeChange = useCallback(async (fileId: string, newScope: string) => {
updateTreeFileNode(fileId, { scope: newScope });
try {
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
await Promise.all([refreshTreeFiles(), _tableRefetch()]);
_tableRefetch();
} catch (err) {
console.error('Failed to update scope:', err);
await Promise.all([refreshTreeFiles(), _tableRefetch()]);
}
}, [refreshTreeFiles, _tableRefetch]);
}, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]);
const _handleNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
updateTreeFileNode(fileId, { neutralize: newValue });
try {
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
await Promise.all([refreshTreeFiles(), _tableRefetch()]);
_tableRefetch();
} catch (err) {
console.error('Failed to toggle neutralize:', err);
await Promise.all([refreshTreeFiles(), _tableRefetch()]);
}
}, [refreshTreeFiles, _tableRefetch]);
}, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]);
// ── Folder nodes for tree (real folders only) ────────────────────────
const folderNodes = useMemo(() => {
@ -171,8 +176,8 @@ export const FilesPage: React.FC = () => {
label: t('Erstellt von'),
type: 'text' as any,
sortable: true,
filterable: false,
searchable: false,
filterable: true,
searchable: true,
width: 150,
minWidth: 100,
maxWidth: 250,

View file

@ -81,8 +81,8 @@ export const PromptsPage: React.FC = () => {
label: t('Erstellt von'),
type: 'text' as any,
sortable: true,
filterable: false,
searchable: false,
filterable: true,
searchable: true,
width: 150,
minWidth: 100,
maxWidth: 250,
@ -210,11 +210,11 @@ export const PromptsPage: React.FC = () => {
sortable={true}
selectable={false}
actionButtons={[
...(canCreate ? [{
{
type: 'copy' as const,
title: t('Duplizieren'),
onAction: handleDuplicate,
}] : []),
title: t('Inhalt kopieren'),
contentField: 'content',
},
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,

View file

@ -33,8 +33,8 @@ const AdminSubscriptionsPage: React.FC = () => {
const _handleForceCancel = useCallback(async (row: any) => {
const ok = await confirm(
`Subscription «${row.planTitle}» für Mandant «${row.mandateName}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.`,
{ confirmLabel: 'Sofort kündigen', cancelLabel: 'Abbrechen', variant: 'danger' },
t('Subscription «{plan}» für Mandant «{mandate}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.', { plan: row.planTitle, mandate: row.mandateName }),
{ confirmLabel: t('Sofort kündigen'), cancelLabel: t('Abbrechen'), variant: 'danger' },
);
if (!ok) return;

View file

@ -107,17 +107,17 @@ const TabNav: React.FC<TabNavProps> = ({ activeTab, onTabChange }) => {
// HELPERS: Convert viewStats to ReportSection arrays
// ============================================================================
function _recordToChartData(record: Record<string, number>, tr: TranslateFn): ReportChartDataPoint[] {
function _recordToChartData(record: Record<string, number>, t: TranslateFn): ReportChartDataPoint[] {
return Object.entries(record)
.sort((a, b) => b[1] - a[1])
.map(([key, value]) => ({ key: key || tr('—'), value }));
.map(([key, value]) => ({ key: key || t('—'), value }));
}
function _buildOverviewSections(
viewStats: ViewStatistics,
totalBalance: number,
totalStorageMB: number,
tr: TranslateFn,
t: TranslateFn,
): ReportSection[] {
const topProvider = Object.entries(viewStats.costByProvider).sort((a, b) => b[1] - a[1])[0];
const topModel = Object.entries(viewStats.costByModel || {}).sort((a, b) => b[1] - a[1])[0];
@ -128,31 +128,31 @@ function _buildOverviewSections(
type: 'kpiGrid',
items: [
{
label: tr('Gesamtkosten'),
label: t('Gesamtkosten'),
value: _formatCurrency(viewStats.totalCost),
subtitle: tr('{n} Transaktionen', { n: String(viewStats.transactionCount) }),
subtitle: t('{n} Transaktionen', { n: String(viewStats.transactionCount) }),
},
{
label: tr('Anbieter'),
label: t('Anbieter'),
value: Object.keys(viewStats.costByProvider).length,
subtitle: topProvider ? tr('Top: {name}', { name: topProvider[0] }) : tr('Keine Nutzung'),
subtitle: topProvider ? t('Top: {name}', { name: topProvider[0] }) : t('Keine Nutzung'),
},
{
label: tr('Modelle'),
label: t('Modelle'),
value: Object.keys(viewStats.costByModel || {}).length,
subtitle: topModel ? tr('Top: {name}', { name: topModel[0] }) : tr('Keine Nutzung'),
subtitle: topModel ? t('Top: {name}', { name: topModel[0] }) : t('Keine Nutzung'),
},
{
label: tr('Features'),
label: t('Features'),
value: Object.keys(viewStats.costByFeature).length,
subtitle: topFeature ? tr('Top: {name}', { name: topFeature[0] }) : tr('Keine Nutzung'),
subtitle: topFeature ? t('Top: {name}', { name: topFeature[0] }) : t('Keine Nutzung'),
},
{
label: tr('Guthaben'),
label: t('Guthaben'),
value: _formatCurrency(totalBalance),
},
{
label: tr('Speicher'),
label: t('Speicher'),
value: formatBinaryDataSizeFromMebibytes(totalStorageMB),
},
]
@ -160,7 +160,7 @@ function _buildOverviewSections(
];
}
function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'bar', tr: TranslateFn): ReportSection[] {
function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'bar', t: TranslateFn): ReportSection[] {
const timeSeriesData: ReportChartDataPoint[] = viewStats.timeSeries.map(ts => ({
key: ts.date,
value: ts.cost
@ -176,49 +176,49 @@ function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'ba
{
type: 'kpiGrid',
items: [
{ label: tr('Gesamtkosten'), value: _formatCurrency(viewStats.totalCost), subtitle: tr('{n} Transaktionen', { n: String(viewStats.transactionCount) }) },
{ label: tr('Durchschnitt'), value: _formatCurrency(avgCost), subtitle: tr('pro Transaktion') },
{ label: tr('Anbieter'), value: Object.keys(viewStats.costByProvider).length },
{ label: tr('Modelle'), value: Object.keys(viewStats.costByModel || {}).length },
{ label: tr('Features'), value: Object.keys(viewStats.costByFeature).length },
{ label: tr('Mandanten'), value: Object.keys(viewStats.costByMandate).length },
{ label: t('Gesamtkosten'), value: _formatCurrency(viewStats.totalCost), subtitle: t('{n} Transaktionen', { n: String(viewStats.transactionCount) }) },
{ label: t('Durchschnitt'), value: _formatCurrency(avgCost), subtitle: t('pro Transaktion') },
{ label: t('Anbieter'), value: Object.keys(viewStats.costByProvider).length },
{ label: t('Modelle'), value: Object.keys(viewStats.costByModel || {}).length },
{ label: t('Features'), value: Object.keys(viewStats.costByFeature).length },
{ label: t('Mandanten'), value: Object.keys(viewStats.costByMandate).length },
]
},
{
type: 'barChart',
title: tr('Kostenentwicklung'),
title: t('Kostenentwicklung'),
data: timeSeriesData,
formatValue: _formatCurrency,
span: 'full' as const
},
{
type: chartType,
title: tr('Kosten nach Anbieter'),
data: _recordToChartData(viewStats.costByProvider, tr),
title: t('Kosten nach Anbieter'),
data: _recordToChartData(viewStats.costByProvider, t),
formatValue: _formatCurrency,
donut: chartMode === 'pie',
span: 'half' as const
},
{
type: chartType,
title: tr('Kosten nach Modell'),
data: _recordToChartData(viewStats.costByModel || {}, tr),
title: t('Kosten nach Modell'),
data: _recordToChartData(viewStats.costByModel || {}, t),
formatValue: _formatCurrency,
donut: chartMode === 'pie',
span: 'half' as const
},
{
type: chartType,
title: tr('Kosten nach Feature'),
data: _recordToChartData(viewStats.costByFeature, tr),
title: t('Kosten nach Feature'),
data: _recordToChartData(viewStats.costByFeature, t),
formatValue: _formatCurrency,
donut: chartMode === 'pie',
span: 'half' as const
},
{
type: chartType,
title: tr('Kosten nach Mandant'),
data: _recordToChartData(viewStats.costByMandate, tr),
title: t('Kosten nach Mandant'),
data: _recordToChartData(viewStats.costByMandate, t),
formatValue: _formatCurrency,
donut: chartMode === 'pie',
span: 'half' as const
@ -319,11 +319,21 @@ export const BillingDataView: React.FC = () => {
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
// Unified scope params -- single source of truth for all tab API calls
// "nur meine Daten" is an additional filter on top of the dropdown scope
const _scopeParams = useMemo((): Record<string, string> => {
if (onlyMyData) return { scope: 'personal' };
if (selectedScope === 'personal') return { scope: 'personal' };
if (selectedScope === 'all') return { scope: 'all' };
return { scope: 'mandate', mandateId: selectedScope };
const params: Record<string, string> = {};
if (selectedScope === 'personal') {
params.scope = 'personal';
} else if (selectedScope === 'all') {
params.scope = 'all';
} else {
params.scope = 'mandate';
params.mandateId = selectedScope;
}
if (onlyMyData) {
params.onlyMine = 'true';
}
return params;
}, [selectedScope, onlyMyData]);
// Load aggregated statistics from the view/statistics route
@ -371,7 +381,9 @@ export const BillingDataView: React.FC = () => {
const results = await Promise.all(
Array.from(mandateIds).map(async (mid) => {
try {
const resp = await api.get(`/api/subscription/data-volume/${mid}`);
const params: Record<string, string> = {};
if (onlyMyData) params.onlyMine = 'true';
const resp = await api.get(`/api/subscription/data-volume/${mid}`, { params });
return { ...resp.data, mandateName: mandateNameMap.get(mid) || mid.slice(0, 8) } as DataVolumeInfo;
} catch {
return null;
@ -384,7 +396,7 @@ export const BillingDataView: React.FC = () => {
} finally {
setStorageLoading(false);
}
}, [balances, selectedScope]);
}, [balances, selectedScope, onlyMyData]);
// Initial data load
useEffect(() => {

View file

@ -11,25 +11,15 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useSubscription } from '../../hooks/useSubscription';
import { useConfirm } from '../../hooks/useConfirm';
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
import type { SubscriptionPlan, MandateSubscription, SubscriptionUsage } from '../../api/subscriptionApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import styles from './Billing.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// ============================================================================
// Helpers
// ============================================================================
const _lang = (): string =>
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en';
const _t = (dict: Record<string, string> | undefined): string => {
if (!dict) return '';
const l = _lang();
return dict[l] || dict['en'] || dict['de'] || Object.values(dict)[0] || '';
};
const _formatCurrency = (amount: number) =>
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount);
@ -42,7 +32,7 @@ const _formatDate = (iso: string | null | undefined): string => {
}
};
function _getStatusLabel(t: (key: string) => string): Record<string, { label: string; color: string }> {
function _getStatusMap(t: (k: string) => string): Record<string, { label: string; color: string }> {
return {
PENDING: { label: t('Zahlung ausstehend'), color: '#f59e0b' },
SCHEDULED: { label: t('Geplant'), color: '#8b5cf6' },
@ -53,16 +43,15 @@ function _getStatusLabel(t: (key: string) => string): Record<string, { label: st
};
}
function _getPeriodLabel(t: (key: string) => string): Record<string, string> {
function _getPeriodMap(t: (k: string) => string): Record<string, string> {
return {
MONTHLY: t('Monatlich'),
YEARLY: t('Jährlich'),
NONE: t('—'),
NONE: '—',
};
}
/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
const storageOverageChfPerGbMonth = 0.5;
const _storageOveragePerGbMonth = 0.5;
// ============================================================================
// Plan Card
@ -75,102 +64,166 @@ interface PlanCardProps {
activatingPlanKey: string | null;
}
const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activatingPlanKey }) => {
const _PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activatingPlanKey }) => {
const { t } = useLanguage();
const periodLabel = _getPeriodLabel(t);
const period = _getPeriodMap(t);
const activating = activatingPlanKey === plan.planKey;
const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0;
const isYearly = plan.billingPeriod === 'YEARLY';
const monthlyEquivalent = isYearly ? plan.pricePerUserCHF / 12 : null;
return (
<div style={{
border: isCurrent ? '2px solid var(--primary-color, #F25843)' : '1px solid var(--color-border, var(--border-color, #333))',
borderRadius: '8px',
padding: '1.25rem',
borderRadius: '12px',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
background: isCurrent ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))' : 'var(--surface-color, var(--bg-secondary, #1a1a2e))',
minWidth: 220,
gap: '1rem',
background: isCurrent ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.08))' : 'var(--surface-color, var(--bg-secondary, #1a1a2e))',
minWidth: 240,
position: 'relative',
transition: 'box-shadow 0.2s',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontSize: '1rem' }}>{_t(plan.title)}</strong>
{isCurrent && (
<span style={{
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
}}>{t('Aktuell')}</span>
)}
{/* Header */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
<strong style={{ fontSize: '1.1rem' }}>{plan.title}</strong>
{isCurrent && (
<span style={{
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
}}>{t('Aktuell')}</span>
)}
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0, lineHeight: 1.4 }}>
{plan.description}
</p>
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>
{_t(plan.description)}
</p>
{/* Pricing */}
{!isFreePlan && (
<div style={{ fontSize: '0.85rem' }}>
<div>{t('User:')} <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
<div>
{t('Module inkl.:')} <strong>{plan.includedModules ?? 0}</strong>
{(plan.pricePerFeatureInstanceCHF ?? 0) > 0 && (
<> · {t('Zusatzmodul:')} <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> {t('/ Monat')}</>
)}
<div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, lineHeight: 1.2 }}>
{_formatCurrency(plan.pricePerUserCHF)}
</div>
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary)' }}>
{t('AI-Budget:')} <strong>{_formatCurrency(plan.budgetAiPerUserCHF ?? 0)}</strong> {t('/ User / Monat')}
{' · '}
{t('Speicher:')}{' '}
<strong>
{plan.maxDataVolumeMB == null
? t('unbegrenzt')
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</strong>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
{t('pro User')} / {period[plan.billingPeriod] || plan.billingPeriod}
</div>
{plan.maxUsers != null && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
{t('Max. User:')} {plan.maxUsers}
{' · '}
{t('Speicher über Plan:')} {_formatCurrency(storageOverageChfPerGbMonth)} {t('/ GB / Monat')}
{monthlyEquivalent != null && (
<div style={{ fontSize: '0.75rem', color: 'var(--primary-color, #F25843)', fontWeight: 500, marginTop: '0.15rem' }}>
{_formatCurrency(monthlyEquivalent)} {t('pro User / Monat')}
</div>
)}
</div>
)}
{isFreePlan && plan.trialDays && (
<div style={{ fontSize: '0.85rem' }}>
{t('{days} Tage kostenlos', { days: plan.trialDays })}
{plan.maxUsers && <> · {t('{count} Users maximal', { count: plan.maxUsers })}</>}
{(plan.includedModules ?? 0) > 0 && <> · {t('{count} Module inkl.', { count: plan.includedModules })}</>}
{(plan.maxDataVolumeMB != null || (plan.budgetAiPerUserCHF ?? 0) > 0) && (
<>
{plan.maxDataVolumeMB != null && (
<> · {t('Speicher')} {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}</>
)}
{(plan.budgetAiPerUserCHF ?? 0) > 0 && <> · {t('AI-Budget')} {_formatCurrency(plan.budgetAiPerUserCHF ?? 0)} {t('/ User')}</>}
</>
{/* Features list */}
{!isFreePlan && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '0.85rem' }}>
<_FeatureRow icon="📦" text={t('{count} Module inklusive', { count: plan.includedModules ?? 0 })} />
{(plan.pricePerFeatureInstanceCHF ?? 0) > 0 && (
<_FeatureRow icon="" text={t('Zusatzmodul: {price} / Monat', { price: _formatCurrency(plan.pricePerFeatureInstanceCHF) })} />
)}
<_FeatureRow icon="🤖" text={t('AI-Budget: {price} / User / Monat', { price: _formatCurrency(plan.budgetAiPerUserCHF ?? 0) })} />
<_FeatureRow
icon="💾"
text={
plan.maxDataVolumeMB == null
? t('Speicher: unbegrenzt')
: t('Speicher: {size}', { size: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB) })
}
/>
{plan.maxUsers != null && (
<_FeatureRow icon="👥" text={t('Max. {count} User', { count: plan.maxUsers })} />
)}
</div>
)}
{/* Trial info */}
{isFreePlan && plan.trialDays && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '0.85rem' }}>
<_FeatureRow icon="🎁" text={t('{days} Tage kostenlos', { days: plan.trialDays })} />
{plan.maxUsers && <_FeatureRow icon="👤" text={t('{count} Users maximal', { count: plan.maxUsers })} />}
{(plan.includedModules ?? 0) > 0 && <_FeatureRow icon="📦" text={t('{count} Module inklusive', { count: plan.includedModules })} />}
{(plan.budgetAiPerUserCHF ?? 0) > 0 && <_FeatureRow icon="🤖" text={t('AI-Budget: {price} / User', { price: _formatCurrency(plan.budgetAiPerUserCHF ?? 0) })} />}
{plan.maxDataVolumeMB != null && <_FeatureRow icon="💾" text={t('Speicher: {size}', { size: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB) })} />}
</div>
)}
{/* Action */}
{!isCurrent && (
<button
onClick={() => onActivate(plan.planKey)}
disabled={!!activatingPlanKey}
style={{
marginTop: 'auto', padding: '8px 16px', borderRadius: '6px', border: 'none',
marginTop: 'auto', padding: '10px 16px', borderRadius: '8px', border: 'none',
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
cursor: activatingPlanKey ? 'wait' : 'pointer',
opacity: activatingPlanKey ? 0.6 : 1,
opacity: activatingPlanKey ? 0.6 : 1, fontSize: '0.9rem',
transition: 'opacity 0.2s',
}}
>
{activating
? t('Weiterleitung…')
: (!isFreePlan && !plan.trialDays) ? t('Kostenpflichtig abonnieren') : t('Auswählen')}
: (!isFreePlan && !plan.trialDays) ? t('Abonnieren') : t('Auswählen')}
</button>
)}
</div>
);
};
const _FeatureRow: React.FC<{ icon: string; text: string }> = ({ icon, text }) => (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.8rem', flexShrink: 0, width: '1.2rem', textAlign: 'center' }}>{icon}</span>
<span>{text}</span>
</div>
);
// ============================================================================
// Usage Metric
// ============================================================================
interface UsageMetricProps {
label: string;
value: number;
max?: number;
formatValue?: (v: number) => string;
}
const _UsageMetric: React.FC<UsageMetricProps> = ({ label, value, max, formatValue }) => {
const fmt = formatValue ?? ((v: number) => String(v));
const percent = max != null && max > 0 ? Math.min((value / max) * 100, 100) : null;
const isWarning = percent != null && percent >= 80;
const barColor = isWarning ? '#f59e0b' : 'var(--primary-color, #F25843)';
return (
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>
{label}
</div>
<div style={{ fontSize: '0.95rem', fontWeight: 600 }}>
{fmt(value)}
{max != null && (
<span style={{ fontWeight: 400, color: 'var(--text-secondary)' }}> / {fmt(max)}</span>
)}
</div>
{percent != null && (
<div style={{
marginTop: '0.3rem', height: 4, borderRadius: 2,
background: 'var(--color-border, rgba(255,255,255,0.1))',
overflow: 'hidden',
}}>
<div style={{
width: `${percent}%`, height: '100%', borderRadius: 2,
background: barColor, transition: 'width 0.3s ease',
}} />
</div>
)}
</div>
);
};
// ============================================================================
// Subscription Info Card
// ============================================================================
@ -178,6 +231,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
interface SubInfoProps {
sub: MandateSubscription;
plan: SubscriptionPlan | null;
usage: SubscriptionUsage | null;
label: string;
onCancel?: (id: string) => void;
onReactivate?: (id: string) => void;
@ -186,11 +240,11 @@ interface SubInfoProps {
justPaid?: boolean;
}
const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onReactivate, cancelling, reactivating, justPaid }) => {
const _SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, usage, label, onCancel, onReactivate, cancelling, reactivating, justPaid }) => {
const { t } = useLanguage();
const statusLabel = _getStatusLabel(t);
const periodLabel = _getPeriodLabel(t);
const statusInfo = statusLabel[sub.status] || statusLabel.EXPIRED;
const statusMap = _getStatusMap(t);
const periodMap = _getPeriodMap(t);
const statusInfo = statusMap[sub.status] || statusMap.EXPIRED;
const isActive = sub.status === 'ACTIVE';
const isPending = sub.status === 'PENDING';
const isScheduled = sub.status === 'SCHEDULED';
@ -198,35 +252,37 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
return (
<div style={{
border: '1px solid var(--color-border, var(--border-color, #333))',
borderRadius: '8px',
padding: '1.25rem',
borderRadius: '12px',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
gap: '0.75rem',
background: 'var(--surface-color, var(--bg-secondary, #1a1a2e))',
}}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{/* Header */}
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{label}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontSize: '1.1rem' }}>{plan ? _t(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' }}>
{isActive && !sub.recurring && (
<span style={{
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
background: '#ef4444', color: '#fff', fontWeight: 600,
}}>{t('Gekündigt')}</span>
)}
<span style={{
fontSize: '0.75rem', padding: '2px 10px', borderRadius: '4px',
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
background: statusInfo.color, color: '#fff', fontWeight: 600,
}}>{statusInfo.label}</span>
</div>
</div>
{/* Pending notice */}
{isPending && (
<div style={{
padding: '0.6rem 0.8rem', borderRadius: '6px',
padding: '0.6rem 0.8rem', borderRadius: '8px',
background: justPaid ? 'rgba(34,197,94,0.1)' : 'rgba(245,158,11,0.1)',
border: `1px solid ${justPaid ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
color: justPaid ? '#22c55e' : '#f59e0b', fontSize: '0.85rem',
@ -237,88 +293,103 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
</div>
)}
{/* Scheduled notice */}
{isScheduled && sub.effectiveFrom && (
<div style={{
padding: '0.6rem 0.8rem', borderRadius: '6px',
padding: '0.6rem 0.8rem', borderRadius: '8px',
background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)',
color: '#8b5cf6', fontSize: '0.85rem',
}}>
{t('Dieses Abonnement wird am {date} aktiv, wenn das aktuelle Abonnement ausläuft.', { date: _formatDate(sub.effectiveFrom) })}
{t('Wird am {date} aktiv, wenn das aktuelle Abonnement ausläuft.', { date: _formatDate(sub.effectiveFrom) })}
</div>
)}
{/* Details grid */}
{!isPending && !isScheduled && (
<div style={{
fontSize: '0.85rem', color: 'var(--text-secondary)',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem 1rem',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem 1.5rem',
}}>
<span>{t('Gestartet:')} {_formatDate(sub.startedAt)}</span>
{plan && <span>{t('Periode:')} {periodLabel[plan.billingPeriod] || t('—')}</span>}
{plan && <span>{t('Periode:')} {periodMap[plan.billingPeriod] || '—'}</span>}
{sub.currentPeriodEnd && <span>{t('Periodenende:')} {_formatDate(sub.currentPeriodEnd)}</span>}
{sub.trialEndsAt && <span>{t('Trial endet:')} {_formatDate(sub.trialEndsAt)}</span>}
{isActive && !sub.recurring && sub.currentPeriodEnd && (
<span style={{ color: '#ef4444' }}>{t('Läuft aus am:')} {_formatDate(sub.currentPeriodEnd)}</span>
)}
{plan && (
<>
<span>{t('AI-Budget:')} {_formatCurrency(plan.budgetAiPerUserCHF ?? 0)} {t('/ User / Monat')}</span>
<span>{t('Module inkl.:')} {plan.includedModules ?? 0}</span>
<span>
{t('Speicher:')}{' '}
{plan.maxDataVolumeMB == null
? t('unbegrenzt')
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</span>
<span>
{t('Speicher über Plan:')} {_formatCurrency(storageOverageChfPerGbMonth)} {t('/ GB / Monat')}
</span>
</>
)}
</div>
)}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
{/* Plan details */}
{plan && !isPending && !isScheduled && (
<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('AI-Budget:')} {_formatCurrency(plan.budgetAiPerUserCHF ?? 0)} {t('/ User / Monat')}</span>
<span>{t('Module inkl.:')} {plan.includedModules ?? 0}</span>
<span>
{t('Speicher:')}{' '}
{plan.maxDataVolumeMB == null
? t('unbegrenzt')
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</span>
<span>{t('Speicher über Plan:')} {_formatCurrency(_storageOveragePerGbMonth)} {t('/ GB / Monat')}</span>
</div>
)}
{/* Current usage */}
{usage && !isPending && !isScheduled && (
<div style={{
marginTop: '0.25rem', padding: '0.75rem', borderRadius: '8px',
background: 'var(--bg-tertiary, rgba(255,255,255,0.04))',
border: '1px solid var(--color-border, var(--border-color, #333))',
}}>
<div style={{
fontSize: '0.7rem', color: 'var(--text-secondary)',
textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.5rem',
}}>
{t('Aktuelle Nutzung')}
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '0.5rem',
}}>
<_UsageMetric label={t('User')} value={usage.activeUsers} max={plan?.maxUsers ?? undefined} />
<_UsageMetric label={t('Module')} value={usage.activeInstances} max={plan?.includedModules ?? undefined} />
<_UsageMetric label={t('Speicher')} value={usage.usedStorageMB} max={usage.maxStorageMB ?? undefined} formatValue={(v) => formatBinaryDataSizeFromMebibytes(v)} />
</div>
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.25rem' }}>
{isActive && !sub.recurring && onReactivate && (
<button
onClick={() => onReactivate(sub.id)}
disabled={reactivating}
style={{
padding: '6px 14px', borderRadius: '6px', border: 'none',
background: 'var(--primary-color, #F25843)', color: '#fff',
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
}}
>
{reactivating ? t('Wird reaktiviert') : t('Reaktivieren')}
<button onClick={() => onReactivate(sub.id)} disabled={reactivating} style={{
padding: '8px 16px', borderRadius: '8px', border: 'none',
background: 'var(--primary-color, #F25843)', color: '#fff',
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
}}>
{reactivating ? t('Wird reaktiviert…') : t('Reaktivieren')}
</button>
)}
{isActive && sub.recurring && onCancel && (
<button
onClick={() => onCancel(sub.id)}
disabled={cancelling}
style={{
padding: '6px 14px', borderRadius: '6px',
border: '1px solid #ef4444', background: 'transparent',
color: '#ef4444', fontWeight: 500,
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
}}
>
{cancelling ? t('Wird gekündigt') : t('Kündigen')}
<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 gekündigt…') : t('Kündigen')}
</button>
)}
{(isPending || isScheduled) && onCancel && (
<button
onClick={() => onCancel(sub.id)}
disabled={cancelling}
style={{
padding: '6px 14px', borderRadius: '6px',
border: '1px solid #ef4444', background: 'transparent',
color: '#ef4444', fontWeight: 500,
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
}}
>
{cancelling ? t('Wird abgebrochen') : t('Abbrechen')}
<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>
@ -341,6 +412,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
subscription,
plan: currentPlan,
scheduled,
usage,
loading,
error,
activatePlan,
@ -380,7 +452,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
setJustPaid(false);
return;
}
} catch { /* handled below via retry */ }
} catch { /* retry */ }
if (retries > 0) {
await new Promise(r => setTimeout(r, delayMs));
await _pollUntilActive(retries - 1, delayMs);
@ -464,7 +536,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
{/* Checkout feedback */}
{checkoutMessage && (
<div style={{
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px',
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '8px',
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))',
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-color, #F25843)'}`,
color: checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-light, #F25843)',
@ -485,9 +557,10 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Aktuelles Abonnement')}</h2>
{subscription ? (
<SubInfoCard
<_SubInfoCard
sub={subscription}
plan={currentPlan}
usage={usage}
label={subscription.status === 'PENDING'
? (justPaid ? t('Zahlung wird verarbeitet…') : t('Checkout läuft…'))
: t('Operatives Abonnement')}
@ -508,9 +581,10 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
{scheduled && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Geplanter Nachfolgeplan')}</h2>
<SubInfoCard
<_SubInfoCard
sub={scheduled}
plan={null}
usage={null}
label={t('Startet nach Ablauf des aktuellen Plans')}
onCancel={_handleCancel}
cancelling={cancelling}
@ -527,11 +601,11 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: '1rem',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1.25rem',
}}>
{plans.map((p) => (
<PlanCard
<_PlanCard
key={p.planKey}
plan={p}
isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'}

View file

@ -184,7 +184,7 @@ export function getDefaultValueForType(attributeType: AttributeType): any {
return [];
}
if (isMultilingualType(attributeType)) {
return { en: '' };
return { xx: '' };
}
if (isNumberType(attributeType)) {
return 0;