1075 lines
44 KiB
TypeScript
1075 lines
44 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import {
|
|
useUserMandates,
|
|
type MandateUser,
|
|
type Mandate,
|
|
type Role,
|
|
} from '../../../hooks/useUserMandates';
|
|
import {
|
|
useFeatureAccess,
|
|
type FeatureInstance,
|
|
type FeatureAccessUser,
|
|
type FeatureInstanceRole,
|
|
type Feature,
|
|
} from '../../../hooks/useFeatureAccess';
|
|
import { useToast } from '../../../contexts/ToastContext';
|
|
import { useApiRequest } from '../../../hooks/useApi';
|
|
import { useMandateFormAttributes } from '../../../hooks/useMandates';
|
|
import { createMandate, type MandateCreateData } from '../../../api/mandateApi';
|
|
import { updateSettingsAdmin } from '../../../api/billingApi';
|
|
import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge';
|
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
|
import styles from '../Admin.module.css';
|
|
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
|
|
|
|
const TOTAL_STEPS = 4;
|
|
|
|
interface RoleOption {
|
|
id: string;
|
|
roleLabel: string;
|
|
}
|
|
|
|
export const AdminMandateWizardPage: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
|
|
const { showSuccess, showWarning, showError } = useToast();
|
|
const { request } = useApiRequest();
|
|
const {
|
|
createFormAttributes,
|
|
createFormAttributesWithBilling,
|
|
loading: mandateAttrLoading,
|
|
} = useMandateFormAttributes();
|
|
|
|
const {
|
|
fetchMandateUsers,
|
|
addUserToMandate,
|
|
removeUserFromMandate,
|
|
updateUserRoles,
|
|
fetchMandates: fetchMandatesList,
|
|
fetchRoles: fetchMandateRolesList,
|
|
fetchAllUsers,
|
|
} = useUserMandates();
|
|
|
|
const {
|
|
fetchFeatures,
|
|
fetchInstances,
|
|
createInstance,
|
|
deleteInstance,
|
|
fetchInstanceUsers,
|
|
addUserToInstance,
|
|
removeUserFromInstance,
|
|
updateInstanceUserRoles,
|
|
fetchInstanceRoles: fetchInstanceRolesList,
|
|
} = useFeatureAccess();
|
|
|
|
// Wizard state
|
|
const [step, setStep] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Step 1: Mandate
|
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
|
const [selectedMandate, setSelectedMandate] = useState<Record<string, any> | null>(null);
|
|
const [isCreatingMandate, setIsCreatingMandate] = useState(false);
|
|
|
|
// Step 2: Mandate Users
|
|
const [mandateUsers, setMandateUsers] = useState<MandateUser[]>([]);
|
|
const [allSystemUsers, setAllSystemUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
|
|
const [mandateRoles, setMandateRoles] = useState<RoleOption[]>([]);
|
|
const [isAddingMandateUser, setIsAddingMandateUser] = useState(false);
|
|
const [addMandateUserForm, setAddMandateUserForm] = useState({ userIds: [] as string[], roleIds: [] as string[] });
|
|
|
|
// Step 3: Instances
|
|
const [features, setFeatures] = useState<Feature[]>([]);
|
|
const [instances, setInstances] = useState<FeatureInstance[]>([]);
|
|
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
|
|
const [instanceForm, setInstanceForm] = useState({ label: '', enabled: true });
|
|
const [isCreatingInstance, setIsCreatingInstance] = useState(false);
|
|
|
|
// Step 4: Users per instance
|
|
const [selectedInstance, setSelectedInstance] = useState<FeatureInstance | null>(null);
|
|
const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]);
|
|
const [instanceRoles, setInstanceRoles] = useState<RoleOption[]>([]);
|
|
const [isAddingInstanceUser, setIsAddingInstanceUser] = useState(false);
|
|
const [addInstanceUserForm, setAddInstanceUserForm] = useState({ userIds: [] as string[], roleIds: [] as string[] });
|
|
|
|
const [roleEditContext, setRoleEditContext] = useState<
|
|
null | { scope: 'mandate' | 'instance'; userId: string }
|
|
>(null);
|
|
const [roleEditDraft, setRoleEditDraft] = useState<string[]>([]);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// HELPERS
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
const getMandateName = (m: Mandate | Record<string, any>): string =>
|
|
mandateDisplayLabel(m as { label?: string | null; name?: string | null; id?: string });
|
|
|
|
const getFeatureLabel = (code: string): string => {
|
|
const f = features.find(feat => feat.code === code);
|
|
return f ? (f.label || code) : code;
|
|
};
|
|
|
|
const getUserDisplayName = (u: { fullName?: string; firstname?: string | null; lastname?: string | null; username: string }): string => {
|
|
if (u.fullName) return u.fullName;
|
|
const parts = [u.firstname, u.lastname].filter(Boolean);
|
|
return parts.length > 0 ? parts.join(' ') : u.username;
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// DATA LOADING
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
const loadMandates = useCallback(async () => {
|
|
try {
|
|
const data = await fetchMandatesList();
|
|
setMandates(data);
|
|
} catch {
|
|
setError(t('Fehler beim Laden der Mandanten'));
|
|
}
|
|
}, [fetchMandatesList, t]);
|
|
|
|
useEffect(() => { loadMandates(); }, [loadMandates]);
|
|
|
|
const stepLabels = useMemo(() => [
|
|
t('Mandant'),
|
|
t('Benutzer'),
|
|
t('Feature-Instanzen'),
|
|
t('Feature-Benutzer'),
|
|
], [t]);
|
|
|
|
useEffect(() => {
|
|
fetchFeatures().then(setFeatures);
|
|
}, [fetchFeatures]);
|
|
|
|
useEffect(() => {
|
|
setRoleEditContext(null);
|
|
setRoleEditDraft([]);
|
|
}, [step]);
|
|
|
|
// Step 2
|
|
const loadMandateUsers = useCallback(async () => {
|
|
if (!selectedMandate) return;
|
|
const data = await fetchMandateUsers(selectedMandate.id);
|
|
setMandateUsers(data);
|
|
}, [selectedMandate, fetchMandateUsers]);
|
|
|
|
const loadAllSystemUsers = useCallback(async () => {
|
|
const data = await fetchAllUsers();
|
|
setAllSystemUsers(data);
|
|
}, [fetchAllUsers]);
|
|
|
|
const loadMandateRoles = useCallback(async () => {
|
|
if (!selectedMandate) return;
|
|
const data = await fetchMandateRolesList(selectedMandate.id);
|
|
setMandateRoles(data.map((r: Role) => ({ id: r.id, roleLabel: r.roleLabel })));
|
|
}, [selectedMandate, fetchMandateRolesList]);
|
|
|
|
useEffect(() => {
|
|
if (step === 2 && selectedMandate) {
|
|
loadMandateUsers();
|
|
loadAllSystemUsers();
|
|
loadMandateRoles();
|
|
}
|
|
}, [step, selectedMandate, loadMandateUsers, loadAllSystemUsers, loadMandateRoles]);
|
|
|
|
// Step 3
|
|
const loadInstances = useCallback(async () => {
|
|
if (!selectedMandate) return;
|
|
const data = await fetchInstances(selectedMandate.id, selectedFeatureCode || undefined);
|
|
setInstances(data);
|
|
}, [selectedMandate, selectedFeatureCode, fetchInstances]);
|
|
|
|
useEffect(() => {
|
|
if (step === 3 && selectedMandate) loadInstances();
|
|
}, [step, selectedMandate, loadInstances]);
|
|
|
|
// Step 4
|
|
const loadInstanceUsers = useCallback(async () => {
|
|
if (!selectedInstance || !selectedMandate) return;
|
|
const data = await fetchInstanceUsers(selectedMandate.id, selectedInstance.id);
|
|
setInstanceUsers(data);
|
|
}, [selectedInstance, selectedMandate, fetchInstanceUsers]);
|
|
|
|
const loadInstanceRoles = useCallback(async () => {
|
|
if (!selectedInstance || !selectedMandate) return;
|
|
const data = await fetchInstanceRolesList(selectedMandate.id, selectedInstance.id);
|
|
setInstanceRoles(data.map((r: FeatureInstanceRole) => ({ id: r.id, roleLabel: r.roleLabel })));
|
|
}, [selectedInstance, selectedMandate, fetchInstanceRolesList]);
|
|
|
|
useEffect(() => {
|
|
if (step === 4 && selectedInstance) {
|
|
loadInstanceUsers();
|
|
loadInstanceRoles();
|
|
loadMandateUsers();
|
|
}
|
|
}, [step, selectedInstance, loadInstanceUsers, loadInstanceRoles, loadMandateUsers]);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// HANDLERS
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
const handleCreateMandate = async (data: Record<string, unknown>) => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
|
const body: MandateCreateData = {
|
|
...(mandatePayload as Record<string, unknown>),
|
|
label: String(mandatePayload.label ?? '').trim(),
|
|
enabled: typeof mandatePayload.enabled === 'boolean' ? mandatePayload.enabled : true,
|
|
};
|
|
const created = await createMandate(request, body);
|
|
let billingSaved = false;
|
|
try {
|
|
await updateSettingsAdmin(request, String(created.id), billingUpdate);
|
|
billingSaved = true;
|
|
} catch (billingErr: unknown) {
|
|
console.error(billingErr);
|
|
showWarning(
|
|
t('Mandant erstellt'),
|
|
t('Abrechnungseinstellungen konnten nicht gespeichert werden. Bitte unter Administration → Abrechnung nachpflegen.'),
|
|
);
|
|
}
|
|
setSelectedMandate(created as Record<string, unknown>);
|
|
setIsCreatingMandate(false);
|
|
if (billingSaved) {
|
|
showSuccess(t('Erstellt'), t('Mandant inkl. Abrechnung gespeichert'));
|
|
}
|
|
window.dispatchEvent(new CustomEvent('features-changed'));
|
|
await loadMandates();
|
|
} catch (err: unknown) {
|
|
const e = err as { response?: { data?: { detail?: string } }; message?: string };
|
|
setError(e?.response?.data?.detail || e?.message || t('Fehler beim Erstellen des Mandanten'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAddMandateUser = async () => {
|
|
if (!selectedMandate || addMandateUserForm.userIds.length === 0) return;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const failures: string[] = [];
|
|
let ok = 0;
|
|
try {
|
|
for (const uid of addMandateUserForm.userIds) {
|
|
const result = await addUserToMandate(selectedMandate.id, {
|
|
targetUserId: uid,
|
|
roleIds: addMandateUserForm.roleIds,
|
|
});
|
|
if (result.success) ok += 1;
|
|
else failures.push(`${uid}: ${result.error || t('Fehler')}`);
|
|
}
|
|
if (ok > 0) {
|
|
setIsAddingMandateUser(false);
|
|
setAddMandateUserForm({ userIds: [], roleIds: [] });
|
|
showSuccess(t('Hinzugefügt'), t('{count} Benutzer zum Mandanten hinzugefügt', { count: ok }));
|
|
await loadMandateUsers();
|
|
}
|
|
if (failures.length > 0) {
|
|
showWarning(
|
|
t('Teilweise fehlgeschlagen'),
|
|
failures.slice(0, 5).join('; ') + (failures.length > 5 ? '…' : ''),
|
|
);
|
|
if (ok === 0) setError(failures.join('; '));
|
|
else await loadMandateUsers();
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveMandateUser = async (userId: string) => {
|
|
if (!selectedMandate) return;
|
|
const result = await removeUserFromMandate(selectedMandate.id, userId);
|
|
if (result.success) {
|
|
showSuccess(t('Entfernt'), t('Benutzer aus Mandant entfernt'));
|
|
await loadMandateUsers();
|
|
} else {
|
|
setError(result.error || t('Fehler beim Entfernen'));
|
|
}
|
|
};
|
|
|
|
const handleCreateInstance = async () => {
|
|
if (!instanceForm.label.trim() || !selectedMandate || !selectedFeatureCode) return;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const result = await createInstance(selectedMandate.id, {
|
|
featureCode: selectedFeatureCode,
|
|
label: instanceForm.label,
|
|
enabled: instanceForm.enabled,
|
|
});
|
|
if (result.success) {
|
|
setIsCreatingInstance(false);
|
|
setInstanceForm({ label: '', enabled: true });
|
|
showSuccess(t('Erstellt'), t('Feature-Instanz erstellt'));
|
|
await loadInstances();
|
|
} else {
|
|
setError(result.error || t('Fehler beim Erstellen der Instanz (Limit erreicht?)'));
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteInstance = async (instanceId: string) => {
|
|
if (!selectedMandate) return;
|
|
const result = await deleteInstance(selectedMandate.id, instanceId);
|
|
if (result.success) {
|
|
showSuccess(t('Gelöscht'), t('Feature-Instanz gelöscht'));
|
|
await loadInstances();
|
|
} else {
|
|
setError(result.error || t('Fehler beim Löschen der Instanz'));
|
|
}
|
|
};
|
|
|
|
const handleAddInstanceUser = async () => {
|
|
if (!selectedInstance || !selectedMandate || addInstanceUserForm.userIds.length === 0) return;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const failures: string[] = [];
|
|
let ok = 0;
|
|
try {
|
|
for (const uid of addInstanceUserForm.userIds) {
|
|
const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, {
|
|
userId: uid,
|
|
roleIds: addInstanceUserForm.roleIds,
|
|
});
|
|
if (result.success) ok += 1;
|
|
else failures.push(`${uid}: ${result.error || t('Fehler')}`);
|
|
}
|
|
if (ok > 0) {
|
|
setIsAddingInstanceUser(false);
|
|
setAddInstanceUserForm({ userIds: [], roleIds: [] });
|
|
showSuccess(t('Hinzugefügt'), t('{count} Benutzer zur Feature-Instanz hinzugefügt', { count: ok }));
|
|
await loadInstanceUsers();
|
|
}
|
|
if (failures.length > 0) {
|
|
showWarning(
|
|
t('Teilweise fehlgeschlagen'),
|
|
failures.slice(0, 5).join('; ') + (failures.length > 5 ? '…' : ''),
|
|
);
|
|
if (ok === 0) setError(failures.join('; '));
|
|
else await loadInstanceUsers();
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const _saveMandateRoleEdit = async () => {
|
|
if (!selectedMandate || roleEditContext?.scope !== 'mandate') return;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const r = await updateUserRoles(selectedMandate.id, roleEditContext.userId, roleEditDraft);
|
|
if (r.success) {
|
|
showSuccess(t('Gespeichert'), t('Rollen aktualisiert'));
|
|
setRoleEditContext(null);
|
|
setRoleEditDraft([]);
|
|
await loadMandateUsers();
|
|
} else {
|
|
showError(t('Fehler'), r.error || t('Rollen konnten nicht gespeichert werden'));
|
|
}
|
|
setIsLoading(false);
|
|
};
|
|
|
|
const _saveInstanceRoleEdit = async () => {
|
|
if (!selectedMandate || !selectedInstance || roleEditContext?.scope !== 'instance') return;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const r = await updateInstanceUserRoles(
|
|
selectedMandate.id,
|
|
selectedInstance.id,
|
|
roleEditContext.userId,
|
|
{ roleIds: roleEditDraft },
|
|
);
|
|
if (r.success) {
|
|
showSuccess(t('Gespeichert'), t('Rollen aktualisiert'));
|
|
setRoleEditContext(null);
|
|
setRoleEditDraft([]);
|
|
await loadInstanceUsers();
|
|
} else {
|
|
showError(t('Fehler'), r.error || t('Rollen konnten nicht gespeichert werden'));
|
|
}
|
|
setIsLoading(false);
|
|
};
|
|
|
|
const handleRemoveInstanceUser = async (userId: string) => {
|
|
if (!selectedInstance || !selectedMandate) return;
|
|
const result = await removeUserFromInstance(selectedMandate.id, selectedInstance.id, userId);
|
|
if (result.success) {
|
|
showSuccess(t('Entfernt'), t('Benutzer aus Feature-Instanz entfernt'));
|
|
await loadInstanceUsers();
|
|
} else {
|
|
setError(result.error || t('Fehler beim Entfernen'));
|
|
}
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// COMPUTED
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
const availableUsersForMandate = allSystemUsers.filter(
|
|
u => !mandateUsers.some(mu => mu.userId === u.id)
|
|
);
|
|
|
|
const availableUsersForInstance = mandateUsers.filter(
|
|
mu => !instanceUsers.some(iu => iu.userId === mu.userId)
|
|
);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// SHARED UI
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
type WizardUserRow = {
|
|
userId?: string;
|
|
id?: string;
|
|
username: string;
|
|
email?: string | null;
|
|
fullName?: string;
|
|
firstname?: string | null;
|
|
lastname?: string | null;
|
|
enabled?: boolean;
|
|
roleIds?: string[];
|
|
roleLabels?: string[];
|
|
};
|
|
|
|
const _roleTextForRow = (u: WizardUserRow, roleLookup: RoleOption[]) => {
|
|
if (u.roleLabels && u.roleLabels.length > 0) return u.roleLabels.join(', ');
|
|
if (u.roleIds && u.roleIds.length > 0) {
|
|
return u.roleIds
|
|
.map(rid => roleLookup.find(r => r.id === rid)?.roleLabel || rid)
|
|
.join(', ');
|
|
}
|
|
return '-';
|
|
};
|
|
|
|
const renderUserTable = (
|
|
users: WizardUserRow[],
|
|
roleLookup: RoleOption[],
|
|
onRemove: (userId: string) => void,
|
|
options?: { onEditRoles?: (userId: string, currentRoleIds: string[]) => void },
|
|
) => (
|
|
users.length > 0 ? (
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ background: 'var(--bg-secondary, #f8fafc)' }}>
|
|
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>{t('Benutzer')}</th>
|
|
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>{t('E-Mail')}</th>
|
|
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>{t('Rollen')}</th>
|
|
<th style={{ padding: '8px 12px', textAlign: 'center', fontSize: '12px', fontWeight: 600 }}>{t('Status')}</th>
|
|
<th style={{ padding: '8px 12px', textAlign: 'right', fontSize: '12px', fontWeight: 600 }}>{t('Aktion')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map(u => {
|
|
const uid = u.userId || u.id || '';
|
|
const ids = u.roleIds || [];
|
|
return (
|
|
<tr key={uid} style={{ borderBottom: '1px solid var(--border-color, #f1f5f9)' }}>
|
|
<td style={{ padding: '8px 12px', fontSize: '13px' }}>{getUserDisplayName(u as any)}</td>
|
|
<td style={{ padding: '8px 12px', fontSize: '13px', color: 'var(--text-secondary)' }}>{u.email || '-'}</td>
|
|
<td style={{ padding: '8px 12px', fontSize: '12px', color: 'var(--text-secondary)' }}>
|
|
<span>{_roleTextForRow(u, roleLookup)}</span>
|
|
{options?.onEditRoles && roleLookup.length > 0 && (
|
|
<button
|
|
type="button"
|
|
style={{
|
|
marginLeft: '8px',
|
|
padding: '2px 6px',
|
|
fontSize: '11px',
|
|
border: '1px solid var(--border-color, #e5e7eb)',
|
|
borderRadius: '4px',
|
|
background: 'var(--surface-color, #fff)',
|
|
cursor: 'pointer',
|
|
}}
|
|
onClick={() => options.onEditRoles!(uid, ids)}
|
|
>
|
|
{t('Bearbeiten')}
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td style={{ padding: '8px 12px', textAlign: 'center' }}>
|
|
<span className={styles.badge} style={{
|
|
background: u.enabled !== false ? '#dcfce7' : 'var(--bg-secondary)',
|
|
color: u.enabled !== false ? '#166534' : 'var(--text-secondary)',
|
|
}}>
|
|
{u.enabled !== false ? t('Aktiv') : t('Inaktiv')}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
|
<button
|
|
style={{
|
|
padding: '3px 8px', fontSize: '11px', border: '1px solid #fecaca', borderRadius: '4px',
|
|
background: 'var(--surface-color, #fff)', color: '#dc2626', cursor: 'pointer',
|
|
}}
|
|
onClick={() => onRemove(uid)}
|
|
>
|
|
{t('Entfernen')}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '13px' }}>
|
|
{t('Noch keine Benutzer zugewiesen')}
|
|
</div>
|
|
)
|
|
);
|
|
|
|
const renderAddUserForm = (
|
|
availableUsers: Array<{ id?: string; userId?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null }>,
|
|
roles: RoleOption[],
|
|
formValue: { userIds: string[]; roleIds: string[] },
|
|
setFormValue: (fn: (prev: { userIds: string[]; roleIds: string[] }) => { userIds: string[]; roleIds: string[] }) => void,
|
|
onSubmit: () => void,
|
|
onCancel: () => void,
|
|
) => (
|
|
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
|
|
<div>
|
|
<label className={styles.formLabel}>{t('Benutzer mehrfach möglich')}</label>
|
|
<div
|
|
style={{
|
|
maxHeight: '220px',
|
|
overflowY: 'auto',
|
|
border: '1px solid var(--border-color, #e5e7eb)',
|
|
borderRadius: '8px',
|
|
padding: '8px',
|
|
display: 'grid',
|
|
gap: '6px',
|
|
background: 'var(--surface-color, #fff)',
|
|
}}
|
|
>
|
|
{availableUsers.map(u => {
|
|
const uid = u.userId || u.id || '';
|
|
const name = getUserDisplayName(u as any);
|
|
return (
|
|
<label key={uid} className={styles.checkboxLabel} style={{ alignItems: 'flex-start' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formValue.userIds.includes(uid)}
|
|
onChange={e => {
|
|
setFormValue(p => ({
|
|
...p,
|
|
userIds: e.target.checked
|
|
? [...p.userIds, uid]
|
|
: p.userIds.filter(id => id !== uid),
|
|
}));
|
|
}}
|
|
/>
|
|
<span>
|
|
{u.username} {u.email ? <span style={{ color: 'var(--text-secondary)' }}>({u.email})</span> : null}
|
|
{name !== u.username ? <span style={{ color: 'var(--text-secondary)' }}> — {name}</span> : null}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
{roles.length > 0 && (
|
|
<div>
|
|
<label className={styles.formLabel}>{t('Rollen für alle ausgewählten Benutzer')}</label>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
{roles.map(r => (
|
|
<label key={r.id} className={styles.checkboxLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formValue.roleIds.includes(r.id)}
|
|
onChange={e => {
|
|
setFormValue(p => ({
|
|
...p,
|
|
roleIds: e.target.checked
|
|
? [...p.roleIds, r.id]
|
|
: p.roleIds.filter(id => id !== r.id),
|
|
}));
|
|
}}
|
|
/>
|
|
{r.roleLabel}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button
|
|
className={styles.primaryButton}
|
|
onClick={onSubmit}
|
|
disabled={isLoading || formValue.userIds.length === 0}
|
|
>
|
|
{isLoading ? t('Hinzufügen') : t('Hinzufügen')}
|
|
</button>
|
|
<button className={styles.secondaryButton} onClick={onCancel}>
|
|
{t('Abbrechen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// STEP INDICATOR
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
const renderStepIndicator = () => (
|
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px', flexWrap: 'wrap' }}>
|
|
{Array.from({ length: TOTAL_STEPS }, (_, i) => i + 1).map(s => (
|
|
<div
|
|
key={s}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
padding: '8px 16px',
|
|
borderRadius: '8px',
|
|
background: s === step ? 'var(--primary-color, #f25843)' : s < step ? '#dcfce7' : 'var(--bg-secondary, #f1f5f9)',
|
|
color: s === step ? '#fff' : s < step ? '#166534' : 'var(--text-secondary)',
|
|
fontSize: '13px',
|
|
fontWeight: 600,
|
|
cursor: s < step ? 'pointer' : 'default',
|
|
transition: 'background 0.2s',
|
|
}}
|
|
onClick={() => { if (s < step) setStep(s); }}
|
|
>
|
|
<span style={{
|
|
width: '24px', height: '24px', borderRadius: '50%',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: s === step ? 'rgba(255,255,255,0.2)' : 'transparent',
|
|
fontSize: '12px',
|
|
}}>
|
|
{s < step ? '\u2713' : s}
|
|
</span>
|
|
{stepLabels[s - 1]}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// CARD WRAPPER (reusable section container matching poweron theme)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
const cardStyle: React.CSSProperties = {
|
|
background: 'var(--surface-color, #fff)',
|
|
border: '1px solid var(--border-color, #e5e7eb)',
|
|
borderRadius: '12px',
|
|
padding: '24px',
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// RENDER
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className={styles.adminPage} style={{ overflow: 'auto' }}>
|
|
<div className={styles.pageHeader}>
|
|
<div>
|
|
<h1 className={styles.pageTitle}>{t('Mandanten-Verwaltung')}</h1>
|
|
<p className={styles.pageSubtitle}>{t('Schritt-für-Schritt-Wizard zur Mandantenkonfiguration')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div style={{
|
|
padding: '12px 16px', background: 'var(--error-bg, #fef2f2)', color: 'var(--danger-color, #dc2626)',
|
|
borderRadius: '8px', marginBottom: '16px', fontSize: '13px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
border: '1px solid var(--danger-color, #fecaca)',
|
|
}}>
|
|
{error}
|
|
<button onClick={() => setError(null)} style={{ background: 'none', border: 'none', fontWeight: 600, cursor: 'pointer', color: 'inherit', fontSize: '16px' }}>×</button>
|
|
</div>
|
|
)}
|
|
|
|
{renderStepIndicator()}
|
|
|
|
{/* ── STEP 1: MANDATE ── */}
|
|
{step === 1 && (
|
|
<div style={cardStyle}>
|
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px', marginTop: 0 }}>{t('Mandant auswählen oder erstellen')}</h3>
|
|
|
|
{!isCreatingMandate ? (
|
|
<>
|
|
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
|
|
{mandates.map(m => (
|
|
<button
|
|
key={m.id}
|
|
onClick={() => setSelectedMandate(m)}
|
|
style={{
|
|
padding: '12px 16px',
|
|
border: `2px solid ${selectedMandate?.id === m.id ? 'var(--primary-color, #f25843)' : 'var(--border-color, #e5e7eb)'}`,
|
|
borderRadius: '8px',
|
|
background: selectedMandate?.id === m.id ? 'var(--primary-bg, rgba(242,88,67,0.06))' : 'var(--surface-color, #fff)',
|
|
cursor: 'pointer', textAlign: 'left',
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
color: 'var(--text-primary)',
|
|
}}
|
|
>
|
|
<div>
|
|
<div style={{ fontWeight: 600, fontSize: '14px' }}>{getMandateName(m)}</div>
|
|
</div>
|
|
{selectedMandate?.id === m.id && <span style={{ color: 'var(--primary-color)', fontWeight: 700, fontSize: '16px' }}>{'\u2713'}</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button className={styles.secondaryButton} onClick={() => setIsCreatingMandate(true)}>
|
|
{t('+ Neuen Mandanten erstellen')}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: '12px' }}>
|
|
{mandateAttrLoading || createFormAttributes.length === 0 ? (
|
|
<div className={styles.loadingContainer} style={{ padding: '24px' }}>
|
|
<div className={styles.spinner} />
|
|
<span>{t('Formular wird geladen')}</span>
|
|
</div>
|
|
) : (
|
|
<FormGeneratorForm
|
|
attributes={createFormAttributesWithBilling}
|
|
mode="create"
|
|
onSubmit={handleCreateMandate}
|
|
onCancel={() => setIsCreatingMandate(false)}
|
|
submitButtonText={isLoading ? t('Erstellen') : t('Mandant erstellen')}
|
|
cancelButtonText={t('Abbrechen')}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'flex-end' }}>
|
|
<button className={styles.primaryButton} disabled={!selectedMandate} onClick={() => setStep(2)}>
|
|
{t('Weiter →')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── STEP 2: MANDATE USERS ── */}
|
|
{step === 2 && selectedMandate && (
|
|
<div style={cardStyle}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
|
<h3 style={{ fontSize: '15px', fontWeight: 600, margin: 0 }}>
|
|
{t('Benutzer von «{name}»', { name: getMandateName(selectedMandate) })}
|
|
</h3>
|
|
<button
|
|
className={styles.primaryButton}
|
|
style={{ fontSize: '12px', padding: '6px 12px' }}
|
|
onClick={() => setIsAddingMandateUser(true)}
|
|
disabled={availableUsersForMandate.length === 0}
|
|
>
|
|
{t('+ Benutzer hinzufügen')}
|
|
</button>
|
|
</div>
|
|
<p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
|
{t('Alle Systembenutzer können dem Mandanten zugewiesen werden.')}
|
|
</p>
|
|
|
|
{isAddingMandateUser && renderAddUserForm(
|
|
availableUsersForMandate,
|
|
mandateRoles,
|
|
addMandateUserForm,
|
|
setAddMandateUserForm,
|
|
handleAddMandateUser,
|
|
() => { setIsAddingMandateUser(false); setAddMandateUserForm({ userIds: [], roleIds: [] }); },
|
|
)}
|
|
|
|
{roleEditContext?.scope === 'mandate' && (
|
|
<div style={{
|
|
padding: '16px',
|
|
background: 'var(--bg-secondary, #f8fafc)',
|
|
borderRadius: '8px',
|
|
marginBottom: '16px',
|
|
display: 'grid',
|
|
gap: '12px',
|
|
border: '1px solid var(--border-color, #e5e7eb)',
|
|
}}>
|
|
<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('Rollen bearbeiten')}</div>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
{mandateRoles.map(r => (
|
|
<label key={r.id} className={styles.checkboxLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={roleEditDraft.includes(r.id)}
|
|
onChange={e => {
|
|
setRoleEditDraft(prev =>
|
|
e.target.checked ? [...prev, r.id] : prev.filter(id => id !== r.id),
|
|
);
|
|
}}
|
|
/>
|
|
{r.roleLabel}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button
|
|
type="button"
|
|
className={styles.primaryButton}
|
|
onClick={() => _saveMandateRoleEdit()}
|
|
disabled={isLoading}
|
|
>
|
|
{t('Speichern')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.secondaryButton}
|
|
onClick={() => { setRoleEditContext(null); setRoleEditDraft([]); }}
|
|
>
|
|
{t('Abbrechen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginBottom: '16px' }}>
|
|
{renderUserTable(mandateUsers as WizardUserRow[], mandateRoles, handleRemoveMandateUser, {
|
|
onEditRoles: (userId, ids) => {
|
|
setError(null);
|
|
setRoleEditContext({ scope: 'mandate', userId });
|
|
setRoleEditDraft([...ids]);
|
|
},
|
|
})}
|
|
</div>
|
|
|
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
|
<button className={styles.secondaryButton} onClick={() => setStep(1)}>{t('← Zurück')}</button>
|
|
<button className={styles.primaryButton} onClick={() => setStep(3)}>
|
|
{t('Weiter →')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── STEP 3: INSTANCES ── */}
|
|
{step === 3 && selectedMandate && (
|
|
<div style={cardStyle}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
|
<h3 style={{ fontSize: '15px', fontWeight: 600, margin: 0 }}>
|
|
{t('Feature-Instanzen für «{name}»', { name: getMandateName(selectedMandate) })}
|
|
</h3>
|
|
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
|
{t('{count} Instanzen', { count: instances.length })}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Feature Filter */}
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<label className={styles.formLabel}>{t('Feature filtern')}</label>
|
|
<select
|
|
className={styles.filterSelect}
|
|
value={selectedFeatureCode}
|
|
onChange={e => setSelectedFeatureCode(e.target.value)}
|
|
>
|
|
<option value="">{t('Alle Features')}</option>
|
|
{features.map(f => (
|
|
<option key={f.code} value={f.code}>{getFeatureLabel(f.code)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
|
|
{instances.map(inst => (
|
|
<div key={inst.id} style={{
|
|
padding: '12px 16px',
|
|
border: '1px solid var(--border-color, #e5e7eb)',
|
|
borderRadius: '8px',
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
}}>
|
|
<div>
|
|
<span style={{ fontWeight: 600 }}>{inst.label}</span>
|
|
<span style={{ fontSize: '11px', color: 'var(--text-secondary)', marginLeft: '8px' }}>
|
|
{getFeatureLabel(inst.featureCode)} | {inst.enabled ? t('Aktiv') : t('Inaktiv')}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button
|
|
className={styles.secondaryButton}
|
|
style={{ padding: '4px 8px', fontSize: '12px' }}
|
|
onClick={() => { setSelectedInstance(inst); setStep(4); }}
|
|
>
|
|
{t('Benutzer')}
|
|
</button>
|
|
<button
|
|
style={{
|
|
padding: '4px 8px', fontSize: '12px', border: '1px solid #fecaca', borderRadius: '6px',
|
|
background: 'var(--surface-color, #fff)', color: '#dc2626', cursor: 'pointer',
|
|
}}
|
|
onClick={() => handleDeleteInstance(inst.id)}
|
|
>
|
|
{t('Löschen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{!isCreatingInstance ? (
|
|
<button className={styles.secondaryButton} onClick={() => setIsCreatingInstance(true)}>
|
|
{t('+ Neue Feature-Instanz erstellen')}
|
|
</button>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: '12px', padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px' }}>
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.formLabel}>{t('Feature')}</label>
|
|
<select
|
|
className={styles.filterSelect}
|
|
style={{ width: '100%' }}
|
|
value={selectedFeatureCode}
|
|
onChange={e => setSelectedFeatureCode(e.target.value)}
|
|
>
|
|
<option value="">{t('Feature wählen')}</option>
|
|
{features.map(f => (
|
|
<option key={f.code} value={f.code}>{getFeatureLabel(f.code)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className={styles.formGroup}>
|
|
<label className={`${styles.formLabel} ${styles.required}`}>{t('Bezeichnung')}</label>
|
|
<input
|
|
className={styles.formInput}
|
|
value={instanceForm.label}
|
|
onChange={e => setInstanceForm(p => ({ ...p, label: e.target.value }))}
|
|
placeholder={t('z.B. Kunde A')}
|
|
/>
|
|
</div>
|
|
<label className={styles.checkboxLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={instanceForm.enabled}
|
|
onChange={e => setInstanceForm(p => ({ ...p, enabled: e.target.checked }))}
|
|
/>
|
|
{t('Instanz aktiviert')}
|
|
</label>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button className={styles.primaryButton} onClick={handleCreateInstance} disabled={isLoading || !selectedFeatureCode}>
|
|
{isLoading ? t('Erstellen') : t('Erstellen')}
|
|
</button>
|
|
<button className={styles.secondaryButton} onClick={() => setIsCreatingInstance(false)}>{t('Abbrechen')}</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
|
<button className={styles.secondaryButton} onClick={() => setStep(2)}>{t('← Zurück')}</button>
|
|
<button
|
|
className={styles.primaryButton}
|
|
onClick={() => { if (instances.length > 0) { setSelectedInstance(instances[0]); setStep(4); } }}
|
|
disabled={instances.length === 0}
|
|
>
|
|
{t('Weiter →')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── STEP 4: FEATURE INSTANCE USERS ── */}
|
|
{step === 4 && selectedMandate && selectedInstance && (
|
|
<div style={cardStyle}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
|
<h3 style={{ fontSize: '15px', fontWeight: 600, margin: 0 }}>
|
|
{t('Feature-Benutzer für «{label}»', { label: selectedInstance.label })}
|
|
</h3>
|
|
<button
|
|
className={styles.primaryButton}
|
|
style={{ fontSize: '12px', padding: '6px 12px' }}
|
|
onClick={() => setIsAddingInstanceUser(true)}
|
|
disabled={availableUsersForInstance.length === 0 || instanceRoles.length === 0}
|
|
>
|
|
{t('+ Benutzer hinzufügen')}
|
|
</button>
|
|
</div>
|
|
<p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
|
{t('Mandant: {mandate} | Mitglieder des Mandanten können der Feature-Instanz zugewiesen werden.', { mandate: getMandateName(selectedMandate) })}
|
|
</p>
|
|
|
|
{isAddingInstanceUser && renderAddUserForm(
|
|
availableUsersForInstance as any[],
|
|
instanceRoles,
|
|
addInstanceUserForm,
|
|
setAddInstanceUserForm,
|
|
handleAddInstanceUser,
|
|
() => { setIsAddingInstanceUser(false); setAddInstanceUserForm({ userIds: [], roleIds: [] }); },
|
|
)}
|
|
|
|
{roleEditContext?.scope === 'instance' && (
|
|
<div style={{
|
|
padding: '16px',
|
|
background: 'var(--bg-secondary, #f8fafc)',
|
|
borderRadius: '8px',
|
|
marginBottom: '16px',
|
|
display: 'grid',
|
|
gap: '12px',
|
|
border: '1px solid var(--border-color, #e5e7eb)',
|
|
}}>
|
|
<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('Rollen bearbeiten (Featureinstanz)')}</div>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
{instanceRoles.map(r => (
|
|
<label key={r.id} className={styles.checkboxLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={roleEditDraft.includes(r.id)}
|
|
onChange={e => {
|
|
setRoleEditDraft(prev =>
|
|
e.target.checked ? [...prev, r.id] : prev.filter(id => id !== r.id),
|
|
);
|
|
}}
|
|
/>
|
|
{r.roleLabel}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button
|
|
type="button"
|
|
className={styles.primaryButton}
|
|
onClick={() => _saveInstanceRoleEdit()}
|
|
disabled={isLoading}
|
|
>
|
|
{t('Speichern')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.secondaryButton}
|
|
onClick={() => { setRoleEditContext(null); setRoleEditDraft([]); }}
|
|
>
|
|
{t('Abbrechen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginBottom: '16px' }}>
|
|
{renderUserTable(
|
|
instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })) as WizardUserRow[],
|
|
instanceRoles,
|
|
handleRemoveInstanceUser,
|
|
{
|
|
onEditRoles: (userId, ids) => {
|
|
setError(null);
|
|
setRoleEditContext({ scope: 'instance', userId });
|
|
setRoleEditDraft([...ids]);
|
|
},
|
|
},
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
|
<button className={styles.secondaryButton} onClick={() => setStep(3)}>{t('← Zurück')}</button>
|
|
<button className={styles.primaryButton} onClick={() => {
|
|
showSuccess(t('Fertig'), t('Konfiguration abgeschlossen!'));
|
|
setSelectedInstance(null);
|
|
setStep(1);
|
|
}}>
|
|
{t('Fertig')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminMandateWizardPage;
|