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 TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
||||||
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
|
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE';
|
||||||
|
|
||||||
export interface BillingBalance {
|
export interface BillingBalance {
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
|
@ -42,12 +42,18 @@ export interface BillingSettings {
|
||||||
warningThresholdPercent: number;
|
warningThresholdPercent: number;
|
||||||
notifyOnWarning: boolean;
|
notifyOnWarning: boolean;
|
||||||
notifyEmails: string[];
|
notifyEmails: string[];
|
||||||
|
autoRechargeEnabled?: boolean;
|
||||||
|
rechargeAmountCHF?: number;
|
||||||
|
rechargeMaxPerMonth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BillingSettingsUpdate {
|
export interface BillingSettingsUpdate {
|
||||||
warningThresholdPercent?: number;
|
warningThresholdPercent?: number;
|
||||||
notifyOnWarning?: boolean;
|
notifyOnWarning?: boolean;
|
||||||
notifyEmails?: string[];
|
notifyEmails?: string[];
|
||||||
|
autoRechargeEnabled?: boolean;
|
||||||
|
rechargeAmountCHF?: number;
|
||||||
|
rechargeMaxPerMonth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageReport {
|
export interface UsageReport {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export interface SubscriptionInfo {
|
||||||
status: string | null;
|
status: string | null;
|
||||||
maxDataVolumeMB: number | null;
|
maxDataVolumeMB: number | null;
|
||||||
maxFeatureInstances: number | null;
|
maxFeatureInstances: number | null;
|
||||||
|
budgetAiCHF: number | null;
|
||||||
currentFeatureInstances: number;
|
currentFeatureInstances: number;
|
||||||
trialEndsAt: string | null;
|
trialEndsAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export interface SubscriptionPlan {
|
||||||
autoRenew: boolean;
|
autoRenew: boolean;
|
||||||
maxUsers: number | null;
|
maxUsers: number | null;
|
||||||
maxFeatureInstances: number | null;
|
maxFeatureInstances: number | null;
|
||||||
|
maxDataVolumeMB?: number | null;
|
||||||
|
budgetAiCHF?: number;
|
||||||
trialDays: number | null;
|
trialDays: number | null;
|
||||||
successorPlanKey: string | null;
|
successorPlanKey: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,11 @@ const typeIcons: Record<string, React.ReactNode> = {
|
||||||
mention: <FaExclamationTriangle />
|
mention: <FaExclamationTriangle />
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format timestamp to relative time
|
// Format timestamp to relative time (Unix seconds)
|
||||||
function formatRelativeTime(timestamp: number): string {
|
function formatRelativeTime(timestamp: number): string {
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
const diff = now - timestamp;
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
|
@ -29,6 +32,9 @@ function formatRelativeTime(timestamp: number): string {
|
||||||
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
|
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
|
||||||
|
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
return date.toLocaleDateString('de-DE');
|
return date.toLocaleDateString('de-DE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
|
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
|
||||||
import { WorkflowFile } from '../../../hooks/usePlayground';
|
import { WorkflowFile } from '../../../hooks/usePlayground';
|
||||||
import styles from './ConnectedFilesList.module.css';
|
import styles from './ConnectedFilesList.module.css';
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
|
|
||||||
export interface ConnectedFilesListActionButton {
|
export interface ConnectedFilesListActionButton {
|
||||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
|
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
|
||||||
|
|
@ -240,7 +241,7 @@ export function ConnectedFilesList({
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.fileMeta}>
|
<div className={styles.fileMeta}>
|
||||||
<span className={styles.fileSize}>
|
<span className={styles.fileSize}>
|
||||||
{formatFileSize(file.fileSize)}
|
{formatBinaryDataSizeBytes(file.fileSize)}
|
||||||
</span>
|
</span>
|
||||||
{file.source && (
|
{file.source && (
|
||||||
<span className={styles.fileSource}>
|
<span className={styles.fileSource}>
|
||||||
|
|
@ -371,13 +372,5 @@ export function ConnectedFilesList({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ConnectedFilesList;
|
export default ConnectedFilesList;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* Utility functions for message formatting and styling
|
* Utility functions for message formatting and styling
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a timestamp to a readable date/time string
|
* Formats a timestamp to a readable date/time string
|
||||||
* Handles both Unix timestamps in seconds and milliseconds
|
* Handles both Unix timestamps in seconds and milliseconds
|
||||||
|
|
@ -50,16 +52,8 @@ export const formatTimestamp = (timestamp?: number): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** File / attachment sizes (bytes). Same as formatBinaryDataSizeBytes (B … TB). */
|
||||||
* Formats file size to human-readable format
|
export const formatFileSize = formatBinaryDataSizeBytes;
|
||||||
*/
|
|
||||||
export const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets status badge color class based on status
|
* Gets status badge color class based on status
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export function useConfirm() {
|
||||||
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
||||||
border: '1px solid var(--color-border, #444)',
|
border: '1px solid var(--color-border, #444)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'var(--text-secondary, #aaa)',
|
color: 'var(--text-primary, #e8e8e8)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,48 @@ import api from '../api';
|
||||||
|
|
||||||
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
|
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
|
||||||
|
|
||||||
|
/** API uses PowerOnModel.sysCreatedAt (seconds); legacy clients used createdAt. */
|
||||||
|
function _coerceToUnixSeconds(value: unknown): number | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value > 1e12 ? value / 1000 : value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const asNum = Number(trimmed);
|
||||||
|
if (!Number.isNaN(asNum)) {
|
||||||
|
return asNum > 1e12 ? asNum / 1000 : asNum;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(trimmed);
|
||||||
|
if (!Number.isNaN(parsed)) return parsed / 1000;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeNotificationFromApi(raw: Record<string, unknown>): UserNotification {
|
||||||
|
const partial = raw as unknown as UserNotification;
|
||||||
|
const createdAt =
|
||||||
|
_coerceToUnixSeconds(raw.createdAt) ??
|
||||||
|
_coerceToUnixSeconds(raw.sysCreatedAt) ??
|
||||||
|
(Number.isFinite(partial.createdAt) ? partial.createdAt : 0) ??
|
||||||
|
0;
|
||||||
|
return {
|
||||||
|
...partial,
|
||||||
|
createdAt,
|
||||||
|
readAt: _coerceToUnixSeconds(raw.readAt) ?? partial.readAt,
|
||||||
|
actionedAt: _coerceToUnixSeconds(raw.actionedAt) ?? partial.actionedAt,
|
||||||
|
expiresAt: _coerceToUnixSeconds(raw.expiresAt) ?? partial.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeNotificationList(data: unknown): UserNotification[] {
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
return data.map(item =>
|
||||||
|
_normalizeNotificationFromApi(item && typeof item === 'object' ? (item as Record<string, unknown>) : {})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface NotificationAction {
|
export interface NotificationAction {
|
||||||
actionId: string;
|
actionId: string;
|
||||||
|
|
@ -30,6 +72,7 @@ export interface UserNotification {
|
||||||
actions?: NotificationAction[];
|
actions?: NotificationAction[];
|
||||||
actionTaken?: string;
|
actionTaken?: string;
|
||||||
actionResult?: string;
|
actionResult?: string;
|
||||||
|
/** Creation time as Unix seconds (UTC); filled from API sysCreatedAt when needed */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
readAt?: number;
|
readAt?: number;
|
||||||
actionedAt?: number;
|
actionedAt?: number;
|
||||||
|
|
@ -74,7 +117,7 @@ export function useNotifications() {
|
||||||
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
|
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
const response = await api.get(url);
|
const response = await api.get(url);
|
||||||
const data = response.data as UserNotification[];
|
const data = _normalizeNotificationList(response.data);
|
||||||
setNotifications(data);
|
setNotifications(data);
|
||||||
return data;
|
return data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -101,9 +144,9 @@ export function useNotifications() {
|
||||||
const listRes = await api.get('/api/notifications', {
|
const listRes = await api.get('/api/notifications', {
|
||||||
params: { status: 'unread', limit: 25 },
|
params: { status: 'unread', limit: 25 },
|
||||||
});
|
});
|
||||||
const list = listRes.data as UserNotification[];
|
const list = _normalizeNotificationList(listRes.data);
|
||||||
if (
|
if (
|
||||||
Array.isArray(list) &&
|
list.length > 0 &&
|
||||||
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
|
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
|
||||||
) {
|
) {
|
||||||
window.dispatchEvent(new Event('features-changed'));
|
window.dispatchEvent(new Event('features-changed'));
|
||||||
|
|
|
||||||
|
|
@ -107,8 +107,8 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [prefsData, languagesData] = await Promise.all([
|
const [prefsData, languagesData] = await Promise.all([
|
||||||
request({ url: '/api/local/voice-preferences', method: 'get' }),
|
request({ url: '/api/voice/preferences', method: 'get' }),
|
||||||
request({ url: '/api/local/voice/languages', method: 'get' }),
|
request({ url: '/api/voice/languages', method: 'get' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const langList = (languagesData as any)?.languages || [];
|
const langList = (languagesData as any)?.languages || [];
|
||||||
|
|
@ -135,7 +135,7 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
|
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
|
||||||
setLoadingVoices(true);
|
setLoadingVoices(true);
|
||||||
try {
|
try {
|
||||||
const result = await request({ url: '/api/local/voice/voices', method: 'get', params: { language: lang } });
|
const result = await request({ url: '/api/voice/voices', method: 'get', params: { language: lang } });
|
||||||
setAddVoices((result as any)?.voices || []);
|
setAddVoices((result as any)?.voices || []);
|
||||||
setAddVoiceName('');
|
setAddVoiceName('');
|
||||||
} catch { setAddVoices([]); }
|
} catch { setAddVoices([]); }
|
||||||
|
|
@ -167,7 +167,7 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
const mapObj: Record<string, any> = {};
|
const mapObj: Record<string, any> = {};
|
||||||
voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; });
|
voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; });
|
||||||
await request({
|
await request({
|
||||||
url: '/api/local/voice-preferences',
|
url: '/api/voice/preferences',
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
|
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
|
||||||
});
|
});
|
||||||
|
|
@ -185,9 +185,9 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
setTesting(lang);
|
setTesting(lang);
|
||||||
try {
|
try {
|
||||||
const result: any = await request({
|
const result: any = await request({
|
||||||
url: '/api/local/voice/test',
|
url: '/api/voice/test',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` },
|
data: { language: lang, voiceId: voice || undefined },
|
||||||
});
|
});
|
||||||
if (result?.success && result?.audio) {
|
if (result?.success && result?.audio) {
|
||||||
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
|
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { useStore } from '../hooks/useStore';
|
||||||
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
||||||
|
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
|
||||||
import styles from './Store.module.css';
|
import styles from './Store.module.css';
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
|
@ -176,7 +177,13 @@ const StorePage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
{subscriptionInfo.maxDataVolumeMB != null && (
|
{subscriptionInfo.maxDataVolumeMB != null && (
|
||||||
<span className={styles.bannerSeparator}>
|
<span className={styles.bannerSeparator}>
|
||||||
{currentLanguage === 'de' ? 'Speicher' : 'Storage'}: {subscriptionInfo.maxDataVolumeMB} MB
|
{currentLanguage === 'de' ? 'Speicher' : 'Storage'}:{' '}
|
||||||
|
{formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subscriptionInfo.budgetAiCHF != null && subscriptionInfo.budgetAiCHF > 0 && (
|
||||||
|
<span className={styles.bannerSeparator}>
|
||||||
|
{currentLanguage === 'de' ? 'AI-Budget' : 'AI budget'}: {subscriptionInfo.budgetAiCHF} CHF
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
|
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
|
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
|
||||||
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
||||||
|
autoRechargeEnabled: settings?.autoRechargeEnabled ?? false,
|
||||||
|
rechargeAmountCHF: Number(settings?.rechargeAmountCHF ?? 10),
|
||||||
|
rechargeMaxPerMonth: Number(settings?.rechargeMaxPerMonth ?? 3),
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
@ -96,6 +99,9 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
setFormData({
|
setFormData({
|
||||||
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
||||||
notifyOnWarning: settings.notifyOnWarning ?? true,
|
notifyOnWarning: settings.notifyOnWarning ?? true,
|
||||||
|
autoRechargeEnabled: settings.autoRechargeEnabled ?? false,
|
||||||
|
rechargeAmountCHF: Number(settings.rechargeAmountCHF ?? 10),
|
||||||
|
rechargeMaxPerMonth: Number(settings.rechargeMaxPerMonth ?? 3),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
@ -155,6 +161,49 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup} style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.autoRechargeEnabled}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, autoRechargeEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Auto-Nachladung (AI-Guthaben bei niedrigem Stand)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{formData.autoRechargeEnabled && (
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>Betrag pro Nachladung (CHF)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.rechargeAmountCHF}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData(prev => ({ ...prev, rechargeAmountCHF: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>Max. Nachladungen / Monat</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.rechargeMaxPerMonth}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData(prev => ({ ...prev, rechargeMaxPerMonth: Math.max(0, Math.floor(Number(e.target.value))) }))
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
|
@ -260,11 +309,12 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
||||||
|
|
||||||
interface AccountsOverviewProps {
|
interface AccountsOverviewProps {
|
||||||
accounts: AccountSummary[];
|
accounts: AccountSummary[];
|
||||||
users: MandateUserSummary[];
|
/** Kept for call-site compatibility; only mandate pool accounts are shown. */
|
||||||
|
users?: MandateUserSummary[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, loading }) => {
|
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }) => {
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat('de-CH', {
|
return new Intl.NumberFormat('de-CH', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
|
|
@ -272,18 +322,7 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a lookup map: userId -> display name
|
const poolAccounts = useMemo(() => accounts.filter((a) => !a.userId), [accounts]);
|
||||||
const _userNameMap = useMemo(() => {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
for (const user of users) {
|
|
||||||
const displayName = user.displayName
|
|
||||||
|| [user.firstName, user.lastName].filter(Boolean).join(' ')
|
|
||||||
|| user.username
|
|
||||||
|| user.id;
|
|
||||||
map.set(user.id, displayName);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [users]);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
|
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
|
||||||
|
|
@ -293,15 +332,18 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
||||||
return <div className={styles.noData}>Keine Konten vorhanden</div>;
|
return <div className={styles.noData}>Keine Konten vorhanden</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (poolAccounts.length === 0) {
|
||||||
|
return <div className={styles.noData}>Kein Mandanten-Konto vorhanden</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminSection}>
|
<div className={styles.adminSection}>
|
||||||
<h3>Konten</h3>
|
<h3>Konten</h3>
|
||||||
<div className={styles.accountsGrid}>
|
<div className={styles.accountsGrid}>
|
||||||
{accounts.map((account) => (
|
{poolAccounts.map((account) => (
|
||||||
<div key={account.id} className={styles.accountCard}>
|
<div key={account.id} className={styles.accountCard}>
|
||||||
<h4>{!account.userId ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
|
<h4>Mandanten-Konto</h4>
|
||||||
<div className={styles.accountInfo}>
|
<div className={styles.accountInfo}>
|
||||||
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
|
|
||||||
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
||||||
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
|
|
@ -48,13 +48,34 @@ interface ViewStatistics {
|
||||||
|
|
||||||
interface BalanceCardProps {
|
interface BalanceCardProps {
|
||||||
balance: BillingBalance;
|
balance: BillingBalance;
|
||||||
|
onOpenMandateAdmin?: (mandateId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
|
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onOpenMandateAdmin }) => {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||||
<div className={styles.balanceHeader}>
|
<div className={styles.balanceHeader}>
|
||||||
|
{onOpenMandateAdmin ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.mandateName}
|
||||||
|
onClick={() => onOpenMandateAdmin(balance.mandateId)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
font: 'inherit',
|
||||||
|
color: 'inherit',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{balance.mandateName}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.balanceAmount}>
|
<div className={styles.balanceAmount}>
|
||||||
{_formatCurrency(balance.balance)}
|
{_formatCurrency(balance.balance)}
|
||||||
|
|
@ -267,8 +288,13 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
|
||||||
export const BillingDataView: React.FC = () => {
|
export const BillingDataView: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const _openMandateBillingAdmin = useCallback((mandateId: string) => {
|
||||||
|
navigate(`/admin/billing?mandate=${encodeURIComponent(mandateId)}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// Scope filter: 'personal' | 'all' | mandateId
|
// Scope filter: 'personal' | 'all' | mandateId
|
||||||
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
||||||
|
|
||||||
|
|
@ -335,10 +361,6 @@ export const BillingDataView: React.FC = () => {
|
||||||
setCheckoutMessage(null);
|
setCheckoutMessage(null);
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
// All user balances (for admin overview cards)
|
|
||||||
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
|
|
||||||
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
|
|
||||||
|
|
||||||
// Statistics state (shared by Overview and Statistics tabs)
|
// Statistics state (shared by Overview and Statistics tabs)
|
||||||
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
|
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
|
@ -349,19 +371,6 @@ export const BillingDataView: React.FC = () => {
|
||||||
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||||
|
|
||||||
// Load all user balances for admin overview
|
|
||||||
const _loadAllUserBalances = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setAllUserBalancesLoading(true);
|
|
||||||
const response = await api.get('/api/billing/view/users/balances');
|
|
||||||
setAllUserBalances(Array.isArray(response.data) ? response.data : []);
|
|
||||||
} catch {
|
|
||||||
setAllUserBalances([]);
|
|
||||||
} finally {
|
|
||||||
setAllUserBalancesLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load aggregated statistics from the view/statistics route
|
// Load aggregated statistics from the view/statistics route
|
||||||
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
|
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -402,10 +411,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
if (activeTab === 'overview' || activeTab === 'statistics') {
|
if (activeTab === 'overview' || activeTab === 'statistics') {
|
||||||
_loadViewStatistics('month', new Date().getFullYear());
|
_loadViewStatistics('month', new Date().getFullYear());
|
||||||
}
|
}
|
||||||
if (activeTab === 'overview') {
|
}, [activeTab, _loadViewStatistics, selectedScope]);
|
||||||
_loadAllUserBalances();
|
|
||||||
}
|
|
||||||
}, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
|
|
||||||
|
|
||||||
// Load transactions with pagination support
|
// Load transactions with pagination support
|
||||||
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
||||||
|
|
@ -556,12 +562,6 @@ export const BillingDataView: React.FC = () => {
|
||||||
? balances
|
? balances
|
||||||
: balances.filter(b => b.mandateId === selectedScope);
|
: balances.filter(b => b.mandateId === selectedScope);
|
||||||
|
|
||||||
const filteredUserBalances = selectedScope === 'personal'
|
|
||||||
? [] // personal view: only own balance cards, no other users
|
|
||||||
: selectedScope === 'all'
|
|
||||||
? allUserBalances
|
|
||||||
: allUserBalances.filter(ub => ub.mandateId === selectedScope);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Balance Cards - own balances */}
|
{/* Balance Cards - own balances */}
|
||||||
|
|
@ -577,36 +577,13 @@ export const BillingDataView: React.FC = () => {
|
||||||
<BalanceCard
|
<BalanceCard
|
||||||
key={balance.mandateId}
|
key={balance.mandateId}
|
||||||
balance={balance}
|
balance={balance}
|
||||||
|
onOpenMandateAdmin={_openMandateBillingAdmin}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* All User Balance Cards (mandate/all scope) */}
|
|
||||||
{filteredUserBalances.length > 0 && (
|
|
||||||
<section className={styles.section}>
|
|
||||||
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
|
|
||||||
{allUserBalancesLoading ? (
|
|
||||||
<div className={styles.loadingPlaceholder}>Lade Benutzer-Guthaben...</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.balanceGrid}>
|
|
||||||
{filteredUserBalances.map((ub, idx) => (
|
|
||||||
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}>
|
|
||||||
<div className={styles.balanceHeader}>
|
|
||||||
<h3 className={styles.mandateName}>{ub.userName || ub.userId?.slice(0, 8)}</h3>
|
|
||||||
<span className={styles.mandateSubtitle}>{ub.mandateName}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.balanceAmount}>
|
|
||||||
{_formatCurrency(ub.balance || 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Usage Statistics via FormGeneratorReport */}
|
{/* Usage Statistics via FormGeneratorReport */}
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<FormGeneratorReport
|
<FormGeneratorReport
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useSubscription } from '../../hooks/useSubscription';
|
import { useSubscription } from '../../hooks/useSubscription';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
|
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
|
||||||
|
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -54,6 +55,9 @@ const _periodLabel: Record<string, string> = {
|
||||||
NONE: '—',
|
NONE: '—',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
|
||||||
|
const storageOverageChfPerGbMonth = 0.5;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Plan Card
|
// Plan Card
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -98,6 +102,19 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
||||||
<div style={{ fontSize: '0.85rem' }}>
|
<div style={{ fontSize: '0.85rem' }}>
|
||||||
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
|
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary, #888)' }}>
|
||||||
|
AI-Budget (inkl.): <strong>{_formatCurrency(plan.budgetAiCHF ?? 0)}</strong> / Periode
|
||||||
|
{' · '}
|
||||||
|
Speicher (inkl.):{' '}
|
||||||
|
<strong>
|
||||||
|
{plan.maxDataVolumeMB == null
|
||||||
|
? 'unbegrenzt'
|
||||||
|
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: '0.25rem' }}>
|
||||||
|
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -106,6 +123,14 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
||||||
{plan.trialDays} Tage kostenlos
|
{plan.trialDays} Tage kostenlos
|
||||||
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
||||||
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
||||||
|
{(plan.maxDataVolumeMB != null || (plan.budgetAiCHF ?? 0) > 0) && (
|
||||||
|
<>
|
||||||
|
{plan.maxDataVolumeMB != null && (
|
||||||
|
<> · Speicher inkl. {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}</>
|
||||||
|
)}
|
||||||
|
{(plan.budgetAiCHF ?? 0) > 0 && <> · AI-Budget { _formatCurrency(plan.budgetAiCHF ?? 0)}</>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -214,6 +239,20 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
|
||||||
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
||||||
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
||||||
)}
|
)}
|
||||||
|
{plan && (
|
||||||
|
<>
|
||||||
|
<span>AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode</span>
|
||||||
|
<span>
|
||||||
|
Speicher (inkl.):{' '}
|
||||||
|
{plan.maxDataVolumeMB == null
|
||||||
|
? 'unbegrenzt'
|
||||||
|
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
||||||
|
</span>
|
||||||
|
<span style={{ gridColumn: '1 / -1' }}>
|
||||||
|
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -358,7 +397,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
|
||||||
{
|
{
|
||||||
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
|
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
|
||||||
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
|
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
|
||||||
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined,
|
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen',
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*
|
*
|
||||||
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
|
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
|
||||||
* Google Streaming STT handles silence detection natively.
|
* Google Streaming STT handles silence detection natively.
|
||||||
* STT language is loaded from central voice preferences (/api/local/voice-preferences).
|
* STT language is loaded from central voice preferences (/api/voice/preferences).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
@ -46,7 +46,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.get('/api/local/voice-preferences').then((res) => {
|
api.get('/api/voice/preferences').then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
|
const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
|
||||||
if (lang) sttLanguageRef.current = lang;
|
if (lang) sttLanguageRef.current = lang;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||||
import type { AgentProgress, FileEditProposal } from './useWorkspace';
|
import type { AgentProgress, FileEditProposal } from './useWorkspace';
|
||||||
|
|
||||||
|
|
@ -339,11 +340,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) {
|
||||||
|
|
||||||
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
|
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
|
||||||
const icon = _getFileIcon(ext);
|
const icon = _getFileIcon(ext);
|
||||||
const sizeLabel = doc.fileSize
|
const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : '';
|
||||||
? doc.fileSize > 1024 * 1024
|
|
||||||
? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
|
|
||||||
: `${(doc.fileSize / 1024).toFixed(1)} KB`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
import type { WorkspaceFile } from './useWorkspace';
|
import type { WorkspaceFile } from './useWorkspace';
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
|
|
@ -66,7 +67,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, fi
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
|
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
|
||||||
<span>{file.mimeType}</span>
|
<span>{file.mimeType}</span>
|
||||||
<span>{_formatFileSize(file.fileSize)}</span>
|
<span>{formatBinaryDataSizeBytes(file.fileSize)}</span>
|
||||||
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
|
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
|
||||||
</div>
|
</div>
|
||||||
{file.description && (
|
{file.description && (
|
||||||
|
|
@ -146,8 +147,3 @@ function _isTextMime(mime: string): boolean {
|
||||||
return textTypes.includes(mime);
|
return textTypes.includes(mime);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _formatFileSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type { editor as monacoEditor } from 'monaco-editor';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
|
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
|
||||||
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
|
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
|
|
||||||
function _getMonacoLanguage(fileName: string): string {
|
function _getMonacoLanguage(fileName: string): string {
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
@ -27,12 +28,6 @@ function _getMonacoLanguage(fileName: string): string {
|
||||||
return langMap[ext] || 'plaintext';
|
return langMap[ext] || 'plaintext';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WorkspaceEditorPage: React.FC = () => {
|
export const WorkspaceEditorPage: React.FC = () => {
|
||||||
const instanceId = useInstanceId() || '';
|
const instanceId = useInstanceId() || '';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -155,8 +150,8 @@ export const WorkspaceEditorPage: React.FC = () => {
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
|
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
|
||||||
<span>{activeEdit.fileName}</span>
|
<span>{activeEdit.fileName}</span>
|
||||||
<span>Original: {_formatBytes(activeEdit.oldContent.length)}</span>
|
<span>Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)}</span>
|
||||||
<span>Geaendert: {_formatBytes(activeEdit.newContent.length)}</span>
|
<span>Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (_sttPrefsLoaded.current) return;
|
if (_sttPrefsLoaded.current) return;
|
||||||
_sttPrefsLoaded.current = true;
|
_sttPrefsLoaded.current = true;
|
||||||
fetch('/api/local/voice-preferences', { credentials: 'include' })
|
fetch('/api/voice/preferences', { credentials: 'include' })
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
.then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); })
|
.then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
@ -702,7 +702,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVoiceLanguage(lang.code);
|
setVoiceLanguage(lang.code);
|
||||||
setShowLangPicker(false);
|
setShowLangPicker(false);
|
||||||
fetch('/api/local/voice-preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {});
|
fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {});
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import styles from './WorkspaceRagInsightsPage.module.css';
|
import styles from './WorkspaceRagInsightsPage.module.css';
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
|
|
||||||
const MIME_LABELS: Record<string, string> = {
|
const MIME_LABELS: Record<string, string> = {
|
||||||
pdf: 'PDF',
|
pdf: 'PDF',
|
||||||
|
|
@ -35,18 +36,6 @@ const MIME_LABELS: Record<string, string> = {
|
||||||
|
|
||||||
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
|
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
|
||||||
|
|
||||||
function _formatBytes(n: number): string {
|
|
||||||
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let v = n;
|
|
||||||
let i = 0;
|
|
||||||
while (v >= 1024 && i < units.length - 1) {
|
|
||||||
v /= 1024;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RagKpis {
|
interface RagKpis {
|
||||||
indexedDocuments: number;
|
indexedDocuments: number;
|
||||||
indexedBytesTotal: number;
|
indexedBytesTotal: number;
|
||||||
|
|
@ -161,7 +150,7 @@ export const WorkspaceRagInsightsPage: React.FC = () => {
|
||||||
<p className={styles.kpiLabel}>Indexierte Dokumente</p>
|
<p className={styles.kpiLabel}>Indexierte Dokumente</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.kpiCard}>
|
<div className={styles.kpiCard}>
|
||||||
<p className={styles.kpiValue}>{_formatBytes(kpis.indexedBytesTotal)}</p>
|
<p className={styles.kpiValue}>{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}</p>
|
||||||
<p className={styles.kpiLabel}>Indexiertes Datenvolumen (geschätzt)</p>
|
<p className={styles.kpiLabel}>Indexiertes Datenvolumen (geschätzt)</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.kpiCard}>
|
<div className={styles.kpiCard}>
|
||||||
|
|
|
||||||
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