746 lines
32 KiB
TypeScript
746 lines
32 KiB
TypeScript
/**
|
||
* AdminInvitationWizardPage
|
||
*
|
||
* 4-step invitation wizard:
|
||
* 1. Choose invite type: "Invite to mandate" OR "Invite to feature instance"
|
||
* 2. Select mandate (and feature instance if applicable)
|
||
* 3. Add invitees (mindestens E-Mail oder Benutzername für neue Benutzer; bestehende Benutzer; Rolle pro Einladung)
|
||
* 4. Summary and send
|
||
*/
|
||
|
||
import React, { useState, useEffect } from 'react';
|
||
import { useInvitations } 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';
|
||
|
||
type InviteType = 'mandate' | 'featureInstance';
|
||
|
||
interface RoleOption {
|
||
id: string;
|
||
roleLabel: string;
|
||
}
|
||
|
||
interface InviteeEntry {
|
||
email: string;
|
||
username?: string;
|
||
roleIds: string[];
|
||
isExisting: boolean;
|
||
userId?: string;
|
||
}
|
||
|
||
interface DispatchResult {
|
||
email: string;
|
||
username?: string;
|
||
success: boolean;
|
||
error?: string;
|
||
emailSent?: boolean;
|
||
}
|
||
|
||
// =============================================================================
|
||
// WIZARD-STYLE CONSTANTS
|
||
// =============================================================================
|
||
|
||
const EXPIRES_IN_HOURS = 72;
|
||
|
||
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 { showSuccess } = useToast();
|
||
const { createInvitation } = useInvitations();
|
||
const { fetchMandates, fetchRoles, fetchAllUsers, fetchMandateUsers } = useUserMandates();
|
||
const { fetchInstances, fetchInstanceRoles, fetchInstanceUsers } = useFeatureAccess();
|
||
|
||
const [step, setStep] = useState(1);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Step 1: Invite type
|
||
const [inviteType, setInviteType] = useState<InviteType | null>(null);
|
||
|
||
// Step 2: Selection
|
||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||
const [selectedMandate, setSelectedMandate] = useState<Mandate | null>(null);
|
||
const [instances, setInstances] = useState<FeatureInstance[]>([]);
|
||
const [selectedInstance, setSelectedInstance] = useState<FeatureInstance | null>(null);
|
||
|
||
// Step 3: Invitees
|
||
const [invitees, setInvitees] = useState<InviteeEntry[]>([]);
|
||
const [inviteeForm, setInviteeForm] = useState({ email: '', username: '', roleIds: [] as string[] });
|
||
const [addMode, setAddMode] = useState<'email' | 'existing'>('email');
|
||
const [selectedExistingUserId, setSelectedExistingUserId] = useState('');
|
||
const [allSystemUsers, setAllSystemUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
|
||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||
|
||
// Step 4: Dispatch
|
||
const [dispatchResults, setDispatchResults] = useState<DispatchResult[] | null>(null);
|
||
|
||
// Users who already have access (to filter from existing-user dropdown)
|
||
const [existingAccessUserIds, setExistingAccessUserIds] = useState<Set<string>>(new Set());
|
||
|
||
// ==========================================================================
|
||
// HELPERS
|
||
// ==========================================================================
|
||
|
||
const getMandateName = (m: Mandate): 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;
|
||
};
|
||
|
||
// ==========================================================================
|
||
// DATA LOADING
|
||
// ==========================================================================
|
||
|
||
useEffect(() => {
|
||
fetchMandates().then(setMandates);
|
||
}, [fetchMandates]);
|
||
|
||
useEffect(() => {
|
||
if (inviteType === 'featureInstance' && selectedMandate) {
|
||
// Show all instances for the mandate (including disabled). Previously only `enabled` instances were listed, which hid deactivated instances from admins.
|
||
fetchInstances(selectedMandate.id).then(data => setInstances(data || []));
|
||
} else {
|
||
setInstances([]);
|
||
setSelectedInstance(null);
|
||
}
|
||
}, [inviteType, selectedMandate, fetchInstances]);
|
||
|
||
useEffect(() => {
|
||
if (step === 3 && selectedMandate) {
|
||
if (inviteType === 'mandate') {
|
||
fetchRoles(selectedMandate.id).then((r: Role[]) => {
|
||
const mandateRoles = r.filter((role: Role) => !role.featureInstanceId);
|
||
setRoles(mandateRoles.map((role: Role) => ({ id: role.id, roleLabel: role.roleLabel })));
|
||
});
|
||
fetchMandateUsers(selectedMandate.id).then((users: { userId: string }[]) => {
|
||
setExistingAccessUserIds(new Set(users.map(u => u.userId)));
|
||
}).catch(() => setExistingAccessUserIds(new Set()));
|
||
} else if (inviteType === 'featureInstance' && selectedInstance) {
|
||
fetchInstanceRoles(selectedMandate.id, selectedInstance.id).then((r: FeatureInstanceRole[]) => {
|
||
setRoles(r.map(role => ({ id: role.id, roleLabel: role.roleLabel })));
|
||
});
|
||
fetchInstanceUsers(selectedMandate.id, selectedInstance.id).then((users: { userId: string }[]) => {
|
||
setExistingAccessUserIds(new Set(users.map(u => u.userId)));
|
||
}).catch(() => setExistingAccessUserIds(new Set()));
|
||
} else {
|
||
setRoles([]);
|
||
setExistingAccessUserIds(new Set());
|
||
}
|
||
fetchAllUsers().then(setAllSystemUsers);
|
||
}
|
||
}, [step, selectedMandate, selectedInstance, inviteType, fetchRoles, fetchInstanceRoles, fetchAllUsers, fetchMandateUsers, fetchInstanceUsers]);
|
||
|
||
// ==========================================================================
|
||
// INVITEE HANDLERS
|
||
// ==========================================================================
|
||
|
||
const addInviteeByEmail = () => {
|
||
const email = inviteeForm.email.trim();
|
||
const username = inviteeForm.username.trim();
|
||
if (!email && !username) {
|
||
setError('Bitte mindestens eine E-Mail-Adresse oder einen Benutzernamen angeben.');
|
||
return;
|
||
}
|
||
const emailLower = email.toLowerCase();
|
||
const userLower = username.toLowerCase();
|
||
if (email && invitees.some(i => !i.isExisting && (i.email || '').toLowerCase() === emailLower)) {
|
||
setError('Diese E-Mail ist bereits in der Liste');
|
||
return;
|
||
}
|
||
if (username && invitees.some(i => !i.isExisting && (i.username || '').toLowerCase() === userLower)) {
|
||
setError('Dieser Benutzername ist bereits in der Liste');
|
||
return;
|
||
}
|
||
setInvitees(prev => [...prev, {
|
||
email,
|
||
username: username || undefined,
|
||
roleIds: [...inviteeForm.roleIds],
|
||
isExisting: false,
|
||
}]);
|
||
setInviteeForm({ email: '', username: '', roleIds: [] });
|
||
setError(null);
|
||
};
|
||
|
||
const addInviteeExisting = () => {
|
||
if (!selectedExistingUserId) {
|
||
setError('Bitte wählen Sie einen Benutzer');
|
||
return;
|
||
}
|
||
const user = allSystemUsers.find(u => u.id === selectedExistingUserId);
|
||
if (!user) return;
|
||
const email = (user.email || '').trim();
|
||
if (invitees.some(i => i.userId === user.id)) {
|
||
setError('Dieser Benutzer ist bereits in der Liste');
|
||
return;
|
||
}
|
||
setInvitees(prev => [...prev, {
|
||
email,
|
||
username: user.username,
|
||
roleIds: inviteeForm.roleIds,
|
||
isExisting: true,
|
||
userId: user.id,
|
||
}]);
|
||
setInviteeForm({ email: '', username: '', roleIds: [] });
|
||
setSelectedExistingUserId('');
|
||
setError(null);
|
||
};
|
||
|
||
const removeInvitee = (index: number) => {
|
||
setInvitees(prev => prev.filter((_, i) => i !== index));
|
||
};
|
||
|
||
const availableExistingUsers = allSystemUsers.filter(
|
||
u => !invitees.some(i => i.userId === u.id) && !existingAccessUserIds.has(u.id)
|
||
);
|
||
|
||
// ==========================================================================
|
||
// DISPATCH
|
||
// ==========================================================================
|
||
|
||
const handleSend = async () => {
|
||
if (!selectedMandate || invitees.length === 0) return;
|
||
if (inviteType === 'featureInstance' && !selectedInstance) {
|
||
setError('Bitte wählen Sie eine Feature-Instanz aus.');
|
||
return;
|
||
}
|
||
setIsLoading(true);
|
||
setError(null);
|
||
const results: DispatchResult[] = [];
|
||
try {
|
||
for (const inv of invitees) {
|
||
const emailTrim = (inv.email || '').trim();
|
||
const payload = {
|
||
...(emailTrim ? { email: emailTrim } : {}),
|
||
targetUsername: inv.username || undefined,
|
||
roleIds: inv.roleIds,
|
||
expiresInHours: EXPIRES_IN_HOURS,
|
||
...(inviteType === 'featureInstance' && selectedInstance?.id
|
||
? { featureInstanceId: selectedInstance.id }
|
||
: {}),
|
||
};
|
||
const result = await createInvitation(selectedMandate.id, payload);
|
||
if (result.success) {
|
||
results.push({
|
||
email: emailTrim,
|
||
username: inv.username,
|
||
success: true,
|
||
emailSent: result.data?.emailSent,
|
||
});
|
||
} else {
|
||
results.push({
|
||
email: emailTrim,
|
||
username: inv.username,
|
||
success: false,
|
||
error: result.error,
|
||
});
|
||
}
|
||
}
|
||
setDispatchResults(results);
|
||
const ok = results.filter(r => r.success).length;
|
||
showSuccess('Versand', `${ok} von ${results.length} Einladungen erfolgreich versendet`);
|
||
} catch (err: any) {
|
||
setError(err?.response?.data?.detail || err?.message || 'Fehler beim Versand');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const resetWizard = () => {
|
||
setStep(1);
|
||
setInviteType(null);
|
||
setSelectedMandate(null);
|
||
setSelectedInstance(null);
|
||
setInvitees([]);
|
||
setInviteeForm({ email: '', username: '', roleIds: [] });
|
||
setDispatchResults(null);
|
||
setError(null);
|
||
};
|
||
|
||
// ==========================================================================
|
||
// STEP LABELS
|
||
// ==========================================================================
|
||
|
||
const stepLabels = inviteType === 'featureInstance'
|
||
? ['Art', 'Mandant & Instanz', 'Einladungen', 'Zusammenfassung']
|
||
: ['Art', 'Mandant', 'Einladungen', 'Zusammenfassung'];
|
||
|
||
const canProceedStep3 = inviteType === 'mandate'
|
||
? selectedMandate !== null
|
||
: selectedMandate !== null && selectedInstance !== null && instances.length > 0;
|
||
|
||
// ==========================================================================
|
||
// RENDER
|
||
// ==========================================================================
|
||
|
||
return (
|
||
<div className={styles.adminPage} style={{ overflow: 'auto' }}>
|
||
<div className={styles.pageHeader}>
|
||
<div>
|
||
<h1 className={styles.pageTitle}>Einladungs-Wizard</h1>
|
||
<p className={styles.pageSubtitle}>
|
||
Benutzer zu Mandant oder Feature-Instanz einladen
|
||
</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>
|
||
)}
|
||
|
||
{/* Step indicator */}
|
||
{!dispatchResults && (
|
||
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px', flexWrap: 'wrap' }}>
|
||
{stepLabels.map((label, idx) => {
|
||
const s = idx + 1;
|
||
return (
|
||
<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' : s}
|
||
</span>
|
||
{label}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── STEP 1: Invite type ── */}
|
||
{step === 1 && (
|
||
<div style={_cardStyle}>
|
||
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Wohin möchten Sie einladen?</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||
<button
|
||
onClick={() => setInviteType('mandate')}
|
||
style={{
|
||
padding: '20px', border: `2px solid ${inviteType === 'mandate' ? 'var(--primary-color, #f25843)' : 'var(--border-color, #e5e7eb)'}`,
|
||
borderRadius: '8px', background: inviteType === 'mandate' ? 'var(--primary-bg, rgba(242,88,67,0.06))' : 'var(--surface-color, #fff)',
|
||
cursor: 'pointer', textAlign: 'left',
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '4px' }}>Zum Mandanten</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||
Einladung zum Mandanten ohne spezifische Feature-Instanz
|
||
</div>
|
||
</button>
|
||
<button
|
||
onClick={() => setInviteType('featureInstance')}
|
||
style={{
|
||
padding: '20px', border: `2px solid ${inviteType === 'featureInstance' ? 'var(--primary-color, #f25843)' : 'var(--border-color, #e5e7eb)'}`,
|
||
borderRadius: '8px', background: inviteType === 'featureInstance' ? 'var(--primary-bg, rgba(242,88,67,0.06))' : 'var(--surface-color, #fff)',
|
||
cursor: 'pointer', textAlign: 'left',
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '4px' }}>Zur Feature-Instanz</div>
|
||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||
Einladung zu einer bestimmten Feature-Instanz mit Rolle
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'flex-end' }}>
|
||
<button className={styles.primaryButton} disabled={!inviteType} onClick={() => setStep(2)}>
|
||
Weiter →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── STEP 2: Select mandate / mandate + instance ── */}
|
||
{step === 2 && (
|
||
<div style={_cardStyle}>
|
||
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
|
||
{inviteType === 'mandate' ? 'Mandant auswählen' : 'Mandant und Feature-Instanz auswählen'}
|
||
</h3>
|
||
<div style={{ marginBottom: '16px' }}>
|
||
<label className={styles.formLabel}>Mandant *</label>
|
||
<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);
|
||
setSelectedInstance(null);
|
||
}}
|
||
>
|
||
<option value="">-- Mandant wählen --</option>
|
||
{mandates.map(m => (
|
||
<option key={m.id} value={m.id}>{getMandateName(m)}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{inviteType === 'featureInstance' && selectedMandate && (
|
||
<div style={{ marginBottom: '16px' }}>
|
||
<label className={styles.formLabel}>Feature-Instanz *</label>
|
||
{instances.length === 0 ? (
|
||
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Keine Feature-Instanzen für diesen Mandanten vorhanden.</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 wählen --</option>
|
||
{instances.map(inst => {
|
||
const baseLabel = inst.label || inst.featureCode;
|
||
const suffix = inst.enabled === false ? ' (deaktiviert)' : '';
|
||
return (
|
||
<option key={inst.id} value={inst.id}>{`${baseLabel}${suffix}`}</option>
|
||
);
|
||
})}
|
||
</select>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
||
<button className={styles.secondaryButton} onClick={() => setStep(1)}>← Zurück</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
disabled={!canProceedStep3}
|
||
onClick={() => setStep(3)}
|
||
>
|
||
Weiter →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── STEP 3: Add invitees ── */}
|
||
{step === 3 && selectedMandate && (
|
||
<div>
|
||
<div style={_cardStyle}>
|
||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>Einladungen hinzufügen</h3>
|
||
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', margin: '0 0 16px 0' }}>
|
||
Für neue Benutzer: mindestens eine E-Mail <em>oder</em> ein Benutzername (vorgegeben). Ohne E-Mail wird kein Link per Mail versendet — der Einladungslink kann manuell geteilt werden. Bestehende Benutzer wählen Sie im zweiten Tab.
|
||
</p>
|
||
|
||
{/* Add form: toggle email vs existing */}
|
||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
||
<button
|
||
className={addMode === 'email' ? styles.primaryButton : styles.secondaryButton}
|
||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||
onClick={() => setAddMode('email')}
|
||
>
|
||
Neue Benutzer (E-Mail und/oder Benutzername)
|
||
</button>
|
||
<button
|
||
className={addMode === 'existing' ? styles.primaryButton : styles.secondaryButton}
|
||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||
onClick={() => setAddMode('existing')}
|
||
>
|
||
Bestehende Benutzer
|
||
</button>
|
||
</div>
|
||
|
||
{addMode === 'email' ? (
|
||
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
|
||
<div>
|
||
<label className={styles.formLabel}>E-Mail (optional)</label>
|
||
<input
|
||
className={styles.formInput}
|
||
type="email"
|
||
value={inviteeForm.email}
|
||
onChange={e => setInviteeForm(p => ({ ...p, email: e.target.value }))}
|
||
placeholder="beispiel@firma.com"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={styles.formLabel}>Benutzername (optional)</label>
|
||
<input
|
||
className={styles.formInput}
|
||
type="text"
|
||
autoComplete="off"
|
||
value={inviteeForm.username}
|
||
onChange={e => setInviteeForm(p => ({ ...p, username: e.target.value }))}
|
||
placeholder="z. B. vorname.nachname"
|
||
/>
|
||
<p style={{ fontSize: '11px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
||
Mindestens eines der beiden Felder ausfüllen. Mit Benutzername muss der Eingeladene genau diesen Namen beim Annehmen verwenden.
|
||
</p>
|
||
</div>
|
||
{roles.length > 0 && (
|
||
<div>
|
||
<label className={styles.formLabel}>Rolle{inviteType === 'featureInstance' ? ' (pro Instanz)' : ''} *</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||
{roles.map(r => (
|
||
<label key={r.id} className={styles.checkboxLabel} style={{
|
||
display: 'flex', alignItems: 'center', gap: '6px',
|
||
padding: '6px 12px', borderRadius: '6px',
|
||
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)',
|
||
border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #F25843)' : 'var(--border-color, #e2e8f0)'}`,
|
||
fontSize: '12px', cursor: 'pointer',
|
||
}}>
|
||
<input
|
||
type="checkbox"
|
||
checked={inviteeForm.roleIds.includes(r.id)}
|
||
onChange={e => {
|
||
setInviteeForm(p => ({
|
||
...p,
|
||
roleIds: e.target.checked ? [...p.roleIds, r.id] : p.roleIds.filter(id => id !== r.id),
|
||
}));
|
||
}}
|
||
/>
|
||
{r.roleLabel}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={addInviteeByEmail}
|
||
disabled={
|
||
(!inviteeForm.email.trim() && !inviteeForm.username.trim())
|
||
|| (roles.length > 0 && inviteeForm.roleIds.length === 0)
|
||
}
|
||
>
|
||
Hinzufügen
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
|
||
<div>
|
||
<label className={styles.formLabel}>Bestehender Benutzer *</label>
|
||
<select
|
||
className={styles.filterSelect}
|
||
style={{ width: '100%' }}
|
||
value={selectedExistingUserId}
|
||
onChange={e => setSelectedExistingUserId(e.target.value)}
|
||
>
|
||
<option value="">-- Benutzer wählen --</option>
|
||
{availableExistingUsers.map(u => (
|
||
<option key={u.id} value={u.id}>
|
||
{u.username} {u.email ? `(${u.email})` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{availableExistingUsers.length === 0 && <p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '4px' }}>Keine weiteren Benutzer verfügbar.</p>}
|
||
</div>
|
||
{roles.length > 0 && (
|
||
<div>
|
||
<label className={styles.formLabel}>Rolle *</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||
{roles.map(r => (
|
||
<label key={r.id} className={styles.checkboxLabel} style={{
|
||
display: 'flex', alignItems: 'center', gap: '6px',
|
||
padding: '6px 12px', borderRadius: '6px',
|
||
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)',
|
||
border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #F25843)' : 'var(--border-color, #e2e8f0)'}`,
|
||
fontSize: '12px', cursor: 'pointer',
|
||
}}>
|
||
<input
|
||
type="checkbox"
|
||
checked={inviteeForm.roleIds.includes(r.id)}
|
||
onChange={e => {
|
||
setInviteeForm(p => ({
|
||
...p,
|
||
roleIds: e.target.checked ? [...p.roleIds, r.id] : p.roleIds.filter(id => id !== r.id),
|
||
}));
|
||
}}
|
||
/>
|
||
{r.roleLabel}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={addInviteeExisting}
|
||
disabled={!selectedExistingUserId || (roles.length > 0 && inviteeForm.roleIds.length === 0)}
|
||
>
|
||
Hinzufügen
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Invitee list */}
|
||
{invitees.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' }}>E-Mail / Benutzer</th>
|
||
<th style={{ textAlign: 'left', padding: '8px' }}>Benutzername</th>
|
||
<th style={{ textAlign: 'left', padding: '8px' }}>Rollen</th>
|
||
<th style={{ textAlign: 'left', padding: '8px' }}>Typ</th>
|
||
<th style={{ textAlign: 'right', padding: '8px' }}>Aktion</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{invitees.map((inv, idx) => (
|
||
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
|
||
<td style={{ padding: '8px' }}>{inv.email || '—'}</td>
|
||
<td style={{ padding: '8px', color: 'var(--text-secondary)' }}>
|
||
{inv.username || ''}
|
||
</td>
|
||
<td style={{ padding: '8px' }}>
|
||
{inv.roleIds.length > 0
|
||
? roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')
|
||
: '-'}
|
||
</td>
|
||
<td style={{ padding: '8px', fontSize: '12px' }}>{inv.isExisting ? 'Bestehend' : 'Neu (Einladung)'}</td>
|
||
<td style={{ padding: '8px', textAlign: 'right' }}>
|
||
<button
|
||
onClick={() => removeInvitee(idx)}
|
||
style={{ background: 'none', border: 'none', color: 'var(--danger-color)', cursor: 'pointer', fontWeight: 600 }}
|
||
>
|
||
Entfernen
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', marginBottom: '16px' }}>Noch keine Einladungen hinzugefügt.</p>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||
<button className={styles.secondaryButton} onClick={() => setStep(2)}>← Zurück</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
disabled={invitees.length === 0}
|
||
onClick={() => setStep(4)}
|
||
>
|
||
Zur Zusammenfassung →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── STEP 4: Summary and send ── */}
|
||
{step === 4 && selectedMandate && !dispatchResults && (
|
||
<div style={_cardStyle}>
|
||
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Zusammenfassung & Versand</h3>
|
||
<div style={{ marginBottom: '16px' }}>
|
||
<strong>Art:</strong> {inviteType === 'mandate' ? 'Einladung zum Mandanten' : 'Einladung zur Feature-Instanz'}
|
||
</div>
|
||
<div style={{ marginBottom: '16px' }}>
|
||
<strong>Mandant:</strong> {getMandateName(selectedMandate)}
|
||
</div>
|
||
{inviteType === 'featureInstance' && selectedInstance && (
|
||
<div style={{ marginBottom: '16px' }}>
|
||
<strong>Feature-Instanz:</strong> {selectedInstance.label || selectedInstance.featureCode}
|
||
</div>
|
||
)}
|
||
<div style={{ marginBottom: '16px' }}>
|
||
<strong>Einladungen ({invitees.length}):</strong>
|
||
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||
{invitees.map((inv, i) => (
|
||
<li key={i} style={{ marginBottom: '4px' }}>
|
||
{[inv.email || null, inv.username ? `@${inv.username}` : null].filter(Boolean).join(' · ')
|
||
|| '—'}
|
||
{inv.roleIds.length > 0 && ` – ${roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')}`}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '24px' }}>
|
||
<button className={styles.secondaryButton} onClick={() => setStep(3)}>← Zurück</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
disabled={isLoading}
|
||
onClick={handleSend}
|
||
>
|
||
{isLoading ? 'Wird versendet...' : `${invitees.length} Einladungen versenden`}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Results ── */}
|
||
{dispatchResults && (
|
||
<div style={_cardStyle}>
|
||
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Ergebnis</h3>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
|
||
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail / Benutzer</th>
|
||
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
|
||
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail gesendet</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{dispatchResults.map((r, idx) => (
|
||
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
|
||
<td style={{ padding: '8px' }}>
|
||
{(r.email || '').trim() && r.username
|
||
? `${(r.email || '').trim()} (@${r.username})`
|
||
: (r.email || '').trim() || (r.username ? `@${r.username}` : '—')}
|
||
</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' }}>{r.emailSent ? 'Ja' : '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<div style={{ marginTop: '16px' }}>
|
||
<button className={styles.primaryButton} onClick={resetWizard}>
|
||
Neuen Wizard starten
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminInvitationWizardPage;
|