streamlined billing incl ai and storage budget

This commit is contained in:
ValueOn AG 2026-03-29 12:18:56 +02:00
parent d5bb102684
commit 0f0f43ce1b
20 changed files with 271 additions and 141 deletions

View file

@ -5,7 +5,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT'; export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM'; export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE';
export interface BillingBalance { export interface BillingBalance {
mandateId: string; mandateId: string;
@ -42,12 +42,18 @@ export interface BillingSettings {
warningThresholdPercent: number; warningThresholdPercent: number;
notifyOnWarning: boolean; notifyOnWarning: boolean;
notifyEmails: string[]; notifyEmails: string[];
autoRechargeEnabled?: boolean;
rechargeAmountCHF?: number;
rechargeMaxPerMonth?: number;
} }
export interface BillingSettingsUpdate { export interface BillingSettingsUpdate {
warningThresholdPercent?: number; warningThresholdPercent?: number;
notifyOnWarning?: boolean; notifyOnWarning?: boolean;
notifyEmails?: string[]; notifyEmails?: string[];
autoRechargeEnabled?: boolean;
rechargeAmountCHF?: number;
rechargeMaxPerMonth?: number;
} }
export interface UsageReport { export interface UsageReport {

View file

@ -49,6 +49,7 @@ export interface SubscriptionInfo {
status: string | null; status: string | null;
maxDataVolumeMB: number | null; maxDataVolumeMB: number | null;
maxFeatureInstances: number | null; maxFeatureInstances: number | null;
budgetAiCHF: number | null;
currentFeatureInstances: number; currentFeatureInstances: number;
trialEndsAt: string | null; trialEndsAt: string | null;
} }

View file

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

View file

@ -18,8 +18,11 @@ const typeIcons: Record<string, React.ReactNode> = {
mention: <FaExclamationTriangle /> mention: <FaExclamationTriangle />
}; };
// Format timestamp to relative time // Format timestamp to relative time (Unix seconds)
function formatRelativeTime(timestamp: number): string { function formatRelativeTime(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return '';
}
const now = Date.now() / 1000; const now = Date.now() / 1000;
const diff = now - timestamp; const diff = now - timestamp;
@ -29,6 +32,9 @@ function formatRelativeTime(timestamp: number): string {
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`; if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleDateString('de-DE'); return date.toLocaleDateString('de-DE');
} }

View file

@ -10,6 +10,7 @@ import {
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa'; import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
import { WorkflowFile } from '../../../hooks/usePlayground'; import { WorkflowFile } from '../../../hooks/usePlayground';
import styles from './ConnectedFilesList.module.css'; import styles from './ConnectedFilesList.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
export interface ConnectedFilesListActionButton { export interface ConnectedFilesListActionButton {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove'; type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
@ -240,7 +241,7 @@ export function ConnectedFilesList({
</div> </div>
<div className={styles.fileMeta}> <div className={styles.fileMeta}>
<span className={styles.fileSize}> <span className={styles.fileSize}>
{formatFileSize(file.fileSize)} {formatBinaryDataSizeBytes(file.fileSize)}
</span> </span>
{file.source && ( {file.source && (
<span className={styles.fileSource}> <span className={styles.fileSource}>
@ -371,13 +372,5 @@ export function ConnectedFilesList({
); );
} }
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
export default ConnectedFilesList; export default ConnectedFilesList;

View file

@ -2,6 +2,8 @@
* Utility functions for message formatting and styling * Utility functions for message formatting and styling
*/ */
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
/** /**
* Formats a timestamp to a readable date/time string * Formats a timestamp to a readable date/time string
* Handles both Unix timestamps in seconds and milliseconds * Handles both Unix timestamps in seconds and milliseconds
@ -50,16 +52,8 @@ export const formatTimestamp = (timestamp?: number): string => {
} }
}; };
/** /** File / attachment sizes (bytes). Same as formatBinaryDataSizeBytes (B … TB). */
* Formats file size to human-readable format export const formatFileSize = formatBinaryDataSizeBytes;
*/
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
/** /**
* Gets status badge color class based on status * Gets status badge color class based on status

View file

@ -104,7 +104,7 @@ export function useConfirm() {
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500, padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
border: '1px solid var(--color-border, #444)', border: '1px solid var(--color-border, #444)',
background: 'transparent', background: 'transparent',
color: 'var(--text-secondary, #aaa)', color: 'var(--text-primary, #e8e8e8)',
cursor: 'pointer', cursor: 'pointer',
}} }}
> >

View file

@ -10,6 +10,48 @@ import api from '../api';
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']); const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
/** API uses PowerOnModel.sysCreatedAt (seconds); legacy clients used createdAt. */
function _coerceToUnixSeconds(value: unknown): number | undefined {
if (value == null) return undefined;
if (typeof value === 'number' && Number.isFinite(value)) {
return value > 1e12 ? value / 1000 : value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return undefined;
const asNum = Number(trimmed);
if (!Number.isNaN(asNum)) {
return asNum > 1e12 ? asNum / 1000 : asNum;
}
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) return parsed / 1000;
}
return undefined;
}
function _normalizeNotificationFromApi(raw: Record<string, unknown>): UserNotification {
const partial = raw as unknown as UserNotification;
const createdAt =
_coerceToUnixSeconds(raw.createdAt) ??
_coerceToUnixSeconds(raw.sysCreatedAt) ??
(Number.isFinite(partial.createdAt) ? partial.createdAt : 0) ??
0;
return {
...partial,
createdAt,
readAt: _coerceToUnixSeconds(raw.readAt) ?? partial.readAt,
actionedAt: _coerceToUnixSeconds(raw.actionedAt) ?? partial.actionedAt,
expiresAt: _coerceToUnixSeconds(raw.expiresAt) ?? partial.expiresAt,
};
}
function _normalizeNotificationList(data: unknown): UserNotification[] {
if (!Array.isArray(data)) return [];
return data.map(item =>
_normalizeNotificationFromApi(item && typeof item === 'object' ? (item as Record<string, unknown>) : {})
);
}
// Types // Types
export interface NotificationAction { export interface NotificationAction {
actionId: string; actionId: string;
@ -30,6 +72,7 @@ export interface UserNotification {
actions?: NotificationAction[]; actions?: NotificationAction[];
actionTaken?: string; actionTaken?: string;
actionResult?: string; actionResult?: string;
/** Creation time as Unix seconds (UTC); filled from API sysCreatedAt when needed */
createdAt: number; createdAt: number;
readAt?: number; readAt?: number;
actionedAt?: number; actionedAt?: number;
@ -74,7 +117,7 @@ export function useNotifications() {
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`; const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
const response = await api.get(url); const response = await api.get(url);
const data = response.data as UserNotification[]; const data = _normalizeNotificationList(response.data);
setNotifications(data); setNotifications(data);
return data; return data;
} catch (err: any) { } catch (err: any) {
@ -101,9 +144,9 @@ export function useNotifications() {
const listRes = await api.get('/api/notifications', { const listRes = await api.get('/api/notifications', {
params: { status: 'unread', limit: 25 }, params: { status: 'unread', limit: 25 },
}); });
const list = listRes.data as UserNotification[]; const list = _normalizeNotificationList(listRes.data);
if ( if (
Array.isArray(list) && list.length > 0 &&
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType)) list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
) { ) {
window.dispatchEvent(new Event('features-changed')); window.dispatchEvent(new Event('features-changed'));

View file

@ -107,8 +107,8 @@ const VoiceSettingsTab: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
const [prefsData, languagesData] = await Promise.all([ const [prefsData, languagesData] = await Promise.all([
request({ url: '/api/local/voice-preferences', method: 'get' }), request({ url: '/api/voice/preferences', method: 'get' }),
request({ url: '/api/local/voice/languages', method: 'get' }), request({ url: '/api/voice/languages', method: 'get' }),
]); ]);
const langList = (languagesData as any)?.languages || []; const langList = (languagesData as any)?.languages || [];
@ -135,7 +135,7 @@ const VoiceSettingsTab: React.FC = () => {
const _loadVoicesForLanguage = useCallback(async (lang: string) => { const _loadVoicesForLanguage = useCallback(async (lang: string) => {
setLoadingVoices(true); setLoadingVoices(true);
try { try {
const result = await request({ url: '/api/local/voice/voices', method: 'get', params: { language: lang } }); const result = await request({ url: '/api/voice/voices', method: 'get', params: { language: lang } });
setAddVoices((result as any)?.voices || []); setAddVoices((result as any)?.voices || []);
setAddVoiceName(''); setAddVoiceName('');
} catch { setAddVoices([]); } } catch { setAddVoices([]); }
@ -167,7 +167,7 @@ const VoiceSettingsTab: React.FC = () => {
const mapObj: Record<string, any> = {}; const mapObj: Record<string, any> = {};
voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; }); voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; });
await request({ await request({
url: '/api/local/voice-preferences', url: '/api/voice/preferences',
method: 'put', method: 'put',
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj }, data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
}); });
@ -185,9 +185,9 @@ const VoiceSettingsTab: React.FC = () => {
setTesting(lang); setTesting(lang);
try { try {
const result: any = await request({ const result: any = await request({
url: '/api/local/voice/test', url: '/api/voice/test',
method: 'post', method: 'post',
data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` }, data: { language: lang, voiceId: voice || undefined },
}); });
if (result?.success && result?.audio) { if (result?.success && result?.audio) {
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`); const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);

View file

@ -9,6 +9,7 @@ import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useStore } from '../hooks/useStore'; import { useStore } from '../hooks/useStore';
import type { StoreFeature, UserMandate } from '../api/storeApi'; import type { StoreFeature, UserMandate } from '../api/storeApi';
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
import styles from './Store.module.css'; import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = { const FEATURE_ICONS: Record<string, React.ReactNode> = {
@ -176,7 +177,13 @@ const StorePage: React.FC = () => {
)} )}
{subscriptionInfo.maxDataVolumeMB != null && ( {subscriptionInfo.maxDataVolumeMB != null && (
<span className={styles.bannerSeparator}> <span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'Speicher' : 'Storage'}: {subscriptionInfo.maxDataVolumeMB} MB {currentLanguage === 'de' ? 'Speicher' : 'Storage'}:{' '}
{formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)}
</span>
)}
{subscriptionInfo.budgetAiCHF != null && subscriptionInfo.budgetAiCHF > 0 && (
<span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'AI-Budget' : 'AI budget'}: {subscriptionInfo.budgetAiCHF} CHF
</span> </span>
)} )}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && ( {subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (

View file

@ -87,6 +87,9 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10), warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
notifyOnWarning: settings?.notifyOnWarning ?? true, notifyOnWarning: settings?.notifyOnWarning ?? true,
autoRechargeEnabled: settings?.autoRechargeEnabled ?? false,
rechargeAmountCHF: Number(settings?.rechargeAmountCHF ?? 10),
rechargeMaxPerMonth: Number(settings?.rechargeMaxPerMonth ?? 3),
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@ -96,6 +99,9 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
setFormData({ setFormData({
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10), warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
notifyOnWarning: settings.notifyOnWarning ?? true, notifyOnWarning: settings.notifyOnWarning ?? true,
autoRechargeEnabled: settings.autoRechargeEnabled ?? false,
rechargeAmountCHF: Number(settings.rechargeAmountCHF ?? 10),
rechargeMaxPerMonth: Number(settings.rechargeMaxPerMonth ?? 3),
}); });
} }
}, [settings]); }, [settings]);
@ -155,6 +161,49 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
</div> </div>
</div> </div>
<div className={styles.formRow}>
<div className={styles.formGroup} style={{ gridColumn: '1 / -1' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.autoRechargeEnabled}
onChange={(e) => setFormData(prev => ({ ...prev, autoRechargeEnabled: e.target.checked }))}
/>
Auto-Nachladung (AI-Guthaben bei niedrigem Stand)
</label>
</div>
</div>
{formData.autoRechargeEnabled && (
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag pro Nachladung (CHF)</label>
<input
type="number"
className={styles.input}
value={formData.rechargeAmountCHF}
onChange={(e) =>
setFormData(prev => ({ ...prev, rechargeAmountCHF: Number(e.target.value) }))
}
min="0.01"
step="0.01"
/>
</div>
<div className={styles.formGroup}>
<label>Max. Nachladungen / Monat</label>
<input
type="number"
className={styles.input}
value={formData.rechargeMaxPerMonth}
onChange={(e) =>
setFormData(prev => ({ ...prev, rechargeMaxPerMonth: Math.max(0, Math.floor(Number(e.target.value))) }))
}
min="0"
step="1"
/>
</div>
</div>
)}
<button <button
type="submit" type="submit"
className={`${styles.button} ${styles.buttonPrimary}`} className={`${styles.button} ${styles.buttonPrimary}`}
@ -260,11 +309,12 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
interface AccountsOverviewProps { interface AccountsOverviewProps {
accounts: AccountSummary[]; accounts: AccountSummary[];
users: MandateUserSummary[]; /** Kept for call-site compatibility; only mandate pool accounts are shown. */
users?: MandateUserSummary[];
loading: boolean; loading: boolean;
} }
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, loading }) => { const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }) => {
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', { return new Intl.NumberFormat('de-CH', {
style: 'currency', style: 'currency',
@ -272,18 +322,7 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
}).format(amount); }).format(amount);
}; };
// Build a lookup map: userId -> display name const poolAccounts = useMemo(() => accounts.filter((a) => !a.userId), [accounts]);
const _userNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const user of users) {
const displayName = user.displayName
|| [user.firstName, user.lastName].filter(Boolean).join(' ')
|| user.username
|| user.id;
map.set(user.id, displayName);
}
return map;
}, [users]);
if (loading) { if (loading) {
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>; return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
@ -293,15 +332,18 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
return <div className={styles.noData}>Keine Konten vorhanden</div>; return <div className={styles.noData}>Keine Konten vorhanden</div>;
} }
if (poolAccounts.length === 0) {
return <div className={styles.noData}>Kein Mandanten-Konto vorhanden</div>;
}
return ( return (
<div className={styles.adminSection}> <div className={styles.adminSection}>
<h3>Konten</h3> <h3>Konten</h3>
<div className={styles.accountsGrid}> <div className={styles.accountsGrid}>
{accounts.map((account) => ( {poolAccounts.map((account) => (
<div key={account.id} className={styles.accountCard}> <div key={account.id} className={styles.accountCard}>
<h4>{!account.userId ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4> <h4>Mandanten-Konto</h4>
<div className={styles.accountInfo}> <div className={styles.accountInfo}>
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span> <span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span> <span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
</div> </div>

View file

@ -8,7 +8,7 @@
*/ */
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport'; import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport'; import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
@ -48,13 +48,34 @@ interface ViewStatistics {
interface BalanceCardProps { interface BalanceCardProps {
balance: BillingBalance; balance: BillingBalance;
onOpenMandateAdmin?: (mandateId: string) => void;
} }
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => { const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onOpenMandateAdmin }) => {
return ( return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}> <div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}> <div className={styles.balanceHeader}>
{onOpenMandateAdmin ? (
<button
type="button"
className={styles.mandateName}
onClick={() => onOpenMandateAdmin(balance.mandateId)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
textDecoration: 'underline',
}}
>
{balance.mandateName}
</button>
) : (
<h3 className={styles.mandateName}>{balance.mandateName}</h3> <h3 className={styles.mandateName}>{balance.mandateName}</h3>
)}
</div> </div>
<div className={styles.balanceAmount}> <div className={styles.balanceAmount}>
{_formatCurrency(balance.balance)} {_formatCurrency(balance.balance)}
@ -267,8 +288,13 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => { export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview'); const [activeTab, setActiveTab] = useState<TabType>('overview');
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const _openMandateBillingAdmin = useCallback((mandateId: string) => {
navigate(`/admin/billing?mandate=${encodeURIComponent(mandateId)}`);
}, [navigate]);
// Scope filter: 'personal' | 'all' | mandateId // Scope filter: 'personal' | 'all' | mandateId
const [selectedScope, setSelectedScope] = useState<string>('personal'); const [selectedScope, setSelectedScope] = useState<string>('personal');
@ -335,10 +361,6 @@ export const BillingDataView: React.FC = () => {
setCheckoutMessage(null); setCheckoutMessage(null);
}, [searchParams, setSearchParams]); }, [searchParams, setSearchParams]);
// All user balances (for admin overview cards)
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
// Statistics state (shared by Overview and Statistics tabs) // Statistics state (shared by Overview and Statistics tabs)
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null); const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(false);
@ -349,19 +371,6 @@ export const BillingDataView: React.FC = () => {
const [transactionsError, setTransactionsError] = useState<string | null>(null); const [transactionsError, setTransactionsError] = useState<string | null>(null);
const [transactionsPagination, setTransactionsPagination] = useState<any>(null); const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
// Load all user balances for admin overview
const _loadAllUserBalances = useCallback(async () => {
try {
setAllUserBalancesLoading(true);
const response = await api.get('/api/billing/view/users/balances');
setAllUserBalances(Array.isArray(response.data) ? response.data : []);
} catch {
setAllUserBalances([]);
} finally {
setAllUserBalancesLoading(false);
}
}, []);
// Load aggregated statistics from the view/statistics route // Load aggregated statistics from the view/statistics route
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => { const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
try { try {
@ -402,10 +411,7 @@ export const BillingDataView: React.FC = () => {
if (activeTab === 'overview' || activeTab === 'statistics') { if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear()); _loadViewStatistics('month', new Date().getFullYear());
} }
if (activeTab === 'overview') { }, [activeTab, _loadViewStatistics, selectedScope]);
_loadAllUserBalances();
}
}, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
// Load transactions with pagination support // Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => { const _loadTransactions = useCallback(async (paginationParams?: any) => {
@ -556,12 +562,6 @@ export const BillingDataView: React.FC = () => {
? balances ? balances
: balances.filter(b => b.mandateId === selectedScope); : balances.filter(b => b.mandateId === selectedScope);
const filteredUserBalances = selectedScope === 'personal'
? [] // personal view: only own balance cards, no other users
: selectedScope === 'all'
? allUserBalances
: allUserBalances.filter(ub => ub.mandateId === selectedScope);
return ( return (
<> <>
{/* Balance Cards - own balances */} {/* Balance Cards - own balances */}
@ -577,36 +577,13 @@ export const BillingDataView: React.FC = () => {
<BalanceCard <BalanceCard
key={balance.mandateId} key={balance.mandateId}
balance={balance} balance={balance}
onOpenMandateAdmin={_openMandateBillingAdmin}
/> />
))} ))}
</div> </div>
)} )}
</section> </section>
{/* All User Balance Cards (mandate/all scope) */}
{filteredUserBalances.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
{allUserBalancesLoading ? (
<div className={styles.loadingPlaceholder}>Lade Benutzer-Guthaben...</div>
) : (
<div className={styles.balanceGrid}>
{filteredUserBalances.map((ub, idx) => (
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{ub.userName || ub.userId?.slice(0, 8)}</h3>
<span className={styles.mandateSubtitle}>{ub.mandateName}</span>
</div>
<div className={styles.balanceAmount}>
{_formatCurrency(ub.balance || 0)}
</div>
</div>
))}
</div>
)}
</section>
)}
{/* Usage Statistics via FormGeneratorReport */} {/* Usage Statistics via FormGeneratorReport */}
<section className={styles.section}> <section className={styles.section}>
<FormGeneratorReport <FormGeneratorReport

