ui-nyla/src/components/admin/MandateInfoPanel.tsx

329 lines
11 KiB
TypeScript

/**
* MandateInfoPanel — inline-editable mandate + billing summary (left expand panel).
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import type { Mandate } from '../../hooks/useMandates';
import { useMandateFormAttributes } from '../../hooks/useMandates';
import { useApiRequest } from '../../hooks/useApi';
import { fetchSettingsAdmin, updateSettingsAdmin, type BillingSettingsUpdate } from '../../api/billingApi';
import {
mergeBillingIntoMandateFormData,
getMandateBillingFormAttributes,
MANDATE_INVOICE_FIELD_NAMES,
formatMandateInvoiceAddress,
isMandateBillingField,
mandateBillingFieldNames,
} from '../../utils/mandateBillingFormMerge';
import { InlineEditableField, type InlineFieldType } from './InlineEditableField';
import { useToast } from '../../contexts/ToastContext';
import styles from './MandateInfoPanel.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
import { getUserDataCache } from '../../utils/userCache';
const STAMMDATEN_FIELDS = ['name', 'label', 'enabled', 'isSystem', 'mfaRequired'] as const;
const FIELD_TYPES: Record<string, InlineFieldType> = {
name: 'text',
label: 'text',
enabled: 'boolean',
isSystem: 'boolean',
mfaRequired: 'boolean',
warningThresholdPercent: 'number',
notifyOnWarning: 'boolean',
notifyEmails: 'textarea',
invoiceEmail: 'email',
};
const MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
export interface MandateInfoPanelProps {
mandateId: string;
mandateLabel?: string;
canUpdate: boolean;
fetchMandateById: (id: string) => Promise<Mandate | null>;
handleUpdate: (mandateId: string, updateData: Partial<Mandate>) => Promise<boolean>;
onMandateUpdated?: () => void;
refreshKey?: number;
}
function isFieldEditable(
fieldName: string,
canUpdate: boolean,
isPlatformAdmin: boolean,
isSystemMandate: boolean,
): boolean {
if (!canUpdate) return false;
if (fieldName === 'isSystem') return false;
if (isSystemMandate && fieldName === 'name') return false;
if (!isPlatformAdmin) {
return MANDATE_ADMIN_EDITABLE.has(fieldName);
}
return fieldName !== 'isSystem' && fieldName !== 'id' && fieldName !== 'deletedAt';
}
function InfoSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className={styles.section}>
<h4 className={styles.sectionTitle}>{title}</h4>
{children}
</section>
);
}
export const MandateInfoPanel: React.FC<MandateInfoPanelProps> = ({
mandateId,
mandateLabel,
canUpdate,
fetchMandateById,
handleUpdate,
onMandateUpdated,
refreshKey = 0,
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { showError, showWarning } = useToast();
const { formAttributes } = useMandateFormAttributes();
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [billingWarning, setBillingWarning] = useState<string | null>(null);
const [savingField, setSavingField] = useState<string | null>(null);
const [editingAddress, setEditingAddress] = useState(false);
const labelMap = useMemo(() => {
const map = new Map<string, string>();
for (const attr of formAttributes) {
map.set(attr.name, attr.label || attr.name);
}
for (const attr of getMandateBillingFormAttributes()) {
map.set(attr.name, attr.label || attr.name);
}
return map;
}, [formAttributes]);
const loadDetail = useCallback(async () => {
setLoading(true);
setBillingWarning(null);
try {
const fullMandate = await fetchMandateById(mandateId);
if (!fullMandate) {
setData(null);
return;
}
try {
const settings = await fetchSettingsAdmin(request, mandateId);
setData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, settings));
} catch {
setData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, null));
setBillingWarning(t('Abrechnungseinstellungen konnten nicht geladen werden.'));
}
} finally {
setLoading(false);
}
}, [mandateId, fetchMandateById, request, t]);
useEffect(() => {
void loadDetail();
}, [loadDetail, refreshKey]);
const isSystemMandate = Boolean(data?.isSystem);
const canEditInvoice = isPlatformAdmin && canUpdate;
const saveField = useCallback(
async (fieldName: string, newValue: unknown) => {
setSavingField(fieldName);
try {
if (isMandateBillingField(fieldName)) {
const billingUpdate: BillingSettingsUpdate = {};
if (fieldName === 'warningThresholdPercent') {
const n = Number(newValue);
if (Number.isNaN(n)) {
showError(t('Fehler'), t('Ungültiger Wert'));
return;
}
billingUpdate.warningThresholdPercent = n;
} else if (fieldName === 'notifyOnWarning') {
billingUpdate.notifyOnWarning = Boolean(newValue);
} else if (fieldName === 'notifyEmails') {
const emails =
typeof newValue === 'string'
? newValue.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean)
: [];
billingUpdate.notifyEmails = emails;
}
await updateSettingsAdmin(request, mandateId, billingUpdate);
} else {
const ok = await handleUpdate(mandateId, { [fieldName]: newValue } as Partial<Mandate>);
if (!ok) {
showWarning(t('Fehler'), t('Speichern fehlgeschlagen.'));
await loadDetail();
return;
}
}
setData((prev) => (prev ? { ...prev, [fieldName]: newValue } : prev));
onMandateUpdated?.();
} catch (e: unknown) {
console.error(e);
showError(t('Fehler'), t('Speichern fehlgeschlagen.'));
await loadDetail();
} finally {
setSavingField(null);
}
},
[mandateId, request, handleUpdate, loadDetail, onMandateUpdated, showError, showWarning, t],
);
const displayTitle =
mandateLabel ||
(data
? mandateDisplayLabel({
label: data.label as string | undefined,
name: data.name as string | undefined,
id: mandateId,
})
: mandateId);
const formattedAddress = data ? formatMandateInvoiceAddress(data) : null;
if (loading) {
return (
<div className={styles.loadingWrap}>
<div className={styles.spinner} />
<span>{t('Laden…')}</span>
</div>
);
}
if (!data) {
return (
<div className={styles.loadingWrap}>
<span>{t('Mandant konnte nicht geladen werden.')}</span>
</div>
);
}
return (
<div className={styles.panelInner}>
<div className={styles.header}>
<div>
<h3 className={styles.headerTitle}>{t('Mandant')}</h3>
<p className={styles.headerSubtitle}>{displayTitle}</p>
</div>
</div>
{billingWarning && <div className={styles.billingWarning}>{billingWarning}</div>}
{Boolean(data.isSystem) && (
<div className={styles.systemHint}>
<strong>{t('System-Mandant')}</strong> {t('Kurzzeichen ist schreibgeschützt.')}
</div>
)}
<InfoSection title={t('Stammdaten')}>
<dl className={styles.fieldGrid}>
{STAMMDATEN_FIELDS.map((fieldName) => (
<InlineEditableField
key={fieldName}
t={t}
label={labelMap.get(fieldName) || fieldName}
value={data[fieldName]}
fieldKey={fieldName}
type={FIELD_TYPES[fieldName] ?? 'text'}
editable={isFieldEditable(fieldName, canUpdate, isPlatformAdmin, isSystemMandate)}
saving={savingField === fieldName}
onSave={saveField}
/>
))}
</dl>
</InfoSection>
<InfoSection title={t('Rechnungsadresse')}>
{editingAddress && canEditInvoice ? (
<div className={styles.addressEditBox}>
<dl className={styles.fieldGrid}>
{MANDATE_INVOICE_FIELD_NAMES.map((fieldName) => (
<InlineEditableField
key={fieldName}
t={t}
label={labelMap.get(fieldName) || fieldName}
value={data[fieldName]}
fieldKey={fieldName}
type={FIELD_TYPES[fieldName] ?? 'text'}
editable
saving={savingField === fieldName}
onSave={saveField}
/>
))}
</dl>
<button
type="button"
className={styles.addressDoneBtn}
onClick={(e) => {
e.stopPropagation();
setEditingAddress(false);
}}
>
{t('Fertig')}
</button>
</div>
) : (
<div
role={canEditInvoice ? 'button' : undefined}
tabIndex={canEditInvoice ? 0 : undefined}
className={`${styles.addressBlock} ${canEditInvoice ? styles.editableValue : ''}`}
onClick={(e) => {
e.stopPropagation();
if (canEditInvoice) setEditingAddress(true);
}}
onKeyDown={(e) => {
if (canEditInvoice && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
setEditingAddress(true);
}
}}
title={canEditInvoice ? t('Klicken zum Bearbeiten') : undefined}
>
{formattedAddress ? (
<pre className={styles.addressPre}>{formattedAddress}</pre>
) : (
<span className={styles.fieldValueMuted}>
{canEditInvoice ? t('Klicken, um Adresse zu erfassen') : '—'}
</span>
)}
</div>
)}
</InfoSection>
<InfoSection title={t('Abrechnung')}>
<dl className={styles.fieldGrid}>
{mandateBillingFieldNames.map((fieldName) => (
<InlineEditableField
key={fieldName}
t={t}
label={labelMap.get(fieldName) || fieldName}
value={data[fieldName]}
fieldKey={fieldName}
type={FIELD_TYPES[fieldName] ?? 'text'}
editable={isFieldEditable(fieldName, canUpdate, isPlatformAdmin, isSystemMandate)}
saving={savingField === fieldName}
onSave={saveField}
/>
))}
</dl>
</InfoSection>
</div>
);
};
export default MandateInfoPanel;