diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 3a99597..326fc25 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -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 { diff --git a/src/api/storeApi.ts b/src/api/storeApi.ts index deb8f1e..78b0768 100644 --- a/src/api/storeApi.ts +++ b/src/api/storeApi.ts @@ -49,6 +49,7 @@ export interface SubscriptionInfo { status: string | null; maxDataVolumeMB: number | null; maxFeatureInstances: number | null; + budgetAiCHF: number | null; currentFeatureInstances: number; trialEndsAt: string | null; } diff --git a/src/api/subscriptionApi.ts b/src/api/subscriptionApi.ts index 9fefe9f..b476433 100644 --- a/src/api/subscriptionApi.ts +++ b/src/api/subscriptionApi.ts @@ -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; } diff --git a/src/components/NotificationBell/NotificationBell.tsx b/src/components/NotificationBell/NotificationBell.tsx index baa33a1..d0f237f 100644 --- a/src/components/NotificationBell/NotificationBell.tsx +++ b/src/components/NotificationBell/NotificationBell.tsx @@ -18,8 +18,11 @@ const typeIcons: Record = { mention: }; -// 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'); } diff --git a/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx b/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx index 371f650..77cf5cd 100644 --- a/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx +++ b/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx @@ -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({
- {formatFileSize(file.fileSize)} + {formatBinaryDataSizeBytes(file.fileSize)} {file.source && ( @@ -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; diff --git a/src/components/UiComponents/Messages/MessageUtils.ts b/src/components/UiComponents/Messages/MessageUtils.ts index 3d40945..ff67d51 100644 --- a/src/components/UiComponents/Messages/MessageUtils.ts +++ b/src/components/UiComponents/Messages/MessageUtils.ts @@ -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 diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx index bb9fbab..ec190d7 100644 --- a/src/hooks/useConfirm.tsx +++ b/src/hooks/useConfirm.tsx @@ -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', }} > diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 58a989b..b3706d9 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -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): 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) : {}) + ); +} + // 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')); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 94f73ce..ff8af52 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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 = {}; 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}`); diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index 1162d26..ee605c1 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -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 = { @@ -176,7 +177,13 @@ const StorePage: React.FC = () => { )} {subscriptionInfo.maxDataVolumeMB != null && ( - {currentLanguage === 'de' ? 'Speicher' : 'Storage'}: {subscriptionInfo.maxDataVolumeMB} MB + {currentLanguage === 'de' ? 'Speicher' : 'Storage'}:{' '} + {formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)} + + )} + {subscriptionInfo.budgetAiCHF != null && subscriptionInfo.budgetAiCHF > 0 && ( + + {currentLanguage === 'de' ? 'AI-Budget' : 'AI budget'}: {subscriptionInfo.budgetAiCHF} CHF )} {subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && ( diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index 62245fd..07c3be0 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -87,6 +87,9 @@ const SettingsEditor: React.FC = ({ 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 = ({ 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 = ({ settings, onSave, loadi
+ +
+
+ +
+
+ {formData.autoRechargeEnabled && ( +
+
+ + + setFormData(prev => ({ ...prev, rechargeAmountCHF: Number(e.target.value) })) + } + min="0.01" + step="0.01" + /> +
+
+ + + setFormData(prev => ({ ...prev, rechargeMaxPerMonth: Math.max(0, Math.floor(Number(e.target.value))) })) + } + min="0" + step="1" + /> +
+
+ )} + ) : ( +

{balance.mandateName}

+ )}
{_formatCurrency(balance.balance)} @@ -267,7 +288,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { export const BillingDataView: React.FC = () => { const [activeTab, setActiveTab] = useState('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('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([]); - const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); - // Statistics state (shared by Overview and Statistics tabs) const [viewStats, setViewStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); @@ -349,19 +371,6 @@ export const BillingDataView: React.FC = () => { const [transactionsError, setTransactionsError] = useState(null); const [transactionsPagination, setTransactionsPagination] = useState(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 = () => { ))}
)} - {/* All User Balance Cards (mandate/all scope) */} - {filteredUserBalances.length > 0 && ( -
-

Benutzer-Guthaben

- {allUserBalancesLoading ? ( -
Lade Benutzer-Guthaben...
- ) : ( -
- {filteredUserBalances.map((ub, idx) => ( -
-
-

{ub.userName || ub.userId?.slice(0, 8)}

- {ub.mandateName} -
-
- {_formatCurrency(ub.balance || 0)} -
-
- ))} -
- )} -
- )} - {/* Usage Statistics via FormGeneratorReport */}
= { 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 = ({ plan, isCurrent, onActivate, activa
User: {_formatCurrency(plan.pricePerUserCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
Instanz: {_formatCurrency(plan.pricePerFeatureInstanceCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
+
+ AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode + {' · '} + Speicher (inkl.):{' '} + + {plan.maxDataVolumeMB == null + ? 'unbegrenzt' + : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} + +
+
+ Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat +
)} @@ -106,6 +123,14 @@ const PlanCard: React.FC = ({ 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)}} + + )} )} @@ -214,6 +239,20 @@ const SubInfoCard: React.FC = ({ sub, plan, label, onCancel, onRea {isActive && !sub.recurring && sub.currentPeriodEnd && ( Läuft aus am: {_formatDate(sub.currentPeriodEnd)} )} + {plan && ( + <> + AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode + + Speicher (inkl.):{' '} + {plan.maxDataVolumeMB == null + ? 'unbegrenzt' + : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} + + + Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark) + + + )} )} @@ -358,7 +397,7 @@ export const SubscriptionTab: React.FC = ({ 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', }, ); diff --git a/src/pages/views/commcoach/useVoiceController.ts b/src/pages/views/commcoach/useVoiceController.ts index c030e9c..5e9e8c8 100644 --- a/src/pages/views/commcoach/useVoiceController.ts +++ b/src/pages/views/commcoach/useVoiceController.ts @@ -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; diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx index 07494d3..a66bc3b 100644 --- a/src/pages/views/workspace/ChatStream.tsx +++ b/src/pages/views/workspace/ChatStream.tsx @@ -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 (
= ({ instanceId, fileId, fi
{file.mimeType} - {_formatFileSize(file.fileSize)} + {formatBinaryDataSizeBytes(file.fileSize)} {file.status && {file.status}}
{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`; -} diff --git a/src/pages/views/workspace/WorkspaceEditorPage.tsx b/src/pages/views/workspace/WorkspaceEditorPage.tsx index 8cb7b5d..7629520 100644 --- a/src/pages/views/workspace/WorkspaceEditorPage.tsx +++ b/src/pages/views/workspace/WorkspaceEditorPage.tsx @@ -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 = () => { }}>
{activeEdit.fileName} - Original: {_formatBytes(activeEdit.oldContent.length)} - Geaendert: {_formatBytes(activeEdit.newContent.length)} + Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)} + Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}
-

{_formatBytes(kpis.indexedBytesTotal)}

+

{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}

Indexiertes Datenvolumen (geschätzt)

diff --git a/src/utils/formatDataSize.ts b/src/utils/formatDataSize.ts new file mode 100644 index 0000000..f0f6668 --- /dev/null +++ b/src/utils/formatDataSize.ts @@ -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); +}