mandate information panel in expanded tenant
This commit is contained in:
parent
fa7988b4b6
commit
78ccea8bac
9 changed files with 850 additions and 134 deletions
163
src/components/admin/InlineEditableField.tsx
Normal file
163
src/components/admin/InlineEditableField.tsx
Normal 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;
|
||||
30
src/components/admin/MandateExpandDashboard.module.css
Normal file
30
src/components/admin/MandateExpandDashboard.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/components/admin/MandateExpandDashboard.tsx
Normal file
52
src/components/admin/MandateExpandDashboard.tsx
Normal 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;
|
||||
194
src/components/admin/MandateInfoPanel.module.css
Normal file
194
src/components/admin/MandateInfoPanel.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
329
src/components/admin/MandateInfoPanel.tsx
Normal file
329
src/components/admin/MandateInfoPanel.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 : ''}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in a new issue