cleaned mandate and unified mandate to be standard type

This commit is contained in:
ValueOn AG 2026-03-28 23:54:17 +01:00
parent e9f7b2016f
commit d5bb102684
19 changed files with 435 additions and 364 deletions

View file

@ -4,14 +4,12 @@ import { ApiRequestOptions } from '../hooks/useApi';
// TYPES & INTERFACES
// ============================================================================
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER';
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
export interface BillingBalance {
mandateId: string;
mandateName: string;
billingModel: BillingModel;
balance: number;
currency: string;
warningThreshold: number;
@ -41,16 +39,12 @@ export interface BillingTransaction {
export interface BillingSettings {
id: string;
mandateId: string;
billingModel: BillingModel;
defaultUserCredit: number;
warningThresholdPercent: number;
notifyOnWarning: boolean;
notifyEmails: string[];
}
export interface BillingSettingsUpdate {
billingModel?: BillingModel;
defaultUserCredit?: number;
warningThresholdPercent?: number;
notifyOnWarning?: boolean;
notifyEmails?: string[];
@ -69,7 +63,6 @@ export interface AccountSummary {
id: string;
mandateId: string;
userId?: string;
accountType: string;
balance: number;
warningThreshold: number;
enabled: boolean;
@ -305,10 +298,8 @@ export async function fetchUsersForMandateAdmin(
export interface MandateBalance {
mandateId: string;
mandateName: string;
billingModel: BillingModel;
totalBalance: number;
userCount: number;
defaultUserCredit: number;
warningThresholdPercent: number;
}

View file

@ -42,7 +42,6 @@ export interface UserMandate {
id: string;
name: string;
label: string;
mandateType: string;
}
export interface SubscriptionInfo {

View file

@ -33,6 +33,8 @@ import type {
import { getPageIcon } from '../../config/pageRegistry';
import { FaSpinner, FaPen } from 'react-icons/fa';
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
import { usePrompt } from '../../hooks/usePrompt';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './MandateNavigation.module.css';
@ -192,14 +194,19 @@ const EmptyState: React.FC = () => (
export const MandateNavigation: React.FC = () => {
const { blocks, loading, refresh } = useNavigation('de');
const { prompt, PromptDialog } = usePrompt();
const { showWarning } = useToast();
const _handleRename = useCallback((instanceId: string, currentLabel: string) => {
const newLabel = window.prompt('Neuer Name:', currentLabel);
const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => {
const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel });
if (!newLabel || newLabel.trim() === currentLabel) return;
api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() })
.then(() => refresh())
.catch((err: any) => alert('Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message)));
}, [refresh]);
try {
await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() });
refresh();
} catch (err: any) {
showWarning('Fehler', 'Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message));
}
}, [refresh, prompt, showWarning]);
const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = [];
@ -280,6 +287,7 @@ export const MandateNavigation: React.FC = () => {
) : (
<EmptyState />
)}
<PromptDialog />
</div>
);
};

View file

@ -7,7 +7,7 @@ interface OnboardingWizardProps {
}
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismiss }) => {
const [mandateType, setMandateType] = useState<'personal' | 'company'>('personal');
const [planKey, setPlanKey] = useState<'TRIAL_7D' | 'STANDARD_MONTHLY'>('TRIAL_7D');
const [companyName, setCompanyName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -17,8 +17,8 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
setError(null);
try {
await api.post('/api/local/onboarding', {
mandateType,
companyName: mandateType === 'company' ? companyName : undefined,
planKey,
companyName: companyName.trim() || undefined,
});
onComplete();
} catch (err: any) {
@ -40,58 +40,56 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
}}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Willkommen bei PowerOn</h2>
<p style={{ color: 'var(--text-secondary, #666)', margin: '0 0 24px' }}>
Wie möchtest du PowerOn nutzen?
Wähle dein Abo und leg los.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
<label style={{
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
border: mandateType === 'personal' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
border: planKey === 'TRIAL_7D' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
borderRadius: '8px', cursor: 'pointer',
}}>
<input type="radio" name="type" checked={mandateType === 'personal'}
onChange={() => setMandateType('personal')} />
<input type="radio" name="plan" checked={planKey === 'TRIAL_7D'}
onChange={() => setPlanKey('TRIAL_7D')} />
<div>
<strong>Persönlich</strong>
<strong>Kostenlos testen</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
7 Tage kostenlos testen, danach flexibel upgraden
7 Tage gratis, danach flexibel upgraden
</div>
</div>
</label>
<label style={{
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
border: mandateType === 'company' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
border: planKey === 'STANDARD_MONTHLY' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
borderRadius: '8px', cursor: 'pointer',
}}>
<input type="radio" name="type" checked={mandateType === 'company'}
onChange={() => setMandateType('company')} />
<input type="radio" name="plan" checked={planKey === 'STANDARD_MONTHLY'}
onChange={() => setPlanKey('STANDARD_MONTHLY')} />
<div>
<strong>Unternehmen</strong>
<strong>Standard (Monatlich)</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
Team-Workspace mit Mandanten-Verwaltung
Team-Workspace mit vollem Funktionsumfang
</div>
</div>
</label>
</div>
{mandateType === 'company' && (
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500 }}>
Firmenname
</label>
<input
type="text" value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Name des Unternehmens"
style={{
width: '100%', padding: '10px 12px', borderRadius: '6px',
border: '1px solid var(--border, #d1d5db)', fontSize: '1rem',
boxSizing: 'border-box',
}}
/>
</div>
)}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500 }}>
Name des Mandanten <span style={{ fontWeight: 400, color: 'var(--text-secondary, #666)' }}>(optional)</span>
</label>
<input
type="text" value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="z. B. Firmenname oder Projektname"
style={{
width: '100%', padding: '10px 12px', borderRadius: '6px',
border: '1px solid var(--border, #d1d5db)', fontSize: '1rem',
boxSizing: 'border-box',
}}
/>
</div>
{error && <p style={{ color: '#ef4444', margin: '0 0 16px' }}>{error}</p>}
@ -102,7 +100,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
}}>
Später
</button>
<button onClick={_handleSubmit} disabled={loading || (mandateType === 'company' && !companyName.trim())}
<button onClick={_handleSubmit} disabled={loading}
style={{
padding: '10px 20px', borderRadius: '6px', border: 'none',
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',

View file

@ -193,7 +193,36 @@
color: var(--text-primary, #111);
}
/* ── Tree groups ── */
/* ── Tree sections (feature code level) ── */
.treeSection {
margin-bottom: 4px;
}
.treeSectionHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-secondary, #6b7280);
}
.treeSectionHeader:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
color: var(--text-primary, #111);
}
.treeSectionLabel {
flex: 1;
}
/* ── Tree groups (feature instance level) ── */
.treeGroup {
margin-bottom: 2px;
@ -247,9 +276,16 @@
color: #f3f4f6;
}
.chatItem:hover,
.treeGroupHeader:hover {
.treeGroupHeader:hover,
.treeSectionHeader:hover {
background: rgba(255, 255, 255, 0.05);
}
.treeSectionHeader {
color: #9ca3af;
}
.treeSectionHeader:hover {
color: #f3f4f6;
}
.chatItemActive,
.chatItemActive:hover {
background: rgba(79, 70, 229, 0.15);

View file

@ -7,6 +7,7 @@ interface ChatItem {
id: string;
label: string;
updatedAt?: string | number;
lastMessageAt?: string | number;
featureInstanceId?: string;
featureCode?: string;
status?: string;
@ -68,12 +69,14 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
const [editName, setEditName] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const _loadChats = useCallback(async () => {
const _loadChats = useCallback(async (serverSearch?: string) => {
setLoading(true);
try {
const params: Record<string, unknown> = { includeArchived: true };
if (serverSearch) params.search = serverSearch;
const response = await api.get(
`/api/workspace/${context.instanceId}/workflows`,
{ params: { includeArchived: true } },
{ params },
);
const body = response.data ?? {};
const nested = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
@ -100,6 +103,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
id: wf.id,
label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`,
updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt,
lastMessageAt: wf.lastMessageAt,
featureInstanceId: fiId,
featureCode: wf.featureCode,
status: wf.status || 'active',
@ -117,7 +121,9 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
setGroups(sorted);
if (expandedGroups.size === 0 && sorted.length > 0) {
setExpandedGroups(new Set([context.instanceId]));
const currentGroup = sorted.find(g => g.featureInstanceId === context.instanceId);
const sectionKey = currentGroup ? `section:${currentGroup.featureCode || 'workspace'}` : 'section:workspace';
setExpandedGroups(new Set([context.instanceId, sectionKey]));
}
} catch (err) {
console.error('Failed to load chats:', err);
@ -128,6 +134,17 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
useEffect(() => { _loadChats(); }, [_loadChats]);
useEffect(() => {
const timer = setTimeout(() => {
if (search.trim().length >= 2) {
_loadChats(search.trim());
} else if (search.trim().length === 0) {
_loadChats();
}
}, 300);
return () => clearTimeout(timer);
}, [search, _loadChats]);
useEffect(() => {
if (activeWorkflowId) {
_loadChats();
@ -196,20 +213,17 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
chats.filter(c => filter === 'archived' ? _isArchived(c) : !_isArchived(c));
const _filteredGroups = groups
.map(g => {
let chats = _applyFilter(g.chats);
if (search) {
chats = chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase()));
}
return { ...g, chats };
})
.map(g => ({ ...g, chats: _applyFilter(g.chats) }))
.filter(g => g.chats.length > 0);
const _toTs = (v?: string | number): number =>
typeof v === 'number' ? v : new Date(v || 0).getTime();
const _allChats = _filteredGroups
.flatMap(g => g.chats)
.sort((a, b) => {
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
const ta = _toTs(a.lastMessageAt ?? a.updatedAt);
const tb = _toTs(b.lastMessageAt ?? b.updatedAt);
return tb - ta;
});
@ -305,6 +319,16 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
);
};
const _featureCodeLabel = (code: string): string => {
const labels: Record<string, string> = {
workspace: 'AI Workspace',
commcoach: 'CommCoach',
trustee: 'Trustee',
automation: 'Automation',
};
return labels[code] || code;
};
if (loading) return <div className={styles.loading}>Lade Chats...</div>;
return (
@ -354,29 +378,57 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
</div>
) : (
<div className={styles.tree}>
{_filteredGroups.map((group) => (
<div key={group.featureInstanceId} className={styles.treeGroup}>
<div
className={`${styles.treeGroupHeader} ${
group.featureInstanceId === context.instanceId ? styles.treeGroupCurrent : ''
}`}
onClick={() => _toggleGroup(group.featureInstanceId)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeGroupLabel}>{group.featureLabel}</span>
<span className={styles.treeGroupCount}>{group.chats.length}</span>
</div>
{expandedGroups.has(group.featureInstanceId) && (
<div className={styles.treeChildren}>
{group.chats.map((chat) =>
_renderChatItem(chat, group.featureInstanceId),
)}
{(() => {
const byFeatureCode = new Map<string, ChatGroup[]>();
for (const g of _filteredGroups) {
const code = g.featureCode || 'workspace';
if (!byFeatureCode.has(code)) byFeatureCode.set(code, []);
byFeatureCode.get(code)!.push(g);
}
return Array.from(byFeatureCode.entries()).map(([code, instances]) => (
<div key={code} className={styles.treeSection}>
<div
className={styles.treeSectionHeader}
onClick={() => _toggleGroup(`section:${code}`)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(`section:${code}`) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeSectionLabel}>
{_featureCodeLabel(code)}
</span>
<span className={styles.treeGroupCount}>
{instances.reduce((n, g) => n + g.chats.length, 0)}
</span>
</div>
)}
</div>
))}
{expandedGroups.has(`section:${code}`) && instances.map((group) => (
<div key={group.featureInstanceId} className={styles.treeGroup}>
{instances.length > 1 && (
<div
className={`${styles.treeGroupHeader} ${
group.featureInstanceId === context.instanceId ? styles.treeGroupCurrent : ''
}`}
onClick={() => _toggleGroup(group.featureInstanceId)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeGroupLabel}>{group.featureLabel}</span>
<span className={styles.treeGroupCount}>{group.chats.length}</span>
</div>
)}
{(instances.length === 1 || expandedGroups.has(group.featureInstanceId)) && (
<div className={styles.treeChildren}>
{group.chats.map((chat) =>
_renderChatItem(chat, group.featureInstanceId),
)}
</div>
)}
</div>
))}
</div>
));
})()}
</div>
)}

View file

@ -43,7 +43,7 @@ export type {
MandateUserSummary,
};
export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi';
export type { TransactionType, ReferenceType } from '../api/billingApi';
/**
* Hook for user billing operations
@ -217,34 +217,21 @@ export function useBillingAdmin(mandateId?: string) {
}
}, [request, mandateId]);
// Update settings — after billing model change, reload dependent data (accounts / users / tx)
const saveSettings = useCallback(
async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
const previousModel = settings?.billingModel;
try {
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
setSettings(data);
const newModel = settingsUpdate.billingModel;
const modelChanged =
newModel !== undefined && newModel !== null && newModel !== previousModel;
if (modelChanged) {
await Promise.all([
loadAccounts(mId),
loadTransactions(mId, 100),
loadUsers(mId),
]);
}
return data;
} catch (err) {
console.error('Error saving billing settings:', err);
throw err;
}
},
[request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers]
[request, mandateId]
);
// Add credit (manual, admin)

161
src/hooks/usePrompt.tsx Normal file
View file

@ -0,0 +1,161 @@
/**
* usePrompt application-level prompt dialog replacing native browser prompt().
*
* Usage:
* const { prompt, PromptDialog } = usePrompt();
* const value = await prompt('Bitte Namen eingeben:', { title: 'Umbenennen' });
* if (value !== null) { ... }
* // Render <PromptDialog /> once in the component tree.
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
export interface PromptOptions {
title?: string;
confirmLabel?: string;
cancelLabel?: string;
placeholder?: string;
defaultValue?: string;
variant?: 'primary' | 'danger';
}
interface PromptState {
message: string;
options: Required<PromptOptions>;
resolve: (value: string | null) => void;
}
const _defaults: Required<PromptOptions> = {
title: 'Eingabe',
confirmLabel: 'OK',
cancelLabel: 'Abbrechen',
placeholder: '',
defaultValue: '',
variant: 'primary',
};
export function usePrompt() {
const [state, setState] = useState<PromptState | null>(null);
const resolveRef = useRef<((v: string | null) => void) | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const prompt = useCallback((message: string, options?: PromptOptions): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
resolveRef.current = resolve;
setState({
message,
options: { ..._defaults, ...options },
resolve,
});
});
}, []);
const _handleConfirm = useCallback(() => {
const val = inputRef.current?.value ?? '';
resolveRef.current?.(val);
resolveRef.current = null;
setState(null);
}, []);
const _handleCancel = useCallback(() => {
resolveRef.current?.(null);
resolveRef.current = null;
setState(null);
}, []);
const PromptDialog: React.FC = useCallback(() => {
if (!state) return null;
const { message, options } = state;
const isDanger = options.variant === 'danger';
return (
<div
onClick={_handleCancel}
style={{
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--surface-color, #1a1a2e)',
border: '1px solid var(--color-border, #333)',
borderRadius: '12px',
padding: '1.5rem',
minWidth: 360, maxWidth: 500,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
display: 'flex', flexDirection: 'column', gap: '1.25rem',
}}
>
<h3 style={{
margin: 0, fontSize: '1.05rem', fontWeight: 600,
color: 'var(--text-primary, #e0e0e0)',
}}>
{options.title}
</h3>
<p style={{
margin: 0, fontSize: '0.9rem', lineHeight: 1.5,
color: 'var(--text-secondary, #999)',
}}>
{message}
</p>
<input
ref={inputRef}
autoFocus
defaultValue={options.defaultValue}
placeholder={options.placeholder}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleConfirm();
if (e.key === 'Escape') _handleCancel();
}}
style={{
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid var(--color-border, #444)',
background: 'var(--input-bg, #0d0d1a)',
color: 'var(--text-primary, #e0e0e0)',
fontSize: '0.9rem',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<button
onClick={_handleCancel}
style={{
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
border: '1px solid var(--color-border, #444)',
background: 'transparent',
color: 'var(--text-secondary, #aaa)',
cursor: 'pointer',
}}
>
{options.cancelLabel}
</button>
<button
onClick={_handleConfirm}
style={{
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
border: 'none',
background: isDanger ? '#ef4444' : 'var(--color-primary, #3b82f6)',
color: '#fff',
cursor: 'pointer',
}}
>
{options.confirmLabel}
</button>
</div>
</div>
</div>
);
}, [state, _handleConfirm, _handleCancel]);
return { prompt, PromptDialog };
}

View file

@ -14,6 +14,7 @@ import {
splitMandateAndBillingFromForm,
} from '../../utils/mandateBillingFormMerge';
import { useToast } from '../../contexts/ToastContext';
import { usePrompt } from '../../hooks/usePrompt';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
@ -23,6 +24,7 @@ export const AdminMandatesPage: React.FC = () => {
const navigate = useNavigate();
const { request } = useApiRequest();
const { showWarning, showSuccess } = useToast();
const { prompt, PromptDialog } = usePrompt();
const {
mandates,
columns,
@ -111,11 +113,18 @@ export const AdminMandatesPage: React.FC = () => {
setEditingBillingWarning(null);
};
// Handle delete (confirmation handled by DeleteActionButton)
// System mandates (isSystem=true) are protected from deletion
const handleDeleteMandate = async (mandate: Mandate) => {
if (mandate.isSystem) {
return; // Safety guard - should not be reachable due to disabled button
return;
}
const entered = await prompt(
`Um den Mandanten "${mandate.name}" unwiderruflich zu löschen, geben Sie den Namen ein:`,
{ title: 'Mandant löschen', confirmLabel: 'Löschen', variant: 'danger', placeholder: mandate.name },
);
if (entered === null) return;
if (entered !== mandate.name) {
showWarning('Löschung abgebrochen', 'Der eingegebene Name stimmt nicht überein.');
return;
}
await handleDelete(mandate.id);
};
@ -267,6 +276,8 @@ export const AdminMandatesPage: React.FC = () => {
</div>
)}
<PromptDialog />
{/* Edit Modal */}
{editingFormData && (
<div

View file

@ -101,7 +101,7 @@
margin: 0;
}
.billingModel {
.mandateSubtitle {
font-size: 0.75rem;
color: var(--text-secondary, #888);
background: var(--bg-secondary, #2a2a2a);

View file

@ -85,8 +85,6 @@ interface SettingsEditorProps {
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
const [formData, setFormData] = useState({
billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'],
defaultUserCredit: Number(settings?.defaultUserCredit ?? 0),
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
notifyOnWarning: settings?.notifyOnWarning ?? true,
});
@ -96,8 +94,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
useEffect(() => {
if (settings) {
setFormData({
billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE',
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
notifyOnWarning: settings.notifyOnWarning ?? true,
});
@ -130,32 +126,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
)}
<form onSubmit={handleSubmit}>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Abrechnungsmodell</label>
<select
className={styles.select}
value={formData.billingModel}
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as BillingSettings['billingModel'] }))}
>
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
<option value="PREPAY_USER">Prepaid (Benutzer)</option>
</select>
</div>
<div className={styles.formGroup}>
<label>Standard-Guthaben (CHF)</label>
<input
type="number"
className={styles.input}
value={formData.defaultUserCredit}
onChange={(e) => setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))}
min="0"
step="0.01"
/>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Warnschwelle (%)</label>
@ -202,28 +172,15 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
// ============================================================================
interface CreditAdderProps {
settings: BillingSettings | null;
accounts: AccountSummary[];
users: MandateUserSummary[];
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
}
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
const [selectedUserId, setSelectedUserId] = useState<string>('');
const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
const accountsByUserId = accounts
.filter(acc => acc.accountType === 'USER')
.reduce((map, acc) => {
if (acc.userId) map[acc.userId] = acc;
return map;
}, {} as Record<string, AccountSummary>);
const _handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const numAmount = parseFloat(amount);
@ -236,7 +193,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
setMessage(null);
try {
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
await onAddCredit(undefined, numAmount, description);
const label = numAmount > 0
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
@ -260,31 +217,6 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
)}
<form onSubmit={_handleSubmit}>
{isPrepayUser && (
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Benutzer</label>
<select
className={styles.select}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
required
>
<option value="">-- Benutzer wählen --</option>
{users.map((user) => {
const account = accountsByUserId[user.id];
const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
return (
<option key={user.id} value={user.id}>
{user.displayName || user.username || user.id}{balanceInfo}
</option>
);
})}
</select>
</div>
</div>
)}
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag (CHF)</label>
@ -313,7 +245,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
disabled={saving || !amount}
>
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
</button>
@ -367,7 +299,7 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
<div className={styles.accountsGrid}>
{accounts.map((account) => (
<div key={account.id} className={styles.accountCard}>
<h4>{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
<h4>{!account.userId ? 'Mandanten-Konto' : 'Benutzer-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>
@ -782,9 +714,6 @@ export const BillingAdmin: React.FC = () => {
<>
{isSysAdmin && (
<CreditAdder
settings={settings}
accounts={accounts}
users={users}
onAddCredit={_handleAddCredit}
/>
)}

View file

@ -26,11 +26,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
return 'Prepaid (Mandant)';
};
return (
<div
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
@ -38,7 +33,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
<span className={styles.billingModel}>{getBillingModelLabel(balance.billingModel)}</span>
</div>
<div className={styles.balanceAmount}>
{formatCurrency(balance.balance)}

View file

@ -13,14 +13,10 @@ import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
import api from '../../api';
import { useApiRequest } from '../../hooks/useApi';
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
import { createCheckoutSession, UserTransaction } from '../../api/billingApi';
import { getUserDataCache } from '../../utils/userCache';
import { UserTransaction } from '../../api/billingApi';
import styles from './Billing.module.css';
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
// ============================================================================
// HELPER: Currency formatter
// ============================================================================
@ -52,28 +48,13 @@ interface ViewStatistics {
interface BalanceCardProps {
balance: BillingBalance;
onCheckout?: (mandateId: string, amount: number) => void;
checkoutLoading?: boolean;
}
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkoutLoading }) => {
const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]);
const [showCheckout, setShowCheckout] = useState(false);
const _getBillingModelLabel = (model: string) => {
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
return 'Prepaid (Mandant)';
};
// Stripe top-up on this page: only personal prepaid wallets. Mandate pool (PREPAY_MANDATE) is topped up by mandate admins via Administration → Billing.
const canStripeTopUpHere = balance.billingModel === 'PREPAY_USER';
const isMandatePrepaidPool = balance.billingModel === 'PREPAY_MANDATE';
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
<span className={styles.billingModel}>{_getBillingModelLabel(balance.billingModel)}</span>
</div>
<div className={styles.balanceAmount}>
{_formatCurrency(balance.balance)}
@ -83,60 +64,17 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
Niedriges Guthaben
</div>
)}
{isMandatePrepaidPool && (
<p
style={{
marginTop: '12px',
fontSize: '13px',
lineHeight: 1.45,
opacity: 0.75,
marginBottom: 0,
}}
>
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration Billing).
</p>
)}
{canStripeTopUpHere && onCheckout && (
<div style={{ marginTop: '12px' }}>
{!showCheckout ? (
<button
className={`${styles.button} ${styles.buttonPrimary}`}
style={{ width: '100%', fontSize: '13px', padding: '6px 12px' }}
onClick={() => setShowCheckout(true)}
>
Budget laden mit Kreditkarte
</button>
) : (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<select
className={styles.select}
value={selectedAmount}
onChange={(e) => setSelectedAmount(Number(e.target.value))}
style={{ flex: 1, fontSize: '13px' }}
>
{STRIPE_AMOUNT_PRESETS.map((preset) => (
<option key={preset} value={preset}>{preset} CHF</option>
))}
</select>
<button
className={`${styles.button} ${styles.buttonPrimary}`}
style={{ fontSize: '13px', padding: '6px 12px', whiteSpace: 'nowrap' }}
disabled={checkoutLoading}
onClick={() => onCheckout(balance.mandateId, selectedAmount)}
>
{checkoutLoading ? 'Laden...' : 'Zahlen'}
</button>
<button
className={`${styles.button} ${styles.buttonSecondary || ''}`}
style={{ fontSize: '13px', padding: '6px 12px' }}
onClick={() => setShowCheckout(false)}
>
&times;
</button>
</div>
)}
</div>
)}
<p
style={{
marginTop: '12px',
fontSize: '13px',
lineHeight: 1.45,
opacity: 0.75,
marginBottom: 0,
}}
>
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration Billing).
</p>
</div>
);
};
@ -329,8 +267,6 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview');
const [searchParams, setSearchParams] = useSearchParams();
const { request } = useApiRequest();
const [checkoutLoading, setCheckoutLoading] = useState(false);
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Scope filter: 'personal' | 'all' | mandateId
@ -399,31 +335,6 @@ export const BillingDataView: React.FC = () => {
setCheckoutMessage(null);
}, [searchParams, setSearchParams]);
const _handleCheckout = useCallback(async (mandateId: string, amount: number) => {
setCheckoutLoading(true);
setCheckoutMessage(null);
try {
const currentUser = getUserDataCache();
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete('success');
currentUrl.searchParams.delete('canceled');
currentUrl.searchParams.delete('session_id');
currentUrl.hash = '';
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
const result = await createCheckoutSession(request, mandateId, {
userId: currentUser?.id,
amount,
returnUrl,
});
if (result?.redirectUrl) {
window.location.href = result.redirectUrl;
}
} catch (err: any) {
setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' });
setCheckoutLoading(false);
}
}, [request]);
// All user balances (for admin overview cards)
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
@ -666,8 +577,6 @@ export const BillingDataView: React.FC = () => {
<BalanceCard
key={balance.mandateId}
balance={balance}
onCheckout={_handleCheckout}
checkoutLoading={checkoutLoading}
/>
))}
</div>
@ -686,7 +595,7 @@ export const BillingDataView: React.FC = () => {
<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.billingModel}>{ub.mandateName}</span>
<span className={styles.mandateSubtitle}>{ub.mandateName}</span>
</div>
<div className={styles.balanceAmount}>
{_formatCurrency(ub.balance || 0)}

View file

@ -38,20 +38,14 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
return 'Prepaid (Mandant)';
};
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Mandant</th>
<th>Billing-Modell</th>
<th>Anzahl Benutzer</th>
<th>Standard-Guthaben</th>
<th>Warnschwelle (%)</th>
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
<th>Aktion</th>
</tr>
@ -63,9 +57,8 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
>
<td>{balance.mandateName || balance.mandateId}</td>
<td>{getBillingModelLabel(balance.billingModel)}</td>
<td>{balance.userCount}</td>
<td>{formatCurrency(balance.defaultUserCredit)}</td>
<td>{balance.warningThresholdPercent}%</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
<td>
<button

View file

@ -6,10 +6,12 @@
*
* 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).
*/
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import api from '../../../api';
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
@ -30,6 +32,8 @@ export interface VoiceControllerCallbacks {
onInterimText?: (text: string) => void;
}
const _DEFAULT_STT_LANGUAGE = 'de-DE';
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState<VoiceState>('idle');
const [muted, setMuted] = useState(false);
@ -38,6 +42,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
const cbRef = useRef(callbacks);
cbRef.current = callbacks;
const sttLanguageRef = useRef<string>(_DEFAULT_STT_LANGUAGE);
useEffect(() => {
let cancelled = false;
api.get('/api/local/voice-preferences').then((res) => {
if (cancelled) return;
const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
if (lang) sttLanguageRef.current = lang;
}).catch(() => {});
return () => { cancelled = true; };
}, []);
const _dlog = useCallback((tag: string, info?: string) => {
const t = new Date();
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
@ -68,16 +84,20 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
onError: (err) => _dlog('VOICE-ERR', String(err)),
});
const _startStream = useCallback(() => {
return voiceStream.start(sttLanguageRef.current);
}, [voiceStream]);
const activate = useCallback(async () => {
if (stateRef.current !== 'idle') return;
_setState('listening');
try {
await voiceStream.start('de-DE');
await _startStream();
} catch (err) {
_dlog('MIC-ERR', String(err));
_setState('idle');
}
}, [_setState, voiceStream, _dlog]);
}, [_setState, _startStream, _dlog]);
const deactivate = useCallback(() => {
voiceStream.stop();
@ -94,15 +114,15 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
const ttsPaused = useCallback(() => {
if (stateRef.current !== 'botSpeaking') return;
_setState('interrupted');
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, voiceStream, _dlog]);
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, _startStream, _dlog]);
const ttsEnded = useCallback(() => {
const cur = stateRef.current;
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
_setState('listening');
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, voiceStream, _dlog]);
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, _startStream, _dlog]);
const toggleMute = useCallback(() => {
const cur = stateRef.current;
@ -110,13 +130,13 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
if (mutedRef.current) {
_setMuted(false);
if (cur === 'listening' || cur === 'interrupted') {
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
}
} else {
_setMuted(true);
voiceStream.stop();
}
}, [_setMuted, voiceStream, _dlog]);
}, [_setMuted, _startStream, voiceStream, _dlog]);
return {
state,

View file

@ -53,6 +53,8 @@ interface WorkspaceInputProps {
isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onPasteAsFile?: (file: File) => void;
draftAppend?: string;
onDraftAppendConsumed?: () => void;
}
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
@ -72,6 +74,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
isMobile = false,
onTreeItemsDrop,
onPasteAsFile,
draftAppend,
onDraftAppendConsumed,
}) => {
const [prompt, setPrompt] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false);
@ -86,6 +90,14 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [neutralizeActive, setNeutralizeActive] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (draftAppend) {
setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend);
onDraftAppendConsumed?.();
}
}, [draftAppend, onDraftAppendConsumed]);
const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef('');
const currentInterimRef = useRef('');

View file

@ -83,6 +83,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [draftAppend, setDraftAppend] = useState('');
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isMobile, setIsMobile] = useState<boolean>(() =>
@ -142,13 +143,28 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragOver(false);
const chatId = e.dataTransfer.getData('application/chat-id');
if (chatId) {
try {
const res = await api.post(`/api/workspace/${instanceId}/resolve-rag`, { chatId });
const body = res.data ?? {};
if (body.summary) {
setDraftAppend(body.summary);
}
} catch (err) {
console.error('RAG resolve failed for dropped chat:', err);
}
return;
}
const droppedFiles = e.dataTransfer.files;
if (droppedFiles.length > 0) {
for (const file of Array.from(droppedFiles)) {
await _uploadAndAttach(file);
}
}
}, [_uploadAndAttach]);
}, [_uploadAndAttach, instanceId, workspace]);
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
@ -396,9 +412,9 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
/>
<WorkspaceInput
instanceId={instanceId}
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds) => {
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds);
workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds, options);
setPendingFiles([]);
}}
isProcessing={workspace.isProcessing}
@ -415,6 +431,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop}
onPasteAsFile={_uploadAndAttach}
draftAppend={draftAppend}
onDraftAppendConsumed={() => setDraftAppend('')}
/>
</main>

View file

@ -95,7 +95,7 @@ export interface DataSourceAccessEvent {
interface UseWorkspaceReturn {
messages: Message[];
isProcessing: boolean;
sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[], featureDataSourceIds?: string[]) => void;
sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[], featureDataSourceIds?: string[], options?: { requireNeutralization?: boolean }) => void;
stopProcessing: () => void;
loadWorkflow: (workflowId: string) => void;
resetToNew: () => void;
@ -197,7 +197,7 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
}, []);
const sendMessage = useCallback(
(prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = []) => {
(prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = [], options?: { requireNeutralization?: boolean }) => {
if (!instanceId || isProcessing) return;
setIsProcessing(true);
@ -242,6 +242,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
if (allowedProviders.length > 0) {
body.allowedProviders = allowedProviders;
}
if (options?.requireNeutralization !== undefined) {
body.requireNeutralization = options.requireNeutralization;
}
cleanupRef.current = startSseStream({
url,

View file

@ -7,8 +7,6 @@ import type { AttributeDefinition } from '../components/FormGenerator/FormGenera
import type { BillingSettings, BillingSettingsUpdate } from '../api/billingApi';
export const mandateBillingFieldNames = [
'billingModel',
'defaultUserCredit',
'warningThresholdPercent',
'notifyOnWarning',
'notifyEmails',
@ -19,31 +17,6 @@ export type MandateBillingFieldName = (typeof mandateBillingFieldNames)[number];
/** FormGenerator attribute definitions for mandate billing (appended after /api/attributes/Mandate). */
export function getMandateBillingFormAttributes(): AttributeDefinition[] {
return [
{
name: 'billingModel',
type: 'select',
label: 'Abrechnungsmodell',
description: 'Vorauszahlung auf Mandanten- oder Benutzerkonten.',
required: false,
default: 'PREPAY_MANDATE',
editable: true,
order: 100,
options: [
{ value: 'PREPAY_MANDATE', label: 'Vorauszahlung (Mandanten-Guthaben)' },
{ value: 'PREPAY_USER', label: 'Vorauszahlung pro Benutzer' },
],
},
{
name: 'defaultUserCredit',
type: 'float',
label: 'Startguthaben neuem Benutzer (CHF)',
description:
'Nur relevant bei PREPAY_USER (u. a. Root-Mandant). Sonst meist 0.',
required: false,
default: 0,
editable: true,
order: 101,
},
{
name: 'warningThresholdPercent',
type: 'float',
@ -61,7 +34,7 @@ export function getMandateBillingFormAttributes(): AttributeDefinition[] {
required: false,
default: true,
editable: true,
order: 103,
order: 102,
},
{
name: 'notifyEmails',
@ -71,7 +44,7 @@ export function getMandateBillingFormAttributes(): AttributeDefinition[] {
required: false,
default: '',
editable: true,
order: 104,
order: 103,
minRows: 2,
maxRows: 6,
},
@ -91,12 +64,6 @@ function _parseNotifyEmailsInput(val: unknown): string[] {
.filter(Boolean);
}
/** Build initial form values: mandate row + billing settings (notifyEmails as multi-line string). */
function _normalizeBillingModelUi(raw: string | undefined): BillingSettings['billingModel'] {
if (raw === 'PREPAY_USER') return 'PREPAY_USER';
return 'PREPAY_MANDATE';
}
export function mergeBillingIntoMandateFormData(
mandate: Record<string, unknown>,
settings: BillingSettings | null
@ -104,8 +71,6 @@ export function mergeBillingIntoMandateFormData(
if (!settings) {
return {
...mandate,
billingModel: 'PREPAY_MANDATE',
defaultUserCredit: 0,
warningThresholdPercent: 10,
notifyOnWarning: true,
notifyEmails: '',
@ -113,8 +78,6 @@ export function mergeBillingIntoMandateFormData(
}
return {
...mandate,
billingModel: _normalizeBillingModelUi(settings.billingModel),
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
notifyOnWarning: settings.notifyOnWarning ?? true,
notifyEmails: (settings.notifyEmails || []).join('\n'),
@ -131,19 +94,6 @@ export function splitMandateAndBillingFromForm(
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
const billingUpdate: BillingSettingsUpdate = {};
if ('billingModel' in formData && formData.billingModel !== undefined && formData.billingModel !== '') {
billingUpdate.billingModel = formData.billingModel as BillingSettingsUpdate['billingModel'];
}
{
const raw = formData.defaultUserCredit;
const n =
raw === undefined || raw === null || raw === ''
? 0
: Number(raw);
if (!Number.isNaN(n)) {
billingUpdate.defaultUserCredit = n;
}
}
if (
'warningThresholdPercent' in formData &&
formData.warningThresholdPercent !== undefined &&