mandate information panel in expanded tenant

This commit is contained in:
Ida 2026-06-04 14:28:52 +02:00
parent fa7988b4b6
commit 78ccea8bac
9 changed files with 850 additions and 134 deletions

View file

@ -0,0 +1,163 @@
import React, { useState, useRef, useEffect } from 'react';
import styles from './MandateInfoPanel.module.css';
export type InlineFieldType = 'text' | 'number' | 'boolean' | 'textarea' | 'email';
export interface InlineEditableFieldProps {
label: string;
value: unknown;
fieldKey: string;
type?: InlineFieldType;
editable?: boolean;
saving?: boolean;
onSave: (fieldKey: string, value: unknown) => Promise<void>;
}
function displayValue(value: unknown, type: InlineFieldType, t: (k: string) => string): string {
if (type === 'boolean') {
if (value === true) return t('Ja');
if (value === false) return t('Nein');
return '—';
}
if (value === null || value === undefined || value === '') return '—';
return String(value);
}
export const InlineEditableField: React.FC<
InlineEditableFieldProps & { t: (key: string) => string }
> = ({
label,
value,
fieldKey,
type = 'text',
editable = false,
saving = false,
onSave,
t,
}) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
if (type !== 'boolean' && inputRef.current instanceof HTMLInputElement) {
inputRef.current.select();
}
}
}, [editing, type]);
const startEdit = () => {
if (!editable || saving || type === 'boolean') return;
setDraft(value === null || value === undefined ? '' : String(value));
setEditing(true);
};
const commit = async () => {
if (!editing) return;
setEditing(false);
let next: unknown = draft;
if (type === 'number') {
const n = Number(draft);
if (Number.isNaN(n)) return;
next = n;
}
if (String(value ?? '') === String(next ?? '')) return;
await onSave(fieldKey, next);
};
const cancel = () => {
setEditing(false);
setDraft('');
};
const handleBooleanToggle = async () => {
if (!editable || saving) return;
const next = value !== true;
await onSave(fieldKey, next);
};
const shown = displayValue(value, type, t);
const muted = shown === '—';
if (type === 'boolean') {
return (
<>
<dt className={styles.fieldLabel}>{label}</dt>
<dd className={styles.fieldValue}>
<button
type="button"
className={`${styles.boolToggle} ${editable ? styles.editableValue : ''} ${saving ? styles.saving : ''}`}
onClick={(e) => {
e.stopPropagation();
void handleBooleanToggle();
}}
disabled={!editable || saving}
>
{saving ? t('Speichern…') : shown}
</button>
</dd>
</>
);
}
return (
<>
<dt className={styles.fieldLabel}>{label}</dt>
<dd className={styles.fieldValue}>
{editing ? (
type === 'textarea' ? (
<textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
className={styles.inlineInput}
rows={3}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Escape') cancel();
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type={type === 'email' ? 'email' : type === 'number' ? 'number' : 'text'}
className={styles.inlineInput}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Enter') void commit();
if (e.key === 'Escape') cancel();
}}
onClick={(e) => e.stopPropagation()}
/>
)
) : (
<span
role={editable ? 'button' : undefined}
tabIndex={editable ? 0 : undefined}
className={`${styles.valueClickable} ${editable ? styles.editableValue : ''} ${muted ? styles.fieldValueMuted : ''} ${saving ? styles.saving : ''}`}
onClick={(e) => {
e.stopPropagation();
startEdit();
}}
onKeyDown={(e) => {
if (editable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
startEdit();
}
}}
title={editable ? t('Klicken zum Bearbeiten') : undefined}
>
{saving ? t('Speichern…') : shown}
</span>
)}
</dd>
</>
);
};
export default InlineEditableField;

View file

@ -0,0 +1,30 @@
.dashboard {
display: grid;
grid-template-columns: 1fr 3fr;
gap: 16px;
width: 100%;
min-width: 0;
align-items: stretch;
}
.panel {
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
background: var(--color-bg, #fff);
padding: 14px 16px;
min-width: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.panelUsers {
padding: 0;
overflow: hidden;
}
@media (max-width: 900px) {
.dashboard {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,52 @@
/**
* MandateExpandDashboard two-panel layout inside expanded mandate table row.
*/
import React from 'react';
import type { Mandate } from '../../hooks/useMandates';
import { MandateInfoPanel } from './MandateInfoPanel';
import { MandateUsersPanel } from './MandateUsersPanel';
import styles from './MandateExpandDashboard.module.css';
export interface MandateExpandDashboardProps {
mandate: Mandate;
canUpdate: boolean;
fetchMandateById: (id: string) => Promise<Mandate | null>;
handleUpdate: (mandateId: string, updateData: Partial<Mandate>) => Promise<boolean>;
onMandateUpdated?: () => void;
refreshKey?: number;
}
export const MandateExpandDashboard: React.FC<MandateExpandDashboardProps> = ({
mandate,
canUpdate,
fetchMandateById,
handleUpdate,
onMandateUpdated,
refreshKey,
}) => {
return (
<div className={styles.dashboard}>
<div className={styles.panel}>
<MandateInfoPanel
mandateId={mandate.id}
mandateLabel={mandate.label || mandate.name}
canUpdate={canUpdate}
fetchMandateById={fetchMandateById}
handleUpdate={handleUpdate}
onMandateUpdated={onMandateUpdated}
refreshKey={refreshKey}
/>
</div>
<div className={`${styles.panel} ${styles.panelUsers}`}>
<MandateUsersPanel
mandateId={mandate.id}
embedded
embeddedInDashboard
/>
</div>
</div>
);
};
export default MandateExpandDashboard;

View file

@ -0,0 +1,194 @@
.panelInner {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.headerTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #0f172a);
line-height: 1.3;
}
.headerSubtitle {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
font-family: var(--font-family-mono, monospace);
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.sectionTitle {
margin: 0;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary, #64748b);
padding-bottom: 4px;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.fieldGrid {
display: grid;
grid-template-columns: minmax(0, 38%) minmax(0, 1fr);
gap: 6px 12px;
font-size: 0.8125rem;
}
.fieldLabel {
color: var(--text-secondary, #64748b);
font-weight: 500;
}
.fieldValue {
color: var(--text-primary, #0f172a);
word-break: break-word;
}
.fieldValueMuted {
color: var(--text-secondary, #94a3b8);
}
.valueClickable {
display: inline-block;
max-width: 100%;
word-break: break-word;
}
.editableValue {
cursor: pointer;
border-radius: 4px;
padding: 2px 4px;
margin: -2px -4px;
transition: background 0.12s;
}
.editableValue:hover {
background: var(--color-primary-light, rgba(74, 111, 165, 0.1));
}
.saving {
opacity: 0.6;
cursor: wait;
}
.inlineInput {
width: 100%;
font-size: 0.8125rem;
font-family: var(--font-family);
padding: 4px 6px;
border: 1px solid var(--color-primary, #4a6fa5);
border-radius: 4px;
box-sizing: border-box;
}
.boolToggle {
background: none;
border: none;
padding: 2px 4px;
margin: -2px -4px;
font: inherit;
color: inherit;
text-align: left;
border-radius: 4px;
}
.addressBlock {
padding: 10px 12px;
background: var(--bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
min-height: 48px;
}
.addressPre {
margin: 0;
font-family: var(--font-family);
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre-wrap;
color: var(--text-primary, #0f172a);
}
.addressEditBox {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
}
.addressDoneBtn {
align-self: flex-end;
font-size: 0.75rem;
padding: 4px 10px;
border-radius: 5px;
border: 1px solid var(--color-border, #e2e8f0);
background: var(--color-bg, #fff);
cursor: pointer;
}
.addressDoneBtn:hover {
background: var(--bg-secondary, #f8fafc);
}
.systemHint {
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
padding: 6px 8px;
background: var(--warning-bg, #fffbeb);
border: 1px solid var(--warning-color, #d69e2e);
border-radius: 6px;
}
.billingWarning {
font-size: 0.75rem;
color: var(--warning-color, #d69e2e);
padding: 6px 8px;
background: var(--warning-bg, #fffbeb);
border: 1px solid var(--warning-color, #d69e2e);
border-radius: 6px;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--text-secondary);
font-size: 0.8125rem;
gap: 8px;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid var(--color-border, #e2e8f0);
border-top-color: var(--primary-color, #f25843);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,329 @@
/**
* 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;

View file

@ -13,6 +13,19 @@
box-sizing: border-box;
}
.panelInDashboard {
height: 100%;
min-height: 280px;
}
.cardInDashboard {
border: none;
border-radius: 0;
box-shadow: none;
height: 100%;
min-height: 0;
}
.card {
display: flex;
flex-direction: column;

View file

@ -27,11 +27,14 @@ import { useLanguage } from '../../providers/language/LanguageContext';
export interface MandateUsersPanelProps {
mandateId: string;
embedded?: boolean;
/** Inside MandateExpandDashboard — no nested card border */
embeddedInDashboard?: boolean;
}
export const MandateUsersPanel: React.FC<MandateUsersPanelProps> = ({
mandateId,
embedded = false,
embeddedInDashboard = false,
}) => {
const { t } = useLanguage();
const { showError, showSuccess } = useToast();
@ -292,8 +295,12 @@ export const MandateUsersPanel: React.FC<MandateUsersPanelProps> = ({
);
return (
<div className={`${panelStyles.panel} ${embedded ? panelStyles.panelEmbedded : ''}`}>
<div className={`${panelStyles.card} ${embedded ? panelStyles.cardEmbedded : ''}`}>
<div
className={`${panelStyles.panel} ${embedded ? panelStyles.panelEmbedded : ''} ${embeddedInDashboard ? panelStyles.panelInDashboard : ''}`}
>
<div
className={`${panelStyles.card} ${embedded && !embeddedInDashboard ? panelStyles.cardEmbedded : ''} ${embeddedInDashboard ? panelStyles.cardInDashboard : ''}`}
>
<div
className={`${panelStyles.headerBar} ${embedded ? panelStyles.headerBarEmbedded : ''}`}
>

View file

@ -4,22 +4,18 @@
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
*/
import React, { useState, useMemo, useCallback } from 'react';
import React, { useState, useCallback } 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 { updateSettingsAdmin } from '../../api/billingApi';
import { 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 { MandateUsersPanel } from '../../components/admin/MandateUsersPanel';
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
import { getUserDataCache } from '../../utils/userCache';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { MandateExpandDashboard } from '../../components/admin/MandateExpandDashboard';
import { FaPlus, FaSync, FaUsers, FaSkullCrossbones } from 'react-icons/fa';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
@ -49,18 +45,14 @@ export const AdminMandatesPage: React.FC = () => {
} = 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 [expandedMandateIds, setExpandedMandateIds] = useState<Set<string>>(new Set());
const [detailRefreshKey, setDetailRefreshKey] = useState(0);
const isMandateExpanded = useCallback(
(mandate: Mandate) => expandedMandateIds.has(mandate.id),
@ -79,40 +71,16 @@ export const AdminMandatesPage: React.FC = () => {
});
}, []);
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]);
const handleMandateUpdated = useCallback(() => {
void refetch();
setDetailRefreshKey((k) => k + 1);
}, [refetch]);
// 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);
@ -131,27 +99,6 @@ export const AdminMandatesPage: React.FC = () => {
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;
@ -269,11 +216,6 @@ export const AdminMandatesPage: React.FC = () => {
type: 'expand' as const,
title: t('Benutzer anzeigen'),
},
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: t('Bearbeiten'),
}] : []),
...(canUpdate ? [{
type: 'roles' as const,
title: t('Rollen & Berechtigungen'),
@ -297,7 +239,14 @@ export const AdminMandatesPage: React.FC = () => {
}] : []}
onDelete={handleDeleteMandate}
renderExpandedRow={(mandate) => (
<MandateUsersPanel mandateId={mandate.id} embedded />
<MandateExpandDashboard
mandate={mandate}
canUpdate={canUpdate}
fetchMandateById={fetchMandateById}
handleUpdate={handleUpdate}
onMandateUpdated={handleMandateUpdated}
refreshKey={detailRefreshKey}
/>
)}
hookData={{
refetch,
@ -353,66 +302,6 @@ export const AdminMandatesPage: React.FC = () => {
)}
<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>
);
};

View file

@ -64,6 +64,42 @@ function _parseNotifyEmailsInput(val: unknown): string[] {
.filter(Boolean);
}
/** Formatted multi-line invoice address for read-only display. */
export function formatMandateInvoiceAddress(data: Record<string, unknown>): string | null {
const lines: string[] = [];
const company = data.invoiceCompanyName;
const contact = data.invoiceContactName;
const line1 = data.invoiceLine1;
const line2 = data.invoiceLine2;
const postal = data.invoicePostalCode;
const city = data.invoiceCity;
const state = data.invoiceState;
const country = data.invoiceCountry;
const vat = data.invoiceVatNumber;
const email = data.invoiceEmail;
if (typeof company === 'string' && company.trim()) lines.push(company.trim());
if (typeof contact === 'string' && contact.trim()) lines.push(`z. H. ${contact.trim()}`);
if (typeof line1 === 'string' && line1.trim()) lines.push(line1.trim());
if (typeof line2 === 'string' && line2.trim()) lines.push(line2.trim());
const cityLine = [postal, city].filter((v) => typeof v === 'string' && v.trim()).join(' ');
if (cityLine.trim()) {
const withState =
typeof state === 'string' && state.trim() ? `${cityLine.trim()} (${state.trim()})` : cityLine.trim();
lines.push(withState);
}
if (typeof country === 'string' && country.trim()) lines.push(country.trim());
if (typeof vat === 'string' && vat.trim()) lines.push(`UID: ${vat.trim()}`);
if (typeof email === 'string' && email.trim()) lines.push(email.trim());
return lines.length > 0 ? lines.join('\n') : null;
}
export function isMandateBillingField(fieldName: string): fieldName is MandateBillingFieldName {
return (mandateBillingFieldNames as readonly string[]).includes(fieldName);
}
export function mergeBillingIntoMandateFormData(
mandate: Record<string, unknown>,
settings: BillingSettings | null
@ -84,8 +120,8 @@ export function mergeBillingIntoMandateFormData(
};
}
/** Mandate fields that the AdminMandates form is allowed to update. */
const _MANDATE_INVOICE_FIELDS = [
/** Mandate invoice address fields (read-only display + form update). */
export const MANDATE_INVOICE_FIELD_NAMES = [
'invoiceCompanyName',
'invoiceContactName',
'invoiceEmail',
@ -98,6 +134,8 @@ const _MANDATE_INVOICE_FIELDS = [
'invoiceVatNumber',
] as const;
const _MANDATE_INVOICE_FIELDS = MANDATE_INVOICE_FIELD_NAMES;
/**
* Split form submit payload into mandate PUT body and billing POST body.
*
@ -114,6 +152,7 @@ export function splitMandateAndBillingFromForm(
if ('name' in formData) mandatePayload.name = formData.name;
if ('label' in formData) mandatePayload.label = formData.label;
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
if ('mfaRequired' in formData) mandatePayload.mfaRequired = formData.mfaRequired;
for (const fieldName of _MANDATE_INVOICE_FIELDS) {
if (!(fieldName in formData)) continue;
const raw = formData[fieldName];