View file

@ -12,6 +12,7 @@ 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 } from '../../api/subscriptionApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
// ============================================================================ // ============================================================================
@ -54,6 +55,9 @@ const _periodLabel: Record<string, string> = {
NONE: '—', NONE: '—',
}; };
/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
const storageOverageChfPerGbMonth = 0.5;
// ============================================================================ // ============================================================================
// Plan Card // Plan Card
// ============================================================================ // ============================================================================
@ -98,6 +102,19 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
<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>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary, #888)' }}>
AI-Budget (inkl.): <strong>{_formatCurrency(plan.budgetAiCHF ?? 0)}</strong> / Periode
{' · '}
Speicher (inkl.):{' '}
<strong>
{plan.maxDataVolumeMB == null
? 'unbegrenzt'
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</strong>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: '0.25rem' }}>
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
</div>
</div> </div>
)} )}
@ -106,6 +123,14 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
{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.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
{(plan.maxDataVolumeMB != null || (plan.budgetAiCHF ?? 0) > 0) && (
<>
{plan.maxDataVolumeMB != null && (
<> · Speicher inkl. {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}</>
)}
{(plan.budgetAiCHF ?? 0) > 0 && <> · AI-Budget { _formatCurrency(plan.budgetAiCHF ?? 0)}</>}
</>
)}
</div> </div>
)} )}
@ -214,6 +239,20 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
{isActive && !sub.recurring && sub.currentPeriodEnd && ( {isActive && !sub.recurring && sub.currentPeriodEnd && (
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span> <span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
)} )}
{plan && (
<>
<span>AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode</span>
<span>
Speicher (inkl.):{' '}
{plan.maxDataVolumeMB == null
? 'unbegrenzt'
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</span>
<span style={{ gridColumn: '1 / -1' }}>
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark)
</span>
</>
)}
</div> </div>
)} )}
@ -358,7 +397,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
{ {
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen', title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen', confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined, cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen',
variant: 'danger', variant: 'danger',
}, },
); );

View file

@ -6,7 +6,7 @@
* *
* Uses the generic useVoiceStream hook for mic capture + STT streaming. * Uses the generic useVoiceStream hook for mic capture + STT streaming.
* Google Streaming STT handles silence detection natively. * Google Streaming STT handles silence detection natively.
* STT language is loaded from central voice preferences (/api/local/voice-preferences). * STT language is loaded from central voice preferences (/api/voice/preferences).
*/ */
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
@ -46,7 +46,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
api.get('/api/local/voice-preferences').then((res) => { api.get('/api/voice/preferences').then((res) => {
if (cancelled) return; if (cancelled) return;
const lang = res.data?.sttLanguage || res.data?.ttsLanguage; const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
if (lang) sttLanguageRef.current = lang; if (lang) sttLanguageRef.current = lang;

View file

@ -9,6 +9,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import api from '../../../api'; import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes'; import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
import type { AgentProgress, FileEditProposal } from './useWorkspace'; import type { AgentProgress, FileEditProposal } from './useWorkspace';
@ -339,11 +340,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) {
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || ''; const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
const icon = _getFileIcon(ext); const icon = _getFileIcon(ext);
const sizeLabel = doc.fileSize const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : '';
? doc.fileSize > 1024 * 1024
? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
: `${(doc.fileSize / 1024).toFixed(1)} KB`
: '';
return ( return (
<div <div

View file

@ -10,6 +10,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import api from '../../../api'; import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { WorkspaceFile } from './useWorkspace'; import type { WorkspaceFile } from './useWorkspace';
interface FilePreviewProps { interface FilePreviewProps {
@ -66,7 +67,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, fi
</div> </div>
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}> <div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
<span>{file.mimeType}</span> <span>{file.mimeType}</span>
<span>{_formatFileSize(file.fileSize)}</span> <span>{formatBinaryDataSizeBytes(file.fileSize)}</span>
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>} {file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
</div> </div>
{file.description && ( {file.description && (
@ -146,8 +147,3 @@ function _isTextMime(mime: string): boolean {
return textTypes.includes(mime); return textTypes.includes(mime);
} }
function _formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View file

@ -15,6 +15,7 @@ import type { editor as monacoEditor } from 'monaco-editor';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor'; import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa'; import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
function _getMonacoLanguage(fileName: string): string { function _getMonacoLanguage(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || ''; const ext = fileName.split('.').pop()?.toLowerCase() || '';
@ -27,12 +28,6 @@ function _getMonacoLanguage(fileName: string): string {
return langMap[ext] || 'plaintext'; return langMap[ext] || 'plaintext';
} }
function _formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export const WorkspaceEditorPage: React.FC = () => { export const WorkspaceEditorPage: React.FC = () => {
const instanceId = useInstanceId() || ''; const instanceId = useInstanceId() || '';
const navigate = useNavigate(); const navigate = useNavigate();
@ -155,8 +150,8 @@ export const WorkspaceEditorPage: React.FC = () => {
}}> }}>
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}> <div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
<span>{activeEdit.fileName}</span> <span>{activeEdit.fileName}</span>
<span>Original: {_formatBytes(activeEdit.oldContent.length)}</span> <span>Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)}</span>
<span>Geaendert: {_formatBytes(activeEdit.newContent.length)}</span> <span>Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}</span>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button <button

View file

@ -105,7 +105,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
useEffect(() => { useEffect(() => {
if (_sttPrefsLoaded.current) return; if (_sttPrefsLoaded.current) return;
_sttPrefsLoaded.current = true; _sttPrefsLoaded.current = true;
fetch('/api/local/voice-preferences', { credentials: 'include' }) fetch('/api/voice/preferences', { credentials: 'include' })
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); }) .then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); })
.catch(() => {}); .catch(() => {});
@ -702,7 +702,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
onClick={() => { onClick={() => {
setVoiceLanguage(lang.code); setVoiceLanguage(lang.code);
setShowLangPicker(false); setShowLangPicker(false);
fetch('/api/local/voice-preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {}); fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {});
}} }}
style={{ style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13, padding: '8px 12px', cursor: 'pointer', fontSize: 13,

View file

@ -21,6 +21,7 @@ import {
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceRagInsightsPage.module.css'; import styles from './WorkspaceRagInsightsPage.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
const MIME_LABELS: Record<string, string> = { const MIME_LABELS: Record<string, string> = {
pdf: 'PDF', pdf: 'PDF',
@ -35,18 +36,6 @@ const MIME_LABELS: Record<string, string> = {
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828']; const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
function _formatBytes(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
interface RagKpis { interface RagKpis {
indexedDocuments: number; indexedDocuments: number;
indexedBytesTotal: number; indexedBytesTotal: number;
@ -161,7 +150,7 @@ export const WorkspaceRagInsightsPage: React.FC = () => {
<p className={styles.kpiLabel}>Indexierte Dokumente</p> <p className={styles.kpiLabel}>Indexierte Dokumente</p>
</div> </div>
<div className={styles.kpiCard}> <div className={styles.kpiCard}>
<p className={styles.kpiValue}>{_formatBytes(kpis.indexedBytesTotal)}</p> <p className={styles.kpiValue}>{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}</p>
<p className={styles.kpiLabel}>Indexiertes Datenvolumen (geschätzt)</p> <p className={styles.kpiLabel}>Indexiertes Datenvolumen (geschätzt)</p>
</div> </div>
<div className={styles.kpiCard}> <div className={styles.kpiCard}>

View file

@ -0,0 +1,43 @@
/**
* Central binary (1024) data-size formatting for the UI.
*
* - Use formatBinaryDataSizeBytes for raw byte counts (files, RAG totals, ).
* - Use formatBinaryDataSizeFromMebibytes for API fields stored as MB (mebibytes), e.g. maxDataVolumeMB.
*/
const BINARY_BASE = 1024;
const BINARY_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
function _maxFractionDigits(value: number): number {
if (value >= 100 || Number.isInteger(value)) return 0;
if (value >= 10) return 1;
return 2;
}
/**
* Human-readable size from a byte count; picks B TB automatically (1024-based).
*/
export function formatBinaryDataSizeBytes(bytes: number, localeId = 'de-CH'): string {
if (!Number.isFinite(bytes)) return '—';
if (bytes < 0) return '—';
if (bytes === 0) return `0 ${BINARY_UNITS[0]}`;
const rawExp = Math.floor(Math.log(bytes) / Math.log(BINARY_BASE));
const exp = Math.max(0, Math.min(BINARY_UNITS.length - 1, rawExp));
const value = bytes / BINARY_BASE ** exp;
const maxFrac = _maxFractionDigits(value);
const formatted = new Intl.NumberFormat(localeId, {
maximumFractionDigits: maxFrac,
minimumFractionDigits: 0,
}).format(value);
return `${formatted} ${BINARY_UNITS[exp]}`;
}
/**
* Same as formatBinaryDataSizeBytes, but input is mebibytes (API convention for plan limits).
*/
export function formatBinaryDataSizeFromMebibytes(mebibytes: number, localeId = 'de-CH'): string {
if (!Number.isFinite(mebibytes) || mebibytes < 0) return '—';
if (mebibytes === 0) return `0 ${BINARY_UNITS[0]}`;
return formatBinaryDataSizeBytes(mebibytes * BINARY_BASE * BINARY_BASE, localeId);
}