Initial commit

This commit is contained in:
Richard
2026-07-31 10:08:13 +02:00
parent 652943fb7e
commit a69f31649c
7192 changed files with 924319 additions and 0 deletions
@@ -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>
);
}
+237
View File
@@ -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>
);
}
+374
View File
@@ -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>
);
}
+226
View File
@@ -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>
);
}
+210
View File
@@ -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>
);
}
+203
View File
@@ -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>
);
}
+170
View File
@@ -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>
);
}