351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
/**
|
|
* FeatureInstanceWizard
|
|
*
|
|
* Guided flow: Create instance → Sync roles → Add users (optional).
|
|
*/
|
|
|
|
import React, { useState, useMemo } from 'react';
|
|
import { useFeatureAccess } from '../../../hooks/useFeatureAccess';
|
|
import { useToast } from '../../../contexts/ToastContext';
|
|
import api from '../../../api';
|
|
import type { Mandate } from '../../../hooks/useUserMandates';
|
|
import type { Feature } from '../../../hooks/useFeatureAccess';
|
|
import styles from '../Admin.module.css';
|
|
import wizardStyles from './FeatureInstanceWizard.module.css';
|
|
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
|
|
|
|
function getMandateName(m: Mandate): string {
|
|
return mandateDisplayLabel(m);
|
|
}
|
|
|
|
export interface FeatureInstanceWizardProps {
|
|
mandateId: string;
|
|
mandates: Mandate[];
|
|
features: Feature[];
|
|
onClose: () => void;
|
|
onComplete: () => void;
|
|
}
|
|
|
|
export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ mandateId: initialMandateId,
|
|
mandates,
|
|
features,
|
|
onClose,
|
|
onComplete,
|
|
}) => {
|
|
const { createInstance, addUserToInstance, fetchInstanceRoles } = useFeatureAccess();
|
|
const { showSuccess, showError } = useToast();
|
|
const { t } = useLanguage();
|
|
|
|
const steps = useMemo(() => [
|
|
{ id: 'create' as const, title: t('Instanz erstellen') },
|
|
{ id: 'roles' as const, title: t('Rollen') },
|
|
{ id: 'users' as const, title: t('Benutzer (optional)') },
|
|
], [t]);
|
|
|
|
const [step, setStep] = useState(0);
|
|
const [mandateId, setMandateId] = useState(initialMandateId || '');
|
|
const [featureCode, setFeatureCode] = useState('');
|
|
const [label, setLabel] = useState('');
|
|
const [enabled, setEnabled] = useState(true);
|
|
const [copyTemplateRoles, setCopyTemplateRoles] = useState(true);
|
|
const [createdInstanceId, setCreatedInstanceId] = useState<string | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [mandateUsers, setMandateUsers] = useState<Array<{ id: string; username: string; email?: string }>>([]);
|
|
const [instanceRoles, setInstanceRoles] = useState<Array<{ id: string; roleLabel: string }>>([]);
|
|
const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]);
|
|
const [labelTouched, setLabelTouched] = useState(false);
|
|
|
|
const trimmedLabel = label.trim();
|
|
const labelMissing = trimmedLabel.length === 0;
|
|
const canSubmitStep1 = !!mandateId && !!featureCode && !labelMissing && !submitting;
|
|
|
|
const handleStep1Submit = async () => {
|
|
setLabelTouched(true);
|
|
if (!canSubmitStep1) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const result = await createInstance(mandateId, {
|
|
featureCode,
|
|
label: trimmedLabel,
|
|
enabled,
|
|
copyTemplateRoles,
|
|
});
|
|
if (result.success && result.data) {
|
|
setLabel(trimmedLabel);
|
|
setCreatedInstanceId(result.data.id);
|
|
setStep(1);
|
|
} else {
|
|
showError(t('Fehler'), result.error || t('Instanz konnte nicht erstellt werden'));
|
|
}
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleStep2Next = async () => {
|
|
if (createdInstanceId && mandateId) {
|
|
setSubmitting(true);
|
|
try {
|
|
const [roleList, usersRes] = await Promise.all([
|
|
fetchInstanceRoles(mandateId, createdInstanceId),
|
|
api.get(`/api/mandates/${mandateId}/users`),
|
|
]);
|
|
setInstanceRoles(Array.isArray(roleList) ? roleList : []);
|
|
const data = usersRes.data?.items || usersRes.data || [];
|
|
setMandateUsers(
|
|
Array.isArray(data)
|
|
? data.map((u: { userId: string; username: string; email?: string }) => ({
|
|
id: u.userId,
|
|
username: u.username,
|
|
email: u.email,
|
|
}))
|
|
: []
|
|
);
|
|
} catch {
|
|
setInstanceRoles([]);
|
|
setMandateUsers([]);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
setStep(2);
|
|
};
|
|
|
|
const handleStep3Complete = async () => {
|
|
if (!createdInstanceId || !mandateId) {
|
|
onComplete();
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
for (const { userId, roleIds } of selectedUserRoles) {
|
|
if (roleIds.length > 0) {
|
|
await addUserToInstance(mandateId, createdInstanceId, { userId, roleIds });
|
|
}
|
|
}
|
|
showSuccess(t('Fertig'), t('Feature-Instanz wurde erstellt und Benutzer zugewiesen.'));
|
|
onComplete();
|
|
} catch {
|
|
showError(t('Fehler'), t('Einige Benutzer konnten nicht zugewiesen werden.'));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleAddUserRole = (userId: string, roleIds: string[]) => {
|
|
setSelectedUserRoles((prev) => {
|
|
const rest = prev.filter((p) => p.userId !== userId);
|
|
if (roleIds.length === 0) return rest;
|
|
return [...rest, { userId, roleIds }];
|
|
});
|
|
};
|
|
|
|
const currentStepId = steps[step]?.id;
|
|
|
|
return (
|
|
<div className={styles.modalOverlay}>
|
|
<div className={`${styles.modal} ${wizardStyles.modal}`}>
|
|
<div className={styles.modalHeader}>
|
|
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz')}</h2>
|
|
<button type="button" className={styles.modalClose} onClick={onClose} aria-label={t('Schließen')}>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div className={wizardStyles.steps}>
|
|
{steps.map((s, i) => (
|
|
<div
|
|
key={s.id}
|
|
className={`${wizardStyles.stepDot} ${i <= step ? wizardStyles.stepDotActive : ''}`}
|
|
title={s.title}
|
|
>
|
|
{i + 1}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className={styles.modalContent}>
|
|
{currentStepId === 'create' && (
|
|
<div className={wizardStyles.stepContent}>
|
|
<div className={wizardStyles.fieldGroup}>
|
|
<span className={wizardStyles.fieldLabel}>
|
|
{t('Mandant')}<span className={wizardStyles.required}>*</span>
|
|
</span>
|
|
{mandates.length === 0 ? (
|
|
<p className={wizardStyles.fieldHint}>{t('Keine Mandanten verfügbar')}</p>
|
|
) : (
|
|
<div className={wizardStyles.cardGrid}>
|
|
{mandates.map((m) => {
|
|
const isActive = mandateId === m.id;
|
|
return (
|
|
<button
|
|
key={m.id}
|
|
type="button"
|
|
className={`${wizardStyles.cardButton} ${isActive ? wizardStyles.cardButtonActive : ''}`}
|
|
onClick={() => setMandateId(m.id)}
|
|
aria-pressed={isActive}
|
|
>
|
|
{getMandateName(m)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={wizardStyles.fieldGroup}>
|
|
<span className={wizardStyles.fieldLabel}>
|
|
{t('Feature')}<span className={wizardStyles.required}>*</span>
|
|
</span>
|
|
{features.length === 0 ? (
|
|
<p className={wizardStyles.fieldHint}>{t('Keine Features verfügbar')}</p>
|
|
) : (
|
|
<div className={wizardStyles.cardGrid}>
|
|
{features.map((f) => {
|
|
const isActive = featureCode === f.code;
|
|
return (
|
|
<button
|
|
key={f.code}
|
|
type="button"
|
|
className={`${wizardStyles.cardButton} ${isActive ? wizardStyles.cardButtonActive : ''}`}
|
|
onClick={() => setFeatureCode(f.code)}
|
|
aria-pressed={isActive}
|
|
>
|
|
{f.label || f.code}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={wizardStyles.fieldGroup}>
|
|
<label className={wizardStyles.fieldLabel} htmlFor="featureInstanceLabel">
|
|
{t('Bezeichnung')}<span className={wizardStyles.required}>*</span>
|
|
</label>
|
|
<input
|
|
id="featureInstanceLabel"
|
|
type="text"
|
|
className={wizardStyles.textInput}
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
onBlur={() => setLabelTouched(true)}
|
|
placeholder={t('z. B. Vertrieb DE')}
|
|
autoComplete="off"
|
|
/>
|
|
{labelTouched && labelMissing && (
|
|
<p className={wizardStyles.fieldError}>{t('Bezeichnung ist erforderlich.')}</p>
|
|
)}
|
|
</div>
|
|
|
|
<label className={wizardStyles.checkLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
onChange={(e) => setEnabled(e.target.checked)}
|
|
/>
|
|
{t('Aktiv')}
|
|
</label>
|
|
|
|
<label className={wizardStyles.checkLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={copyTemplateRoles}
|
|
onChange={(e) => setCopyTemplateRoles(e.target.checked)}
|
|
/>
|
|
{t('Rollen von Feature-Vorlage übernehmen (empfohlen)')}
|
|
</label>
|
|
|
|
<div className={wizardStyles.stepActions}>
|
|
<button type="button" className={styles.secondaryButton} onClick={onClose}>
|
|
{t('Abbrechen')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.primaryButton}
|
|
onClick={handleStep1Submit}
|
|
disabled={!canSubmitStep1}
|
|
title={!canSubmitStep1 ? t('Bitte Mandant, Feature und Bezeichnung wählen.') : undefined}
|
|
>
|
|
{submitting ? t('Speichern…') : t('Weiter')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentStepId === 'roles' && (
|
|
<div className={wizardStyles.stepContent}>
|
|
<p className={wizardStyles.stepText}>
|
|
{t('Die Rollen wurden beim Erstellen der Instanz übernommen. Sie können später unter „Benutzer verwalten“ weitere Rollen synchronisieren.')}
|
|
</p>
|
|
<div className={wizardStyles.stepActions}>
|
|
<button type="button" className={styles.secondaryButton} onClick={() => setStep(0)}>
|
|
{t('← Zurück')}
|
|
</button>
|
|
<button type="button" className={styles.primaryButton} onClick={handleStep2Next}>
|
|
{t('Weiter →')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentStepId === 'users' && (
|
|
<div className={wizardStyles.stepContent}>
|
|
<p className={wizardStyles.stepText}>
|
|
{t('Optional: Weisen Sie Benutzern Rollen zu. Sie können dies auch später in der Zugriffsverwaltung tun.')}
|
|
</p>
|
|
{mandateUsers.length === 0 ? (
|
|
<p className={wizardStyles.stepText}>{t('Keine Mandantenbenutzer vorhanden')}</p>
|
|
) : (
|
|
<div className={wizardStyles.userList}>
|
|
{mandateUsers.map((u) => {
|
|
const selected = selectedUserRoles.find((s) => s.userId === u.id);
|
|
const roleIds = selected?.roleIds ?? [];
|
|
return (
|
|
<div key={u.id} className={wizardStyles.userRow}>
|
|
<span className={wizardStyles.userName}>{u.username}</span>
|
|
<select
|
|
className={wizardStyles.roleSelect}
|
|
value={roleIds[0] ?? ''}
|
|
onChange={(e) => {
|
|
const roleId = e.target.value;
|
|
const rids = roleId ? [roleId] : [];
|
|
handleAddUserRole(u.id, rids);
|
|
}}
|
|
>
|
|
<option value="">{t('Keine Rolle')}</option>
|
|
{instanceRoles.map((r) => (
|
|
<option key={r.id} value={r.id}>
|
|
{r.roleLabel}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className={wizardStyles.stepActions}>
|
|
<button type="button" className={styles.secondaryButton} onClick={() => setStep(1)}>
|
|
{t('← Zurück')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.primaryButton}
|
|
onClick={handleStep3Complete}
|
|
disabled={submitting}
|
|
>
|
|
{submitting ? t('Speichern…') : t('Erstellen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FeatureInstanceWizard;
|