Initial commit
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Shield, Settings, Plus, Trash2, Palette, CheckCircle2, AlertCircle, UserCheck, Clock, UserX, Layout } from 'lucide-react';
|
||||
import { apiFetch } from '../api/client';
|
||||
|
||||
export default function AdminSettingsModal({ branding, onClose, onRefreshBranding }) {
|
||||
const [restrictionEnabled, setRestrictionEnabled] = useState(false);
|
||||
const [requireApproval, setRequireApproval] = useState(false);
|
||||
const [domainRules, setDomainRules] = useState([]);
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
|
||||
const [pendingUsers, setPendingUsers] = useState([]);
|
||||
const [loadingRules, setLoadingRules] = useState(true);
|
||||
|
||||
// Branding & Sidebar boxes form state
|
||||
const [appName, setAppName] = useState(branding?.app_name || 'Veranstaltungsschichtplaner');
|
||||
const [logoUrl, setLogoUrl] = useState(branding?.logo_url || '');
|
||||
const [primaryColor, setPrimaryColor] = useState(branding?.primary_color || '#E05A47');
|
||||
const [bannerText, setBannerText] = useState(branding?.custom_banner_text || '');
|
||||
|
||||
// Community Info Box
|
||||
const [showCommunityInfoBox, setShowCommunityInfoBox] = useState(branding?.show_community_info_box ?? true);
|
||||
const [communityInfoTitle, setCommunityInfoTitle] = useState(branding?.community_info_title || '📌 Verein & Infos');
|
||||
const [communityInfoText, setCommunityInfoText] = useState(branding?.community_info_text || 'Initiative e.V. Hausverein\nOffene Angebote, DIY-Kultur & engagierte Schichten.');
|
||||
|
||||
// Support Box
|
||||
const [showSupportBox, setShowSupportBox] = useState(branding?.show_support_box ?? true);
|
||||
const [supportBoxTitle, setSupportBoxTitle] = useState(branding?.support_box_title || '❤️ Unterstützen');
|
||||
const [supportBoxText, setSupportBoxText] = useState(branding?.support_box_text || 'Hilf mit als Helfer*in an der Bar, beim Essen kochen oder beim Auf- & Abbau.');
|
||||
|
||||
const [savingBranding, setSavingBranding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminSettings();
|
||||
}, []);
|
||||
|
||||
const fetchAdminSettings = async () => {
|
||||
try {
|
||||
const setting = await apiFetch('/users/restriction-setting/');
|
||||
setRestrictionEnabled(setting.is_restriction_enabled);
|
||||
setRequireApproval(setting.require_admin_approval);
|
||||
|
||||
const rules = await apiFetch('/users/domain-rules/');
|
||||
setDomainRules(rules.results || rules);
|
||||
|
||||
const pending = await apiFetch('/users/pending/');
|
||||
setPendingUsers(pending.results || pending);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Laden der Admin-Einstellungen fehlgeschlagen.');
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRestriction = async () => {
|
||||
try {
|
||||
const updated = await apiFetch('/users/restriction-setting/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ is_restriction_enabled: !restrictionEnabled })
|
||||
});
|
||||
setRestrictionEnabled(updated.is_restriction_enabled);
|
||||
setSuccess(`Domain-Beschränkung ist jetzt ${updated.is_restriction_enabled ? 'AKTIV' : 'INAKTIV'}`);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Umschalten.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRequireApproval = async () => {
|
||||
try {
|
||||
const updated = await apiFetch('/users/restriction-setting/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ require_admin_approval: !requireApproval })
|
||||
});
|
||||
setRequireApproval(updated.require_admin_approval);
|
||||
setSuccess(`Manuelle Admin-Freischaltung ist jetzt ${updated.require_admin_approval ? 'AKTIV' : 'INAKTIV'}`);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Umschalten.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDomain = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newDomain.trim()) return;
|
||||
|
||||
try {
|
||||
const created = await apiFetch('/users/domain-rules/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ domain: newDomain.trim(), is_active: true })
|
||||
});
|
||||
setDomainRules([...domainRules, created]);
|
||||
setNewDomain('');
|
||||
setSuccess('Domain erfolgreich hinzugefügt!');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Hinzufügen der Domain.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDomain = async (id) => {
|
||||
try {
|
||||
await apiFetch(`/users/domain-rules/${id}/`, { method: 'DELETE' });
|
||||
setDomainRules(domainRules.filter(r => r.id !== id));
|
||||
setSuccess('Domain entfernt.');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Löschen der Domain.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveUser = async (userId) => {
|
||||
try {
|
||||
const res = await apiFetch(`/users/${userId}/approve/`, { method: 'POST' });
|
||||
setPendingUsers(pendingUsers.filter(u => u.id !== userId));
|
||||
setSuccess(res.message || 'Nutzer wurde erfolgreich freigeschaltet!');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Freischaltung fehlgeschlagen.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectUser = async (userId) => {
|
||||
try {
|
||||
const res = await apiFetch(`/users/${userId}/approve/`, { method: 'DELETE' });
|
||||
setPendingUsers(pendingUsers.filter(u => u.id !== userId));
|
||||
setSuccess(res.message || 'Registrierung abgelehnt und gelöscht.');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Ablehnen fehlgeschlagen.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBranding = async (e) => {
|
||||
e.preventDefault();
|
||||
setSavingBranding(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
await apiFetch('/branding/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
app_name: appName,
|
||||
logo_url: logoUrl,
|
||||
primary_color: primaryColor,
|
||||
custom_banner_text: bannerText,
|
||||
show_community_info_box: showCommunityInfoBox,
|
||||
community_info_title: communityInfoTitle,
|
||||
community_info_text: communityInfoText,
|
||||
show_support_box: showSupportBox,
|
||||
support_box_title: supportBoxTitle,
|
||||
support_box_text: supportBoxText
|
||||
})
|
||||
});
|
||||
setSuccess('Branding & Info-Boxen erfolgreich gespeichert!');
|
||||
onRefreshBranding();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Speichern der Einstellungen.');
|
||||
} finally {
|
||||
setSavingBranding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm overflow-y-auto">
|
||||
<div className="hallmark-panel w-full max-w-2xl rounded-sm border border-grid p-6 shadow-2xl relative my-8 font-sans">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 text-muted hover:text-main rounded-sm transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 border-b border-grid pb-4">
|
||||
<div className="w-10 h-10 rounded-sm bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 flex items-center justify-center">
|
||||
<Settings className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-lg text-main">Admin-Einstellungen</h3>
|
||||
<p className="text-xs text-muted font-mono">Freischaltungen, Info-Boxen & Branding verwalten</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-sm bg-red-500/10 border border-red-500/20 text-red-500 text-xs flex items-center gap-2 font-mono">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-4 p-3 rounded-sm bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 text-xs flex items-center gap-2 font-mono">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 1: Admin Approval for Registrations */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex items-center justify-between p-4 rounded-sm bg-subtle border border-grid">
|
||||
<div>
|
||||
<h4 className="font-mono font-bold text-xs uppercase tracking-wider text-main flex items-center gap-2">
|
||||
<UserCheck className="w-4 h-4 text-blue-400" /> Manuelle Admin-Freischaltung erforderlich
|
||||
</h4>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Wenn aktiviert, müssen sich neue Konten registrieren, können sich aber erst nach deiner Freigabe anmelden.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleRequireApproval}
|
||||
className={`px-3 py-1.5 rounded-sm text-xs font-mono font-bold transition ${
|
||||
requireApproval
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-surface text-muted border border-grid hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{requireApproval ? 'AKTIV' : 'INAKTIV'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending Users List */}
|
||||
{requireApproval && (
|
||||
<div className="p-4 rounded-sm bg-subtle border border-grid space-y-3 font-mono">
|
||||
<h5 className="font-bold text-xs text-main flex items-center gap-2 uppercase tracking-wider">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" /> Ausstehende Registrierungen ({pendingUsers.length})
|
||||
</h5>
|
||||
|
||||
{pendingUsers.length === 0 ? (
|
||||
<p className="text-xs text-muted italic">[ Keine ausstehenden Registrierungen ]</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pendingUsers.map((pUser) => (
|
||||
<div key={pUser.id} className="flex items-center justify-between p-3 rounded-sm bg-surface border border-grid text-xs">
|
||||
<div>
|
||||
<div className="font-bold text-main">{pUser.display_name || pUser.username}</div>
|
||||
<div className="text-[11px] text-muted">{pUser.email}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleApproveUser(pUser.id)}
|
||||
className="px-3 py-1 rounded-sm font-bold bg-emerald-600 hover:bg-emerald-500 text-white transition flex items-center gap-1"
|
||||
>
|
||||
<UserCheck className="w-3.5 h-3.5" /> Freischalten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectUser(pUser.id)}
|
||||
className="px-2.5 py-1 rounded-sm font-semibold bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 transition flex items-center gap-1"
|
||||
>
|
||||
<UserX className="w-3.5 h-3.5" /> Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 2: Email Domain Restriction */}
|
||||
<div className="space-y-4 mb-8 border-t border-grid pt-6">
|
||||
<div className="flex items-center justify-between p-4 rounded-sm bg-subtle border border-grid">
|
||||
<div>
|
||||
<h4 className="font-mono font-bold text-xs uppercase tracking-wider text-main flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-emerald-400" /> Registrierung auf E-Mail-Domains beschränken
|
||||
</h4>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Wenn aktiviert, können sich neue Nutzer nur mit freigegebenen E-Mail-Domains (z. B. @verein.de) registrieren.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleRestriction}
|
||||
className={`px-3 py-1.5 rounded-sm text-xs font-mono font-bold transition ${
|
||||
restrictionEnabled
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-surface text-muted border border-grid hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{restrictionEnabled ? 'AKTIV' : 'INAKTIV'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{restrictionEnabled && (
|
||||
<div className="p-4 rounded-sm bg-subtle border border-grid space-y-3 font-mono">
|
||||
<form onSubmit={handleAddDomain} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="E-Mail-Domain z. B. verein.de oder @beispiel.org"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-sm input-field text-xs"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-1.5 rounded-sm text-xs font-bold bg-blue-600 hover:bg-blue-500 text-white transition flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Domain erlauben
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{domainRules.length === 0 ? (
|
||||
<p className="text-xs text-muted italic">[ Noch keine E-Mail-Domains konfiguriert ]</p>
|
||||
) : (
|
||||
domainRules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center justify-between p-2.5 rounded-sm bg-surface border border-grid text-xs">
|
||||
<span className="font-semibold text-main">{rule.domain}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteDomain(rule.id)}
|
||||
className="text-muted hover:text-red-400 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 3: Sidebar Info & Support Boxes */}
|
||||
<div className="space-y-4 mb-8 border-t border-grid pt-6">
|
||||
<h4 className="font-serif font-bold text-base text-main flex items-center gap-2">
|
||||
<Layout className="w-4 h-4 text-amber-500" /> Startseiten Info- & Unterstützen-Boxen
|
||||
</h4>
|
||||
|
||||
{/* Community Info Box Settings */}
|
||||
<div className="p-4 rounded-sm bg-subtle border border-grid space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-grid pb-2">
|
||||
<span className="font-mono font-bold text-xs uppercase text-main">📌 Verein & Infos Box</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCommunityInfoBox(!showCommunityInfoBox)}
|
||||
className={`px-2.5 py-1 rounded-sm text-xs font-mono font-bold transition ${
|
||||
showCommunityInfoBox ? 'bg-emerald-600 text-white' : 'bg-surface text-muted border border-grid'
|
||||
}`}
|
||||
>
|
||||
{showCommunityInfoBox ? 'AN' : 'AUS'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCommunityInfoBox && (
|
||||
<div className="space-y-2 font-mono text-xs">
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={communityInfoTitle}
|
||||
onChange={(e) => setCommunityInfoTitle(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Inhalt</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={communityInfoText}
|
||||
onChange={(e) => setCommunityInfoText(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Support Box Settings */}
|
||||
<div className="p-4 rounded-sm bg-subtle border border-grid space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-grid pb-2">
|
||||
<span className="font-mono font-bold text-xs uppercase text-main">❤️ Unterstützen Box</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSupportBox(!showSupportBox)}
|
||||
className={`px-2.5 py-1 rounded-sm text-xs font-mono font-bold transition ${
|
||||
showSupportBox ? 'bg-emerald-600 text-white' : 'bg-surface text-muted border border-grid'
|
||||
}`}
|
||||
>
|
||||
{showSupportBox ? 'AN' : 'AUS'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSupportBox && (
|
||||
<div className="space-y-2 font-mono text-xs">
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={supportBoxTitle}
|
||||
onChange={(e) => setSupportBoxTitle(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Inhalt</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={supportBoxText}
|
||||
onChange={(e) => setSupportBoxText(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Branding Settings */}
|
||||
<form onSubmit={handleSaveBranding} className="border-t border-grid pt-6 space-y-4">
|
||||
<h4 className="font-serif font-bold text-base text-main flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-indigo-400" /> App Branding & Design anpassen
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 font-mono text-xs">
|
||||
<div>
|
||||
<label className="block text-muted mb-1">App Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={appName}
|
||||
onChange={(e) => setAppName(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Primärfarbe</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={primaryColor}
|
||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||
className="w-8 h-8 rounded-sm border-0 cursor-pointer bg-subtle p-0.5"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={primaryColor}
|
||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-sm input-field text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-muted mb-1">Logo Bild-URL (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://beispiel.de/logo.png"
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-muted mb-1">Benutzerdefinierte Ankündigung (Banner)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="z. B. Willkommen beim Sommerfest Schichtplaner!"
|
||||
value={bannerText}
|
||||
onChange={(e) => setBannerText(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 flex justify-end border-t border-grid">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingBranding}
|
||||
style={{ backgroundColor: 'var(--brand-primary)' }}
|
||||
className="px-5 py-2 rounded-sm text-xs font-mono font-bold text-white transition shadow-sm hover:brightness-110"
|
||||
>
|
||||
{savingBranding ? 'Speichern...' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, User, Lock, Mail, AlertCircle, ShieldAlert, Sparkles } from 'lucide-react';
|
||||
import { apiFetch, setAuthToken } from '../api/client';
|
||||
|
||||
export default function AuthModal({ onClose, onSuccess, claimToken, prefilledGuestName }) {
|
||||
const [isRegister, setIsRegister] = useState(Boolean(claimToken || prefilledGuestName));
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState(prefilledGuestName || '');
|
||||
|
||||
const [restrictionSetting, setRestrictionSetting] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRestrictionSetting();
|
||||
}, []);
|
||||
|
||||
const fetchRestrictionSetting = async () => {
|
||||
try {
|
||||
const data = await apiFetch('/users/restriction-setting/');
|
||||
setRestrictionSetting(data);
|
||||
} catch (e) {
|
||||
// Ignore if fetch fails
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isRegister) {
|
||||
const bodyObj = {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
display_name: displayName || username
|
||||
};
|
||||
if (claimToken) {
|
||||
bodyObj.claim_token = claimToken;
|
||||
}
|
||||
|
||||
const data = await apiFetch('/users/register/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(bodyObj)
|
||||
});
|
||||
|
||||
if (data.requires_approval) {
|
||||
onSuccess(null, data.message);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthToken(data.token);
|
||||
onSuccess(data.user, data.message);
|
||||
} else {
|
||||
const data = await apiFetch('/users/login/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
setAuthToken(data.token);
|
||||
onSuccess(data.user, 'Erfolgreich angemeldet!');
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Authentifizierung fehlgeschlagen.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="hallmark-panel w-full max-w-md rounded-sm border border-grid p-6 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 text-muted hover:text-main rounded-sm transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-sm bg-blue-500/10 text-blue-500 border border-blue-500/20 flex items-center justify-center">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-lg text-main">
|
||||
{isRegister ? 'Konto erstellen' : 'Anmelden'}
|
||||
</h3>
|
||||
<p className="text-xs text-muted font-sans">
|
||||
{isRegister ? 'Erstelle einen Account für volle Funktionen & Schichteinsicht' : 'Melde dich an, um Events zu verwalten'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prefilledGuestName && (
|
||||
<div className="mb-4 p-3 rounded-sm bg-amber-500/10 border border-amber-500/20 text-amber-500 text-xs flex items-center gap-2 font-mono">
|
||||
<Sparkles className="w-4 h-4 shrink-0 text-amber-500" />
|
||||
<span>
|
||||
Einladung für Gast: <strong>{prefilledGuestName}</strong>. Die Schicht wird deinem neuen Konto zugewiesen!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restrictionSetting?.is_restriction_enabled && isRegister && (
|
||||
<div className="mb-4 p-3 rounded-sm bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 text-xs flex items-center gap-2 font-mono">
|
||||
<ShieldAlert className="w-4 h-4 shrink-0 text-indigo-400" />
|
||||
<span>
|
||||
Registrierungen beschränkt auf: <strong>{restrictionSetting.active_domains.join(', ')}</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-sm bg-red-500/10 border border-red-500/20 text-red-500 text-xs flex items-center gap-2 font-mono">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Demo Login Box */}
|
||||
{!isRegister && (
|
||||
<div className="mb-5 p-3.5 rounded-sm bg-subtle border border-grid space-y-2 font-mono">
|
||||
<div className="text-[11px] font-semibold text-muted uppercase tracking-wider flex items-center justify-between">
|
||||
<span>⚡ Dev Schnell-Login</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setUsername('admin'); setPassword('adminpassword'); }}
|
||||
className="px-2.5 py-1.5 rounded-sm bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 text-xs font-semibold transition text-left"
|
||||
>
|
||||
👑 Demo Admin
|
||||
<div className="text-[10px] text-indigo-400 font-normal">admin / adminpassword</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setUsername('demouser'); setPassword('demouser123'); }}
|
||||
className="px-2.5 py-1.5 rounded-sm bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 border border-emerald-500/30 text-xs font-semibold transition text-left"
|
||||
>
|
||||
👤 Demo User
|
||||
<div className="text-[10px] text-emerald-500 font-normal">demouser / demouser123</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 font-sans">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-main mb-1 font-mono">Benutzername</label>
|
||||
<div className="relative">
|
||||
<User className="w-4 h-4 absolute left-3 top-3 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="z. B. max_muster"
|
||||
className="w-full pl-9 pr-3.5 py-2 rounded-sm input-field text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRegister && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-main mb-1 font-mono">E-Mail-Adresse</label>
|
||||
<div className="relative">
|
||||
<Mail className="w-4 h-4 absolute left-3 top-3 text-muted" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="max@beispiel.de"
|
||||
className="w-full pl-9 pr-3.5 py-2 rounded-sm input-field text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-main mb-1 font-mono">Anzeigename (für Schichtlisten)</label>
|
||||
<div className="relative">
|
||||
<User className="w-4 h-4 absolute left-3 top-3 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="z. B. Max M."
|
||||
className="w-full pl-9 pr-3.5 py-2 rounded-sm input-field text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-main mb-1 font-mono">Passwort</label>
|
||||
<div className="relative">
|
||||
<Lock className="w-4 h-4 absolute left-3 top-3 text-muted" />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-9 pr-3.5 py-2 rounded-sm input-field text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
style={{ backgroundColor: 'var(--brand-primary)' }}
|
||||
className="w-full py-2.5 rounded-sm text-xs font-mono font-bold text-white transition hover:brightness-110 shadow-sm"
|
||||
>
|
||||
{submitting ? 'Verarbeite...' : isRegister ? 'Registrieren' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-grid text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsRegister(!isRegister); setError(''); }}
|
||||
className="text-xs font-mono text-muted hover:text-main transition"
|
||||
>
|
||||
{isRegister ? 'Bereits ein Konto? Hier anmelden' : 'Noch kein Konto? Jetzt registrieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
|
||||
/* Hallmark · component: EventFormModal · genre: editorial · theme: Atelier (Dark & Light) */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Plus, Trash2, Calendar, Layers, Edit3 } from 'lucide-react';
|
||||
|
||||
export default function EventFormModal({ eventToEdit, skills, onClose, onSubmit }) {
|
||||
const [title, setTitle] = useState(eventToEdit?.title || '');
|
||||
const [description, setDescription] = useState(eventToEdit?.description || '');
|
||||
const [location, setLocation] = useState(eventToEdit?.location || '');
|
||||
const [startDate, setStartDate] = useState(eventToEdit?.start_date || new Date().toISOString().split('T')[0]);
|
||||
const [endDate, setEndDate] = useState(eventToEdit?.end_date || new Date().toISOString().split('T')[0]);
|
||||
|
||||
// Task areas state
|
||||
const [taskAreas, setTaskAreas] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventToEdit && eventToEdit.task_areas && eventToEdit.task_areas.length > 0) {
|
||||
const formattedAreas = eventToEdit.task_areas.map(ta => ({
|
||||
id: ta.id,
|
||||
name: ta.name,
|
||||
description: ta.description || '',
|
||||
shifts: (ta.shifts || []).map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
start_time: s.start_time,
|
||||
end_time: s.end_time,
|
||||
max_participants: s.max_participants || 1,
|
||||
required_skill_ids: s.required_skills ? s.required_skills.map(sk => sk.id) : []
|
||||
}))
|
||||
}));
|
||||
setTaskAreas(formattedAreas);
|
||||
} else if (!eventToEdit) {
|
||||
setTaskAreas([
|
||||
{
|
||||
name: 'Tresendienst',
|
||||
description: 'Getränke- und Barverkauf',
|
||||
shifts: [{ title: 'Schicht 1', start_time: '18:00', end_time: '22:00', max_participants: 2, required_skill_ids: [] }]
|
||||
},
|
||||
{
|
||||
name: 'Essen kochen',
|
||||
description: 'Zubereitung von Speisen',
|
||||
shifts: [{ title: 'Frühschicht', start_time: '15:00', end_time: '19:00', max_participants: 3, required_skill_ids: [] }]
|
||||
},
|
||||
{
|
||||
name: 'Aufbau & Aufräumen',
|
||||
description: 'Tische, Stühle & Technik',
|
||||
shifts: [{ title: 'Aufbau', start_time: '12:00', end_time: '15:00', max_participants: 4, required_skill_ids: [] }]
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [eventToEdit]);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addTaskArea = () => {
|
||||
setTaskAreas([
|
||||
...taskAreas,
|
||||
{
|
||||
name: '',
|
||||
description: '',
|
||||
shifts: [{ title: 'Schicht 1', start_time: '10:00', end_time: '14:00', max_participants: 1, required_skill_ids: [] }]
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const removeTaskArea = (index) => {
|
||||
setTaskAreas(taskAreas.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateTaskArea = (index, field, value) => {
|
||||
const updated = [...taskAreas];
|
||||
updated[index][field] = value;
|
||||
setTaskAreas(updated);
|
||||
};
|
||||
|
||||
const addShift = (areaIndex) => {
|
||||
const updated = [...taskAreas];
|
||||
updated[areaIndex].shifts.push({
|
||||
title: `Schicht ${updated[areaIndex].shifts.length + 1}`,
|
||||
start_time: '14:00',
|
||||
end_time: '18:00',
|
||||
max_participants: 1,
|
||||
required_skill_ids: []
|
||||
});
|
||||
setTaskAreas(updated);
|
||||
};
|
||||
|
||||
const removeShift = (areaIndex, shiftIndex) => {
|
||||
const updated = [...taskAreas];
|
||||
updated[areaIndex].shifts = updated[areaIndex].shifts.filter((_, i) => i !== shiftIndex);
|
||||
setTaskAreas(updated);
|
||||
};
|
||||
|
||||
const updateShift = (areaIndex, shiftIndex, field, value) => {
|
||||
const updated = [...taskAreas];
|
||||
updated[areaIndex].shifts[shiftIndex][field] = value;
|
||||
setTaskAreas(updated);
|
||||
};
|
||||
|
||||
const toggleShiftSkill = (areaIndex, shiftIndex, skillId) => {
|
||||
const updated = [...taskAreas];
|
||||
const currentSkills = updated[areaIndex].shifts[shiftIndex].required_skill_ids || [];
|
||||
if (currentSkills.includes(skillId)) {
|
||||
updated[areaIndex].shifts[shiftIndex].required_skill_ids = currentSkills.filter(id => id !== skillId);
|
||||
} else {
|
||||
updated[areaIndex].shifts[shiftIndex].required_skill_ids = [...currentSkills, skillId];
|
||||
}
|
||||
setTaskAreas(updated);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!title.trim()) {
|
||||
setError('Bitte gib einen Veranstaltungstitel an.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
id: eventToEdit?.id,
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
task_areas: taskAreas
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Speichern der Veranstaltung fehlgeschlagen.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm overflow-y-auto font-sans">
|
||||
<div className="hallmark-panel w-full max-w-3xl rounded-sm border border-grid p-6 shadow-2xl relative my-8">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 text-muted hover:text-main rounded-sm transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 border-b border-grid pb-4">
|
||||
<div className="w-10 h-10 rounded-sm bg-blue-500/10 text-blue-500 border border-blue-500/20 flex items-center justify-center font-bold">
|
||||
{eventToEdit ? <Edit3 className="w-5 h-5" /> : <Calendar className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-xl uppercase text-main">
|
||||
{eventToEdit ? 'Veranstaltung & Schichten bearbeiten' : 'Neue Veranstaltung erstellen'}
|
||||
</h3>
|
||||
<p className="text-xs text-muted font-mono">Definiere Titel, Datum, Aufgabenfelder, Schichten & Qualifikationen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-sm bg-red-500/10 border border-red-500/20 text-red-500 text-xs font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 font-mono text-xs">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-main mb-1 font-bold">Titel der Veranstaltung</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z. B. Sommerfest 2026"
|
||||
className="w-full px-3 py-2 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-main mb-1 font-bold">Startdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-main mb-1 font-bold">Enddatum</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-main mb-1 font-bold">Ort (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="z. B. Vereinsheim, Großer Saal"
|
||||
className="w-full px-3 py-2 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-main mb-1 font-bold">Beschreibung (optional)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Details zur Veranstaltung..."
|
||||
className="w-full px-3 py-2 rounded-sm input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Areas & Shifts Section */}
|
||||
<div className="border-t border-grid pt-5 space-y-4 font-mono">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-serif font-bold text-base text-main flex items-center gap-2">
|
||||
<Layers className="w-4.5 h-4.5 text-blue-500" /> Aufgabenfelder & Schichten
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTaskArea}
|
||||
className="px-3 py-1.5 rounded-sm text-xs font-mono font-bold bg-blue-500/10 text-blue-500 border border-blue-500/20 hover:bg-blue-500/20 transition flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Bereich hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{taskAreas.map((area, aIdx) => (
|
||||
<div key={aIdx} className="p-4 rounded-sm bg-subtle border border-grid space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Name des Aufgabenfeldes (z. B. Tresendienst)"
|
||||
value={area.name}
|
||||
onChange={(e) => updateTaskArea(aIdx, 'name', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-sm input-field text-xs font-bold"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTaskArea(aIdx)}
|
||||
className="p-1.5 text-muted hover:text-red-400 transition"
|
||||
title="Bereich entfernen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shifts inside this area */}
|
||||
<div className="pl-3 border-l-2 border-grid space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-muted font-bold">
|
||||
<span>Schichten & Zeitfenster</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addShift(aIdx)}
|
||||
className="text-blue-500 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Schicht hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{area.shifts.map((shift, sIdx) => (
|
||||
<div key={sIdx} className="p-3 rounded-sm bg-surface border border-grid space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-center text-xs">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Titel"
|
||||
value={shift.title}
|
||||
onChange={(e) => updateShift(aIdx, sIdx, 'title', e.target.value)}
|
||||
className="sm:col-span-4 px-2.5 py-1 rounded-sm input-field text-xs"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Start (14:00)"
|
||||
value={shift.start_time}
|
||||
onChange={(e) => updateShift(aIdx, sIdx, 'start_time', e.target.value)}
|
||||
className="sm:col-span-2 px-2.5 py-1 rounded-sm input-field text-xs"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ende (18:00)"
|
||||
value={shift.end_time}
|
||||
onChange={(e) => updateShift(aIdx, sIdx, 'end_time', e.target.value)}
|
||||
className="sm:col-span-2 px-2.5 py-1 rounded-sm input-field text-xs"
|
||||
/>
|
||||
<div className="sm:col-span-3 flex items-center gap-1">
|
||||
<span className="text-[11px] text-muted">Plätze:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={shift.max_participants}
|
||||
onChange={(e) => updateShift(aIdx, sIdx, 'max_participants', parseInt(e.target.value) || 1)}
|
||||
className="w-full px-2 py-1 rounded-sm input-field text-xs font-bold"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeShift(aIdx, sIdx)}
|
||||
className="sm:col-span-1 p-1 text-muted hover:text-red-400 text-center"
|
||||
title="Schicht löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mx-auto" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Required Skills selection */}
|
||||
{skills && skills.length > 0 && (
|
||||
<div className="pt-1.5 border-t border-grid text-[11px]">
|
||||
<span className="text-muted block mb-1">Erforderliche Qualifikationen:</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{skills.map(sk => {
|
||||
const isSelected = (shift.required_skill_ids || []).includes(sk.id);
|
||||
return (
|
||||
<button
|
||||
key={sk.id}
|
||||
type="button"
|
||||
onClick={() => toggleShiftSkill(aIdx, sIdx, sk.id)}
|
||||
style={{
|
||||
backgroundColor: isSelected ? sk.color : `${sk.color}15`,
|
||||
borderColor: sk.color,
|
||||
color: isSelected ? '#ffffff' : sk.color
|
||||
}}
|
||||
className="px-2 py-0.5 rounded-sm border text-[10px] font-bold transition"
|
||||
>
|
||||
{sk.name} {isSelected ? '✓' : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end gap-2 border-t border-grid font-mono">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-sm text-xs font-semibold text-muted hover:bg-surface-hover transition border border-grid"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
style={{ backgroundColor: 'var(--brand-primary)' }}
|
||||
className="px-5 py-2 rounded-sm text-xs font-bold text-white transition shadow-sm hover:brightness-110"
|
||||
>
|
||||
{submitting ? 'Speichern...' : eventToEdit ? 'Änderungen speichern' : 'Veranstaltung erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ShieldCheck, UserCheck, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export default function GuestSignupModal({ shift, onClose, onSubmit }) {
|
||||
const [guestName, setGuestName] = useState('');
|
||||
const [captchaToken, setCaptchaToken] = useState('');
|
||||
const [isCaptchaSolved, setIsCaptchaSolved] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if guest name is pre-saved in localStorage / session
|
||||
const savedName = localStorage.getItem('guest_display_name') || '';
|
||||
if (savedName) {
|
||||
setGuestName(savedName);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSimulateCaptchaSolve = () => {
|
||||
// Generate captcha token from acaptcha widget/event
|
||||
const token = 'acaptcha-verified-' + Math.random().toString(36).substring(2, 10);
|
||||
setCaptchaToken(token);
|
||||
setIsCaptchaSolved(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!guestName.trim()) {
|
||||
setError('Bitte gib deinen Namen ein.');
|
||||
return;
|
||||
}
|
||||
if (!isCaptchaSolved || !captchaToken) {
|
||||
setError('Bitte löse zuerst das Captcha.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save in localStorage for guest convenience
|
||||
localStorage.setItem('guest_display_name', guestName.trim());
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit({ guest_name: guestName.trim(), captcha_token: captchaToken });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Eintragen fehlgeschlagen.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-md rounded-2xl border border-slate-800 p-6 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white rounded-lg transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20 flex items-center justify-center">
|
||||
<UserCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-white">Als Gast eintragen</h3>
|
||||
<p className="text-xs text-slate-400">Schicht: {shift?.title} ({shift?.start_time} - {shift?.end_time})</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Dein Name / Anzeigename
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
placeholder="z. B. Alex Muster"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-white text-sm focus:outline-none focus:border-blue-500 transition"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500 mt-1">
|
||||
Dein Name wird in deiner Sitzung gespeichert. Falls du später ein Konto mit diesem Namen erstellst, werden deine Gast-Schichten automatisch übertragen!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* acaptcha integration iframe & verification box */}
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400" /> Security Check (acaptcha.vercel.app)
|
||||
</span>
|
||||
<a
|
||||
href="https://acaptcha.vercel.app/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-blue-400 hover:underline"
|
||||
>
|
||||
Website öffnen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="border border-dashed border-slate-700 rounded-lg p-3 text-center bg-slate-950/60">
|
||||
<iframe
|
||||
src="https://acaptcha.vercel.app/"
|
||||
title="acaptcha"
|
||||
className="w-full h-16 border-0 rounded"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-center gap-2">
|
||||
{!isCaptchaSolved ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSimulateCaptchaSolve}
|
||||
className="px-3 py-1.5 text-xs font-semibold rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white transition flex items-center gap-1"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Captcha gelöst bestätigen
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-xs font-medium text-emerald-400 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-4 h-4" /> Captcha erfolgreich verifiziert!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:bg-slate-800 transition"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-5 py-2 rounded-xl text-xs font-bold bg-blue-600 hover:bg-blue-500 text-white transition shadow-lg shadow-blue-600/30 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Eintragen...' : 'Jetzt eintragen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
|
||||
/* Hallmark · redesign: Navbar · studied-DNA: https://inihaus.de · nav: N1b Architectural Masthead */
|
||||
import React from 'react';
|
||||
import { Calendar, User, LogOut, Sparkles, Plus, Layers, Settings, Edit3, Home, CalendarDays, TableProperties, Sun, Moon, Monitor } from 'lucide-react';
|
||||
|
||||
export default function Navbar({
|
||||
branding,
|
||||
user,
|
||||
activeTab,
|
||||
onChangeTab,
|
||||
themeMode,
|
||||
onChangeThemeMode,
|
||||
onLogout,
|
||||
onOpenAuth,
|
||||
onOpenCreateEvent,
|
||||
onOpenTemplates,
|
||||
onOpenProfile,
|
||||
onOpenAdmin,
|
||||
pwaInstallPrompt,
|
||||
onInstallPwa
|
||||
}) {
|
||||
const primaryColor = branding?.primary_color || 'var(--brand-primary)';
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (themeMode === 'dark') onChangeThemeMode('light');
|
||||
else if (themeMode === 'light') onChangeThemeMode('auto');
|
||||
else onChangeThemeMode('dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-surface border-b border-grid sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||
{/* Brand Section */}
|
||||
<div className="flex items-center space-x-3 shrink-0">
|
||||
<div
|
||||
onClick={() => onChangeTab('home')}
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
className="w-8 h-8 rounded-sm flex items-center justify-center text-white font-bold cursor-pointer shadow-sm"
|
||||
>
|
||||
{branding?.logo_url ? (
|
||||
<img src={branding.logo_url} alt="Logo" className="w-4 h-4 object-contain" />
|
||||
) : (
|
||||
<Calendar className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div onClick={() => onChangeTab('home')} className="cursor-pointer">
|
||||
<h1 className="font-serif text-base font-bold tracking-tight text-main leading-none">
|
||||
{branding?.app_name || "Schichtplaner"}
|
||||
</h1>
|
||||
<div className="text-[10px] font-mono text-muted mt-1 uppercase tracking-wider flex items-center gap-1.5">
|
||||
{user ? (
|
||||
<span className="text-emerald-500 font-semibold">[ {user.display_name || user.username} ]</span>
|
||||
) : (
|
||||
<span className="text-amber-500 font-semibold">[ GAST-MODUS ]</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Navigation Tabs (Clean Hallmark N1b Style) */}
|
||||
<nav className="hidden md:flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChangeTab('home')}
|
||||
className={`px-3 py-1.5 rounded-sm text-xs font-mono transition flex items-center gap-1.5 border ${
|
||||
activeTab === 'home'
|
||||
? 'bg-surface-hover text-main font-bold border-grid'
|
||||
: 'text-muted hover:text-main border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" /> Startseite
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onChangeTab('calendar')}
|
||||
className={`px-3 py-1.5 rounded-sm text-xs font-mono transition flex items-center gap-1.5 border ${
|
||||
activeTab === 'calendar'
|
||||
? 'bg-surface-hover text-main font-bold border-grid'
|
||||
: 'text-muted hover:text-main border-transparent'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5" /> Kalender
|
||||
</button>
|
||||
|
||||
{(user?.is_admin_user || user?.is_staff || user?.is_superuser) && (
|
||||
<button
|
||||
onClick={() => onChangeTab('admin')}
|
||||
className={`px-3 py-1.5 rounded-sm text-xs font-mono transition flex items-center gap-1.5 border ${
|
||||
activeTab === 'admin'
|
||||
? 'bg-surface-hover text-main font-bold border-grid'
|
||||
: 'text-muted hover:text-main border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5 text-indigo-400" /> Admin
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Right Action Controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Theme Mode Button */}
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
className="h-8 px-2.5 rounded-sm text-xs font-mono bg-subtle hover:bg-surface-hover text-main border border-grid transition flex items-center gap-1.5"
|
||||
title={`Design-Modus: ${themeMode.toUpperCase()}`}
|
||||
>
|
||||
{themeMode === 'dark' ? (
|
||||
<>
|
||||
<Moon className="w-3.5 h-3.5 text-indigo-400" />
|
||||
<span className="hidden lg:inline text-[11px] font-bold">DARK</span>
|
||||
</>
|
||||
) : themeMode === 'light' ? (
|
||||
<>
|
||||
<Sun className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="hidden lg:inline text-[11px] font-bold">LIGHT</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Monitor className="w-3.5 h-3.5 text-blue-500" />
|
||||
<span className="hidden lg:inline text-[11px] font-bold">AUTO</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{pwaInstallPrompt && (
|
||||
<button
|
||||
onClick={onInstallPwa}
|
||||
className="h-8 px-2.5 rounded-sm text-xs font-mono bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 hover:bg-emerald-500/20 transition flex items-center gap-1"
|
||||
>
|
||||
<Sparkles className="w-3 h-3" /> PWA
|
||||
</button>
|
||||
)}
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenCreateEvent}
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
className="h-8 px-3 rounded-sm text-xs font-mono font-bold text-white transition flex items-center gap-1 shadow-sm hover:brightness-110"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Event
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onOpenTemplates}
|
||||
className="h-8 px-2.5 rounded-sm text-xs font-mono bg-subtle hover:bg-surface-hover text-main border border-grid transition hidden sm:flex items-center gap-1"
|
||||
title="Vorlagen"
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 text-muted" /> Vorlagen
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onOpenProfile}
|
||||
className="h-8 px-2.5 rounded-sm text-xs font-mono bg-subtle hover:bg-surface-hover text-main border border-grid transition flex items-center gap-1"
|
||||
title="Profil & Namen ändern"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5 text-amber-500" /> Profil
|
||||
</button>
|
||||
|
||||
{(user.is_admin_user || user.is_staff || user.is_superuser) && (
|
||||
<button
|
||||
onClick={() => onChangeTab('admin')}
|
||||
className={`h-8 px-2.5 rounded-sm text-xs font-mono transition flex items-center gap-1 border ${
|
||||
activeTab === 'admin'
|
||||
? 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40 font-bold'
|
||||
: 'bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border-indigo-500/30'
|
||||
}`}
|
||||
title="Admin-Zentrale"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" /> Admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-4 w-px bg-grid mx-1"></div>
|
||||
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="h-8 w-8 rounded-sm text-muted hover:text-red-500 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 transition flex items-center justify-center"
|
||||
title="Abmelden"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onOpenAuth}
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
className="h-8 px-3.5 rounded-sm text-xs font-mono font-bold text-white transition hover:brightness-110 flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<User className="w-3.5 h-3.5" /> Anmelden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<div className="flex md:hidden items-center justify-around border-t border-grid py-2 bg-subtle text-xs font-mono">
|
||||
<button
|
||||
onClick={() => onChangeTab('home')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-sm border ${
|
||||
activeTab === 'home' ? 'text-main font-bold bg-surface border-grid' : 'text-muted border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" /> Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onChangeTab('calendar')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-sm border ${
|
||||
activeTab === 'calendar' ? 'text-main font-bold bg-surface border-grid' : 'text-muted border-transparent'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5" /> Termine
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onChangeTab('schedule')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-sm border ${
|
||||
activeTab === 'schedule' ? 'text-main font-bold bg-surface border-grid' : 'text-muted border-transparent'
|
||||
}`}
|
||||
>
|
||||
<TableProperties className="w-3.5 h-3.5" /> Schichtplan
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, User, Award, Check, AlertCircle } from 'lucide-react';
|
||||
import { apiFetch } from '../api/client';
|
||||
|
||||
export default function ProfileModal({ user, skills, onClose, onUserUpdated, onRefreshSkills }) {
|
||||
const [displayName, setDisplayName] = useState(user?.display_name || '');
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState(
|
||||
user?.skills ? user.skills.map(s => s.id) : []
|
||||
);
|
||||
|
||||
// New skill fields
|
||||
const [newSkillName, setNewSkillName] = useState('');
|
||||
const [newSkillDesc, setNewSkillDesc] = useState('');
|
||||
const [newSkillColor, setNewSkillColor] = useState('#2563eb');
|
||||
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [creatingSkill, setCreatingSkill] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleSaveProfile = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setSavingProfile(true);
|
||||
|
||||
try {
|
||||
const updatedUser = await apiFetch('/users/me/', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
display_name: displayName.trim(),
|
||||
skill_ids: selectedSkillIds
|
||||
})
|
||||
});
|
||||
setSuccess('Profil und Anzeigename erfolgreich aktualisiert!');
|
||||
onUserUpdated(updatedUser);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Speichern des Profils fehlgeschlagen.');
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSkill = (skillId) => {
|
||||
if (selectedSkillIds.includes(skillId)) {
|
||||
setSelectedSkillIds(selectedSkillIds.filter(id => id !== skillId));
|
||||
} else {
|
||||
setSelectedSkillIds([...selectedSkillIds, skillId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSkill = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newSkillName.trim()) return;
|
||||
|
||||
setCreatingSkill(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
await apiFetch('/skills/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: newSkillName.trim(),
|
||||
description: newSkillDesc.trim(),
|
||||
color: newSkillColor
|
||||
})
|
||||
});
|
||||
setNewSkillName('');
|
||||
setNewSkillDesc('');
|
||||
setSuccess('Neue Fähigkeit erfolgreich angelegt!');
|
||||
onRefreshSkills();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erstellen der Fähigkeit fehlgeschlagen.');
|
||||
} finally {
|
||||
setCreatingSkill(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/70 backdrop-blur-sm overflow-y-auto">
|
||||
<div className="bg-slate-900 w-full max-w-lg rounded-2xl border border-slate-800 p-6 shadow-xl relative my-8">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white rounded-lg transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-800 text-slate-200 border border-slate-700 flex items-center justify-center">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-white">Profil & Einstellungen</h3>
|
||||
<p className="text-xs text-slate-400">Anzeigenamen und Qualifikationen verwalten</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs flex items-center gap-2">
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="space-y-5">
|
||||
{/* Display Name Edit Field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Dein Anzeigename (wird in allen Schichtlisten angezeigt)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="z. B. Alex Muster"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-800 text-white text-sm focus:outline-none focus:border-slate-600"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500 mt-1">
|
||||
Änderungen werden sofort in allen von dir gebuchten Schichten wirksam.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User Skills Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Deine Qualifikationen & Fähigkeiten
|
||||
</label>
|
||||
{skills.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 italic">Noch keine Fähigkeiten definiert.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{skills.map((s) => {
|
||||
const isSelected = selectedSkillIds.includes(s.id);
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => toggleSkill(s.id)}
|
||||
className={`p-2.5 rounded-xl border cursor-pointer transition flex items-center justify-between ${
|
||||
isSelected
|
||||
? 'bg-slate-800 border-slate-700 text-white'
|
||||
: 'bg-slate-950/60 border-slate-800/80 text-slate-400 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: s.color }}></span>
|
||||
<span className="text-xs font-medium">{s.name}</span>
|
||||
</div>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-emerald-400" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingProfile}
|
||||
className="px-5 py-2.5 rounded-xl text-xs font-bold bg-slate-100 hover:bg-white text-slate-950 transition shadow-sm disabled:opacity-50"
|
||||
>
|
||||
{savingProfile ? 'Speichern...' : 'Profil speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Add New Skill Section */}
|
||||
<form onSubmit={handleCreateSkill} className="border-t border-slate-800 mt-6 pt-5 space-y-3">
|
||||
<h4 className="font-bold text-xs text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Award className="w-3.5 h-3.5" /> Neue Qualifikation im System anlegen
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Fähigkeit (z. B. Ersthelfer)"
|
||||
value={newSkillName}
|
||||
onChange={(e) => setNewSkillName(e.target.value)}
|
||||
className="sm:col-span-2 px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={newSkillColor}
|
||||
onChange={(e) => setNewSkillColor(e.target.value)}
|
||||
className="w-full h-8 rounded-xl border-0 cursor-pointer bg-slate-950 p-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creatingSkill}
|
||||
className="px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition"
|
||||
>
|
||||
{creatingSkill ? 'Anlegen...' : 'Fähigkeit hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
|
||||
/* Hallmark · component: shift-matrix-table · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
|
||||
import React from 'react';
|
||||
import { Clock, UserCheck, Shield, Printer, Download, Award, CheckCircle2, UserX, UserPlus, MapPin, Edit3 } from 'lucide-react';
|
||||
|
||||
export default function ShiftMatrixTable({
|
||||
event,
|
||||
user,
|
||||
onSignupClick,
|
||||
onCancelClick,
|
||||
onRemoveUserFromShift,
|
||||
onGenerateClaimLink,
|
||||
onExportPdf,
|
||||
onPrintView,
|
||||
onEditEvent
|
||||
}) {
|
||||
if (!event || !event.task_areas || event.task_areas.length === 0) {
|
||||
return (
|
||||
<div className="hallmark-panel rounded-sm p-12 text-center border border-grid font-mono">
|
||||
<Clock className="w-8 h-8 text-muted mx-auto mb-3" />
|
||||
<h3 className="font-serif text-xl font-bold text-main uppercase">Keine Aufgabenfelder vorhanden</h3>
|
||||
<p className="text-xs text-muted mt-1 mb-4">[ Event hat noch keine definierten Aufgabenfelder oder Schichten ]</p>
|
||||
{user && (user.is_admin_user || user.is_staff || user.is_superuser || event?.created_by === user.id || event?.created_by?.id === user.id) && onEditEvent && (
|
||||
<button
|
||||
onClick={() => onEditEvent(event)}
|
||||
className="px-4 py-2 rounded-sm bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 font-bold text-xs inline-flex items-center gap-2"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" /> SCHICHTEN & BEREICHE ANLEGEN
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isGuest = !user;
|
||||
const isManager = user && (user.is_admin_user || user.is_staff || user.is_superuser || event.created_by === user.id || event.created_by?.id === user.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Event Header Panel */}
|
||||
<div className="hallmark-panel rounded-sm p-6 sm:p-8 border border-grid flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1 font-mono">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted mb-1">
|
||||
<span className="bg-subtle px-2.5 py-0.5 rounded-sm border border-grid font-bold text-main">
|
||||
{new Date(event.start_date).toLocaleDateString('de-DE')} — {new Date(event.end_date).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
{event.location && (
|
||||
<span className="flex items-center gap-1 text-muted">
|
||||
<MapPin className="w-3.5 h-3.5 shrink-0" />
|
||||
{event.location}
|
||||
</span>
|
||||
)}
|
||||
{event.is_active === false && (
|
||||
<span className="px-2 py-0.5 rounded-sm bg-amber-500/10 text-amber-500 border border-amber-500/20 font-bold">
|
||||
[ DEAKTIVIERT ]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="font-serif text-3xl sm:text-4xl font-bold uppercase tracking-tight text-main">{event.title}</h2>
|
||||
{event.description && <p className="text-xs font-sans text-muted max-w-2xl mt-1">{event.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 no-print font-mono text-xs shrink-0">
|
||||
{isManager && onEditEvent && (
|
||||
<button
|
||||
onClick={() => onEditEvent(event)}
|
||||
className="px-3.5 py-1.5 rounded-sm bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 transition flex items-center gap-1.5 font-bold"
|
||||
title="Schichten und Aufgabenbereiche für diese Veranstaltung bearbeiten"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" /> SCHICHTEN BEARBEITEN
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onPrintView}
|
||||
className="px-3.5 py-1.5 rounded-sm bg-surface hover:bg-surface-hover text-main border border-grid transition flex items-center gap-1.5 font-bold"
|
||||
>
|
||||
<Printer className="w-3.5 h-3.5" /> DRUCKEN
|
||||
</button>
|
||||
<button
|
||||
onClick={onExportPdf}
|
||||
className="px-3.5 py-1.5 rounded-sm bg-surface hover:bg-surface-hover text-main border border-grid transition flex items-center gap-1.5 font-bold"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Privacy Banner */}
|
||||
{isGuest && (
|
||||
<div className="p-3.5 rounded-sm bg-amber-500/10 border border-amber-500/20 text-amber-500 text-xs font-mono flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 shrink-0 text-amber-500" />
|
||||
<span>
|
||||
[ GAST-DATENSCHUTZ ]: Belegungszahlen sichtbar, Namensanzeige aus Datenschutzgründen <strong>anonymisiert</strong>.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hallmark Matrix Schedule Table */}
|
||||
<div className="hallmark-panel rounded-sm border border-grid overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-subtle border-b border-grid text-[11px] font-mono text-muted uppercase tracking-wider">
|
||||
<th className="py-3.5 px-4 w-1/4">Aufgabenfeld</th>
|
||||
<th className="py-3.5 px-4 w-1/4">Schicht & Zeitraum</th>
|
||||
<th className="py-3.5 px-4 w-1/6">Qualifikation</th>
|
||||
<th className="py-3.5 px-4 w-1/4">Belegung & Personen</th>
|
||||
<th className="py-3.5 px-4 w-1/6 text-right">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-grid text-xs font-sans">
|
||||
{event.task_areas.map((area) => {
|
||||
if (!area.shifts || area.shifts.length === 0) {
|
||||
return (
|
||||
<tr key={`area-${area.id}`}>
|
||||
<td className="py-3.5 px-4 font-serif text-lg font-bold text-main uppercase">{area.name}</td>
|
||||
<td colSpan={4} className="py-3.5 px-4 text-muted font-mono text-[11px]">
|
||||
[ Keine Schichten angelegt ]
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return area.shifts.map((shift, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isFull = shift.is_full;
|
||||
|
||||
const userSignup = user ? shift.signups.find(s => !s.is_guest && s.display_name === user.display_name) : null;
|
||||
const isUserSignedUp = Boolean(userSignup);
|
||||
|
||||
const hasSkillReqs = shift.required_skills && shift.required_skills.length > 0;
|
||||
|
||||
return (
|
||||
<tr key={`shift-${shift.id}`} className="hover:bg-surface-hover/60 transition">
|
||||
{isFirst ? (
|
||||
<td
|
||||
rowSpan={area.shifts.length}
|
||||
className="py-3.5 px-4 font-serif text-lg font-bold text-main uppercase bg-subtle/80 align-top border-r border-grid"
|
||||
>
|
||||
<div>{area.name}</div>
|
||||
{area.description && <div className="text-[11px] font-sans font-normal text-muted mt-0.5">{area.description}</div>}
|
||||
</td>
|
||||
) : null}
|
||||
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="font-semibold text-main">{shift.title}</div>
|
||||
<div className="text-[11px] font-mono text-muted flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-3 h-3 text-muted shrink-0" />
|
||||
<span>{shift.start_time} — {shift.end_time} Uhr</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3.5 px-4 font-mono">
|
||||
{hasSkillReqs ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{shift.required_skills.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
style={{ backgroundColor: `${s.color}15`, borderColor: `${s.color}35`, color: s.color }}
|
||||
className="px-2 py-0.5 rounded-sm text-[10px] font-semibold border flex items-center gap-1"
|
||||
>
|
||||
<Award className="w-3 h-3 shrink-0" /> {s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="py-3.5 px-4 font-mono">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-sm text-[11px] font-bold border ${
|
||||
isFull
|
||||
? 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
}`}>
|
||||
[ {shift.signups_count} / {shift.max_participants} BELEGT ]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Attendees List */}
|
||||
<div className="mt-2 space-y-1 font-sans">
|
||||
{shift.signups.map((su) => (
|
||||
<div key={su.id} className="text-[11px] text-muted flex items-center justify-between gap-2 p-1.5 rounded-sm bg-subtle border border-grid">
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
<UserCheck className="w-3 h-3 text-muted shrink-0" />
|
||||
<span className="truncate">{su.display_name}</span>
|
||||
{su.is_guest && <span className="text-[9px] font-mono text-amber-500 px-1 rounded-sm bg-amber-500/10 border border-amber-500/20 font-bold">GAST</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{user && su.is_guest && onGenerateClaimLink && (
|
||||
<button
|
||||
onClick={() => onGenerateClaimLink(su)}
|
||||
className="text-indigo-400 hover:text-indigo-300 transition p-1 rounded-sm bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/20 flex items-center gap-1 text-[10px] font-mono font-bold"
|
||||
title="Konto-Einladungslink für diese Gast-Eintragung kopieren"
|
||||
>
|
||||
<UserPlus className="w-3 h-3" /> LINK
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isManager && onRemoveUserFromShift && (
|
||||
<button
|
||||
onClick={() => onRemoveUserFromShift(shift, su.id)}
|
||||
className="text-muted hover:text-red-500 transition p-1 rounded-sm hover:bg-red-500/10"
|
||||
title="Person aus dieser Schicht entfernen"
|
||||
>
|
||||
<UserX className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3.5 px-4 text-right font-mono">
|
||||
{isUserSignedUp ? (
|
||||
<button
|
||||
onClick={() => onCancelClick(shift)}
|
||||
className="px-3 py-1.5 rounded-sm text-xs font-semibold bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 transition flex items-center gap-1 ml-auto"
|
||||
>
|
||||
<UserX className="w-3.5 h-3.5" /> AUSTRAGEN
|
||||
</button>
|
||||
) : isFull ? (
|
||||
<span className="text-xs text-muted font-bold">[ VOLL ]</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onSignupClick(shift)}
|
||||
style={{ backgroundColor: 'var(--brand-primary)' }}
|
||||
className="px-3.5 py-1.5 rounded-sm text-xs font-bold text-white transition hover:brightness-110 flex items-center gap-1 ml-auto shadow-sm"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> EINTRAGEN
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Award, Plus, Check, AlertCircle } from 'lucide-react';
|
||||
import { apiFetch } from '../api/client';
|
||||
|
||||
export default function SkillsModal({ skills, user, onClose, onRefreshSkills, onRefreshUser }) {
|
||||
const [newSkillName, setNewSkillName] = useState('');
|
||||
const [newSkillDesc, setNewSkillDesc] = useState('');
|
||||
const [newSkillColor, setNewSkillColor] = useState('#3b82f6');
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [savingUserSkills, setSavingUserSkills] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
// Selected skill IDs for logged in user
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState(
|
||||
user?.skills ? user.skills.map(s => s.id) : []
|
||||
);
|
||||
|
||||
const handleCreateSkill = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (!newSkillName.trim()) {
|
||||
setError('Bitte gib einen Namen für die Fähigkeit an.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
await apiFetch('/skills/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: newSkillName.trim(),
|
||||
description: newSkillDesc.trim(),
|
||||
color: newSkillColor
|
||||
})
|
||||
});
|
||||
setNewSkillName('');
|
||||
setNewSkillDesc('');
|
||||
setSuccess('Fähigkeit erfolgreich angelegt!');
|
||||
onRefreshSkills();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erstellen der Fähigkeit fehlgeschlagen.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUserSkill = (skillId) => {
|
||||
if (selectedSkillIds.includes(skillId)) {
|
||||
setSelectedSkillIds(selectedSkillIds.filter(id => id !== skillId));
|
||||
} else {
|
||||
setSelectedSkillIds([...selectedSkillIds, skillId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMySkills = async () => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setSavingUserSkills(true);
|
||||
try {
|
||||
await apiFetch('/users/me/', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
skill_ids: selectedSkillIds
|
||||
})
|
||||
});
|
||||
setSuccess('Deine Fähigkeiten wurden aktualisiert!');
|
||||
onRefreshUser();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Speichern der Fähigkeiten fehlgeschlagen.');
|
||||
} finally {
|
||||
setSavingUserSkills(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm overflow-y-auto">
|
||||
<div className="glass-panel w-full max-w-xl rounded-2xl border border-slate-800 p-6 shadow-2xl relative my-8">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white rounded-lg transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 flex items-center justify-center">
|
||||
<Award className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-white">Fähigkeiten & Qualifikationen</h3>
|
||||
<p className="text-xs text-slate-400">Verwalte verfügbare Fähigkeiten und ordne sie deinem Profil zu</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs flex items-center gap-2">
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 1: Select My Skills */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<h4 className="font-bold text-xs text-slate-300 uppercase tracking-wider">
|
||||
Meine Qualifikationen ankreuzen
|
||||
</h4>
|
||||
|
||||
{skills.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 italic">Noch keine Fähigkeiten im System vorhanden.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{skills.map((skill) => {
|
||||
const isSelected = selectedSkillIds.includes(skill.id);
|
||||
return (
|
||||
<div
|
||||
key={skill.id}
|
||||
onClick={() => toggleUserSkill(skill.id)}
|
||||
style={{ borderColor: isSelected ? skill.color : 'rgba(255,255,255,0.06)' }}
|
||||
className={`p-3 rounded-xl border cursor-pointer transition flex items-center justify-between ${
|
||||
isSelected ? 'bg-slate-900' : 'bg-slate-950/60 hover:bg-slate-900/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: skill.color }}></span>
|
||||
<span className="text-xs font-semibold text-white">{skill.name}</span>
|
||||
</div>
|
||||
{isSelected && <Check className="w-4 h-4" style={{ color: skill.color }} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
onClick={handleSaveMySkills}
|
||||
disabled={savingUserSkills}
|
||||
className="px-4 py-2 rounded-xl text-xs font-bold bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/30 transition"
|
||||
>
|
||||
{savingUserSkills ? 'Speichern...' : 'Meine Fähigkeiten speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Create New Skill */}
|
||||
<form onSubmit={handleCreateSkill} className="border-t border-slate-800 pt-5 space-y-3">
|
||||
<h4 className="font-bold text-xs text-blue-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Plus className="w-4 h-4" /> Neue Fähigkeit im System erstellen
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Name (z.B. Ersthelfer, Führerschein B)"
|
||||
value={newSkillName}
|
||||
onChange={(e) => setNewSkillName(e.target.value)}
|
||||
className="sm:col-span-2 px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white text-xs"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={newSkillColor}
|
||||
onChange={(e) => setNewSkillColor(e.target.value)}
|
||||
className="w-9 h-9 rounded-xl border-0 cursor-pointer bg-slate-900 p-1"
|
||||
/>
|
||||
<span className="text-[11px] text-slate-400">Farbe</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Beschreibung (optional)"
|
||||
value={newSkillDesc}
|
||||
onChange={(e) => setNewSkillDesc(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white text-xs"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="px-4 py-2 rounded-xl text-xs font-bold bg-blue-600 hover:bg-blue-500 text-white transition"
|
||||
>
|
||||
{creating ? 'Erstellen...' : 'Fähigkeit anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Layers, Play, Plus, Trash2, CheckCircle2 } from 'lucide-react';
|
||||
import { apiFetch } from '../api/client';
|
||||
|
||||
export default function TemplateModal({ onClose, onInstantiateTemplate }) {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
|
||||
// Form for instantiating template
|
||||
const [title, setTitle] = useState('');
|
||||
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [endDate, setEndDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [location, setLocation] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const data = await apiFetch('/templates/');
|
||||
setTemplates(data.results || data);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Laden der Vorlagen fehlgeschlagen.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectTemplate = (template) => {
|
||||
setSelectedTemplate(template);
|
||||
setTitle(template.name);
|
||||
};
|
||||
|
||||
const handleInstantiate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onInstantiateTemplate(selectedTemplate.id, {
|
||||
title,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
location
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erstellen der Veranstaltung aus Vorlage fehlgeschlagen.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm overflow-y-auto">
|
||||
<div className="glass-panel w-full max-w-2xl rounded-2xl border border-slate-800 p-6 shadow-2xl relative my-8">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white rounded-lg transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 flex items-center justify-center">
|
||||
<Layers className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-white">Veranstaltungsvorlagen</h3>
|
||||
<p className="text-xs text-slate-400">Erstelle neue Veranstaltungen im Handumdrehen aus Vorlagen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-xs text-slate-400">Vorlagen laden...</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="text-center py-8 text-xs text-slate-500">
|
||||
Noch keine Vorlagen gespeichert. Du kannst eine bestehende Veranstaltung als Vorlage speichern.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
onClick={() => handleSelectTemplate(tpl)}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition ${
|
||||
selectedTemplate?.id === tpl.id
|
||||
? 'bg-blue-600/10 border-blue-500 shadow-md shadow-blue-500/10'
|
||||
: 'bg-slate-900/60 border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-bold text-sm text-white">{tpl.name}</h4>
|
||||
{selectedTemplate?.id === tpl.id && <CheckCircle2 className="w-4 h-4 text-blue-400" />}
|
||||
</div>
|
||||
{tpl.description && <p className="text-xs text-slate-400 mt-1">{tpl.description}</p>}
|
||||
<div className="text-[10px] text-slate-500 mt-2">
|
||||
Erstellt von {tpl.created_by_name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplate && (
|
||||
<form onSubmit={handleInstantiate} className="border-t border-slate-800 pt-5 space-y-4">
|
||||
<h4 className="font-bold text-xs text-blue-400 uppercase tracking-wider">
|
||||
Veranstaltung aus "{selectedTemplate.name}" erstellen
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Veranstaltungstitel</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Startdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Enddatum</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-5 py-2 rounded-xl text-xs font-bold bg-blue-600 hover:bg-blue-500 text-white transition flex items-center gap-1.5 shadow-lg shadow-blue-600/30"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" /> {submitting ? 'Erstellen...' : 'Veranstaltung jetzt erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user