ui-nyla/src/pages/admin/AdminMandatesPage.tsx
ValueOn AG 9b99020686 feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE)
Gateway
- InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF /
  CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck()
- HTTP 402 + JSON detail für globale API-Fehlerbehandlung
- AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails
  (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify
- Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload
- datamodelBilling: notifyEmails-Doku für Pool-Alerts
frontend_nyla
- useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En
  und Hinweis auf Billing-Pfad bei TOP_UP_SELF
2026-03-21 01:34:47 +01:00

336 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AdminMandatesPage
*
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
*/
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
import { useApiRequest } from '../../hooks/useApi';
import { fetchSettingsAdmin, updateSettingsAdmin } from '../../api/billingApi';
import {
mergeBillingIntoMandateFormData,
splitMandateAndBillingFromForm,
} from '../../utils/mandateBillingFormMerge';
import { useToast } from '../../contexts/ToastContext';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
import styles from './Admin.module.css';
export const AdminMandatesPage: React.FC = () => {
const navigate = useNavigate();
const { request } = useApiRequest();
const { showWarning, showSuccess } = useToast();
const {
mandates,
columns,
permissions,
pagination,
loading,
error,
refetch,
fetchMandateById,
handleCreate,
handleUpdate,
handleDelete,
handleInlineUpdate,
updateOptimistically,
} = useAdminMandates();
const {
formAttributes,
createFormAttributes,
formAttributesWithBilling,
createFormAttributesWithBilling,
loading: mandateAttrsLoading,
} = useMandateFormAttributes();
const [showCreateModal, setShowCreateModal] = useState(false);
/** Mandate row merged with billing fields for FormGenerator */
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
// Check if user can create
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click — load mandate + billing settings (separate persistence)
const handleEditClick = async (mandate: Mandate) => {
setEditingBillingWarning(null);
const fullMandate = await fetchMandateById(mandate.id);
if (!fullMandate) return;
try {
const settings = await fetchSettingsAdmin(request, fullMandate.id);
setEditingFormData(
mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, settings)
);
} catch {
setEditingFormData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, null));
setEditingBillingWarning(
'Abrechnungseinstellungen konnten nicht geladen werden. Nur Mandantendaten sind sicher bearbeitbar.'
);
}
};
// Handle create submit — POST mandate, then billing settings
const handleCreateSubmit = async (data: Record<string, unknown>) => {
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
const created = await handleCreate(mandatePayload as Partial<Mandate>);
if (!created?.id) return;
try {
await updateSettingsAdmin(request, created.id, billingUpdate);
showSuccess('Erstellt', 'Mandant inkl. Abrechnungseinstellungen gespeichert.');
} catch (e: unknown) {
console.error(e);
showWarning(
'Mandant erstellt',
'Abrechnungseinstellungen konnten nicht gespeichert werden. Bitte unter Administration → Abrechnung nachpflegen.'
);
}
setShowCreateModal(false);
};
// Handle edit submit — PUT mandate + POST billing settings
const handleEditSubmit = async (data: Record<string, unknown>) => {
if (!editingFormData?.id) return;
const mandateId = String(editingFormData.id);
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial<Mandate>);
if (!mandateOk) return;
try {
await updateSettingsAdmin(request, mandateId, billingUpdate);
showSuccess('Gespeichert', 'Mandant und Abrechnung aktualisiert.');
} catch (e: unknown) {
console.error(e);
showWarning('Teilweise gespeichert', 'Mandant gespeichert, Abrechnung konnte nicht aktualisiert werden.');
}
setEditingFormData(null);
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
}
await handleDelete(mandate.id);
};
if (error) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Mandanten: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Mandanten</h1>
<p className={styles.pageSubtitle}>Verwalten Sie alle Mandanten im System</p>
</div>
<div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/user-mandates')}
>
<FaUsers /> Benutzer-Zuweisungen
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neuer Mandant
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && mandates.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Mandanten...</span>
</div>
) : mandates.length === 0 ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Mandanten vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie einen neuen Mandanten, um loszulegen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Ersten Mandanten erstellen
</button>
)}
</div>
) : (
<FormGeneratorTable
data={mandates}
columns={columns}
apiEndpoint="/api/mandates/"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
disabled: (row: Mandate) => row.isSystem
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
: false
}] : []),
]}
onDelete={handleDeleteMandate}
hookData={{
refetch,
permissions,
pagination,
handleDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Mandanten gefunden"
/>
)}
</div>
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neuer Mandant</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: 0, marginBottom: '12px' }}>
Stammdaten kommen aus dem Modell <code>Mandate</code> (API). Abrechnung wird in{' '}
<code>BillingSettings</code> pro Mandant gespeichert.
</p>
{mandateAttrsLoading || createFormAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={createFormAttributesWithBilling}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingFormData && (
<div
className={styles.modalOverlay}
onClick={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Mandant bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
>
</button>
</div>
<div className={styles.modalContent}>
{Boolean(editingFormData.isSystem) && (
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
<span>
Dies ist ein <strong>System-Mandant</strong>. Er kann nicht gelöscht werden und der Name sollte nicht geändert werden.
</span>
</div>
)}
{editingBillingWarning && (
<div
className={styles.infoBox}
style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}
>
{editingBillingWarning}
</div>
)}
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributesWithBilling}
data={editingFormData}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminMandatesPage;