streamlined billing incl ai and storage budget
This commit is contained in:
parent
d5bb102684
commit
0f0f43ce1b
20 changed files with 271 additions and 141 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export interface SubscriptionInfo {
|
|||
status: string | null;
|
||||
maxDataVolumeMB: number | null;
|
||||
maxFeatureInstances: number | null;
|
||||
budgetAiCHF: number | null;
|
||||
currentFeatureInstances: number;
|
||||
trialEndsAt: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
43
src/utils/formatDataSize.ts
Normal file
43
src/utils/formatDataSize.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue