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
|
// TYPES & INTERFACES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER';
|
|
||||||
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
||||||
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
|
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
|
||||||
|
|
||||||
export interface BillingBalance {
|
export interface BillingBalance {
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
mandateName: string;
|
mandateName: string;
|
||||||
billingModel: BillingModel;
|
|
||||||
balance: number;
|
balance: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
warningThreshold: number;
|
warningThreshold: number;
|
||||||
|
|
@ -41,16 +39,12 @@ export interface BillingTransaction {
|
||||||
export interface BillingSettings {
|
export interface BillingSettings {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
billingModel: BillingModel;
|
|
||||||
defaultUserCredit: number;
|
|
||||||
warningThresholdPercent: number;
|
warningThresholdPercent: number;
|
||||||
notifyOnWarning: boolean;
|
notifyOnWarning: boolean;
|
||||||
notifyEmails: string[];
|
notifyEmails: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BillingSettingsUpdate {
|
export interface BillingSettingsUpdate {
|
||||||
billingModel?: BillingModel;
|
|
||||||
defaultUserCredit?: number;
|
|
||||||
warningThresholdPercent?: number;
|
warningThresholdPercent?: number;
|
||||||
notifyOnWarning?: boolean;
|
notifyOnWarning?: boolean;
|
||||||
notifyEmails?: string[];
|
notifyEmails?: string[];
|
||||||
|
|
@ -69,7 +63,6 @@ export interface AccountSummary {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
accountType: string;
|
|
||||||
balance: number;
|
balance: number;
|
||||||
warningThreshold: number;
|
warningThreshold: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -305,10 +298,8 @@ export async function fetchUsersForMandateAdmin(
|
||||||
export interface MandateBalance {
|
export interface MandateBalance {
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
mandateName: string;
|
mandateName: string;
|
||||||
billingModel: BillingModel;
|
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
defaultUserCredit: number;
|
|
||||||
warningThresholdPercent: number;
|
warningThresholdPercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ export interface UserMandate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
mandateType: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionInfo {
|
export interface SubscriptionInfo {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import type {
|
||||||
import { getPageIcon } from '../../config/pageRegistry';
|
import { getPageIcon } from '../../config/pageRegistry';
|
||||||
import { FaSpinner, FaPen } from 'react-icons/fa';
|
import { FaSpinner, FaPen } from 'react-icons/fa';
|
||||||
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import styles from './MandateNavigation.module.css';
|
import styles from './MandateNavigation.module.css';
|
||||||
|
|
||||||
|
|
@ -192,14 +194,19 @@ const EmptyState: React.FC = () => (
|
||||||
|
|
||||||
export const MandateNavigation: React.FC = () => {
|
export const MandateNavigation: React.FC = () => {
|
||||||
const { blocks, loading, refresh } = useNavigation('de');
|
const { blocks, loading, refresh } = useNavigation('de');
|
||||||
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
|
const { showWarning } = useToast();
|
||||||
|
|
||||||
const _handleRename = useCallback((instanceId: string, currentLabel: string) => {
|
const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => {
|
||||||
const newLabel = window.prompt('Neuer Name:', currentLabel);
|
const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel });
|
||||||
if (!newLabel || newLabel.trim() === currentLabel) return;
|
if (!newLabel || newLabel.trim() === currentLabel) return;
|
||||||
api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() })
|
try {
|
||||||
.then(() => refresh())
|
await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() });
|
||||||
.catch((err: any) => alert('Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message)));
|
refresh();
|
||||||
}, [refresh]);
|
} catch (err: any) {
|
||||||
|
showWarning('Fehler', 'Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message));
|
||||||
|
}
|
||||||
|
}, [refresh, prompt, showWarning]);
|
||||||
|
|
||||||
const navigationItems: TreeItem[] = useMemo(() => {
|
const navigationItems: TreeItem[] = useMemo(() => {
|
||||||
const items: TreeItem[] = [];
|
const items: TreeItem[] = [];
|
||||||
|
|
@ -280,6 +287,7 @@ export const MandateNavigation: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
)}
|
)}
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ interface OnboardingWizardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismiss }) => {
|
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 [companyName, setCompanyName] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -17,8 +17,8 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.post('/api/local/onboarding', {
|
await api.post('/api/local/onboarding', {
|
||||||
mandateType,
|
planKey,
|
||||||
companyName: mandateType === 'company' ? companyName : undefined,
|
companyName: companyName.trim() || undefined,
|
||||||
});
|
});
|
||||||
onComplete();
|
onComplete();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -40,50 +40,49 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Willkommen bei PowerOn</h2>
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Willkommen bei PowerOn</h2>
|
||||||
<p style={{ color: 'var(--text-secondary, #666)', margin: '0 0 24px' }}>
|
<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>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
|
||||||
<label style={{
|
<label style={{
|
||||||
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
|
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',
|
borderRadius: '8px', cursor: 'pointer',
|
||||||
}}>
|
}}>
|
||||||
<input type="radio" name="type" checked={mandateType === 'personal'}
|
<input type="radio" name="plan" checked={planKey === 'TRIAL_7D'}
|
||||||
onChange={() => setMandateType('personal')} />
|
onChange={() => setPlanKey('TRIAL_7D')} />
|
||||||
<div>
|
<div>
|
||||||
<strong>Persönlich</strong>
|
<strong>Kostenlos testen</strong>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label style={{
|
<label style={{
|
||||||
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
|
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',
|
borderRadius: '8px', cursor: 'pointer',
|
||||||
}}>
|
}}>
|
||||||
<input type="radio" name="type" checked={mandateType === 'company'}
|
<input type="radio" name="plan" checked={planKey === 'STANDARD_MONTHLY'}
|
||||||
onChange={() => setMandateType('company')} />
|
onChange={() => setPlanKey('STANDARD_MONTHLY')} />
|
||||||
<div>
|
<div>
|
||||||
<strong>Unternehmen</strong>
|
<strong>Standard (Monatlich)</strong>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
|
||||||
Team-Workspace mit Mandanten-Verwaltung
|
Team-Workspace mit vollem Funktionsumfang
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mandateType === 'company' && (
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500 }}>
|
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500 }}>
|
||||||
Firmenname
|
Name des Mandanten <span style={{ fontWeight: 400, color: 'var(--text-secondary, #666)' }}>(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text" value={companyName}
|
type="text" value={companyName}
|
||||||
onChange={(e) => setCompanyName(e.target.value)}
|
onChange={(e) => setCompanyName(e.target.value)}
|
||||||
placeholder="Name des Unternehmens"
|
placeholder="z. B. Firmenname oder Projektname"
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '10px 12px', borderRadius: '6px',
|
width: '100%', padding: '10px 12px', borderRadius: '6px',
|
||||||
border: '1px solid var(--border, #d1d5db)', fontSize: '1rem',
|
border: '1px solid var(--border, #d1d5db)', fontSize: '1rem',
|
||||||
|
|
@ -91,7 +90,6 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p style={{ color: '#ef4444', margin: '0 0 16px' }}>{error}</p>}
|
{error && <p style={{ color: '#ef4444', margin: '0 0 16px' }}>{error}</p>}
|
||||||
|
|
||||||
|
|
@ -102,7 +100,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
|
||||||
}}>
|
}}>
|
||||||
Später
|
Später
|
||||||
</button>
|
</button>
|
||||||
<button onClick={_handleSubmit} disabled={loading || (mandateType === 'company' && !companyName.trim())}
|
<button onClick={_handleSubmit} disabled={loading}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 20px', borderRadius: '6px', border: 'none',
|
padding: '10px 20px', borderRadius: '6px', border: 'none',
|
||||||
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',
|
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,36 @@
|
||||||
color: var(--text-primary, #111);
|
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 {
|
.treeGroup {
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
|
@ -247,9 +276,16 @@
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
.chatItem:hover,
|
.chatItem:hover,
|
||||||
.treeGroupHeader:hover {
|
.treeGroupHeader:hover,
|
||||||
|
.treeSectionHeader:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
.treeSectionHeader {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.treeSectionHeader:hover {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
.chatItemActive,
|
.chatItemActive,
|
||||||
.chatItemActive:hover {
|
.chatItemActive:hover {
|
||||||
background: rgba(79, 70, 229, 0.15);
|
background: rgba(79, 70, 229, 0.15);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ interface ChatItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
updatedAt?: string | number;
|
updatedAt?: string | number;
|
||||||
|
lastMessageAt?: string | number;
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
featureCode?: string;
|
featureCode?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
@ -68,12 +69,14 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const _loadChats = useCallback(async () => {
|
const _loadChats = useCallback(async (serverSearch?: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const params: Record<string, unknown> = { includeArchived: true };
|
||||||
|
if (serverSearch) params.search = serverSearch;
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`/api/workspace/${context.instanceId}/workflows`,
|
`/api/workspace/${context.instanceId}/workflows`,
|
||||||
{ params: { includeArchived: true } },
|
{ params },
|
||||||
);
|
);
|
||||||
const body = response.data ?? {};
|
const body = response.data ?? {};
|
||||||
const nested = body.data && typeof body.data === 'object' && !Array.isArray(body.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,
|
id: wf.id,
|
||||||
label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`,
|
label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`,
|
||||||
updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt,
|
updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt,
|
||||||
|
lastMessageAt: wf.lastMessageAt,
|
||||||
featureInstanceId: fiId,
|
featureInstanceId: fiId,
|
||||||
featureCode: wf.featureCode,
|
featureCode: wf.featureCode,
|
||||||
status: wf.status || 'active',
|
status: wf.status || 'active',
|
||||||
|
|
@ -117,7 +121,9 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
|
||||||
setGroups(sorted);
|
setGroups(sorted);
|
||||||
|
|
||||||
if (expandedGroups.size === 0 && sorted.length > 0) {
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to load chats:', err);
|
console.error('Failed to load chats:', err);
|
||||||
|
|
@ -128,6 +134,17 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
|
||||||
|
|
||||||
useEffect(() => { _loadChats(); }, [_loadChats]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (activeWorkflowId) {
|
if (activeWorkflowId) {
|
||||||
_loadChats();
|
_loadChats();
|
||||||
|
|
@ -196,20 +213,17 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
|
||||||
chats.filter(c => filter === 'archived' ? _isArchived(c) : !_isArchived(c));
|
chats.filter(c => filter === 'archived' ? _isArchived(c) : !_isArchived(c));
|
||||||
|
|
||||||
const _filteredGroups = groups
|
const _filteredGroups = groups
|
||||||
.map(g => {
|
.map(g => ({ ...g, chats: _applyFilter(g.chats) }))
|
||||||
let chats = _applyFilter(g.chats);
|
|
||||||
if (search) {
|
|
||||||
chats = chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase()));
|
|
||||||
}
|
|
||||||
return { ...g, chats };
|
|
||||||
})
|
|
||||||
.filter(g => g.chats.length > 0);
|
.filter(g => g.chats.length > 0);
|
||||||
|
|
||||||
|
const _toTs = (v?: string | number): number =>
|
||||||
|
typeof v === 'number' ? v : new Date(v || 0).getTime();
|
||||||
|
|
||||||
const _allChats = _filteredGroups
|
const _allChats = _filteredGroups
|
||||||
.flatMap(g => g.chats)
|
.flatMap(g => g.chats)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
|
const ta = _toTs(a.lastMessageAt ?? a.updatedAt);
|
||||||
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
|
const tb = _toTs(b.lastMessageAt ?? b.updatedAt);
|
||||||
return tb - ta;
|
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>;
|
if (loading) return <div className={styles.loading}>Lade Chats...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -354,8 +378,32 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.tree}>
|
<div className={styles.tree}>
|
||||||
{_filteredGroups.map((group) => (
|
{(() => {
|
||||||
|
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>
|
||||||
|
{expandedGroups.has(`section:${code}`) && instances.map((group) => (
|
||||||
<div key={group.featureInstanceId} className={styles.treeGroup}>
|
<div key={group.featureInstanceId} className={styles.treeGroup}>
|
||||||
|
{instances.length > 1 && (
|
||||||
<div
|
<div
|
||||||
className={`${styles.treeGroupHeader} ${
|
className={`${styles.treeGroupHeader} ${
|
||||||
group.featureInstanceId === context.instanceId ? styles.treeGroupCurrent : ''
|
group.featureInstanceId === context.instanceId ? styles.treeGroupCurrent : ''
|
||||||
|
|
@ -368,7 +416,8 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
|
||||||
<span className={styles.treeGroupLabel}>{group.featureLabel}</span>
|
<span className={styles.treeGroupLabel}>{group.featureLabel}</span>
|
||||||
<span className={styles.treeGroupCount}>{group.chats.length}</span>
|
<span className={styles.treeGroupCount}>{group.chats.length}</span>
|
||||||
</div>
|
</div>
|
||||||
{expandedGroups.has(group.featureInstanceId) && (
|
)}
|
||||||
|
{(instances.length === 1 || expandedGroups.has(group.featureInstanceId)) && (
|
||||||
<div className={styles.treeChildren}>
|
<div className={styles.treeChildren}>
|
||||||
{group.chats.map((chat) =>
|
{group.chats.map((chat) =>
|
||||||
_renderChatItem(chat, group.featureInstanceId),
|
_renderChatItem(chat, group.featureInstanceId),
|
||||||
|
|
@ -378,6 +427,9 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{_allChats.length === 0 && (
|
{_allChats.length === 0 && (
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export type {
|
||||||
MandateUserSummary,
|
MandateUserSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi';
|
export type { TransactionType, ReferenceType } from '../api/billingApi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for user billing operations
|
* Hook for user billing operations
|
||||||
|
|
@ -217,34 +217,21 @@ export function useBillingAdmin(mandateId?: string) {
|
||||||
}
|
}
|
||||||
}, [request, mandateId]);
|
}, [request, mandateId]);
|
||||||
|
|
||||||
// Update settings — after billing model change, reload dependent data (accounts / users / tx)
|
|
||||||
const saveSettings = useCallback(
|
const saveSettings = useCallback(
|
||||||
async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => {
|
async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => {
|
||||||
const mId = targetMandateId || mandateId;
|
const mId = targetMandateId || mandateId;
|
||||||
if (!mId) return null;
|
if (!mId) return null;
|
||||||
|
|
||||||
const previousModel = settings?.billingModel;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
|
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
|
||||||
setSettings(data);
|
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;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving billing settings:', err);
|
console.error('Error saving billing settings:', err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers]
|
[request, mandateId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add credit (manual, admin)
|
// 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,
|
splitMandateAndBillingFromForm,
|
||||||
} from '../../utils/mandateBillingFormMerge';
|
} from '../../utils/mandateBillingFormMerge';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
||||||
|
|
@ -23,6 +24,7 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showWarning, showSuccess } = useToast();
|
const { showWarning, showSuccess } = useToast();
|
||||||
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
const {
|
const {
|
||||||
mandates,
|
mandates,
|
||||||
columns,
|
columns,
|
||||||
|
|
@ -111,11 +113,18 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
setEditingBillingWarning(null);
|
setEditingBillingWarning(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete (confirmation handled by DeleteActionButton)
|
|
||||||
// System mandates (isSystem=true) are protected from deletion
|
|
||||||
const handleDeleteMandate = async (mandate: Mandate) => {
|
const handleDeleteMandate = async (mandate: Mandate) => {
|
||||||
if (mandate.isSystem) {
|
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);
|
await handleDelete(mandate.id);
|
||||||
};
|
};
|
||||||
|
|
@ -267,6 +276,8 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PromptDialog />
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingFormData && (
|
{editingFormData && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.billingModel {
|
.mandateSubtitle {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-secondary, #888);
|
||||||
background: var(--bg-secondary, #2a2a2a);
|
background: var(--bg-secondary, #2a2a2a);
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,6 @@ interface SettingsEditorProps {
|
||||||
|
|
||||||
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
|
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
|
||||||
const [formData, setFormData] = useState({
|
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),
|
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
|
||||||
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
||||||
});
|
});
|
||||||
|
|
@ -96,8 +94,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
setFormData({
|
setFormData({
|
||||||
billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE',
|
|
||||||
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
|
|
||||||
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
||||||
notifyOnWarning: settings.notifyOnWarning ?? true,
|
notifyOnWarning: settings.notifyOnWarning ?? true,
|
||||||
});
|
});
|
||||||
|
|
@ -130,32 +126,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<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.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>Warnschwelle (%)</label>
|
<label>Warnschwelle (%)</label>
|
||||||
|
|
@ -202,28 +172,15 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
interface CreditAdderProps {
|
interface CreditAdderProps {
|
||||||
settings: BillingSettings | null;
|
|
||||||
accounts: AccountSummary[];
|
|
||||||
users: MandateUserSummary[];
|
|
||||||
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
|
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
|
||||||
const [amount, setAmount] = useState<string>('');
|
const [amount, setAmount] = useState<string>('');
|
||||||
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
|
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
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) => {
|
const _handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const numAmount = parseFloat(amount);
|
const numAmount = parseFloat(amount);
|
||||||
|
|
@ -236,7 +193,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
await onAddCredit(undefined, numAmount, description);
|
||||||
const label = numAmount > 0
|
const label = numAmount > 0
|
||||||
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
|
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
|
||||||
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
|
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
|
||||||
|
|
@ -260,31 +217,6 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={_handleSubmit}>
|
<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.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>Betrag (CHF)</label>
|
<label>Betrag (CHF)</label>
|
||||||
|
|
@ -313,7 +245,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
disabled={saving || !amount}
|
||||||
>
|
>
|
||||||
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -367,7 +299,7 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
||||||
<div className={styles.accountsGrid}>
|
<div className={styles.accountsGrid}>
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div key={account.id} className={styles.accountCard}>
|
<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}>
|
<div className={styles.accountInfo}>
|
||||||
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
|
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
|
||||||
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
||||||
|
|
@ -782,9 +714,6 @@ export const BillingAdmin: React.FC = () => {
|
||||||
<>
|
<>
|
||||||
{isSysAdmin && (
|
{isSysAdmin && (
|
||||||
<CreditAdder
|
<CreditAdder
|
||||||
settings={settings}
|
|
||||||
accounts={accounts}
|
|
||||||
users={users}
|
|
||||||
onAddCredit={_handleAddCredit}
|
onAddCredit={_handleAddCredit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBillingModelLabel = (model: string) => {
|
|
||||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
|
||||||
return 'Prepaid (Mandant)';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
|
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
|
||||||
|
|
@ -38,7 +33,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
|
||||||
>
|
>
|
||||||
<div className={styles.balanceHeader}>
|
<div className={styles.balanceHeader}>
|
||||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||||
<span className={styles.billingModel}>{getBillingModelLabel(balance.billingModel)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.balanceAmount}>
|
<div className={styles.balanceAmount}>
|
||||||
{formatCurrency(balance.balance)}
|
{formatCurrency(balance.balance)}
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,10 @@ import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator
|
||||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
|
||||||
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
||||||
import { createCheckoutSession, UserTransaction } from '../../api/billingApi';
|
import { UserTransaction } from '../../api/billingApi';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HELPER: Currency formatter
|
// HELPER: Currency formatter
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -52,28 +48,13 @@ interface ViewStatistics {
|
||||||
|
|
||||||
interface BalanceCardProps {
|
interface BalanceCardProps {
|
||||||
balance: BillingBalance;
|
balance: BillingBalance;
|
||||||
onCheckout?: (mandateId: string, amount: number) => void;
|
|
||||||
checkoutLoading?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkoutLoading }) => {
|
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
|
||||||
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';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||||
<div className={styles.balanceHeader}>
|
<div className={styles.balanceHeader}>
|
||||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||||
<span className={styles.billingModel}>{_getBillingModelLabel(balance.billingModel)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.balanceAmount}>
|
<div className={styles.balanceAmount}>
|
||||||
{_formatCurrency(balance.balance)}
|
{_formatCurrency(balance.balance)}
|
||||||
|
|
@ -83,7 +64,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
|
||||||
Niedriges Guthaben
|
Niedriges Guthaben
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isMandatePrepaidPool && (
|
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
marginTop: '12px',
|
marginTop: '12px',
|
||||||
|
|
@ -95,48 +75,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
|
||||||
>
|
>
|
||||||
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).
|
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).
|
||||||
</p>
|
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -329,8 +267,6 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
|
||||||
export const BillingDataView: React.FC = () => {
|
export const BillingDataView: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { request } = useApiRequest();
|
|
||||||
const [checkoutLoading, setCheckoutLoading] = useState(false);
|
|
||||||
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
// Scope filter: 'personal' | 'all' | mandateId
|
// Scope filter: 'personal' | 'all' | mandateId
|
||||||
|
|
@ -399,31 +335,6 @@ export const BillingDataView: React.FC = () => {
|
||||||
setCheckoutMessage(null);
|
setCheckoutMessage(null);
|
||||||
}, [searchParams, setSearchParams]);
|
}, [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)
|
// All user balances (for admin overview cards)
|
||||||
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
|
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
|
||||||
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
|
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
|
||||||
|
|
@ -666,8 +577,6 @@ export const BillingDataView: React.FC = () => {
|
||||||
<BalanceCard
|
<BalanceCard
|
||||||
key={balance.mandateId}
|
key={balance.mandateId}
|
||||||
balance={balance}
|
balance={balance}
|
||||||
onCheckout={_handleCheckout}
|
|
||||||
checkoutLoading={checkoutLoading}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -686,7 +595,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}>
|
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}>
|
||||||
<div className={styles.balanceHeader}>
|
<div className={styles.balanceHeader}>
|
||||||
<h3 className={styles.mandateName}>{ub.userName || ub.userId?.slice(0, 8)}</h3>
|
<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>
|
||||||
<div className={styles.balanceAmount}>
|
<div className={styles.balanceAmount}>
|
||||||
{_formatCurrency(ub.balance || 0)}
|
{_formatCurrency(ub.balance || 0)}
|
||||||
|
|
|
||||||
|
|
@ -38,20 +38,14 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBillingModelLabel = (model: string) => {
|
|
||||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
|
||||||
return 'Prepaid (Mandant)';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className={styles.transactionsTable}>
|
<table className={styles.transactionsTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Mandant</th>
|
<th>Mandant</th>
|
||||||
<th>Billing-Modell</th>
|
|
||||||
<th>Anzahl Benutzer</th>
|
<th>Anzahl Benutzer</th>
|
||||||
<th>Standard-Guthaben</th>
|
<th>Warnschwelle (%)</th>
|
||||||
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
|
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
|
||||||
<th>Aktion</th>
|
<th>Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -63,9 +57,8 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
|
||||||
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
|
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
|
||||||
>
|
>
|
||||||
<td>{balance.mandateName || balance.mandateId}</td>
|
<td>{balance.mandateName || balance.mandateId}</td>
|
||||||
<td>{getBillingModelLabel(balance.billingModel)}</td>
|
|
||||||
<td>{balance.userCount}</td>
|
<td>{balance.userCount}</td>
|
||||||
<td>{formatCurrency(balance.defaultUserCredit)}</td>
|
<td>{balance.warningThresholdPercent}%</td>
|
||||||
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
|
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@
|
||||||
*
|
*
|
||||||
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
|
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
|
||||||
* Google Streaming STT handles silence detection natively.
|
* Google Streaming STT handles silence detection natively.
|
||||||
|
* STT language is loaded from central voice preferences (/api/local/voice-preferences).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||||
|
import api from '../../../api';
|
||||||
|
|
||||||
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
||||||
|
|
||||||
|
|
@ -30,6 +32,8 @@ export interface VoiceControllerCallbacks {
|
||||||
onInterimText?: (text: string) => void;
|
onInterimText?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _DEFAULT_STT_LANGUAGE = 'de-DE';
|
||||||
|
|
||||||
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
||||||
const [state, setState] = useState<VoiceState>('idle');
|
const [state, setState] = useState<VoiceState>('idle');
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
|
|
@ -38,6 +42,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
const cbRef = useRef(callbacks);
|
const cbRef = useRef(callbacks);
|
||||||
cbRef.current = 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 _dlog = useCallback((tag: string, info?: string) => {
|
||||||
const t = new Date();
|
const t = new Date();
|
||||||
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
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)),
|
onError: (err) => _dlog('VOICE-ERR', String(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const _startStream = useCallback(() => {
|
||||||
|
return voiceStream.start(sttLanguageRef.current);
|
||||||
|
}, [voiceStream]);
|
||||||
|
|
||||||
const activate = useCallback(async () => {
|
const activate = useCallback(async () => {
|
||||||
if (stateRef.current !== 'idle') return;
|
if (stateRef.current !== 'idle') return;
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
try {
|
try {
|
||||||
await voiceStream.start('de-DE');
|
await _startStream();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_dlog('MIC-ERR', String(err));
|
_dlog('MIC-ERR', String(err));
|
||||||
_setState('idle');
|
_setState('idle');
|
||||||
}
|
}
|
||||||
}, [_setState, voiceStream, _dlog]);
|
}, [_setState, _startStream, _dlog]);
|
||||||
|
|
||||||
const deactivate = useCallback(() => {
|
const deactivate = useCallback(() => {
|
||||||
voiceStream.stop();
|
voiceStream.stop();
|
||||||
|
|
@ -94,15 +114,15 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
const ttsPaused = useCallback(() => {
|
const ttsPaused = useCallback(() => {
|
||||||
if (stateRef.current !== 'botSpeaking') return;
|
if (stateRef.current !== 'botSpeaking') return;
|
||||||
_setState('interrupted');
|
_setState('interrupted');
|
||||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, voiceStream, _dlog]);
|
}, [_setState, _startStream, _dlog]);
|
||||||
|
|
||||||
const ttsEnded = useCallback(() => {
|
const ttsEnded = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, voiceStream, _dlog]);
|
}, [_setState, _startStream, _dlog]);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
|
|
@ -110,13 +130,13 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
if (mutedRef.current) {
|
if (mutedRef.current) {
|
||||||
_setMuted(false);
|
_setMuted(false);
|
||||||
if (cur === 'listening' || cur === 'interrupted') {
|
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 {
|
} else {
|
||||||
_setMuted(true);
|
_setMuted(true);
|
||||||
voiceStream.stop();
|
voiceStream.stop();
|
||||||
}
|
}
|
||||||
}, [_setMuted, voiceStream, _dlog]);
|
}, [_setMuted, _startStream, voiceStream, _dlog]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ interface WorkspaceInputProps {
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
|
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
|
||||||
onPasteAsFile?: (file: File) => void;
|
onPasteAsFile?: (file: File) => void;
|
||||||
|
draftAppend?: string;
|
||||||
|
onDraftAppendConsumed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
|
|
@ -72,6 +74,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
onTreeItemsDrop,
|
onTreeItemsDrop,
|
||||||
onPasteAsFile,
|
onPasteAsFile,
|
||||||
|
draftAppend,
|
||||||
|
onDraftAppendConsumed,
|
||||||
}) => {
|
}) => {
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
|
@ -86,6 +90,14 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||||
const [neutralizeActive, setNeutralizeActive] = useState(false);
|
const [neutralizeActive, setNeutralizeActive] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (draftAppend) {
|
||||||
|
setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend);
|
||||||
|
onDraftAppendConsumed?.();
|
||||||
|
}
|
||||||
|
}, [draftAppend, onDraftAppendConsumed]);
|
||||||
|
|
||||||
const promptBeforeVoiceRef = useRef('');
|
const promptBeforeVoiceRef = useRef('');
|
||||||
const finalizedTextRef = useRef('');
|
const finalizedTextRef = useRef('');
|
||||||
const currentInterimRef = useRef('');
|
const currentInterimRef = useRef('');
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
|
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [draftAppend, setDraftAppend] = useState('');
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isMobile, setIsMobile] = useState<boolean>(() =>
|
const [isMobile, setIsMobile] = useState<boolean>(() =>
|
||||||
|
|
@ -142,13 +143,28 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
setIsDragOver(false);
|
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;
|
const droppedFiles = e.dataTransfer.files;
|
||||||
if (droppedFiles.length > 0) {
|
if (droppedFiles.length > 0) {
|
||||||
for (const file of Array.from(droppedFiles)) {
|
for (const file of Array.from(droppedFiles)) {
|
||||||
await _uploadAndAttach(file);
|
await _uploadAndAttach(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [_uploadAndAttach]);
|
}, [_uploadAndAttach, instanceId, workspace]);
|
||||||
|
|
||||||
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
|
@ -396,9 +412,9 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
/>
|
/>
|
||||||
<WorkspaceInput
|
<WorkspaceInput
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds) => {
|
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
|
||||||
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
|
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([]);
|
setPendingFiles([]);
|
||||||
}}
|
}}
|
||||||
isProcessing={workspace.isProcessing}
|
isProcessing={workspace.isProcessing}
|
||||||
|
|
@ -415,6 +431,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onTreeItemsDrop={_handleTreeItemsDrop}
|
onTreeItemsDrop={_handleTreeItemsDrop}
|
||||||
onPasteAsFile={_uploadAndAttach}
|
onPasteAsFile={_uploadAndAttach}
|
||||||
|
draftAppend={draftAppend}
|
||||||
|
onDraftAppendConsumed={() => setDraftAppend('')}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ export interface DataSourceAccessEvent {
|
||||||
interface UseWorkspaceReturn {
|
interface UseWorkspaceReturn {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isProcessing: boolean;
|
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;
|
stopProcessing: () => void;
|
||||||
loadWorkflow: (workflowId: string) => void;
|
loadWorkflow: (workflowId: string) => void;
|
||||||
resetToNew: () => void;
|
resetToNew: () => void;
|
||||||
|
|
@ -197,7 +197,7 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
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;
|
if (!instanceId || isProcessing) return;
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
@ -242,6 +242,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
if (allowedProviders.length > 0) {
|
if (allowedProviders.length > 0) {
|
||||||
body.allowedProviders = allowedProviders;
|
body.allowedProviders = allowedProviders;
|
||||||
}
|
}
|
||||||
|
if (options?.requireNeutralization !== undefined) {
|
||||||
|
body.requireNeutralization = options.requireNeutralization;
|
||||||
|
}
|
||||||
|
|
||||||
cleanupRef.current = startSseStream({
|
cleanupRef.current = startSseStream({
|
||||||
url,
|
url,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import type { AttributeDefinition } from '../components/FormGenerator/FormGenera
|
||||||
import type { BillingSettings, BillingSettingsUpdate } from '../api/billingApi';
|
import type { BillingSettings, BillingSettingsUpdate } from '../api/billingApi';
|
||||||
|
|
||||||
export const mandateBillingFieldNames = [
|
export const mandateBillingFieldNames = [
|
||||||
'billingModel',
|
|
||||||
'defaultUserCredit',
|
|
||||||
'warningThresholdPercent',
|
'warningThresholdPercent',
|
||||||
'notifyOnWarning',
|
'notifyOnWarning',
|
||||||
'notifyEmails',
|
'notifyEmails',
|
||||||
|
|
@ -19,31 +17,6 @@ export type MandateBillingFieldName = (typeof mandateBillingFieldNames)[number];
|
||||||
/** FormGenerator attribute definitions for mandate billing (appended after /api/attributes/Mandate). */
|
/** FormGenerator attribute definitions for mandate billing (appended after /api/attributes/Mandate). */
|
||||||
export function getMandateBillingFormAttributes(): AttributeDefinition[] {
|
export function getMandateBillingFormAttributes(): AttributeDefinition[] {
|
||||||
return [
|
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',
|
name: 'warningThresholdPercent',
|
||||||
type: 'float',
|
type: 'float',
|
||||||
|
|
@ -61,7 +34,7 @@ export function getMandateBillingFormAttributes(): AttributeDefinition[] {
|
||||||
required: false,
|
required: false,
|
||||||
default: true,
|
default: true,
|
||||||
editable: true,
|
editable: true,
|
||||||
order: 103,
|
order: 102,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'notifyEmails',
|
name: 'notifyEmails',
|
||||||
|
|
@ -71,7 +44,7 @@ export function getMandateBillingFormAttributes(): AttributeDefinition[] {
|
||||||
required: false,
|
required: false,
|
||||||
default: '',
|
default: '',
|
||||||
editable: true,
|
editable: true,
|
||||||
order: 104,
|
order: 103,
|
||||||
minRows: 2,
|
minRows: 2,
|
||||||
maxRows: 6,
|
maxRows: 6,
|
||||||
},
|
},
|
||||||
|
|
@ -91,12 +64,6 @@ function _parseNotifyEmailsInput(val: unknown): string[] {
|
||||||
.filter(Boolean);
|
.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(
|
export function mergeBillingIntoMandateFormData(
|
||||||
mandate: Record<string, unknown>,
|
mandate: Record<string, unknown>,
|
||||||
settings: BillingSettings | null
|
settings: BillingSettings | null
|
||||||
|
|
@ -104,8 +71,6 @@ export function mergeBillingIntoMandateFormData(
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return {
|
return {
|
||||||
...mandate,
|
...mandate,
|
||||||
billingModel: 'PREPAY_MANDATE',
|
|
||||||
defaultUserCredit: 0,
|
|
||||||
warningThresholdPercent: 10,
|
warningThresholdPercent: 10,
|
||||||
notifyOnWarning: true,
|
notifyOnWarning: true,
|
||||||
notifyEmails: '',
|
notifyEmails: '',
|
||||||
|
|
@ -113,8 +78,6 @@ export function mergeBillingIntoMandateFormData(
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...mandate,
|
...mandate,
|
||||||
billingModel: _normalizeBillingModelUi(settings.billingModel),
|
|
||||||
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
|
|
||||||
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
||||||
notifyOnWarning: settings.notifyOnWarning ?? true,
|
notifyOnWarning: settings.notifyOnWarning ?? true,
|
||||||
notifyEmails: (settings.notifyEmails || []).join('\n'),
|
notifyEmails: (settings.notifyEmails || []).join('\n'),
|
||||||
|
|
@ -131,19 +94,6 @@ export function splitMandateAndBillingFromForm(
|
||||||
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
|
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
|
||||||
|
|
||||||
const billingUpdate: BillingSettingsUpdate = {};
|
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 (
|
if (
|
||||||
'warningThresholdPercent' in formData &&
|
'warningThresholdPercent' in formData &&
|
||||||
formData.warningThresholdPercent !== undefined &&
|
formData.warningThresholdPercent !== undefined &&
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue