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 {
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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() === '') {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
|
||||||
|
<strong style={{ fontSize: '1.1rem' }}>{plan.title}</strong>
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
|
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
|
||||||
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
|
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
|
||||||
}}>{t('Aktuell')}</span>
|
}}>{t('Aktuell')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0, lineHeight: 1.4 }}>
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>
|
{plan.description}
|
||||||
{_t(plan.description)}
|
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
{!isFreePlan && (
|
{!isFreePlan && (
|
||||||
<div style={{ fontSize: '0.85rem' }}>
|
|
||||||
<div>{t('User:')} <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
|
||||||
<div>
|
<div>
|
||||||
{t('Module inkl.:')} <strong>{plan.includedModules ?? 0}</strong>
|
<div style={{ fontSize: '1.5rem', fontWeight: 700, lineHeight: 1.2 }}>
|
||||||
|
{_formatCurrency(plan.pricePerUserCHF)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('pro User')} / {period[plan.billingPeriod] || plan.billingPeriod}
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 && (
|
{(plan.pricePerFeatureInstanceCHF ?? 0) > 0 && (
|
||||||
<> · {t('Zusatzmodul:')} <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> {t('/ Monat')}</>
|
<_FeatureRow icon="➕" text={t('Zusatzmodul: {price} / Monat', { price: _formatCurrency(plan.pricePerFeatureInstanceCHF) })} />
|
||||||
)}
|
)}
|
||||||
</div>
|
<_FeatureRow icon="🤖" text={t('AI-Budget: {price} / User / Monat', { price: _formatCurrency(plan.budgetAiPerUserCHF ?? 0) })} />
|
||||||
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary)' }}>
|
<_FeatureRow
|
||||||
{t('AI-Budget:')} <strong>{_formatCurrency(plan.budgetAiPerUserCHF ?? 0)}</strong> {t('/ User / Monat')}
|
icon="💾"
|
||||||
{' · '}
|
text={
|
||||||
{t('Speicher:')}{' '}
|
plan.maxDataVolumeMB == null
|
||||||
<strong>
|
? t('Speicher: unbegrenzt')
|
||||||
{plan.maxDataVolumeMB == null
|
: t('Speicher: {size}', { size: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB) })
|
||||||
? t('unbegrenzt')
|
}
|
||||||
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
/>
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
{plan.maxUsers != null && (
|
{plan.maxUsers != null && (
|
||||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
<_FeatureRow icon="👥" text={t('Max. {count} User', { count: plan.maxUsers })} />
|
||||||
{t('Max. User:')} {plan.maxUsers}
|
|
||||||
{' · '}
|
|
||||||
{t('Speicher über Plan:')} {_formatCurrency(storageOverageChfPerGbMonth)} {t('/ GB / Monat')}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Trial info */}
|
||||||
{isFreePlan && plan.trialDays && (
|
{isFreePlan && plan.trialDays && (
|
||||||
<div style={{ fontSize: '0.85rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '0.85rem' }}>
|
||||||
{t('{days} Tage kostenlos', { days: plan.trialDays })}
|
<_FeatureRow icon="🎁" text={t('{days} Tage kostenlos', { days: plan.trialDays })} />
|
||||||
{plan.maxUsers && <> · {t('{count} Users maximal', { count: plan.maxUsers })}</>}
|
{plan.maxUsers && <_FeatureRow icon="👤" text={t('{count} Users maximal', { count: plan.maxUsers })} />}
|
||||||
{(plan.includedModules ?? 0) > 0 && <> · {t('{count} Module inkl.', { count: plan.includedModules })}</>}
|
{(plan.includedModules ?? 0) > 0 && <_FeatureRow icon="📦" text={t('{count} Module inklusive', { count: plan.includedModules })} />}
|
||||||
{(plan.maxDataVolumeMB != null || (plan.budgetAiPerUserCHF ?? 0) > 0) && (
|
{(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) })} />}
|
||||||
{plan.maxDataVolumeMB != null && (
|
|
||||||
<> · {t('Speicher')} {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}</>
|
|
||||||
)}
|
|
||||||
{(plan.budgetAiPerUserCHF ?? 0) > 0 && <> · {t('AI-Budget')} {_formatCurrency(plan.budgetAiPerUserCHF ?? 0)} {t('/ User')}</>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</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,30 +293,40 @@ 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 && (
|
</div>
|
||||||
<>
|
)}
|
||||||
|
|
||||||
|
{/* 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('AI-Budget:')} {_formatCurrency(plan.budgetAiPerUserCHF ?? 0)} {t('/ User / Monat')}</span>
|
||||||
<span>{t('Module inkl.:')} {plan.includedModules ?? 0}</span>
|
<span>{t('Module inkl.:')} {plan.includedModules ?? 0}</span>
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -269,56 +335,61 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
|
||||||
? t('unbegrenzt')
|
? t('unbegrenzt')
|
||||||
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>{t('Speicher über Plan:')} {_formatCurrency(_storageOveragePerGbMonth)} {t('/ GB / Monat')}</span>
|
||||||
{t('Speicher über Plan:')} {_formatCurrency(storageOverageChfPerGbMonth)} {t('/ GB / Monat')}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
|
{/* 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}
|
|
||||||
style={{
|
|
||||||
padding: '6px 14px', borderRadius: '6px', border: 'none',
|
|
||||||
background: 'var(--primary-color, #F25843)', color: '#fff',
|
background: 'var(--primary-color, #F25843)', color: '#fff',
|
||||||
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
|
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
}}
|
}}>
|
||||||
>
|
{reactivating ? t('Wird reaktiviert…') : t('Reaktivieren')}
|
||||||
{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}
|
|
||||||
style={{
|
|
||||||
padding: '6px 14px', borderRadius: '6px',
|
|
||||||
border: '1px solid #ef4444', background: 'transparent',
|
border: '1px solid #ef4444', background: 'transparent',
|
||||||
color: '#ef4444', fontWeight: 500,
|
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
}}>
|
||||||
}}
|
{cancelling ? t('Wird gekündigt…') : t('Kündigen')}
|
||||||
>
|
|
||||||
{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}
|
|
||||||
style={{
|
|
||||||
padding: '6px 14px', borderRadius: '6px',
|
|
||||||
border: '1px solid #ef4444', background: 'transparent',
|
border: '1px solid #ef4444', background: 'transparent',
|
||||||
color: '#ef4444', fontWeight: 500,
|
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
}}>
|
||||||
}}
|
{cancelling ? t('Wird abgebrochen…') : t('Abbrechen')}
|
||||||
>
|
|
||||||
{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'}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue