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 ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE';
export interface BillingBalance {
mandateId: string;
@ -42,12 +42,18 @@ export interface BillingSettings {
warningThresholdPercent: number;
notifyOnWarning: boolean;
notifyEmails: string[];
autoRechargeEnabled?: boolean;
rechargeAmountCHF?: number;
rechargeMaxPerMonth?: number;
}
export interface BillingSettingsUpdate {
warningThresholdPercent?: number;
notifyOnWarning?: boolean;
notifyEmails?: string[];
autoRechargeEnabled?: boolean;
rechargeAmountCHF?: number;
rechargeMaxPerMonth?: number;
}
export interface UsageReport {

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import {
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
import { WorkflowFile } from '../../../hooks/usePlayground';
import styles from './ConnectedFilesList.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
export interface ConnectedFilesListActionButton {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
@ -240,7 +241,7 @@ export function ConnectedFilesList({
</div>
<div className={styles.fileMeta}>
<span className={styles.fileSize}>
{formatFileSize(file.fileSize)}
{formatBinaryDataSizeBytes(file.fileSize)}
</span>
{file.source && (
<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;

View file

@ -2,6 +2,8 @@
* Utility functions for message formatting and styling
*/
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
/**
* Formats a timestamp to a readable date/time string
* Handles both Unix timestamps in seconds and milliseconds
@ -50,16 +52,8 @@ export const formatTimestamp = (timestamp?: number): string => {
}
};
/**
* Formats file size to human-readable format
*/
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];
};
/** File / attachment sizes (bytes). Same as formatBinaryDataSizeBytes (B … TB). */
export const formatFileSize = formatBinaryDataSizeBytes;
/**
* 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,
border: '1px solid var(--color-border, #444)',
background: 'transparent',
color: 'var(--text-secondary, #aaa)',
color: 'var(--text-primary, #e8e8e8)',
cursor: 'pointer',
}}
>

View file

@ -10,6 +10,48 @@ import api from '../api';
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
export interface NotificationAction {
actionId: string;
@ -30,6 +72,7 @@ export interface UserNotification {
actions?: NotificationAction[];
actionTaken?: string;
actionResult?: string;
/** Creation time as Unix seconds (UTC); filled from API sysCreatedAt when needed */
createdAt: number;
readAt?: number;
actionedAt?: number;
@ -74,7 +117,7 @@ export function useNotifications() {
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
const response = await api.get(url);
const data = response.data as UserNotification[];
const data = _normalizeNotificationList(response.data);
setNotifications(data);
return data;
} catch (err: any) {
@ -101,9 +144,9 @@ export function useNotifications() {
const listRes = await api.get('/api/notifications', {
params: { status: 'unread', limit: 25 },
});
const list = listRes.data as UserNotification[];
const list = _normalizeNotificationList(listRes.data);
if (
Array.isArray(list) &&
list.length > 0 &&
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
) {
window.dispatchEvent(new Event('features-changed'));

View file

@ -107,8 +107,8 @@ const VoiceSettingsTab: React.FC = () => {
setLoading(true);
try {
const [prefsData, languagesData] = await Promise.all([
request({ url: '/api/local/voice-preferences', method: 'get' }),
request({ url: '/api/local/voice/languages', method: 'get' }),
request({ url: '/api/voice/preferences', method: 'get' }),
request({ url: '/api/voice/languages', method: 'get' }),
]);
const langList = (languagesData as any)?.languages || [];
@ -135,7 +135,7 @@ const VoiceSettingsTab: React.FC = () => {
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
setLoadingVoices(true);
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 || []);
setAddVoiceName('');
} catch { setAddVoices([]); }
@ -167,7 +167,7 @@ const VoiceSettingsTab: React.FC = () => {
const mapObj: Record<string, any> = {};
voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; });
await request({
url: '/api/local/voice-preferences',
url: '/api/voice/preferences',
method: 'put',
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
});
@ -185,9 +185,9 @@ const VoiceSettingsTab: React.FC = () => {
setTesting(lang);
try {
const result: any = await request({
url: '/api/local/voice/test',
url: '/api/voice/test',
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) {
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 { useStore } from '../hooks/useStore';
import type { StoreFeature, UserMandate } from '../api/storeApi';
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = {
@ -176,7 +177,13 @@ const StorePage: React.FC = () => {
)}
{subscriptionInfo.maxDataVolumeMB != null && (
<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>
)}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (

View file

@ -87,6 +87,9 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
const [formData, setFormData] = useState({
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
notifyOnWarning: settings?.notifyOnWarning ?? true,
autoRechargeEnabled: settings?.autoRechargeEnabled ?? false,
rechargeAmountCHF: Number(settings?.rechargeAmountCHF ?? 10),
rechargeMaxPerMonth: Number(settings?.rechargeMaxPerMonth ?? 3),
});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@ -96,6 +99,9 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
setFormData({
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
notifyOnWarning: settings.notifyOnWarning ?? true,
autoRechargeEnabled: settings.autoRechargeEnabled ?? false,
rechargeAmountCHF: Number(settings.rechargeAmountCHF ?? 10),
rechargeMaxPerMonth: Number(settings.rechargeMaxPerMonth ?? 3),
});
}
}, [settings]);
@ -154,6 +160,49 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
</label>
</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
type="submit"
@ -260,11 +309,12 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
interface AccountsOverviewProps {
accounts: AccountSummary[];
users: MandateUserSummary[];
/** Kept for call-site compatibility; only mandate pool accounts are shown. */
users?: MandateUserSummary[];
loading: boolean;
}
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, loading }) => {
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
@ -272,19 +322,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
}).format(amount);
};
// Build a lookup map: userId -> display name
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]);
const poolAccounts = useMemo(() => accounts.filter((a) => !a.userId), [accounts]);
if (loading) {
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
}
@ -292,16 +331,19 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
if (accounts.length === 0) {
return <div className={styles.noData}>Keine Konten vorhanden</div>;
}
if (poolAccounts.length === 0) {
return <div className={styles.noData}>Kein Mandanten-Konto vorhanden</div>;
}
return (
<div className={styles.adminSection}>
<h3>Konten</h3>
<div className={styles.accountsGrid}>
{accounts.map((account) => (
{poolAccounts.map((account) => (
<div key={account.id} className={styles.accountCard}>
<h4>{!account.userId ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
<h4>Mandanten-Konto</h4>
<div className={styles.accountInfo}>
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
</div>

View file

@ -8,7 +8,7 @@
*/
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 { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
@ -48,13 +48,34 @@ interface ViewStatistics {
interface BalanceCardProps {
balance: BillingBalance;
onOpenMandateAdmin?: (mandateId: string) => void;
}
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onOpenMandateAdmin }) => {
return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
{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>
)}
</div>
<div className={styles.balanceAmount}>
{_formatCurrency(balance.balance)}
@ -267,7 +288,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview');
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
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
const [selectedScope, setSelectedScope] = useState<string>('personal');
@ -335,10 +361,6 @@ export const BillingDataView: React.FC = () => {
setCheckoutMessage(null);
}, [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)
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
@ -349,19 +371,6 @@ export const BillingDataView: React.FC = () => {
const [transactionsError, setTransactionsError] = useState<string | null>(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
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
try {
@ -402,10 +411,7 @@ export const BillingDataView: React.FC = () => {
if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear());
}
if (activeTab === 'overview') {
_loadAllUserBalances();
}
}, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
}, [activeTab, _loadViewStatistics, selectedScope]);
// Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => {
@ -555,12 +561,6 @@ export const BillingDataView: React.FC = () => {
const filteredBalances = selectedScope === 'personal' || selectedScope === 'all'
? balances
: 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 (
<>
@ -577,36 +577,13 @@ export const BillingDataView: React.FC = () => {
<BalanceCard
key={balance.mandateId}
balance={balance}
onOpenMandateAdmin={_openMandateBillingAdmin}
/>
))}
</div>
)}
</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 */}
<section className={styles.section}>
<FormGeneratorReport

View file

@ -12,6 +12,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useSubscription } from '../../hooks/useSubscription';
import { useConfirm } from '../../hooks/useConfirm';
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import styles from './Billing.module.css';
// ============================================================================
@ -54,6 +55,9 @@ const _periodLabel: Record<string, string> = {
NONE: '—',
};
/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
const storageOverageChfPerGbMonth = 0.5;
// ============================================================================
// Plan Card
// ============================================================================
@ -98,6 +102,19 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
<div style={{ fontSize: '0.85rem' }}>
<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 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>
)}
@ -106,6 +123,14 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
{plan.trialDays} Tage kostenlos
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
{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>
)}
@ -214,6 +239,20 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
{isActive && !sub.recurring && sub.currentPeriodEnd && (
<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>
)}
@ -358,7 +397,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
{
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined,
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen',
variant: 'danger',
},
);

View file

@ -6,7 +6,7 @@
*
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
* 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';
@ -46,7 +46,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
useEffect(() => {
let cancelled = false;
api.get('/api/local/voice-preferences').then((res) => {
api.get('/api/voice/preferences').then((res) => {
if (cancelled) return;
const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
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 remarkGfm from 'remark-gfm';
import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
import type { AgentProgress, FileEditProposal } from './useWorkspace';
@ -339,11 +340,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) {
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
const icon = _getFileIcon(ext);
const sizeLabel = doc.fileSize
? doc.fileSize > 1024 * 1024
? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
: `${(doc.fileSize / 1024).toFixed(1)} KB`
: '';
const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : '';
return (
<div

View file

@ -10,6 +10,7 @@
import React, { useState, useEffect } from 'react';
import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { WorkspaceFile } from './useWorkspace';
interface FilePreviewProps {
@ -66,7 +67,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, fi
</div>
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
<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>}
</div>
{file.description && (
@ -146,8 +147,3 @@ function _isTextMime(mime: string): boolean {
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 { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
function _getMonacoLanguage(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
@ -27,12 +28,6 @@ function _getMonacoLanguage(fileName: string): string {
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 = () => {
const instanceId = useInstanceId() || '';
const navigate = useNavigate();
@ -155,8 +150,8 @@ export const WorkspaceEditorPage: React.FC = () => {
}}>
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
<span>{activeEdit.fileName}</span>
<span>Original: {_formatBytes(activeEdit.oldContent.length)}</span>
<span>Geaendert: {_formatBytes(activeEdit.newContent.length)}</span>
<span>Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)}</span>
<span>Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button

View file

@ -105,7 +105,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
useEffect(() => {
if (_sttPrefsLoaded.current) return;
_sttPrefsLoaded.current = true;
fetch('/api/local/voice-preferences', { credentials: 'include' })
fetch('/api/voice/preferences', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); })
.catch(() => {});
@ -702,7 +702,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
onClick={() => {
setVoiceLanguage(lang.code);
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={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,

View file

@ -21,6 +21,7 @@ import {
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceRagInsightsPage.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
const MIME_LABELS: Record<string, string> = {
pdf: 'PDF',
@ -35,18 +36,6 @@ const MIME_LABELS: Record<string, string> = {
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 {
indexedDocuments: number;
indexedBytesTotal: number;
@ -161,7 +150,7 @@ export const WorkspaceRagInsightsPage: React.FC = () => {
<p className={styles.kpiLabel}>Indexierte Dokumente</p>
</div>
<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>
</div>
<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);
}