cleaned mandate and unified mandate to be standard type
This commit is contained in:
parent
e9f7b2016f
commit
d5bb102684
19 changed files with 435 additions and 364 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export interface UserMandate {
|
|||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
mandateType: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
161
src/hooks/usePrompt.tsx
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.billingModel {
|
||||
.mandateSubtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #888);
|
||||
background: var(--bg-secondary, #2a2a2a);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
×
|
||||
</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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
Loading…
Reference in a new issue