329 lines
11 KiB
TypeScript
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;
|