ui fixes
This commit is contained in:
parent
3803ebb274
commit
4762818d3d
18 changed files with 434 additions and 378 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() === '') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ export function getDefaultValueForType(attributeType: AttributeType): any {
|
|||
return [];
|
||||
}
|
||||
if (isMultilingualType(attributeType)) {
|
||||
return { en: '' };
|
||||
return { xx: '' };
|
||||
}
|
||||
if (isNumberType(attributeType)) {
|
||||
return 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue