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 { export interface SubscriptionPlan {
planKey: string; planKey: string;
selectableByUser: boolean; selectableByUser: boolean;
title: Record<string, string>; title: string;
description: Record<string, string>; description: string;
currency: string; currency: string;
billingPeriod: BillingPeriod; billingPeriod: BillingPeriod;
pricePerUserCHF: number; pricePerUserCHF: number;
@ -44,11 +44,20 @@ export interface MandateSubscription {
stripeSubscriptionId: string | null; stripeSubscriptionId: string | null;
} }
export interface SubscriptionUsage {
activeUsers: number;
activeInstances: number;
usedStorageMB: number;
maxStorageMB: number | null;
storagePercent: number | null;
}
export interface SubscriptionStatusResponse { export interface SubscriptionStatusResponse {
active: boolean; active: boolean;
subscription: MandateSubscription | null; subscription: MandateSubscription | null;
plan: SubscriptionPlan | null; plan: SubscriptionPlan | null;
scheduled: MandateSubscription | null; scheduled: MandateSubscription | null;
usage: SubscriptionUsage | null;
} }
export interface ActivatePlanResponse { export interface ActivatePlanResponse {

View file

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

View file

@ -78,33 +78,21 @@ import api from '../../../api';
// FK Cache type: maps fkSource -> { id -> displayLabel } // FK Cache type: maps fkSource -> { id -> displayLabel }
type FkCacheType = Record<string, Record<string, string>>; type FkCacheType = Record<string, Record<string, string>>;
const isTextMultilingual = (value: any): boolean => { /**
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) { * Stringify any cell value for display.
return false; * 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'; for (const field of ['label', 'name', 'title', 'id', 'value', 'text']) {
}; const v = value[field];
if (v !== undefined && v !== null) return String(v);
const formatTextMultilingual = (value: any, currentLanguage?: string): string => {
if (!isTextMultilingual(value)) {
return String(value);
} }
try { return JSON.stringify(value); } catch { return String(value); }
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 '-';
}; };
// Types for the FormGeneratorTable // Types for the FormGeneratorTable
@ -575,9 +563,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
} }
}).current; }).current;
// Helper function to convert any field value to display string const convertToDisplayString = useCallback((fieldValue: any, _language: string): string => {
// Handles: string, boolean, number, TextMultilingual, objects
const convertToDisplayString = useCallback((fieldValue: any, language: string): string => {
if (fieldValue === null || fieldValue === undefined) { if (fieldValue === null || fieldValue === undefined) {
return '-'; return '-';
} }
@ -597,18 +583,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return fieldValue; return fieldValue;
} }
// Object - check for TextMultilingual (has 'en' key)
if (typeof fieldValue === 'object' && fieldValue !== null) { if (typeof fieldValue === 'object' && fieldValue !== null) {
if ('en' in fieldValue) { return _objectToDisplayString(fieldValue as Record<string, unknown>);
return formatTextMultilingual(fieldValue, language);
}
// Other objects → try to stringify
try {
return JSON.stringify(fieldValue);
} catch {
return String(fieldValue);
}
} }
// Fallback // Fallback
@ -1299,11 +1275,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
if (typeof val === 'boolean') { if (typeof val === 'boolean') {
str = val ? 'true' : 'false'; str = val ? 'true' : 'false';
} else if (typeof val === 'object') { } else if (typeof val === 'object') {
if (isTextMultilingual(val)) { str = _objectToDisplayString(val as Record<string, unknown>);
str = formatTextMultilingual(val, currentLanguage);
} else {
try { str = JSON.stringify(val); } catch { str = String(val); }
}
} else { } else {
str = String(val); str = String(val);
} }
@ -1554,45 +1526,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
} }
} }
// Handle objects/arrays (e.g., references to other entities) // Handle objects (e.g., references to other entities, or unresolved TextMultilingual)
// Check if value is an object (but not Date, Array, or null)
if (typeof value === 'object' && value !== null && !(value instanceof Date) && !Array.isArray(value)) { if (typeof value === 'object' && value !== null && !(value instanceof Date) && !Array.isArray(value)) {
// Check if this is a TextMultilingual object first return _objectToDisplayString(value as Record<string, unknown>);
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'}]`;
}
} }
// Handle arrays (e.g., multiselect values) // 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. * 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 { return {
id: item.objectKey, id: item.objectKey,
label: tr(item.uiLabel), label: item.uiLabel,
icon: getPageIcon(item.uiComponent), icon: getPageIcon(item.uiComponent),
path: item.uiPath, path: item.uiPath,
}; };
@ -66,25 +66,24 @@ function _staticItemsToTreeNode(
id: string, id: string,
label: string, label: string,
items: NavigationItem[], items: NavigationItem[],
tr: NavTranslateFn,
defaultExpanded: boolean = true, defaultExpanded: boolean = true,
): TreeNodeItem { ): TreeNodeItem {
return { return {
id, id,
label, label,
children: items.map(i => navigationItemToTreeNode(i, tr)), children: items.map(i => _navigationItemToTreeNode(i)),
defaultExpanded, defaultExpanded,
}; };
} }
/** /**
* Convert a FeatureView to TreeNodeItem. * 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 { return {
id: view.objectKey, id: view.objectKey,
label: tr(view.uiLabel), label: view.uiLabel,
path: view.uiPath, 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. * Shows the feature icon next to the instance name for visual distinction.
* If user is instance admin, a rename icon appears on hover. * If user is instance admin, a rename icon appears on hover.
*/ */
function featureInstanceToTreeNode( function _featureInstanceToTreeNode(
instance: FeatureInstance, instance: FeatureInstance,
featureUiComponent: string, featureUiComponent: string,
onRename: ((instanceId: string, currentLabel: string) => void) | undefined, onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
tr: NavTranslateFn, t: NavTranslateFn,
): TreeNodeItem { ): TreeNodeItem {
const children = instance.views.map(v => featureViewToTreeNode(v, tr)); const children = instance.views.map(v => _featureViewToTreeNode(v));
const renameAction = instance.isAdmin && onRename ? ( const renameAction = instance.isAdmin && onRename ? (
<button <button
className={styles.renameButton} className={styles.renameButton}
title={tr('Umbenennen')} title={t('Umbenennen')}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRename(instance.id, instance.uiLabel); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRename(instance.id, instance.uiLabel); }}
> >
<FaPen size={10} /> <FaPen size={10} />
@ -132,10 +131,10 @@ function featureInstanceToTreeNode(
* Before: Mandate Feature Instance Views * Before: Mandate Feature Instance Views
* Now: Mandate Instance (with feature icon) Views * Now: Mandate Instance (with feature icon) Views
*/ */
function navigationMandateToTreeNode( function _navigationMandateToTreeNode(
mandate: NavigationMandate, mandate: NavigationMandate,
onRename: ((instanceId: string, currentLabel: string) => void) | undefined, onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
tr: NavTranslateFn, t: NavTranslateFn,
): TreeNodeItem | null { ): TreeNodeItem | null {
if (mandate.features.length === 0) { if (mandate.features.length === 0) {
return null; return null;
@ -144,7 +143,7 @@ function navigationMandateToTreeNode(
const instanceNodes: TreeNodeItem[] = []; const instanceNodes: TreeNodeItem[] = [];
for (const feature of mandate.features) { for (const feature of mandate.features) {
for (const instance of feature.instances) { 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) * Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
*/ */
function dynamicBlockToTreeNodes( function _dynamicBlockToTreeNodes(
block: DynamicBlock, block: DynamicBlock,
onRename: ((instanceId: string, currentLabel: string) => void) | undefined, onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
tr: NavTranslateFn, t: NavTranslateFn,
): TreeNodeItem[] { ): TreeNodeItem[] {
return block.mandates return block.mandates
.map((m) => navigationMandateToTreeNode(m, onRename, tr)) .map((m) => _navigationMandateToTreeNode(m, onRename, t))
.filter((node): node is TreeNodeItem => node !== null); .filter((node): node is TreeNodeItem => node !== null);
} }
@ -227,23 +226,17 @@ export const MandateNavigation: React.FC = () => {
const navigationItems: TreeItem[] = useMemo(() => { const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = []; const items: TreeItem[] = [];
let systemBlock: { items: NavigationItem[]; subgroups?: NavSubgroup[] } | null = null; let systemBlock: { title: string; items: NavigationItem[]; subgroups?: NavSubgroup[] } | null = null;
let adminItems: NavigationItem[] = []; let adminBlock: { title: string; items: NavigationItem[]; subgroups: NavSubgroup[] } | null = null;
let adminSubgroups: NavSubgroup[] = [];
for (const block of blocks) { for (const block of blocks) {
if (block.type === 'static') { if (block.type === 'static') {
if (block.id === 'admin') { if (block.id === 'admin') {
if (block.subgroups && block.subgroups.length > 0) { adminBlock = { title: block.title, items: [...block.items], subgroups: block.subgroups || [] };
adminSubgroups = block.subgroups;
} else {
adminItems = [...block.items];
}
} else if (block.id === 'system') { } 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) { } else if (block.items.length > 0) {
// Legacy: other static blocks get merged as flat items if (!systemBlock) systemBlock = { title: block.title, items: [], subgroups: [] };
if (!systemBlock) systemBlock = { items: [], subgroups: [] };
systemBlock.items.push(...block.items); systemBlock.items.push(...block.items);
} }
} }
@ -252,14 +245,14 @@ export const MandateNavigation: React.FC = () => {
if (systemBlock) { if (systemBlock) {
const children: TreeNodeItem[] = []; const children: TreeNodeItem[] = [];
for (const item of systemBlock.items) { for (const item of systemBlock.items) {
children.push(navigationItemToTreeNode(item, t)); children.push(_navigationItemToTreeNode(item));
} }
if (systemBlock.subgroups && systemBlock.subgroups.length > 0) { if (systemBlock.subgroups && systemBlock.subgroups.length > 0) {
for (const sg of systemBlock.subgroups) { for (const sg of systemBlock.subgroups) {
children.push({ children.push({
id: sg.id, id: sg.id,
label: sg.title, label: sg.title,
children: sg.items.map(i => navigationItemToTreeNode(i, t)), children: sg.items.map(i => _navigationItemToTreeNode(i)),
defaultExpanded: true, defaultExpanded: true,
}); });
} }
@ -267,7 +260,7 @@ export const MandateNavigation: React.FC = () => {
if (children.length > 0) { if (children.length > 0) {
items.push({ items.push({
id: 'meine-sicht', id: 'meine-sicht',
label: t('Meine Sicht'), label: systemBlock.title,
children, children,
defaultExpanded: true, defaultExpanded: true,
}); });
@ -276,7 +269,7 @@ export const MandateNavigation: React.FC = () => {
for (const block of blocks) { for (const block of blocks) {
if (block.type === 'dynamic') { if (block.type === 'dynamic') {
const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename, t); const mandateNodes = _dynamicBlockToTreeNodes(block, _handleRename, t);
if (mandateNodes.length > 0) { if (mandateNodes.length > 0) {
if (items.length > 0) items.push({ type: 'separator' }); if (items.length > 0) items.push({ type: 'separator' });
items.push(...mandateNodes); 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' }); if (items.length > 0) items.push({ type: 'separator' });
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({ const subgroupNodes: TreeNodeItem[] = adminBlock.subgroups.map(sg => ({
id: sg.id, id: sg.id,
label: sg.title, label: sg.title,
children: sg.items.map(i => navigationItemToTreeNode(i, t)), children: sg.items.map(i => _navigationItemToTreeNode(i)),
defaultExpanded: false, defaultExpanded: false,
})); }));
items.push({ items.push({
id: 'administration', id: 'administration',
label: t('Administration'), label: adminBlock.title,
children: subgroupNodes, children: subgroupNodes,
defaultExpanded: false, defaultExpanded: false,
}); });
} else if (adminItems.length > 0) { } else if (adminBlock && adminBlock.items.length > 0) {
if (items.length > 0) items.push({ type: 'separator' }); 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; return items;

View file

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

View file

@ -16,6 +16,7 @@ interface FileContextType {
treeFilesLoading: boolean; treeFilesLoading: boolean;
loadTreeFiles: (folderId: string) => Promise<void>; loadTreeFiles: (folderId: string) => Promise<void>;
refreshTreeFiles: () => Promise<void>; refreshTreeFiles: () => Promise<void>;
updateTreeFileNode: (fileId: string, patch: Partial<FileNode>) => void;
expandedFolderIds: Set<string>; expandedFolderIds: Set<string>;
toggleFolderExpanded: (id: string) => void; toggleFolderExpanded: (id: string) => void;
@ -160,6 +161,24 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
); );
}, [treeFilesMap, loadTreeFiles]); }, [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 // Load root files on mount and on context change
useEffect(() => { loadTreeFiles(''); }, [loadTreeFiles, storageKey]); useEffect(() => { loadTreeFiles(''); }, [loadTreeFiles, storageKey]);
@ -284,6 +303,7 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
treeFilesLoading, treeFilesLoading,
loadTreeFiles, loadTreeFiles,
refreshTreeFiles, refreshTreeFiles,
updateTreeFileNode,
expandedFolderIds, expandedFolderIds,
toggleFolderExpanded, toggleFolderExpanded,
handleCreateFolder, handleCreateFolder,

View file

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

View file

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

View file

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

View file

@ -130,7 +130,7 @@ const VoiceSettingsTab: React.FC = () => {
})); }));
setVoiceMap(entries); setVoiceMap(entries);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Fehler beim Laden der Voice-Einstellungen'); setError(err.message || t('Fehler beim Laden der Voice-Einstellungen'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -181,7 +181,7 @@ const VoiceSettingsTab: React.FC = () => {
setTimeout(() => setSuccess(null), 3000); setTimeout(() => setSuccess(null), 3000);
await _loadSettings(); await _loadSettings();
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Fehler beim Speichern'); setError(err.message || t('Fehler beim Speichern'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -242,12 +242,12 @@ const VoiceSettingsTab: React.FC = () => {
<section className={styles.section}> <section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('TTS-Stimmen Sprachausgabe')}</h2> <h2 className={styles.sectionTitle}>{t('TTS-Stimmen Sprachausgabe')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}> <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> </p>
{voiceMap.length === 0 ? ( {voiceMap.length === 0 ? (
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}> <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> </div>
) : ( ) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
@ -256,10 +256,10 @@ const VoiceSettingsTab: React.FC = () => {
{voiceMap.map(entry => ( {voiceMap.map(entry => (
<tr key={entry.language} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}> <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' }}>{_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' }}> <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}> <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> </button>
</td> </td>
<td style={{ padding: '0.5rem' }}> <td style={{ padding: '0.5rem' }}>
@ -291,7 +291,7 @@ const VoiceSettingsTab: React.FC = () => {
</div> </div>
<button className={styles.button} onClick={_handleAddEntry} style={{ padding: '0.5rem 1rem' }}>{t('Zuweisen')}</button> <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' }}> <button className={styles.button} onClick={() => _handleTestVoice(addLanguage, addVoiceName)} disabled={testing !== null} style={{ padding: '0.5rem 1rem' }}>
{testing === addLanguage ? '...' : 'Testen'} {testing === addLanguage ? '...' : t('Testen')}
</button> </button>
</div> </div>
</section> </section>
@ -336,7 +336,7 @@ const NeutralizationMappingsTab: React.FC = () => {
})); }));
setMappings(items); setMappings(items);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Fehler beim Laden'); setError(err.message || t('Fehler beim Laden'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -349,7 +349,7 @@ const NeutralizationMappingsTab: React.FC = () => {
await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' }); await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' });
setMappings(prev => prev.filter(m => m.id !== id)); setMappings(prev => prev.filter(m => m.id !== id));
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Fehler beim Loeschen'); setError(err.message || t('Fehler beim Löschen'));
} }
}, [request]); }, [request]);
@ -378,27 +378,24 @@ const NeutralizationMappingsTab: React.FC = () => {
color: 'var(--text-primary, #1e3a5f)', color: 'var(--text-primary, #1e3a5f)',
}} }}
> >
<strong>AI-Workspace:</strong> Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '} <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> (nicht auf dieser <strong>{t('Mandant AI-Workspace-Instanz Einstellungen Tab Neutralisierung')}</strong> {t('(nicht auf dieser Seite). Dieser Tab zeigt nur die lokale Liste.')}
Seite). Dieser Tab zeigt nur die <strong>lokale</strong> Liste über <code>/api/local/neutralization-mappings</code>.
</div> </div>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}> <p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle {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.')}
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.
</p> </p>
{mappings.length === 0 ? ( {mappings.length === 0 ? (
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}> <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> </div>
) : ( ) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead> <thead>
<tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}> <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' }}>{t('Platzhalter-ID')}</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Originaltext</th> <th style={{ textAlign: 'left', padding: '0.5rem' }}>{t('Originaltext')}</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Typ</th> <th style={{ textAlign: 'left', padding: '0.5rem' }}>{t('Typ')}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@ -418,7 +415,7 @@ const NeutralizationMappingsTab: React.FC = () => {
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }}
onClick={() => _handleDelete(m.id)} onClick={() => _handleDelete(m.id)}
> >
Loeschen {t('Löschen')}
</button> </button>
</td> </td>
</tr> </tr>
@ -470,12 +467,12 @@ export const SettingsPage: React.FC = () => {
if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage }); if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage });
setLanguage(newLanguage); setLanguage(newLanguage);
window.dispatchEvent(new CustomEvent('userInfoUpdated')); window.dispatchEvent(new CustomEvent('userInfoUpdated'));
} catch { setLanguageError('Sprache konnte nicht gespeichert werden'); } } catch { setLanguageError(t('Sprache konnte nicht gespeichert werden')); }
finally { setIsSavingLanguage(false); } finally { setIsSavingLanguage(false); }
}, [currentUser, updateUser, setLanguage]); }, [currentUser, updateUser, setLanguage, t]);
const handleProfileSave = useCallback(async (formData: any) => { 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 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 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(); const cachedUser = getUserDataCache();
@ -540,7 +537,7 @@ export const SettingsPage: React.FC = () => {
<section className={styles.section}> <section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Darstellung')}</h2> <h2 className={styles.sectionTitle}>{t('Darstellung')}</h2>
<div className={styles.settingRow}> <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.settingControl}>
<div className={styles.themeToggle}> <div className={styles.themeToggle}>
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>{t('Thema Hell')}</button> <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();
}, [fetchRoles]); }, [fetchRoles]);
const getTextValue = (value: string | undefined): string => { const getTextValue = (value: any): string => {
return value || '-'; if (!value) return '-';
if (typeof value === 'string') return value;
return String(value);
}; };
// Table columns // Table columns

View file

@ -27,7 +27,7 @@ import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminMandateRolesPage: React.FC = () => { export const AdminMandateRolesPage: React.FC = () => {
const { t } = useLanguage(); const { t, currentLanguage } = useLanguage();
const navigate = useNavigate(); const navigate = useNavigate();
const { showError, showWarning } = useToast(); const { showError, showWarning } = useToast();
@ -92,8 +92,13 @@ export const AdminMandateRolesPage: React.FC = () => {
}); });
}, [selectedMandateId, fetchRoles]); }, [selectedMandateId, fetchRoles]);
const getDescriptionText = (desc: string | undefined) => { const getDescriptionText = (desc: any) => {
return desc || '-'; 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 // Table columns - scopeType is now a backend-computed field
@ -190,7 +195,7 @@ export const AdminMandateRolesPage: React.FC = () => {
}, [backendAttributes]); }, [backendAttributes]);
// Handle create role // 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; if (!selectedMandateId) return;
setIsSubmitting(true); setIsSubmitting(true);
try { try {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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