387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
/**
|
||
* AdminMandatesPage
|
||
*
|
||
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
|
||
*/
|
||
|
||
import React, { useState, useMemo } 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 { usePrompt } from '../../hooks/usePrompt';
|
||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
|
||
import { getUserDataCache } from '../../utils/userCache';
|
||
import styles from './Admin.module.css';
|
||
|
||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||
|
||
export const AdminMandatesPage: React.FC = () => {
|
||
const { t } = useLanguage();
|
||
|
||
const navigate = useNavigate();
|
||
const { request } = useApiRequest();
|
||
const { showWarning, showSuccess } = useToast();
|
||
const { prompt, PromptDialog } = usePrompt();
|
||
const {
|
||
mandates,
|
||
columns,
|
||
permissions,
|
||
pagination,
|
||
loading,
|
||
error,
|
||
refetch,
|
||
fetchMandateById,
|
||
handleCreate,
|
||
handleUpdate,
|
||
handleDelete,
|
||
handleHardDelete,
|
||
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);
|
||
|
||
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
|
||
|
||
// MandateAdmin: only label + billing fields editable; rest readonly
|
||
const _MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
|
||
const editFormAttrs: AttributeDefinition[] = useMemo(() => {
|
||
if (isPlatformAdmin) return formAttributesWithBilling;
|
||
return formAttributesWithBilling.map(attr =>
|
||
_MANDATE_ADMIN_EDITABLE.has(attr.name) ? attr : { ...attr, editable: false, readonly: true }
|
||
);
|
||
}, [formAttributesWithBilling, isPlatformAdmin]);
|
||
|
||
// 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(
|
||
t('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(t('Erstellt'), t('Mandant inkl. Abrechnungseinstellungen gespeichert.'));
|
||
} catch (e: unknown) {
|
||
console.error(e);
|
||
showWarning(
|
||
t('Mandant erstellt'),
|
||
t('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) {
|
||
showWarning(t('Fehler'), t('Mandant konnte nicht gespeichert werden. Fehlende Berechtigung oder Serverfehler.'));
|
||
return;
|
||
}
|
||
try {
|
||
await updateSettingsAdmin(request, mandateId, billingUpdate);
|
||
showSuccess(t('Gespeichert'), t('Mandant und Abrechnung aktualisiert.'));
|
||
} catch (e: unknown) {
|
||
console.error(e);
|
||
showWarning(t('Teilweise gespeichert'), t('Mandant gespeichert, Abrechnung konnte nicht aktualisiert werden.'));
|
||
}
|
||
setEditingFormData(null);
|
||
setEditingBillingWarning(null);
|
||
};
|
||
|
||
const handleDeleteMandate = async (mandate: Mandate) => {
|
||
if (mandate.isSystem) {
|
||
return;
|
||
}
|
||
const entered = await prompt(
|
||
t(
|
||
'Um den Mandanten zu deaktivieren (Soft-Delete), geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).',
|
||
{ slug: mandate.name, label: mandate.label || mandate.name }
|
||
),
|
||
{ title: t('Mandat deaktivieren'), confirmLabel: t('Deaktivieren'), variant: 'danger', placeholder: mandate.name },
|
||
);
|
||
if (entered === null) return;
|
||
if (entered !== mandate.name) {
|
||
showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.'));
|
||
return;
|
||
}
|
||
await handleDelete(mandate.id);
|
||
};
|
||
|
||
const handleHardDeleteMandate = async (mandate: Mandate) => {
|
||
if (mandate.isSystem) {
|
||
showWarning(t('Nicht erlaubt'), t('System-Mandanten können nicht gelöscht werden.'));
|
||
return;
|
||
}
|
||
const entered = await prompt(
|
||
t(
|
||
'ACHTUNG: Dies löscht den Mandanten unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).',
|
||
{ slug: mandate.name, label: mandate.label || mandate.name }
|
||
),
|
||
{ title: t('Unwiderrufliches Löschen'), confirmLabel: t('Dauerhaft löschen'), variant: 'danger', placeholder: mandate.name },
|
||
);
|
||
if (entered === null) return;
|
||
if (entered !== mandate.name) {
|
||
showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.'));
|
||
return;
|
||
}
|
||
const ok = await handleHardDelete(mandate.id, entered);
|
||
if (ok) {
|
||
showSuccess(
|
||
t('Gelöscht'),
|
||
t('Mandant «{name}» wurde endgültig gelöscht.', { name: mandate.label || mandate.name })
|
||
);
|
||
}
|
||
};
|
||
|
||
if (error) {
|
||
return (
|
||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||
<div className={styles.errorContainer}>
|
||
<span className={styles.errorIcon}>⚠️</span>
|
||
<p className={styles.errorMessage}>
|
||
{t('Fehler beim Laden der Mandanten')}: {error}
|
||
</p>
|
||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||
<FaSync /> {t('Erneut versuchen')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||
<div className={styles.pageHeader}>
|
||
<div>
|
||
<h1 className={styles.pageTitle}>{t('Mandanten')}</h1>
|
||
<p className={styles.pageSubtitle}>
|
||
{t('Verwalten Sie alle Mandanten im')}
|
||
{' '}
|
||
{t(
|
||
'Der Volle Name erscheint in der Oberfläche; das Kurzzeichen ist systemweit eindeutig und dient Referenzierung und Bestätigungsabfragen.'
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className={styles.headerActions}>
|
||
<button
|
||
type="button"
|
||
className={styles.secondaryButton}
|
||
onClick={() => navigate('/admin/user-mandates')}
|
||
>
|
||
<FaUsers /> {t('Benutzer-Zuweisungen')}
|
||
</button>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => refetch()}
|
||
disabled={loading}
|
||
>
|
||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||
</button>
|
||
{canCreate && (
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => setShowCreateModal(true)}
|
||
>
|
||
<FaPlus /> {t('Neuer Mandant')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.tableContainer}>
|
||
<FormGeneratorTable
|
||
data={mandates}
|
||
columns={columns}
|
||
apiEndpoint="/api/mandates/"
|
||
loading={loading}
|
||
pagination={true}
|
||
pageSize={25}
|
||
searchable={true}
|
||
filterable={true}
|
||
sortable={true}
|
||
selectable={true}
|
||
actionButtons={[
|
||
...(canUpdate ? [{
|
||
type: 'edit' as const,
|
||
onAction: handleEditClick,
|
||
title: t('Bearbeiten'),
|
||
}] : []),
|
||
...(canDelete ? [{
|
||
type: 'delete' as const,
|
||
title: t('Soft-Löschen deaktivieren'),
|
||
disabled: (row: Mandate) => row.isSystem
|
||
? { disabled: true, message: t('System-Mandanten können nicht gelöscht werden') }
|
||
: false
|
||
}] : []),
|
||
]}
|
||
customActions={canDelete ? [{
|
||
id: 'hard-delete',
|
||
icon: <FaSkullCrossbones />,
|
||
onClick: handleHardDeleteMandate,
|
||
title: t('Unwiderrufliches Löschen'),
|
||
disabled: (row: Mandate) => row.isSystem
|
||
? { disabled: true, message: t('System-Mandanten können nicht gelöscht werden') }
|
||
: false,
|
||
}] : []}
|
||
onDelete={handleDeleteMandate}
|
||
hookData={{
|
||
refetch,
|
||
permissions,
|
||
pagination,
|
||
handleDelete,
|
||
handleInlineUpdate,
|
||
updateOptimistically,
|
||
}}
|
||
emptyMessage={t('Keine Mandanten gefunden')}
|
||
/>
|
||
</div>
|
||
|
||
{/* Create Modal */}
|
||
{showCreateModal && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modal}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>{t('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' }}>
|
||
{t('Stammdaten kommen aus dem Modell')} <code>Mandate</code> {t('(API). Abrechnung wird in')}{' '}
|
||
<code>BillingSettings</code> {t('pro Mandant gespeichert.')}
|
||
</p>
|
||
{mandateAttrsLoading || createFormAttributes.length === 0 ? (
|
||
<div className={styles.loadingContainer}>
|
||
<div className={styles.spinner} />
|
||
<span>{t('Lade Formular')}</span>
|
||
</div>
|
||
) : (
|
||
<FormGeneratorForm
|
||
attributes={createFormAttributesWithBilling}
|
||
mode="create"
|
||
onSubmit={handleCreateSubmit}
|
||
onCancel={() => setShowCreateModal(false)}
|
||
submitButtonText={t('Erstellen')}
|
||
cancelButtonText={t('Abbrechen')}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<PromptDialog />
|
||
|
||
{/* Edit Modal */}
|
||
{editingFormData && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modal}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>{t('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>
|
||
{t('Dies ist ein')} <strong>{t('System-Mandant')}</strong>.{' '}
|
||
{t(
|
||
'Er kann nicht gelöscht werden. Das Kurzzeichen (technischer Identifier) soll nicht geändert werden; der Volle Name kann bei Bedarf angepasst 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>{t('Lade Formular')}</span>
|
||
</div>
|
||
) : (
|
||
<FormGeneratorForm
|
||
attributes={editFormAttrs}
|
||
data={editingFormData}
|
||
mode="edit"
|
||
onSubmit={handleEditSubmit}
|
||
onCancel={() => {
|
||
setEditingFormData(null);
|
||
setEditingBillingWarning(null);
|
||
}}
|
||
submitButtonText={t('Speichern')}
|
||
cancelButtonText={t('Abbrechen')}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminMandatesPage;
|