decision subscription

This commit is contained in:
ValueOn AG 2026-04-10 22:44:14 +02:00
parent abe6ba60d4
commit 9ac2d5a6c1
21 changed files with 221 additions and 238 deletions

View file

@ -76,7 +76,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
features: [ features: [
{ {
code: 'trustee', code: 'trustee',
label: { de: 'Treuhand', en: 'Trustee' }, label: 'Treuhand',
icon: 'briefcase', icon: 'briefcase',
instances: [ instances: [
{ {
@ -101,7 +101,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
}, },
{ {
code: 'chatworkflow', code: 'chatworkflow',
label: { de: 'Workflow', en: 'Workflow' }, label: 'Workflow',
icon: 'play_circle', icon: 'play_circle',
instances: [ instances: [
{ {
@ -124,7 +124,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
features: [ features: [
{ {
code: 'trustee', code: 'trustee',
label: { de: 'Treuhand', en: 'Trustee' }, label: 'Treuhand',
icon: 'briefcase', icon: 'briefcase',
instances: [ instances: [
{ {
@ -234,9 +234,9 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
export async function fetchAvailableFeatures(): Promise<MandateFeature[]> { export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
if (USE_MOCK) { if (USE_MOCK) {
return [ return [
{ code: 'trustee', label: { de: 'Treuhand', en: 'Trustee' }, icon: 'briefcase', instances: [] }, { code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
{ code: 'chatworkflow', label: { de: 'Workflow', en: 'Workflow' }, icon: 'play_circle', instances: [] }, { code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
{ code: 'chatbot', label: { de: 'Chatbot', en: 'Chatbot' }, icon: 'chat', instances: [] }, { code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
]; ];
} }

View file

@ -17,9 +17,9 @@ export interface StoreFeatureInstance {
export interface StoreFeature { export interface StoreFeature {
featureCode: string; featureCode: string;
label: Record<string, string>; label: string;
icon: string; icon: string;
description: Record<string, string>; description: string;
instances: StoreFeatureInstance[]; instances: StoreFeatureInstance[];
canActivate: boolean; canActivate: boolean;
} }
@ -49,7 +49,9 @@ export interface SubscriptionInfo {
status: string | null; status: string | null;
maxDataVolumeMB: number | null; maxDataVolumeMB: number | null;
maxFeatureInstances: number | null; maxFeatureInstances: number | null;
includedModules: number;
budgetAiCHF: number | null; budgetAiCHF: number | null;
budgetAiPerUserCHF: number | null;
currentFeatureInstances: number; currentFeatureInstances: number;
trialEndsAt: string | null; trialEndsAt: string | null;
} }

View file

@ -19,8 +19,10 @@ export interface SubscriptionPlan {
autoRenew: boolean; autoRenew: boolean;
maxUsers: number | null; maxUsers: number | null;
maxFeatureInstances: number | null; maxFeatureInstances: number | null;
includedModules: number;
maxDataVolumeMB?: number | null; maxDataVolumeMB?: number | null;
budgetAiCHF?: number; budgetAiCHF?: number;
budgetAiPerUserCHF?: number;
trialDays: number | null; trialDays: number | null;
successorPlanKey: string | null; successorPlanKey: string | null;
} }

View file

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import ChatsTab from './ChatsTab'; import ChatsTab from './ChatsTab';
import FilesTab from './FilesTab'; import FilesTab from './FilesTab';
import SourcesTab from './SourcesTab'; import SourcesTab from './SourcesTab';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './UnifiedDataBar.module.css'; import styles from './UnifiedDataBar.module.css';
export type UdbTab = 'chats' | 'files' | 'sources'; export type UdbTab = 'chats' | 'files' | 'sources';
@ -29,10 +30,10 @@ interface UnifiedDataBarProps {
className?: string; className?: string;
} }
const _TAB_LABELS: Record<UdbTab, Record<string, string>> = { const _TAB_KEYS: Record<UdbTab, string> = {
chats: { de: 'Chats', en: 'Chats', fr: 'Chats' }, chats: 'Chats',
files: { de: 'Dateien', en: 'Files', fr: 'Fichiers' }, files: 'Dateien',
sources: { de: 'Quellen', en: 'Sources', fr: 'Sources' }, sources: 'Quellen',
}; };
const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
@ -50,8 +51,9 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
onSourcesChanged, onSourcesChanged,
className, className,
}) => { }) => {
const { t } = useLanguage();
const visibleTabs = (['chats', 'files', 'sources'] as UdbTab[]).filter( const visibleTabs = (['chats', 'files', 'sources'] as UdbTab[]).filter(
t => !hideTabs?.includes(t), (ubTab) => !hideTabs?.includes(ubTab),
); );
const [internalTab, setInternalTab] = useState<UdbTab>(controlledTab ?? visibleTabs[0] ?? 'chats'); const [internalTab, setInternalTab] = useState<UdbTab>(controlledTab ?? visibleTabs[0] ?? 'chats');
const currentTab = controlledTab ?? internalTab; const currentTab = controlledTab ?? internalTab;
@ -70,7 +72,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
className={`${styles.tab} ${currentTab === tab ? styles.tabActive : ''}`} className={`${styles.tab} ${currentTab === tab ? styles.tabActive : ''}`}
onClick={() => _handleTabChange(tab)} onClick={() => _handleTabChange(tab)}
> >
{_TAB_LABELS[tab].de} {t(_TAB_KEYS[tab])}
</button> </button>
))} ))}
</div> </div>

View file

@ -20,47 +20,24 @@ const FEATURE_ICONS: Record<string, React.ReactNode> = {
commcoach: <FaComments />, commcoach: <FaComments />,
}; };
const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = { /** Fallback when GET /store/features omits description (German i18n keys). */
automation: { const STORE_FEATURE_DESCRIPTION_FALLBACK: Record<string, string> = {
de: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.', automation: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.',
en: 'Create and manage automations to handle recurring tasks efficiently.', graphicalEditor: 'n8n-style Flow-Automatisierung mit grafischem Editor, RAG und Tools.',
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.', teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
}, workspace: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.',
graphicalEditor: { commcoach: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.',
de: 'n8n-style Flow-Automatisierung mit grafischem Editor, RAG und Tools.',
en: 'n8n-style flow automation with visual editor, RAG and tools.',
fr: 'Automatisation de flux style n8n avec editeur visuel, RAG et outils.',
},
teamsbot: {
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
fr: 'Integrez un bot IA dans vos reunions et canaux Microsoft Teams.',
},
workspace: {
de: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.',
en: 'Use the shared AI workspace: chats, tools, and context per instance.',
fr: 'Utilisez l\'espace de travail IA partage: chats, outils et contexte par instance.',
},
commcoach: {
de: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.',
en: 'CommCoach: practice communication with AI-assisted coaching and feedback.',
fr: 'CommCoach: entrainer la communication avec un coaching assiste par IA.',
},
}; };
function _getLabel(labels: Record<string, string>, lang: string): string { function _storeCardDescription(feature: StoreFeature, t: (key: string, fallback?: string) => string): string {
return labels[lang] || labels['en'] || labels['de'] || Object.values(labels)[0] || ''; const raw =
} (feature.description && feature.description.trim()) ||
STORE_FEATURE_DESCRIPTION_FALLBACK[feature.featureCode];
function _getDescription(featureCode: string, lang: string): string { return raw ? t(raw) : '';
const desc = FEATURE_DESCRIPTIONS[featureCode];
if (!desc) return '';
return desc[lang] || desc['en'] || desc['de'] || '';
} }
interface FeatureCardProps { interface FeatureCardProps {
feature: StoreFeature; feature: StoreFeature;
language: string;
mandates: UserMandate[]; mandates: UserMandate[];
actionLoading: string | null; actionLoading: string | null;
onActivate: (code: string, mandateId?: string) => void; onActivate: (code: string, mandateId?: string) => void;
@ -69,7 +46,6 @@ interface FeatureCardProps {
const FeatureCard: React.FC<FeatureCardProps> = ({ const FeatureCard: React.FC<FeatureCardProps> = ({
feature, feature,
language,
mandates, mandates,
actionLoading, actionLoading,
onActivate, onActivate,
@ -86,13 +62,13 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
{icon && <span className={styles.cardIcon}>{icon}</span>} {icon && <span className={styles.cardIcon}>{icon}</span>}
<h3 className={styles.cardTitle}> <h3 className={styles.cardTitle}>
{_getLabel(feature.label, language)} {t(feature.label)}
</h3> </h3>
</div> </div>
<div className={styles.cardBody}> <div className={styles.cardBody}>
<p className={styles.cardDescription}> <p className={styles.cardDescription}>
{_getDescription(feature.featureCode, language)} {_storeCardDescription(feature, t)}
</p> </p>
</div> </div>
@ -111,9 +87,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)} onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
disabled={isProcessing} disabled={isProcessing}
> >
{isProcessing {isProcessing ? '...' : t('Deaktivieren')}
? '...'
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
</button> </button>
</div> </div>
))} ))}
@ -124,7 +98,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
<div> <div>
<span className={`${styles.statusBadge} ${styles.statusInactive}`}> <span className={`${styles.statusBadge} ${styles.statusInactive}`}>
<span className={styles.statusDot} /> <span className={styles.statusDot} />
{language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'} {t('Verfügbar')}
</span> </span>
</div> </div>
)} )}
@ -138,12 +112,8 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
disabled={isProcessing} disabled={isProcessing}
> >
{isProcessing {isProcessing
? (language === 'de' ? t('store.wirdAktiviert') : t('store.activating')) ? t('store.wirdAktiviert', t('store.activating'))
: (language === 'de' : t('Aktivieren für {name}', { name: String(m.label || m.name) })}
? `Aktivieren fuer ${m.label || m.name}`
: language === 'fr'
? `Activer pour ${m.label || m.name}`
: `Activate for ${m.label || m.name}`)}
</button> </button>
))} ))}
</div> </div>
@ -152,44 +122,48 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
}; };
const StorePage: React.FC = () => { const StorePage: React.FC = () => {
const { t, currentLanguage } = useLanguage(); const { t } = useLanguage();
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore(); const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
return ( return (
<div className={styles.store}> <div className={styles.store}>
<div className={styles.header}> <div className={styles.header}>
<h1>{currentLanguage === 'de' ? 'Feature Store' : currentLanguage === 'fr' ? t('store.featureStore') : t('store.featureStore')}</h1> <h1>{t('Feature Store')}</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
{currentLanguage === 'de' {t(
? 'Aktiviere Features fuer dein Konto. Deine Daten sind isoliert und nur fuer dich sichtbar.' 'Aktiviere Features für dein Konto. Deine Daten sind isoliert und nur für dich sichtbar.',
: currentLanguage === 'fr' t('store.activateFeaturesForYourAccount')
? t('store.activezDesFonctionnalitesPourVotre') )}
: t('store.activateFeaturesForYourAccount')}
</p> </p>
</div> </div>
{subscriptionInfo && subscriptionInfo.plan && ( {subscriptionInfo && subscriptionInfo.plan && (
<div className={styles.subscriptionBanner}> <div className={styles.subscriptionBanner}>
<span>Plan: <strong>{subscriptionInfo.plan}</strong></span> <span>{t('Plan:')} <strong>{subscriptionInfo.plan}</strong></span>
{subscriptionInfo.maxFeatureInstances != null && ( <span className={styles.bannerSeparator}>
<span className={styles.bannerSeparator}> {subscriptionInfo.maxFeatureInstances != null
{currentLanguage === 'de' ? 'Instanzen' : 'Instances'}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances} ? <>{t('Module')}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances}</>
</span> : <>{subscriptionInfo.currentFeatureInstances} {t('Module aktiv')}
)} {subscriptionInfo.includedModules != null && subscriptionInfo.includedModules > 0 && (
<> ({subscriptionInfo.includedModules} {t('inklusive')})</>
)}
</>
}
</span>
{subscriptionInfo.maxDataVolumeMB != null && ( {subscriptionInfo.maxDataVolumeMB != null && (
<span className={styles.bannerSeparator}> <span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'Speicher' : 'Storage'}:{' '} {t('Speicher')}:{' '}
{formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)} {formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)}
</span> </span>
)} )}
{subscriptionInfo.budgetAiCHF != null && subscriptionInfo.budgetAiCHF > 0 && ( {subscriptionInfo.budgetAiPerUserCHF != null && subscriptionInfo.budgetAiPerUserCHF > 0 && (
<span className={styles.bannerSeparator}> <span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'AI-Budget' : 'AI budget'}: {subscriptionInfo.budgetAiCHF} CHF {t('AI-Budget')}: {subscriptionInfo.budgetAiPerUserCHF} CHF / User
</span> </span>
)} )}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && ( {subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
<span className={styles.bannerSeparator}> <span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? t('store.trialEndet') : t('store.trialEnds')}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()} {t('store.trialEndet', t('store.trialEnds'))}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()}
</span> </span>
)} )}
</div> </div>
@ -199,13 +173,11 @@ const StorePage: React.FC = () => {
{loading ? ( {loading ? (
<div className={styles.loading}> <div className={styles.loading}>
{currentLanguage === 'de' ? t('store.ladeFeatures') : t('store.loadingFeatures')} {t('store.ladeFeatures', t('store.loadingFeatures'))}
</div> </div>
) : features.length === 0 ? ( ) : features.length === 0 ? (
<div className={styles.empty}> <div className={styles.empty}>
{currentLanguage === 'de' {t('store.keineFeaturesImStoreVerfuegbar', t('store.noFeaturesAvailableInThe'))}
? t('store.keineFeaturesImStoreVerfuegbar')
: t('store.noFeaturesAvailableInThe')}
</div> </div>
) : ( ) : (
<div className={styles.grid}> <div className={styles.grid}>
@ -213,7 +185,6 @@ const StorePage: React.FC = () => {
<FeatureCard <FeatureCard
key={feature.featureCode} key={feature.featureCode}
feature={feature} feature={feature}
language={currentLanguage}
mandates={mandates} mandates={mandates}
actionLoading={actionLoading} actionLoading={actionLoading}
onActivate={activate} onActivate={activate}

View file

@ -24,6 +24,7 @@ import { FeatureInstanceWizard } from './wizards/FeatureInstanceWizard';
import { InstanceHierarchyView } from './InstanceHierarchyView'; import { InstanceHierarchyView } from './InstanceHierarchyView';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { labelAsI18nKey } from '../../types/mandate';
function getMandateName(mandate: Mandate): string { function getMandateName(mandate: Mandate): string {
if (mandate.label) return mandate.label; if (mandate.label) return mandate.label;
@ -33,11 +34,8 @@ function getMandateName(mandate: Mandate): string {
return mandate.name || mandate.id; return mandate.name || mandate.id;
} }
function getFeatureLabel(feature: Feature): string { function getFeatureLabel(feature: Feature, t: (k: string) => string): string {
if (typeof feature.label === 'object') { return t(labelAsI18nKey(feature.label, feature.code));
return feature.label.de || feature.label.en || feature.code;
}
return feature.label || feature.code;
} }
export interface InstanceWithStats extends FeatureInstance { export interface InstanceWithStats extends FeatureInstance {
@ -168,7 +166,7 @@ export const AccessManagementHub: React.FC = () => {
instance, instance,
mandateId: mandateId || '', mandateId: mandateId || '',
mandateName: mandate ? getMandateName(mandate) : mandateId || '', mandateName: mandate ? getMandateName(mandate) : mandateId || '',
featureLabel: feature ? getFeatureLabel(feature) : instance.featureCode, featureLabel: feature ? getFeatureLabel(feature, t) : instance.featureCode,
}); });
}; };
@ -302,7 +300,7 @@ export const AccessManagementHub: React.FC = () => {
}; };
}), }),
}; };
}, [selectedMandateId, mandates, filteredInstances, features]); }, [selectedMandateId, mandates, filteredInstances, features, t]);
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
@ -364,7 +362,7 @@ export const AccessManagementHub: React.FC = () => {
<option value="">{t('accessManagementHub.alle')}</option> <option value="">{t('accessManagementHub.alle')}</option>
{features.map((f) => ( {features.map((f) => (
<option key={f.code} value={f.code}> <option key={f.code} value={f.code}>
{getFeatureLabel(f)} {getFeatureLabel(f, t)}
</option> </option>
))} ))}
</select> </select>
@ -527,7 +525,7 @@ export const AccessManagementHub: React.FC = () => {
</span> </span>
</div> </div>
<div className={hubStyles.instanceMeta}> <div className={hubStyles.instanceMeta}>
<span>{getFeatureLabel(features.find((f) => f.code === inst.featureCode) || { code: inst.featureCode, label: inst.featureCode })}</span> <span>{getFeatureLabel(features.find((f) => f.code === inst.featureCode) || { code: inst.featureCode, label: inst.featureCode }, t)}</span>
<span>{inst.userCount ?? '—'} Benutzer</span> <span>{inst.userCount ?? '—'} Benutzer</span>
<span>{inst.roleCount ?? '—'} Rollen</span> <span>{inst.roleCount ?? '—'} Rollen</span>
</div> </div>

View file

@ -20,6 +20,7 @@ import { TextField } from '../../components/UiComponents/TextField';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { labelAsI18nKey } from '../../types/mandate';
export const AdminFeatureAccessPage: React.FC = () => { export const AdminFeatureAccessPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -93,10 +94,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
render: (value: string) => { render: (value: string) => {
const feature = features.find(f => f.code === value); const feature = features.find(f => f.code === value);
if (feature) { if (feature) {
const label = typeof feature.label === 'object' return t(labelAsI18nKey(feature.label, value));
? (feature.label.de || feature.label.en || value)
: feature.label;
return label;
} }
return value; return value;
} }
@ -327,9 +325,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
const getFeatureLabel = (code: string) => { const getFeatureLabel = (code: string) => {
const feature = features.find(f => f.code === code); const feature = features.find(f => f.code === code);
if (feature) { if (feature) {
return typeof feature.label === 'object' return t(labelAsI18nKey(feature.label, code));
? (feature.label.de || feature.label.en || code)
: (feature.label || code);
} }
return code; return code;
}; };
@ -514,9 +510,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<DropdownSelect <DropdownSelect
items={features.map(f => ({ items={features.map(f => ({
id: f.code, id: f.code,
label: typeof f.label === 'object' label: t(labelAsI18nKey(f.label, f.code)),
? (f.label.de || f.label.en || f.code)
: (f.label || f.code),
value: f.code value: f.code
}))} }))}
selectedItemId={createFeatureCode} selectedItemId={createFeatureCode}

View file

@ -17,6 +17,7 @@ import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { labelAsI18nKey } from '../../types/mandate';
export const AdminFeatureInstanceUsersPage: React.FC = () => { export const AdminFeatureInstanceUsersPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -368,9 +369,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
const getFeatureLabel = (code: string) => { const getFeatureLabel = (code: string) => {
const feature = features.find(f => f.code === code); const feature = features.find(f => f.code === code);
if (feature) { if (feature) {
return typeof feature.label === 'object' return t(labelAsI18nKey(feature.label, code));
? (feature.label.de || feature.label.en || code)
: (feature.label || code);
} }
return code; return code;
}; };

View file

@ -263,8 +263,11 @@ export const AdminFeatureRolesPage: React.FC = () => {
setEditingRole(role); setEditingRole(role);
}; };
// Get feature name - Backend uses 'label' field // Get feature name - Backend uses 'label' field (German i18n key or legacy multilingual)
const getFeatureName = (feature: Feature) => getTextValue(feature.label || feature.name); const getFeatureName = (feature: Feature) => {
const raw = getTextValue(feature.label || feature.name);
return raw === '-' ? '-' : t(raw);
};
if (error && !selectedFeatureCode) { if (error && !selectedFeatureCode) {
return ( return (

View file

@ -22,6 +22,7 @@ import { FormGeneratorForm } from '../../../components/FormGenerator/FormGenerat
import styles from '../Admin.module.css'; import styles from '../Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { labelAsI18nKey } from '../../../types/mandate';
const TOTAL_STEPS = 4; const TOTAL_STEPS = 4;
const STEP_LABELS = ['Mandant', 'Benutzer', 'Instances', 'Feature-Benutzer']; const STEP_LABELS = ['Mandant', 'Benutzer', 'Instances', 'Feature-Benutzer'];
@ -115,9 +116,7 @@ export const AdminMandateWizardPage: React.FC = () => {
const getFeatureLabel = (code: string): string => { const getFeatureLabel = (code: string): string => {
const f = features.find(feat => feat.code === code); const f = features.find(feat => feat.code === code);
if (f) { if (f) {
return typeof f.label === 'object' return t(labelAsI18nKey(f.label, code));
? (f.label.de || f.label.en || code)
: (f.label || code);
} }
return code; return code;
}; };

View file

@ -15,17 +15,13 @@ import styles from '../Admin.module.css';
import wizardStyles from './FeatureInstanceWizard.module.css'; import wizardStyles from './FeatureInstanceWizard.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { labelAsI18nKey } from '../../../types/mandate';
function getMandateName(m: Mandate): string { function getMandateName(m: Mandate): string {
if (typeof m.name === 'object') return m.name.de || m.name.en || Object.values(m.name)[0] || m.id; if (typeof m.name === 'object') return m.name.de || m.name.en || Object.values(m.name)[0] || m.id;
return m.name || m.id; return m.name || m.id;
} }
function getFeatureLabel(f: Feature): string {
if (typeof f.label === 'object') return f.label.de || f.label.en || f.code;
return f.label || f.code;
}
export interface FeatureInstanceWizardProps { export interface FeatureInstanceWizardProps {
mandateId: string; mandateId: string;
mandates: Mandate[]; mandates: Mandate[];
@ -63,8 +59,8 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]); const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]);
const featureOptions = useMemo( const featureOptions = useMemo(
() => features.map((f) => ({ value: f.code, label: getFeatureLabel(f) })), () => features.map((f) => ({ value: f.code, label: t(labelAsI18nKey(f.label, f.code)) })),
[features] [features, t]
); );
const mandateOptions = useMemo( const mandateOptions = useMemo(
() => mandates.map((m) => ({ value: m.id, label: getMandateName(m) })), () => mandates.map((m) => ({ value: m.id, label: getMandateName(m) })),

View file

@ -16,12 +16,12 @@ function _getColumns(t: (key: string) => string): ColumnConfig[] {
{ key: 'status', label: t('adminSubscriptions.status'), type: 'text', sortable: true, filterable: true, width: 110 }, { key: 'status', label: t('adminSubscriptions.status'), type: 'text', sortable: true, filterable: true, width: 110 },
{ key: 'recurring', label: t('adminSubscriptions.wiederkehrend'), type: 'boolean', sortable: true, filterable: true, width: 120 }, { key: 'recurring', label: t('adminSubscriptions.wiederkehrend'), type: 'boolean', sortable: true, filterable: true, width: 120 },
{ key: 'activeUsers', label: t('adminSubscriptions.user'), type: 'number', sortable: true, width: 70 }, { key: 'activeUsers', label: t('adminSubscriptions.user'), type: 'number', sortable: true, width: 70 },
{ key: 'activeInstances', label: t('adminSubscriptions.instanzen'), type: 'number', sortable: true, width: 90 }, { key: 'activeInstances', label: t('adminSubscriptions.module'), type: 'number', sortable: true, width: 90 },
{ key: 'monthlyRevenueCHF', label: t('adminSubscriptions.revenueProMonat'), type: 'number', sortable: true, width: 140 }, { key: 'monthlyRevenueCHF', label: t('adminSubscriptions.revenueProMonat'), type: 'number', sortable: true, width: 140 },
{ key: 'startedAt', label: t('adminSubscriptions.gestartet'), type: 'date', sortable: true, filterable: true, width: 130 }, { key: 'startedAt', label: t('adminSubscriptions.gestartet'), type: 'date', sortable: true, filterable: true, width: 130 },
{ key: 'currentPeriodEnd', label: t('adminSubscriptions.periodenende'), type: 'date', sortable: true, filterable: true, width: 130 }, { key: 'currentPeriodEnd', label: t('adminSubscriptions.periodenende'), type: 'date', sortable: true, filterable: true, width: 130 },
{ key: 'snapshotPricePerUserCHF', label: t('adminSubscriptions.preisProUser'), type: 'number', sortable: true, width: 100 }, { key: 'snapshotPricePerUserCHF', label: t('adminSubscriptions.preisProUser'), type: 'number', sortable: true, width: 100 },
{ key: 'snapshotPricePerInstanceCHF', label: t('adminSubscriptions.preisProInstanz'), type: 'number', sortable: true, width: 110 }, { key: 'snapshotPricePerInstanceCHF', label: t('adminSubscriptions.preisProModul'), type: 'number', sortable: true, width: 110 },
]; ];
} }

View file

@ -109,20 +109,29 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
{!isFreePlan && ( {!isFreePlan && (
<div style={{ fontSize: '0.85rem' }}> <div style={{ fontSize: '0.85rem' }}>
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {periodLabel[plan.billingPeriod] || plan.billingPeriod}</div> <div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {periodLabel[plan.billingPeriod] || plan.billingPeriod}</div> <div>
Module inkl.: <strong>{plan.includedModules ?? 0}</strong>
{(plan.pricePerFeatureInstanceCHF ?? 0) > 0 && (
<> · Zusatzmodul: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / Monat</>
)}
</div>
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary)' }}> <div style={{ marginTop: '0.35rem', color: 'var(--text-secondary)' }}>
AI-Budget (inkl.): <strong>{_formatCurrency(plan.budgetAiCHF ?? 0)}</strong> / Periode AI-Budget: <strong>{_formatCurrency(plan.budgetAiPerUserCHF ?? 0)}</strong> / User / Monat
{' · '} {' · '}
Speicher (inkl.):{' '} Speicher:{' '}
<strong> <strong>
{plan.maxDataVolumeMB == null {plan.maxDataVolumeMB == null
? 'unbegrenzt' ? 'unbegrenzt'
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</strong> </strong>
</div> </div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}> {plan.maxUsers != null && (
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat <div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
</div> Max. User: {plan.maxUsers}
{' · '}
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
</div>
)}
</div> </div>
)} )}
@ -130,13 +139,13 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
<div style={{ fontSize: '0.85rem' }}> <div style={{ fontSize: '0.85rem' }}>
{plan.trialDays} Tage kostenlos {plan.trialDays} Tage kostenlos
{plan.maxUsers && <> · max. {plan.maxUsers} User</>} {plan.maxUsers && <> · max. {plan.maxUsers} User</>}
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>} {(plan.includedModules ?? 0) > 0 && <> · {plan.includedModules} Module inkl.</>}
{(plan.maxDataVolumeMB != null || (plan.budgetAiCHF ?? 0) > 0) && ( {(plan.maxDataVolumeMB != null || (plan.budgetAiPerUserCHF ?? 0) > 0) && (
<> <>
{plan.maxDataVolumeMB != null && ( {plan.maxDataVolumeMB != null && (
<> · Speicher inkl. {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}</> <> · Speicher {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}</>
)} )}
{(plan.budgetAiCHF ?? 0) > 0 && <> · AI-Budget { _formatCurrency(plan.budgetAiCHF ?? 0)}</>} {(plan.budgetAiPerUserCHF ?? 0) > 0 && <> · AI-Budget {_formatCurrency(plan.budgetAiPerUserCHF ?? 0)} / User</>}
</> </>
)} )}
</div> </div>
@ -252,15 +261,16 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
)} )}
{plan && ( {plan && (
<> <>
<span>AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode</span> <span>AI-Budget: {_formatCurrency(plan.budgetAiPerUserCHF ?? 0)} / User / Monat</span>
<span>Module inkl.: {plan.includedModules ?? 0}</span>
<span> <span>
Speicher (inkl.):{' '} Speicher:{' '}
{plan.maxDataVolumeMB == null {plan.maxDataVolumeMB == null
? 'unbegrenzt' ? 'unbegrenzt'
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</span> </span>
<span style={{ gridColumn: '1 / -1' }}> <span>
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark) Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
</span> </span>
</> </>
)} )}

View file

@ -30,15 +30,13 @@ const _TABS: TabDef[] = [
{ id: 'year-end', templateTag: 'template:trustee-year-end-check', icon: '\u2705', color: '#795548' }, { id: 'year-end', templateTag: 'template:trustee-year-end-check', icon: '\u2705', color: '#795548' },
]; ];
const _TAB_LABELS: Record<string, Record<string, string>> = { const _TAB_LABEL_KEYS: Record<string, string> = {
'year-end': { de: 'Jahresabschluss prüfen', en: 'Year-End Review', fr: 'Contrôle de clôture' }, 'year-end': 'Jahresabschluss prüfen',
}; };
const _TAB_DESCRIPTIONS: Record<string, Record<string, string>> = { const _TAB_DESCRIPTION_KEYS: Record<string, string> = {
'year-end': { 'year-end':
de: 'Automatische Prüfungen für den Jahresabschluss: Saldovalidierung, Vorjahresvergleich, gesetzliche Checks.', 'Automatische Prüfungen für den Jahresabschluss: Saldovalidierung, Vorjahresvergleich, gesetzliche Checks.',
en: 'Automated year-end review: balance validation, prior-year comparison, legal compliance checks.',
},
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -58,8 +56,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const TrusteeAbschlussView: React.FC = () => { export const TrusteeAbschlussView: React.FC = () => {
const { t, currentLanguage } = useLanguage(); const { t } = useLanguage();
const lang = currentLanguage || 'de';
const { instanceId } = useCurrentInstance(); const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -228,7 +225,7 @@ export const TrusteeAbschlussView: React.FC = () => {
}} }}
> >
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span> <span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
{_TAB_LABELS[tab.id]?.[lang] || _TAB_LABELS[tab.id]?.de || tab.id} {t(_TAB_LABEL_KEYS[tab.id] || tab.id)}
</button> </button>
))} ))}
</div> </div>
@ -237,7 +234,7 @@ export const TrusteeAbschlussView: React.FC = () => {
{/* Tab content */} {/* Tab content */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p className={styles.sectionDescription}> <p className={styles.sectionDescription}>
{_TAB_DESCRIPTIONS[activeTab]?.[lang] || _TAB_DESCRIPTIONS[activeTab]?.de || ''} {_TAB_DESCRIPTION_KEYS[activeTab] ? t(_TAB_DESCRIPTION_KEYS[activeTab]) : ''}
</p> </p>
{workflowsLoading ? ( {workflowsLoading ? (

View file

@ -33,18 +33,18 @@ const _TABS: TabDef[] = [
{ id: 'forecast', templateTag: 'template:trustee-forecast', icon: '\uD83D\uDCC8', color: '#E91E63' }, { id: 'forecast', templateTag: 'template:trustee-forecast', icon: '\uD83D\uDCC8', color: '#E91E63' },
]; ];
const _TAB_LABELS: Record<string, Record<string, string>> = { const _TAB_LABEL_KEYS: Record<string, string> = {
budget: { de: 'Budget-Vergleich', en: 'Budget Comparison', fr: 'Comparaison budgétaire' }, budget: 'Budget-Vergleich',
kpi: { de: 'KPI-Dashboard', en: 'KPI Dashboard', fr: 'Tableau de bord KPI' }, kpi: 'KPI-Dashboard',
cashflow: { de: 'Cashflow-Rechnung', en: 'Cash Flow Statement', fr: 'Flux de trésorerie' }, cashflow: 'Cashflow-Rechnung',
forecast: { de: 'Prognose', en: 'Forecast', fr: 'Prévision' }, forecast: 'Prognose',
}; };
const _TAB_DESCRIPTIONS: Record<string, Record<string, string>> = { const _TAB_DESCRIPTION_KEYS: Record<string, string> = {
budget: { de: 'Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel', en: 'Compare actuals vs. budget from Excel' }, budget: 'Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel',
kpi: { de: 'Kennzahlen berechnen und visualisieren', en: 'Calculate and visualize key metrics' }, kpi: 'Kennzahlen berechnen und visualisieren',
cashflow: { de: 'Cashflow berechnen und analysieren', en: 'Calculate and analyze cash flow' }, cashflow: 'Cashflow berechnen und analysieren',
forecast: { de: 'Trend-Analyse und Prognose der nächsten Monate', en: 'Trend analysis and forecast for coming months' }, forecast: 'Trend-Analyse und Prognose der nächsten Monate',
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -64,8 +64,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const TrusteeAnalyseView: React.FC = () => { export const TrusteeAnalyseView: React.FC = () => {
const { t, currentLanguage } = useLanguage(); const { t } = useLanguage();
const lang = currentLanguage || 'de';
const { instanceId } = useCurrentInstance(); const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -241,7 +240,7 @@ export const TrusteeAnalyseView: React.FC = () => {
}} }}
> >
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span> <span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
{_TAB_LABELS[tab.id]?.[lang] || _TAB_LABELS[tab.id]?.de || tab.id} {t(_TAB_LABEL_KEYS[tab.id] || tab.id)}
</button> </button>
))} ))}
</div> </div>
@ -249,7 +248,7 @@ export const TrusteeAnalyseView: React.FC = () => {
{/* Tab content */} {/* Tab content */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p className={styles.sectionDescription}> <p className={styles.sectionDescription}>
{_TAB_DESCRIPTIONS[activeTab]?.[lang] || _TAB_DESCRIPTIONS[activeTab]?.de || ''} {_TAB_DESCRIPTION_KEYS[activeTab] ? t(_TAB_DESCRIPTION_KEYS[activeTab]) : ''}
</p> </p>
{workflowsLoading ? ( {workflowsLoading ? (

View file

@ -100,7 +100,7 @@ export const TrusteeDashboardView: React.FC = () => {
<div className={styles.statValue}> <div className={styles.statValue}>
{isLoading ? '...' : positions.length} {isLoading ? '...' : positions.length}
</div> </div>
<div className={styles.statLabel}>Positionen</div> <div className={styles.statLabel}>{t('Positionen')}</div>
</div> </div>
</div> </div>
@ -110,7 +110,7 @@ export const TrusteeDashboardView: React.FC = () => {
<div className={styles.statValue}> <div className={styles.statValue}>
{isLoading ? '...' : documents.length} {isLoading ? '...' : documents.length}
</div> </div>
<div className={styles.statLabel}>Dokumente</div> <div className={styles.statLabel}>{t('Dokumente')}</div>
</div> </div>
</div> </div>
@ -122,11 +122,11 @@ export const TrusteeDashboardView: React.FC = () => {
<div className={styles.statValueSmall}> <div className={styles.statValueSmall}>
{isLoading ? '...' : ( {isLoading ? '...' : (
accountingConfig?.configured accountingConfig?.configured
? <>{syncedCount} synced{syncErrorCount > 0 && <span style={{ color: 'var(--error-color, #dc2626)' }}> / {syncErrorCount} errors</span>}</> ? <>{syncedCount} {t('synchronisiert')}{syncErrorCount > 0 && <span style={{ color: 'var(--error-color, #dc2626)' }}> / {syncErrorCount} {t('Fehler')}</span>}</>
: 'Not configured' : t('Nicht konfiguriert')
)} )}
</div> </div>
<div className={styles.statLabel}>Buchhaltung</div> <div className={styles.statLabel}>{t('Buchhaltung')}</div>
</div> </div>
</div> </div>
@ -155,10 +155,10 @@ export const TrusteeDashboardView: React.FC = () => {
/> />
<div className={styles.infoSection}> <div className={styles.infoSection}>
<h3>Instanz-Details</h3> <h3>{t('Instanz-Details')}</h3>
<div className={styles.infoGrid}> <div className={styles.infoGrid}>
<div className={styles.infoItem}> <div className={styles.infoItem}>
<span className={styles.infoLabel}>Instanz:</span> <span className={styles.infoLabel}>{t('Instanz:')}</span>
<span className={styles.infoValue}>{instance?.instanceLabel}</span> <span className={styles.infoValue}>{instance?.instanceLabel}</span>
</div> </div>
<div className={styles.infoItem}> <div className={styles.infoItem}>
@ -167,7 +167,7 @@ export const TrusteeDashboardView: React.FC = () => {
</div> </div>
{accountingConfig?.configured && ( {accountingConfig?.configured && (
<div className={styles.infoItem}> <div className={styles.infoItem}>
<span className={styles.infoLabel}>Buchhaltungssystem:</span> <span className={styles.infoLabel}>{t('Buchhaltungssystem:')}</span>
<span className={styles.infoValue}> <span className={styles.infoValue}>
{accountingConfig.displayLabel || accountingConfig.connectorType} {accountingConfig.displayLabel || accountingConfig.connectorType}
{accountingConfig.lastSyncStatus && ` (${accountingConfig.lastSyncStatus})`} {accountingConfig.lastSyncStatus && ` (${accountingConfig.lastSyncStatus})`}

View file

@ -140,7 +140,7 @@ export const TrusteeDocumentsView: React.FC = () => {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
console.error('Download error:', err); console.error('Download error:', err);
showError('Fehler', 'Fehler beim Herunterladen des Dokuments.'); showError(t('Fehler'), t('Fehler beim Herunterladen des Dokuments.'));
} finally { } finally {
setDownloadingId(null); setDownloadingId(null);
} }
@ -172,9 +172,9 @@ export const TrusteeDocumentsView: React.FC = () => {
<div className={styles.adminPage}> <div className={styles.adminPage}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Dokumente: {error}</p> <p className={styles.errorMessage}>{t('Fehler beim Laden der Dokumente: {detail}', { detail: String(error) })}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}> <button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen <FaSync /> {t('Erneut versuchen')}
</button> </button>
</div> </div>
</div> </div>
@ -193,14 +193,14 @@ export const TrusteeDocumentsView: React.FC = () => {
onClick={() => refetch()} onClick={() => refetch()}
disabled={loading} disabled={loading}
> >
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button> </button>
{canCreate && ( {canCreate && (
<button <button
className={styles.primaryButton} className={styles.primaryButton}
onClick={handleCreateClick} onClick={handleCreateClick}
> >
+ Neues Dokument + {t('Neues Dokument')}
</button> </button>
)} )}
</div> </div>

View file

@ -57,13 +57,13 @@ export const TrusteeInstanceRolesView: React.FC = () => {
const rolesList = response.data?.items || response.data || []; const rolesList = response.data?.items || response.data || [];
setRoles(Array.isArray(rolesList) ? rolesList : []); setRoles(Array.isArray(rolesList) ? rolesList : []);
} catch (err: any) { } catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Rollen'; const errorMsg = err.response?.data?.detail || err.message || t('Fehler beim Laden der Rollen');
setError(errorMsg); setError(errorMsg);
console.error('Error loading instance roles:', err); console.error('Error loading instance roles:', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [instance?.id, instance?.mandateId]); }, [instance?.id, instance?.mandateId, t]);
useEffect(() => { useEffect(() => {
fetchRoles(); fetchRoles();
@ -96,7 +96,7 @@ export const TrusteeInstanceRolesView: React.FC = () => {
<div className={styles.error}> <div className={styles.error}>
<p>{error}</p> <p>{error}</p>
<button onClick={fetchRoles} className={styles.retryButton}> <button onClick={fetchRoles} className={styles.retryButton}>
<FaSync /> Erneut versuchen <FaSync /> {t('Erneut versuchen')}
</button> </button>
</div> </div>
</div> </div>
@ -109,7 +109,7 @@ export const TrusteeInstanceRolesView: React.FC = () => {
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<p className={styles.viewSubtitle}> <p className={styles.viewSubtitle}>
<FaUserShield style={{ marginRight: '0.5rem', verticalAlign: 'middle' }} /> <FaUserShield style={{ marginRight: '0.5rem', verticalAlign: 'middle' }} />
Verwalten Sie die Berechtigungen für die Rollen dieser Trustee-Instanz {t('Verwalten Sie die Berechtigungen für die Rollen dieser Trustee-Instanz')}
</p> </p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
@ -122,8 +122,7 @@ export const TrusteeInstanceRolesView: React.FC = () => {
<div className={styles.infoBox}> <div className={styles.infoBox}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} /> <FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span> <span>
Diese Rollen wurden von den Feature-Templates kopiert. {t('Diese Rollen wurden von den Feature-Templates kopiert. Änderungen hier gelten nur für diese Instanz.')}
Änderungen hier gelten nur für diese Instanz.
</span> </span>
</div> </div>
@ -132,7 +131,7 @@ export const TrusteeInstanceRolesView: React.FC = () => {
<FaUserShield className={styles.emptyIcon} /> <FaUserShield className={styles.emptyIcon} />
<p>{t('trusteeInstanceRoles.keineInstanzrollenGefunden')}</p> <p>{t('trusteeInstanceRoles.keineInstanzrollenGefunden')}</p>
<p className={styles.emptyHint}> <p className={styles.emptyHint}>
Instanz-Rollen werden automatisch erstellt, wenn Benutzer dieser Instanz zugewiesen werden. {t('Instanz-Rollen werden automatisch erstellt, wenn Benutzer dieser Instanz zugewiesen werden.')}
</p> </p>
</div> </div>
) : ( ) : (
@ -154,7 +153,7 @@ export const TrusteeInstanceRolesView: React.FC = () => {
</div> </div>
<div className={styles.roleBadges}> <div className={styles.roleBadges}>
{role.isSystemRole && ( {role.isSystemRole && (
<span className={styles.systemBadge}>System</span> <span className={styles.systemBadge}>{t('System')}</span>
)} )}
</div> </div>
</div> </div>

View file

@ -140,9 +140,9 @@ export const TrusteePositionDocumentsView: React.FC = () => {
<div className={styles.adminPage}> <div className={styles.adminPage}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Verknüpfungen: {error}</p> <p className={styles.errorMessage}>{t('Fehler beim Laden der Verknüpfungen: {detail}', { detail: String(error) })}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}> <button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen <FaSync /> {t('Erneut versuchen')}
</button> </button>
</div> </div>
</div> </div>
@ -161,14 +161,14 @@ export const TrusteePositionDocumentsView: React.FC = () => {
onClick={() => refetch()} onClick={() => refetch()}
disabled={loading} disabled={loading}
> >
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button> </button>
{canCreate && ( {canCreate && (
<button <button
className={styles.primaryButton} className={styles.primaryButton}
onClick={handleCreateClick} onClick={handleCreateClick}
> >
+ Neue Verknüpfung + {t('Neue Verknüpfung')}
</button> </button>
)} )}
</div> </div>

View file

@ -146,7 +146,7 @@ export const TrusteePositionsView: React.FC = () => {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
console.error('Download error:', err); console.error('Download error:', err);
showError('Fehler', 'Fehler beim Herunterladen des Dokuments.'); showError(t('Fehler'), t('Fehler beim Herunterladen des Dokuments.'));
} finally { } finally {
setDownloadingDocIds(prev => { setDownloadingDocIds(prev => {
const next = new Set(prev); const next = new Set(prev);
@ -394,9 +394,9 @@ export const TrusteePositionsView: React.FC = () => {
<div className={styles.adminPage}> <div className={styles.adminPage}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Positionen: {error}</p> <p className={styles.errorMessage}>{t('Fehler beim Laden der Positionen: {detail}', { detail: String(error) })}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}> <button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen <FaSync /> {t('Erneut versuchen')}
</button> </button>
</div> </div>
</div> </div>
@ -415,14 +415,14 @@ export const TrusteePositionsView: React.FC = () => {
onClick={() => refetch()} onClick={() => refetch()}
disabled={loading} disabled={loading}
> >
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button> </button>
{canCreate && ( {canCreate && (
<button <button
className={styles.primaryButton} className={styles.primaryButton}
onClick={handleCreateClick} onClick={handleCreateClick}
> >
+ Neue Position + {t('Neue Position')}
</button> </button>
)} )}
</div> </div>

View file

@ -101,7 +101,7 @@ export interface FeatureInstance {
*/ */
export interface MandateFeature { export interface MandateFeature {
code: string; // "trustee", "chatbot", "chatworkflow", etc. code: string; // "trustee", "chatbot", "chatworkflow", etc.
label: I18nLabel; // { de: "Treuhand", en: "Trustee" } label: string; // German plaintext i18n key
icon: string; // Material/React Icon Name icon: string; // Material/React Icon Name
instances: FeatureInstance[]; instances: FeatureInstance[];
} }
@ -163,7 +163,7 @@ export interface User {
*/ */
export interface FeatureView { export interface FeatureView {
code: string; // z.B. "dashboard", "contracts", "documents" code: string; // z.B. "dashboard", "contracts", "documents"
label: I18nLabel; label: string; // German plaintext i18n key
icon?: string; icon?: string;
path: string; // Relativer Pfad innerhalb der Instanz path: string; // Relativer Pfad innerhalb der Instanz
adminOnly?: boolean; // Nur für Admin-Rollen sichtbar adminOnly?: boolean; // Nur für Admin-Rollen sichtbar
@ -175,7 +175,7 @@ export interface FeatureView {
*/ */
export interface FeatureConfig { export interface FeatureConfig {
code: string; code: string;
label: I18nLabel; label: string; // German plaintext i18n key
icon: string; icon: string;
views: FeatureView[]; views: FeatureView[];
deprecated?: boolean; deprecated?: boolean;
@ -201,100 +201,100 @@ export interface FeatureConfig {
export const FEATURE_REGISTRY: Record<string, FeatureConfig> = { export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
trustee: { trustee: {
code: 'trustee', code: 'trustee',
label: { de: 'Treuhand', en: 'Trustee' }, label: 'Treuhand',
icon: 'briefcase', icon: 'briefcase',
views: [ views: [
{ code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' }, { code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
{ code: 'positions', label: { de: 'Positionen', en: 'Positions' }, path: 'positions' }, { code: 'positions', label: 'Positionen', path: 'positions' },
{ code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' }, { code: 'documents', label: 'Dokumente', path: 'documents' },
{ code: 'position-documents', label: { de: 'Zuordnungen', en: 'Assignments' }, path: 'position-documents' }, { code: 'position-documents', label: 'Zuordnungen', path: 'position-documents' },
{ code: 'expense-import', label: { de: 'Spesen Import', en: 'Expense Import' }, path: 'expense-import' }, { code: 'expense-import', label: 'Spesen Import', path: 'expense-import' },
{ code: 'scan-upload', label: { de: 'Scannen / Hochladen', en: 'Scan / Upload' }, path: 'scan-upload' }, { code: 'scan-upload', label: 'Scannen / Hochladen', path: 'scan-upload' },
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true }, { code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true },
{ code: 'settings', label: { de: 'Buchhaltungseinstellungen', en: 'Accounting Settings' }, path: 'settings' }, { code: 'settings', label: 'Buchhaltungseinstellungen', path: 'settings' },
] ]
}, },
chatworkflow: { chatworkflow: {
code: 'chatworkflow', code: 'chatworkflow',
label: { de: 'Workflow', en: 'Workflow' }, label: 'Workflow',
icon: 'play_circle', icon: 'play_circle',
views: [ views: [
{ code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' }, { code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
{ code: 'runs', label: { de: 'Runs', en: 'Runs' }, path: 'runs' }, { code: 'runs', label: 'Runs', path: 'runs' },
{ code: 'files', label: { de: 'Dateien', en: 'Files' }, path: 'files' }, { code: 'files', label: 'Dateien', path: 'files' },
] ]
}, },
chatbot: { chatbot: {
code: 'chatbot', code: 'chatbot',
label: { de: 'Chatbot', en: 'Chatbot' }, label: 'Chatbot',
icon: 'chat', icon: 'chat',
views: [ views: [
{ code: 'conversations', label: { de: 'Konversationen', en: 'Conversations' }, path: 'conversations' }, { code: 'conversations', label: 'Konversationen', path: 'conversations' },
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings' }, path: 'settings' }, { code: 'settings', label: 'Einstellungen', path: 'settings' },
] ]
}, },
realestate: { realestate: {
code: 'realestate', code: 'realestate',
label: { de: 'Immobilien', en: 'Real Estate' }, label: 'Immobilien',
icon: 'home', icon: 'home',
views: [ views: [
{ code: 'dashboard', label: { de: 'Karte', en: 'Map' }, path: 'dashboard' }, { code: 'dashboard', label: 'Karte', path: 'dashboard' },
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true }, { code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true },
] ]
}, },
teamsbot: { teamsbot: {
code: 'teamsbot', code: 'teamsbot',
label: { de: 'Teams Bot', en: 'Teams Bot' }, label: 'Teams Bot',
icon: 'headset_mic', icon: 'headset_mic',
views: [ views: [
{ code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' }, { code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
{ code: 'sessions', label: { de: 'Sitzungen', en: 'Sessions' }, path: 'sessions' }, { code: 'sessions', label: 'Sitzungen', path: 'sessions' },
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings' }, path: 'settings' }, { code: 'settings', label: 'Einstellungen', path: 'settings' },
] ]
}, },
graphicalEditor: { graphicalEditor: {
code: 'graphicalEditor', code: 'graphicalEditor',
label: { de: 'Grafischer Editor', en: 'Graphical Editor' }, label: 'Grafischer Editor',
icon: 'sitemap', icon: 'sitemap',
views: [ views: [
{ code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' }, { code: 'editor', label: 'Editor', path: 'editor' },
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' }, { code: 'workflows', label: 'Workflows', path: 'workflows' },
{ code: 'templates', label: { de: 'Vorlagen', en: 'Templates' }, path: 'templates' }, { code: 'templates', label: 'Vorlagen', path: 'templates' },
{ code: 'workflows-tasks', label: { de: 'Tasks', en: 'Tasks' }, path: 'workflows-tasks' }, { code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' },
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard' }, path: 'dashboard' }, { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
] ]
}, },
neutralization: { neutralization: {
code: 'neutralization', code: 'neutralization',
label: { de: 'Neutralisierung', en: 'Neutralization', fr: 'Neutralisation' }, label: 'Neutralisierung',
icon: 'shield_check', icon: 'shield_check',
views: [ views: [
{ code: 'dashboard', label: { de: 'Neutralisierung testen', en: 'Test Neutralization', fr: 'Tester neutralisation' }, path: 'playground' }, { code: 'dashboard', label: 'Neutralisierung testen', path: 'playground' },
{ code: 'playground', label: { de: 'Neutralisierung testen', en: 'Test Neutralization', fr: 'Tester neutralisation' }, path: 'playground' }, { code: 'playground', label: 'Neutralisierung testen', path: 'playground' },
{ code: 'config', label: { de: 'Einstellungen', en: 'Settings', fr: 'Paramètres' }, path: 'config' }, { code: 'config', label: 'Einstellungen', path: 'config' },
{ code: 'attributes', label: { de: 'Attribute', en: 'Attributes', fr: 'Attributs' }, path: 'attributes' }, { code: 'attributes', label: 'Attribute', path: 'attributes' },
] ]
}, },
commcoach: { commcoach: {
code: 'commcoach', code: 'commcoach',
label: { de: 'Kommunikations-Coach', en: 'Communication Coach', fr: 'Coach Communication' }, label: 'Kommunikations-Coach',
icon: 'account_voice', icon: 'account_voice',
views: [ views: [
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' }, { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
{ code: 'coaching', label: { de: 'Coaching', en: 'Coaching', fr: 'Coaching' }, path: 'coaching' }, { code: 'coaching', label: 'Coaching', path: 'coaching' },
{ code: 'dossier', label: { de: 'Dossier', en: 'Dossier', fr: 'Dossier' }, path: 'dossier' }, { code: 'dossier', label: 'Dossier', path: 'dossier' },
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Paramètres' }, path: 'settings' }, { code: 'settings', label: 'Einstellungen', path: 'settings' },
] ]
}, },
workspace: { workspace: {
code: 'workspace', code: 'workspace',
label: { de: 'AI Workspace', en: 'AI Workspace', fr: 'AI Workspace' }, label: 'AI Workspace',
icon: 'psychology', icon: 'psychology',
views: [ views: [
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' }, { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
{ code: 'editor', label: { de: 'Editor', en: 'Editor', fr: 'Editeur' }, path: 'editor' }, { code: 'editor', label: 'Editor', path: 'editor' },
{ code: 'rag-insights', label: { de: 'Wissens-Insights', en: 'Knowledge insights', fr: 'Aperçu des connaissances' }, path: 'rag-insights' }, { code: 'rag-insights', label: 'Wissens-Insights', path: 'rag-insights' },
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' }, { code: 'settings', label: 'Einstellungen', path: 'settings' },
] ]
}, },
}; };
@ -332,8 +332,20 @@ export function canAccessRecord(
} }
/** /**
* Holt das Label für die aktuelle Sprache * Holt Navigations-Label: i18n-Key (String) oder Legacy-I18nLabel.
*/ */
export function getLabel(label: I18nLabel, lang: 'de' | 'en' | 'fr' = 'de'): string { export function getLabel(label: I18nLabel | string, lang: 'de' | 'en' | 'fr' = 'de'): string {
if (typeof label === 'string') return label;
return label[lang] || label.de || label.en || ''; return label[lang] || label.de || label.en || '';
} }
/** German i18n key from API label (string or legacy multilingual object). */
export function labelAsI18nKey(
label: string | I18nLabel | { [key: string]: string } | undefined,
fallback: string
): string {
if (label === undefined || label === null) return fallback;
if (typeof label === 'string') return label || fallback;
const o = label as I18nLabel;
return o.de || o.en || o.fr || fallback;
}