frontend_nyla/src/pages/admin/AdminMandatesPage.tsx

387 lines
15 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, 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;