frontend_nyla/src/pages/admin/AdminInvitationWizardPage.tsx
2026-02-20 23:20:20 +01:00

683 lines
27 KiB
TypeScript

/**
* 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 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 [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 }}>&times;</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 }}>&times;</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 &ldquo;{mandateName}&rdquo;
</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 &ldquo;{selectedInstance.label}&rdquo;
</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 &amp; 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 &ldquo;{selectedInstance.label}&rdquo; 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;