sync features a and p
This commit is contained in:
parent
b5a9ee2d4a
commit
d4b2cb1dd6
6 changed files with 1533 additions and 3 deletions
|
|
@ -37,7 +37,7 @@ import { DashboardPage } from './pages/Dashboard';
|
||||||
import { SettingsPage } from './pages/Settings';
|
import { SettingsPage } from './pages/Settings';
|
||||||
import { GDPRPage } from './pages/GDPR';
|
import { GDPRPage } from './pages/GDPR';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
|
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage, AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin';
|
||||||
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin } from './pages/billing';
|
import { BillingDataView, BillingAdmin } from './pages/billing';
|
||||||
|
|
@ -188,6 +188,8 @@ function App() {
|
||||||
<Route path="billing" element={<BillingAdmin />} />
|
<Route path="billing" element={<BillingAdmin />} />
|
||||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
|
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -918,6 +918,7 @@ tbody .actionsColumn {
|
||||||
@keyframes booleanPulse {
|
@keyframes booleanPulse {
|
||||||
0%, 100% { opacity: 0.4; }
|
0%, 100% { opacity: 0.4; }
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Grouping */
|
/* Grouping */
|
||||||
.groupHeader {
|
.groupHeader {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
||||||
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||||
FaHeadset, FaVideo,
|
FaHeadset, FaVideo, FaHatWizard,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -68,6 +68,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.admin.automationEvents': <FaClock />,
|
'page.admin.automationEvents': <FaClock />,
|
||||||
'page.admin.automation-events': <FaClock />,
|
'page.admin.automation-events': <FaClock />,
|
||||||
'page.admin.logs': <FaFileAlt />,
|
'page.admin.logs': <FaFileAlt />,
|
||||||
|
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||||
|
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
|
||||||
|
|
||||||
// Feature pages - Trustee
|
// Feature pages - Trustee
|
||||||
'page.feature.trustee.dashboard': <FaChartLine />,
|
'page.feature.trustee.dashboard': <FaChartLine />,
|
||||||
|
|
|
||||||
685
src/pages/admin/AdminInvitationWizardPage.tsx
Normal file
685
src/pages/admin/AdminInvitationWizardPage.tsx
Normal file
|
|
@ -0,0 +1,685 @@
|
||||||
|
/**
|
||||||
|
* AdminInvitationWizardPage
|
||||||
|
*
|
||||||
|
* 5-step wizard for batch user invitations:
|
||||||
|
* 1. Select Mandate
|
||||||
|
* 2. Add users with roles for the mandate
|
||||||
|
* 3. Optionally select a Feature Instance
|
||||||
|
* 4. Add users with roles for the feature instance
|
||||||
|
* 5. Review & batch dispatch
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useInvitations, type InvitationCreate } from '../../hooks/useInvitations';
|
||||||
|
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
|
||||||
|
import { useFeatureAccess, type FeatureInstance, type FeatureInstanceRole } from '../../hooks/useFeatureAccess';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface InviteeEntry {
|
||||||
|
targetUsername: string;
|
||||||
|
email: string;
|
||||||
|
roleIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleInfo = { id: string; roleLabel: string };
|
||||||
|
|
||||||
|
interface DispatchResult {
|
||||||
|
targetUsername: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
emailSent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchResults {
|
||||||
|
successful: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
results: DispatchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// WIZARD-SPECIFIC INLINE STYLES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const _stepStyle = (stepNum: number, currentStep: number) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: stepNum === currentStep
|
||||||
|
? 'var(--primary-color, #f25843)'
|
||||||
|
: stepNum < currentStep ? '#dcfce7' : 'var(--bg-secondary, #f1f5f9)',
|
||||||
|
color: stepNum === currentStep
|
||||||
|
? '#fff'
|
||||||
|
: stepNum < currentStep ? '#166534' : 'var(--text-secondary, #64748b)',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600 as const,
|
||||||
|
cursor: stepNum < currentStep ? 'pointer' as const : 'default' as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _cardStyle: React.CSSProperties = {
|
||||||
|
background: 'var(--surface-color, #fff)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid var(--border-color, #C5D9E8)',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMPONENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
|
const { createInvitation } = useInvitations();
|
||||||
|
const { fetchMandates, fetchRoles } = useUserMandates();
|
||||||
|
const { fetchInstances, fetchInstanceRoles } = useFeatureAccess();
|
||||||
|
const { showError: showToastError } = useToast();
|
||||||
|
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 1: Mandate
|
||||||
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
|
const [selectedMandate, setSelectedMandate] = useState<Mandate | null>(null);
|
||||||
|
|
||||||
|
// Step 2: Mandate invitees
|
||||||
|
const [mandateRoles, setMandateRoles] = useState<Role[]>([]);
|
||||||
|
const [mandateInvitees, setMandateInvitees] = useState<InviteeEntry[]>([]);
|
||||||
|
const [mandateInviteeForm, setMandateInviteeForm] = useState({ username: '', email: '', roleIds: [] as string[] });
|
||||||
|
|
||||||
|
// Step 3: Feature instance (optional)
|
||||||
|
const [instances, setInstances] = useState<FeatureInstance[]>([]);
|
||||||
|
const [selectedInstance, setSelectedInstance] = useState<FeatureInstance | null>(null);
|
||||||
|
const [skipInstance, setSkipInstance] = useState(false);
|
||||||
|
|
||||||
|
// Step 4: Instance invitees
|
||||||
|
const [instRoles, setInstRoles] = useState<FeatureInstanceRole[]>([]);
|
||||||
|
const [instanceInvitees, setInstanceInvitees] = useState<InviteeEntry[]>([]);
|
||||||
|
const [instanceInviteeForm, setInstanceInviteeForm] = useState({ username: '', email: '', roleIds: [] as string[] });
|
||||||
|
|
||||||
|
// Dispatch options
|
||||||
|
const [expiresInHours, setExpiresInHours] = useState(72);
|
||||||
|
|
||||||
|
// Dispatch results
|
||||||
|
const [dispatchResults, setDispatchResults] = useState<DispatchResults | null>(null);
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// DATA LOADING
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMandates().then(setMandates);
|
||||||
|
}, [fetchMandates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMandate) return;
|
||||||
|
fetchRoles(selectedMandate.id).then(roles => {
|
||||||
|
setMandateRoles(roles.filter(r => !r.featureInstanceId));
|
||||||
|
});
|
||||||
|
fetchInstances(selectedMandate.id).then(data => {
|
||||||
|
setInstances(data.filter(i => i.enabled));
|
||||||
|
});
|
||||||
|
}, [selectedMandate, fetchRoles, fetchInstances]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMandate || !selectedInstance) return;
|
||||||
|
fetchInstanceRoles(selectedMandate.id, selectedInstance.id).then(setInstRoles);
|
||||||
|
}, [selectedMandate, selectedInstance, fetchInstanceRoles]);
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
const getMandateName = (m: Mandate) => {
|
||||||
|
if (m.label) return m.label;
|
||||||
|
if (typeof m.name === 'object') {
|
||||||
|
return m.name.de || m.name.en || Object.values(m.name)[0] || m.id;
|
||||||
|
}
|
||||||
|
return m.name || m.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// INVITEE MANAGEMENT
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
const _addMandateInvitee = () => {
|
||||||
|
if (!mandateInviteeForm.username.trim()) {
|
||||||
|
setError('Benutzername ist erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mandateInvitees.some(i => i.targetUsername === mandateInviteeForm.username.trim())) {
|
||||||
|
setError('Benutzer bereits in der Liste');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMandateInvitees(prev => [...prev, {
|
||||||
|
targetUsername: mandateInviteeForm.username.trim(),
|
||||||
|
email: mandateInviteeForm.email.trim(),
|
||||||
|
roleIds: [...mandateInviteeForm.roleIds],
|
||||||
|
}]);
|
||||||
|
setMandateInviteeForm({ username: '', email: '', roleIds: [] });
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _removeMandateInvitee = (username: string) => {
|
||||||
|
setMandateInvitees(prev => prev.filter(i => i.targetUsername !== username));
|
||||||
|
};
|
||||||
|
|
||||||
|
const _addInstanceInvitee = () => {
|
||||||
|
if (!instanceInviteeForm.username.trim()) {
|
||||||
|
setError('Benutzername ist erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (instanceInvitees.some(i => i.targetUsername === instanceInviteeForm.username.trim())) {
|
||||||
|
setError('Benutzer bereits in der Liste');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInstanceInvitees(prev => [...prev, {
|
||||||
|
targetUsername: instanceInviteeForm.username.trim(),
|
||||||
|
email: instanceInviteeForm.email.trim(),
|
||||||
|
roleIds: [...instanceInviteeForm.roleIds],
|
||||||
|
}]);
|
||||||
|
setInstanceInviteeForm({ username: '', email: '', roleIds: [] });
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _removeInstanceInvitee = (username: string) => {
|
||||||
|
setInstanceInvitees(prev => prev.filter(i => i.targetUsername !== username));
|
||||||
|
};
|
||||||
|
|
||||||
|
const _toggleRole = (roleId: string, target: 'mandate' | 'instance') => {
|
||||||
|
const setter = target === 'mandate' ? setMandateInviteeForm : setInstanceInviteeForm;
|
||||||
|
setter(prev => ({
|
||||||
|
...prev,
|
||||||
|
roleIds: prev.roleIds.includes(roleId)
|
||||||
|
? prev.roleIds.filter(r => r !== roleId)
|
||||||
|
: [...prev.roleIds, roleId],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// DISPATCH
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
const _handleDispatch = async () => {
|
||||||
|
if (!selectedMandate) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const allInvitations: InvitationCreate[] = [];
|
||||||
|
|
||||||
|
for (const inv of mandateInvitees) {
|
||||||
|
allInvitations.push({
|
||||||
|
targetUsername: inv.targetUsername,
|
||||||
|
email: inv.email || undefined,
|
||||||
|
roleIds: inv.roleIds,
|
||||||
|
expiresInHours,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedInstance && !skipInstance) {
|
||||||
|
for (const inv of instanceInvitees) {
|
||||||
|
allInvitations.push({
|
||||||
|
targetUsername: inv.targetUsername,
|
||||||
|
email: inv.email || undefined,
|
||||||
|
roleIds: inv.roleIds,
|
||||||
|
featureInstanceId: selectedInstance.id,
|
||||||
|
expiresInHours,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allInvitations.length === 0) {
|
||||||
|
setError('Keine Einladungen zu versenden');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successful = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const results: DispatchResult[] = [];
|
||||||
|
|
||||||
|
for (const inv of allInvitations) {
|
||||||
|
const result = await createInvitation(selectedMandate.id, inv);
|
||||||
|
if (result.success) {
|
||||||
|
successful++;
|
||||||
|
results.push({ targetUsername: inv.targetUsername, success: true, emailSent: result.data?.emailSent });
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
results.push({ targetUsername: inv.targetUsername, success: false, error: result.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dr: DispatchResults = { successful, failed, total: allInvitations.length, results };
|
||||||
|
setDispatchResults(dr);
|
||||||
|
setSuccess(`${successful} von ${dr.total} Einladungen erfolgreich versendet.`);
|
||||||
|
setStep(5);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// STEP INDICATOR
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
const _renderStepIndicator = () => {
|
||||||
|
const visibleSteps = skipInstance ? [1, 2, 3] : [1, 2, 3, 4];
|
||||||
|
const labels = skipInstance
|
||||||
|
? ['Mandant', 'Benutzer', 'Versand']
|
||||||
|
: ['Mandant', 'Benutzer', 'Feature Instanz', 'Instanz-Benutzer'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px', flexWrap: 'wrap' }}>
|
||||||
|
{visibleSteps.map((s, idx) => (
|
||||||
|
<div
|
||||||
|
key={s}
|
||||||
|
style={_stepStyle(s, step)}
|
||||||
|
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' : idx + 1}
|
||||||
|
</span>
|
||||||
|
{labels[idx]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// RENDER: INVITEE TABLE
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
const _renderInviteeList = (
|
||||||
|
invitees: InviteeEntry[],
|
||||||
|
roles: RoleInfo[],
|
||||||
|
onRemove: (u: string) => void,
|
||||||
|
) => {
|
||||||
|
if (invitees.length === 0) {
|
||||||
|
return <p style={{ color: 'var(--text-secondary, #94a3b8)', fontSize: '13px' }}>Noch keine Benutzer hinzugefuegt.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px' }}>Benutzername</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px' }}>Rollen</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px' }}>Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invitees.map(inv => (
|
||||||
|
<tr key={inv.targetUsername} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
|
||||||
|
<td style={{ padding: '8px', fontWeight: 600 }}>{inv.targetUsername}</td>
|
||||||
|
<td style={{ padding: '8px', color: 'var(--text-secondary, #64748b)' }}>{inv.email || '-'}</td>
|
||||||
|
<td style={{ padding: '8px' }}>
|
||||||
|
{inv.roleIds.length > 0
|
||||||
|
? inv.roleIds.map(rid => roles.find(r => r.id === rid)?.roleLabel || rid).join(', ')
|
||||||
|
: <span style={{ color: 'var(--text-secondary, #94a3b8)' }}>Keine</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px', textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(inv.targetUsername)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--danger-color, #ef4444)', cursor: 'pointer', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// RENDER: ADD INVITEE FORM
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
const _renderAddForm = (
|
||||||
|
form: { username: string; email: string; roleIds: string[] },
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<{ username: string; email: string; roleIds: string[] }>>,
|
||||||
|
roles: RoleInfo[],
|
||||||
|
target: 'mandate' | 'instance',
|
||||||
|
onAdd: () => void,
|
||||||
|
) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', ..._cardStyle }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>Benutzer hinzufuegen</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<label className={styles.formLabel}>Benutzername *</label>
|
||||||
|
<input
|
||||||
|
className={styles.formInput}
|
||||||
|
value={form.username}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, username: e.target.value }))}
|
||||||
|
placeholder="z.B. hans.muster"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={styles.formLabel}>E-Mail (optional)</label>
|
||||||
|
<input
|
||||||
|
className={styles.formInput}
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, email: e.target.value }))}
|
||||||
|
placeholder="hans.muster@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{roles.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className={styles.formLabel}>Rollen</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{roles.map(role => (
|
||||||
|
<label key={role.id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
|
padding: '6px 12px', borderRadius: '6px',
|
||||||
|
background: form.roleIds.includes(role.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)',
|
||||||
|
border: `1px solid ${form.roleIds.includes(role.id) ? 'var(--primary-color, #3b82f6)' : 'var(--border-color, #e2e8f0)'}`,
|
||||||
|
fontSize: '12px', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.roleIds.includes(role.id)}
|
||||||
|
onChange={() => _toggleRole(role.id, target)}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
{role.roleLabel}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button className={styles.primaryButton} onClick={onAdd}>Hinzufuegen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// RENDER
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
const mandateName = selectedMandate ? getMandateName(selectedMandate) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.adminPage} style={{ overflow: 'auto' }}>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<h1 className={styles.pageTitle}>Einladungs-Wizard</h1>
|
||||||
|
<p className={styles.pageSubtitle}>Erstellen Sie mehrere Einladungen in einem Schritt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.errorContainer} style={{
|
||||||
|
flexDirection: 'row', padding: '12px 16px', marginBottom: '16px',
|
||||||
|
background: '#fef2f2', borderRadius: '8px', fontSize: '13px', justifyContent: 'flex-start',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} style={{ marginLeft: '8px', cursor: 'pointer', background: 'none', border: 'none', fontWeight: 600 }}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div style={{ padding: '12px 16px', background: '#dcfce7', color: '#166534', borderRadius: '8px', marginBottom: '16px', fontSize: '13px' }}>
|
||||||
|
{success}
|
||||||
|
<button onClick={() => setSuccess(null)} style={{ marginLeft: '8px', cursor: 'pointer', background: 'none', border: 'none', fontWeight: 600 }}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step < 5 && _renderStepIndicator()}
|
||||||
|
|
||||||
|
{/* â•â•â• STEP 1: SELECT MANDATE â•â•â• */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div style={_cardStyle}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Schritt 1: Mandant auswaehlen</h3>
|
||||||
|
<select
|
||||||
|
className={styles.filterSelect}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selectedMandate?.id || ''}
|
||||||
|
onChange={e => {
|
||||||
|
const m = mandates.find(x => x.id === e.target.value);
|
||||||
|
setSelectedMandate(m || null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">-- Mandant waehlen --</option>
|
||||||
|
{mandates.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>{getMandateName(m)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
disabled={!selectedMandate}
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* â•â•â• STEP 2: MANDATE INVITEES â•â•â• */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div>
|
||||||
|
<div style={_cardStyle}>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>
|
||||||
|
Schritt 2: Benutzer fuer Mandant “{mandateName}”
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', margin: '0 0 16px 0' }}>
|
||||||
|
Fuegen Sie Benutzer hinzu, die zum Mandanten eingeladen werden sollen.
|
||||||
|
</p>
|
||||||
|
{_renderInviteeList(mandateInvitees, mandateRoles, _removeMandateInvitee)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{_renderAddForm(mandateInviteeForm, setMandateInviteeForm, mandateRoles, 'mandate', _addMandateInvitee)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setStep(1)}>Zurueck</button>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => { setSkipInstance(true); setStep(3); }}>
|
||||||
|
Ohne Feature Instanz weiter
|
||||||
|
</button>
|
||||||
|
<button className={styles.primaryButton} onClick={() => { setSkipInstance(false); setStep(3); }}>
|
||||||
|
Feature Instanz waehlen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* â•â•â• STEP 3: FEATURE INSTANCE (optional) â•â•â• */}
|
||||||
|
{step === 3 && !skipInstance && (
|
||||||
|
<div>
|
||||||
|
<div style={_cardStyle}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Schritt 3: Feature Instanz auswaehlen (optional)</h3>
|
||||||
|
{instances.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-secondary, #94a3b8)', fontSize: '13px' }}>Keine Feature-Instanzen fuer diesen Mandanten verfuegbar.</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
className={styles.filterSelect}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selectedInstance?.id || ''}
|
||||||
|
onChange={e => {
|
||||||
|
const inst = instances.find(i => i.id === e.target.value);
|
||||||
|
setSelectedInstance(inst || null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">-- Feature Instanz waehlen --</option>
|
||||||
|
{instances.map(inst => (
|
||||||
|
<option key={inst.id} value={inst.id}>{inst.label || inst.featureCode}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setStep(2)}>Zurueck</button>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
disabled={!selectedInstance}
|
||||||
|
onClick={() => setStep(4)}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* â•â•â• STEP 4: INSTANCE INVITEES â•â•â• */}
|
||||||
|
{step === 4 && !skipInstance && selectedInstance && (
|
||||||
|
<div>
|
||||||
|
<div style={_cardStyle}>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>
|
||||||
|
Schritt 4: Benutzer fuer Feature Instanz “{selectedInstance.label}”
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', margin: '0 0 16px 0' }}>
|
||||||
|
Fuegen Sie Benutzer hinzu, die zur Feature Instanz eingeladen werden sollen.
|
||||||
|
</p>
|
||||||
|
{_renderInviteeList(instanceInvitees, instRoles, _removeInstanceInvitee)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{_renderAddForm(instanceInviteeForm, setInstanceInviteeForm, instRoles, 'instance', _addInstanceInvitee)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setStep(3)}>Zurueck</button>
|
||||||
|
<button className={styles.primaryButton} onClick={() => setStep(5)}>
|
||||||
|
Zur Zusammenfassung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* â•â•â• DISPATCH / SUMMARY STEP â•â•â• */}
|
||||||
|
{((step === 3 && skipInstance) || (step === 5 && !dispatchResults)) && (
|
||||||
|
<div style={_cardStyle}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Zusammenfassung & Versand</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<strong>Mandant:</strong> {mandateName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mandateInvitees.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<h4 style={{ fontSize: '14px', margin: '0 0 8px 0' }}>Mandant-Einladungen ({mandateInvitees.length})</h4>
|
||||||
|
{_renderInviteeList(mandateInvitees, mandateRoles, () => {})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!skipInstance && selectedInstance && instanceInvitees.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<h4 style={{ fontSize: '14px', margin: '0 0 8px 0' }}>
|
||||||
|
Feature Instanz “{selectedInstance.label}” Einladungen ({instanceInvitees.length})
|
||||||
|
</h4>
|
||||||
|
{_renderInviteeList(instanceInvitees, instRoles, () => {})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label className={styles.formLabel}>Gueltigkeitsdauer (Stunden)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.formInput}
|
||||||
|
style={{ width: '120px' }}
|
||||||
|
value={expiresInHours}
|
||||||
|
min={1}
|
||||||
|
max={720}
|
||||||
|
onChange={e => setExpiresInHours(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setStep(skipInstance ? 2 : 4)}>Zurueck</button>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
disabled={isLoading || (mandateInvitees.length === 0 && instanceInvitees.length === 0)}
|
||||||
|
onClick={_handleDispatch}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Wird versendet...' : `${mandateInvitees.length + instanceInvitees.length} Einladungen versenden`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* â•â•â• RESULTS â•â•â• */}
|
||||||
|
{step === 5 && dispatchResults && (
|
||||||
|
<div style={_cardStyle}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Ergebnis</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px', marginBottom: '16px' }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '12px', background: '#f0fdf4', borderRadius: '8px' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 700, color: '#166534' }}>{dispatchResults.successful}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>Erfolgreich</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: '12px', background: '#fef2f2', borderRadius: '8px' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 700, color: '#991b1b' }}>{dispatchResults.failed}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>Fehlgeschlagen</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: '12px', background: 'var(--bg-secondary, #f1f5f9)', borderRadius: '8px' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 700, color: 'var(--text-primary, #334155)' }}>{dispatchResults.total}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>Gesamt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dispatchResults.results.length > 0 && (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px' }}>Benutzer</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dispatchResults.results.map((r, idx) => (
|
||||||
|
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
|
||||||
|
<td style={{ padding: '8px', fontWeight: 600 }}>{r.targetUsername}</td>
|
||||||
|
<td style={{ padding: '8px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 8px', borderRadius: '4px', fontSize: '12px',
|
||||||
|
background: r.success ? '#dcfce7' : '#fef2f2',
|
||||||
|
color: r.success ? '#166534' : '#991b1b',
|
||||||
|
}}>
|
||||||
|
{r.success ? 'Erfolgreich' : r.error || 'Fehler'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px', color: 'var(--text-secondary, #64748b)' }}>
|
||||||
|
{r.emailSent ? 'Versendet' : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<button className={styles.primaryButton} onClick={() => {
|
||||||
|
setStep(1);
|
||||||
|
setMandateInvitees([]);
|
||||||
|
setInstanceInvitees([]);
|
||||||
|
setSelectedMandate(null);
|
||||||
|
setSelectedInstance(null);
|
||||||
|
setDispatchResults(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setSkipInstance(false);
|
||||||
|
}}>
|
||||||
|
Neuen Wizard starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminInvitationWizardPage;
|
||||||
838
src/pages/admin/AdminMandateWizardPage.tsx
Normal file
838
src/pages/admin/AdminMandateWizardPage.tsx
Normal file
|
|
@ -0,0 +1,838 @@
|
||||||
|
/**
|
||||||
|
* AdminMandateWizardPage (v4.0 - poweron port)
|
||||||
|
*
|
||||||
|
* 4-step wizard for mandate management:
|
||||||
|
* 1. Select/Create Mandate
|
||||||
|
* 2. Manage Mandate Users (add/remove users to/from mandate)
|
||||||
|
* 3. Manage Feature Instances (CRUD)
|
||||||
|
* 4. Manage Users per Feature Instance (CRUD + Roles)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } 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 api from '../../api';
|
||||||
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
|
const TOTAL_STEPS = 4;
|
||||||
|
const STEP_LABELS = ['Mandant', 'Benutzer', 'Instances', 'Feature-Benutzer'];
|
||||||
|
|
||||||
|
interface RoleOption {
|
||||||
|
id: string;
|
||||||
|
roleLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminMandateWizardPage: React.FC = () => {
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchMandateUsers,
|
||||||
|
addUserToMandate,
|
||||||
|
removeUserFromMandate,
|
||||||
|
fetchMandates: fetchMandatesList,
|
||||||
|
fetchRoles: fetchMandateRolesList,
|
||||||
|
fetchAllUsers,
|
||||||
|
} = useUserMandates();
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchFeatures,
|
||||||
|
fetchInstances,
|
||||||
|
createInstance,
|
||||||
|
deleteInstance,
|
||||||
|
fetchInstanceUsers,
|
||||||
|
addUserToInstance,
|
||||||
|
removeUserFromInstance,
|
||||||
|
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);
|
||||||
|
const [mandateForm, setMandateForm] = useState({ name: '', maxInstances: 1, quotaNamesPerYear: 100 });
|
||||||
|
|
||||||
|
// 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({ userId: '', 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({ userId: '', roleIds: [] as string[] });
|
||||||
|
|
||||||
|
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
||||||
|
// HELPERS
|
||||||
|
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
||||||
|
|
||||||
|
const getMandateName = (m: Mandate | Record<string, any>): string => {
|
||||||
|
if (m.label) return m.label;
|
||||||
|
if (typeof m.name === 'object') {
|
||||||
|
return m.name.de || m.name.en || Object.values(m.name)[0] || m.id;
|
||||||
|
}
|
||||||
|
return m.name || m.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFeatureLabel = (code: string): string => {
|
||||||
|
const f = features.find(feat => feat.code === code);
|
||||||
|
if (f) {
|
||||||
|
return typeof f.label === 'object'
|
||||||
|
? (f.label.de || f.label.en || code)
|
||||||
|
: (f.label || code);
|
||||||
|
}
|
||||||
|
return 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('Fehler beim Laden der Mandanten');
|
||||||
|
}
|
||||||
|
}, [fetchMandatesList]);
|
||||||
|
|
||||||
|
useEffect(() => { loadMandates(); }, [loadMandates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFeatures().then(setFeatures);
|
||||||
|
}, [fetchFeatures]);
|
||||||
|
|
||||||
|
// 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 () => {
|
||||||
|
if (!mandateForm.name.trim()) { setError('Name ist erforderlich'); return; }
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/mandates/', {
|
||||||
|
name: mandateForm.name,
|
||||||
|
maxInstances: mandateForm.maxInstances,
|
||||||
|
quotaNamesPerYear: mandateForm.quotaNamesPerYear,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
setSelectedMandate(response.data);
|
||||||
|
setIsCreatingMandate(false);
|
||||||
|
showSuccess('Erstellt', 'Mandant erstellt');
|
||||||
|
await loadMandates();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.detail || err?.message || 'Fehler beim Erstellen');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMandateUser = async () => {
|
||||||
|
if (!selectedMandate || !addMandateUserForm.userId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await addUserToMandate(selectedMandate.id, {
|
||||||
|
targetUserId: addMandateUserForm.userId,
|
||||||
|
roleIds: addMandateUserForm.roleIds,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
setIsAddingMandateUser(false);
|
||||||
|
setAddMandateUserForm({ userId: '', roleIds: [] });
|
||||||
|
showSuccess('Hinzugefügt', 'Benutzer zum Mandanten hinzugefügt');
|
||||||
|
await loadMandateUsers();
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Fehler beim Hinzufügen');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMandateUser = async (userId: string) => {
|
||||||
|
if (!selectedMandate) return;
|
||||||
|
const result = await removeUserFromMandate(selectedMandate.id, userId);
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Entfernt', 'Benutzer aus Mandant entfernt');
|
||||||
|
await loadMandateUsers();
|
||||||
|
} else {
|
||||||
|
setError(result.error || '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('Erstellt', 'Instance erstellt');
|
||||||
|
await loadInstances();
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Fehler beim Erstellen (Limit erreicht?)');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteInstance = async (instanceId: string) => {
|
||||||
|
if (!selectedMandate) return;
|
||||||
|
const result = await deleteInstance(selectedMandate.id, instanceId);
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Gelöscht', 'Instance gelöscht');
|
||||||
|
await loadInstances();
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddInstanceUser = async () => {
|
||||||
|
if (!selectedInstance || !selectedMandate || !addInstanceUserForm.userId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, {
|
||||||
|
userId: addInstanceUserForm.userId,
|
||||||
|
roleIds: addInstanceUserForm.roleIds,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
setIsAddingInstanceUser(false);
|
||||||
|
setAddInstanceUserForm({ userId: '', roleIds: [] });
|
||||||
|
showSuccess('Hinzugefügt', 'Benutzer zur Feature-Instanz hinzugefügt');
|
||||||
|
await loadInstanceUsers();
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Fehler beim Hinzufügen');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveInstanceUser = async (userId: string) => {
|
||||||
|
if (!selectedInstance || !selectedMandate) return;
|
||||||
|
const result = await removeUserFromInstance(selectedMandate.id, selectedInstance.id, userId);
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Entfernt', 'Benutzer aus Feature-Instanz entfernt');
|
||||||
|
await loadInstanceUsers();
|
||||||
|
} else {
|
||||||
|
setError(result.error || '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
|
||||||
|
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
||||||
|
|
||||||
|
const renderUserTable = (
|
||||||
|
users: Array<{ userId?: string; id?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null; enabled?: boolean; roleLabels?: string[] }>,
|
||||||
|
onRemove: (userId: 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 }}>Benutzer</th>
|
||||||
|
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>E-Mail</th>
|
||||||
|
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>Rollen</th>
|
||||||
|
<th style={{ padding: '8px 12px', textAlign: 'center', fontSize: '12px', fontWeight: 600 }}>Status</th>
|
||||||
|
<th style={{ padding: '8px 12px', textAlign: 'right', fontSize: '12px', fontWeight: 600 }}>Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(u => {
|
||||||
|
const uid = u.userId || u.id || '';
|
||||||
|
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)' }}>
|
||||||
|
{u.roleLabels?.join(', ') || '-'}
|
||||||
|
</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 ? 'Aktiv' : '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)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||||
|
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: { userId: string; roleIds: string[] },
|
||||||
|
setFormValue: (fn: (prev: { userId: string; roleIds: string[] }) => { userId: 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}>Benutzer *</label>
|
||||||
|
<select
|
||||||
|
className={styles.filterSelect}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={formValue.userId}
|
||||||
|
onChange={e => setFormValue(p => ({ ...p, userId: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">-- Benutzer wählen --</option>
|
||||||
|
{availableUsers.map(u => {
|
||||||
|
const uid = u.userId || u.id || '';
|
||||||
|
const name = getUserDisplayName(u as any);
|
||||||
|
return (
|
||||||
|
<option key={uid} value={uid}>
|
||||||
|
{u.username} {u.email ? `(${u.email})` : ''} {name !== u.username ? `- ${name}` : ''}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{roles.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className={styles.formLabel}>Rollen</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.userId}>
|
||||||
|
{isLoading ? 'Hinzufügen...' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
<button className={styles.secondaryButton} onClick={onCancel}>
|
||||||
|
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>
|
||||||
|
{STEP_LABELS[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}>Mandanten-Verwaltung</h1>
|
||||||
|
<p className={styles.pageSubtitle}>Schritt-für-Schritt Wizard zur Mandanten-Konfiguration</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 }}>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 style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||||
|
Max. {(m as any).maxInstances || '?'} Instances | Quota: {(m as any).quotaNamesPerYear || '?'} Namen/Jahr
|
||||||
|
</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)}>
|
||||||
|
+ Neuen Mandanten erstellen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '12px' }}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={`${styles.formLabel} ${styles.required}`}>Name</label>
|
||||||
|
<input
|
||||||
|
className={styles.formInput}
|
||||||
|
value={mandateForm.name}
|
||||||
|
onChange={e => setMandateForm(p => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="z.B. Swiss Trust AG"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.formLabel}>Max. Instances</label>
|
||||||
|
<input
|
||||||
|
className={styles.formInput}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={mandateForm.maxInstances}
|
||||||
|
onChange={e => setMandateForm(p => ({ ...p, maxInstances: parseInt(e.target.value) || 1 }))}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>1 = Einzelkunde, >1 = Service Provider</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.formLabel}>Kontingent (Namen/Jahr)</label>
|
||||||
|
<input
|
||||||
|
className={styles.formInput}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={mandateForm.quotaNamesPerYear}
|
||||||
|
onChange={e => setMandateForm(p => ({ ...p, quotaNamesPerYear: parseInt(e.target.value) || 0 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className={styles.primaryButton} onClick={handleCreateMandate} disabled={isLoading}>
|
||||||
|
{isLoading ? 'Erstellen...' : 'Mandant erstellen'}
|
||||||
|
</button>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setIsCreatingMandate(false)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button className={styles.primaryButton} disabled={!selectedMandate} onClick={() => setStep(2)}>
|
||||||
|
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 }}>
|
||||||
|
Benutzer von «{getMandateName(selectedMandate)}»
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||||
|
onClick={() => setIsAddingMandateUser(true)}
|
||||||
|
disabled={availableUsersForMandate.length === 0}
|
||||||
|
>
|
||||||
|
+ Benutzer hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Alle Systembenutzer können dem Mandanten zugewiesen werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isAddingMandateUser && renderAddUserForm(
|
||||||
|
availableUsersForMandate,
|
||||||
|
mandateRoles,
|
||||||
|
addMandateUserForm,
|
||||||
|
setAddMandateUserForm,
|
||||||
|
handleAddMandateUser,
|
||||||
|
() => { setIsAddingMandateUser(false); setAddMandateUserForm({ userId: '', roleIds: [] }); },
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
{renderUserTable(mandateUsers as any[], handleRemoveMandateUser)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setStep(1)}>← Zurück</button>
|
||||||
|
<button className={styles.primaryButton} onClick={() => setStep(3)}>
|
||||||
|
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 }}>
|
||||||
|
Feature-Instances für «{getMandateName(selectedMandate)}»
|
||||||
|
</h3>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||||
|
{instances.length} / {(selectedMandate as any).maxInstances || '?'} Instances
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature Filter */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label className={styles.formLabel}>Feature filtern:</label>
|
||||||
|
<select
|
||||||
|
className={styles.filterSelect}
|
||||||
|
value={selectedFeatureCode}
|
||||||
|
onChange={e => setSelectedFeatureCode(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">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 ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
style={{ padding: '4px 8px', fontSize: '12px' }}
|
||||||
|
onClick={() => { setSelectedInstance(inst); setStep(4); }}
|
||||||
|
>
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCreatingInstance ? (
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setIsCreatingInstance(true)}>
|
||||||
|
+ Neue Instance 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}>Feature *</label>
|
||||||
|
<select
|
||||||
|
className={styles.filterSelect}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selectedFeatureCode}
|
||||||
|
onChange={e => setSelectedFeatureCode(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">-- 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}`}>Bezeichnung</label>
|
||||||
|
<input
|
||||||
|
className={styles.formInput}
|
||||||
|
value={instanceForm.label}
|
||||||
|
onChange={e => setInstanceForm(p => ({ ...p, label: e.target.value }))}
|
||||||
|
placeholder="z.B. Kunde A"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className={styles.checkboxLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={instanceForm.enabled}
|
||||||
|
onChange={e => setInstanceForm(p => ({ ...p, enabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Instance aktiviert
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className={styles.primaryButton} onClick={handleCreateInstance} disabled={isLoading || !selectedFeatureCode}>
|
||||||
|
{isLoading ? 'Erstellen...' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setIsCreatingInstance(false)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setStep(2)}>← Zurück</button>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={() => { if (instances.length > 0) { setSelectedInstance(instances[0]); setStep(4); } }}
|
||||||
|
disabled={instances.length === 0}
|
||||||
|
>
|
||||||
|
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 }}>
|
||||||
|
Feature-Benutzer für «{selectedInstance.label}»
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||||
|
onClick={() => setIsAddingInstanceUser(true)}
|
||||||
|
disabled={availableUsersForInstance.length === 0 || instanceRoles.length === 0}
|
||||||
|
>
|
||||||
|
+ Benutzer hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Mandant: {getMandateName(selectedMandate)} | Mitglieder des Mandanten können der Feature-Instanz zugewiesen werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isAddingInstanceUser && renderAddUserForm(
|
||||||
|
availableUsersForInstance as any[],
|
||||||
|
instanceRoles,
|
||||||
|
addInstanceUserForm,
|
||||||
|
setAddInstanceUserForm,
|
||||||
|
handleAddInstanceUser,
|
||||||
|
() => { setIsAddingInstanceUser(false); setAddInstanceUserForm({ userId: '', roleIds: [] }); },
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
{renderUserTable(
|
||||||
|
instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })),
|
||||||
|
handleRemoveInstanceUser,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => setStep(3)}>← Zurück</button>
|
||||||
|
<button className={styles.primaryButton} onClick={() => {
|
||||||
|
showSuccess('Fertig', 'Konfiguration abgeschlossen!');
|
||||||
|
setSelectedInstance(null);
|
||||||
|
setStep(1);
|
||||||
|
}}>
|
||||||
|
Fertig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminMandateWizardPage;
|
||||||
|
|
||||||
|
|
@ -17,3 +17,5 @@ export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPa
|
||||||
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||||
export { AdminAutomationEventsPage } from './AdminAutomationEventsPage';
|
export { AdminAutomationEventsPage } from './AdminAutomationEventsPage';
|
||||||
export { AdminLogsPage } from './AdminLogsPage';
|
export { AdminLogsPage } from './AdminLogsPage';
|
||||||
|
export { default as AdminMandateWizardPage } from './AdminMandateWizardPage';
|
||||||
|
export { default as AdminInvitationWizardPage } from './AdminInvitationWizardPage';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue