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
+544
View File
@@ -0,0 +1,544 @@
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
/* Hallmark · component: App · genre: editorial · theme: Atelier (Dark & Light) */
import React, { useState, useEffect } from 'react';
import Navbar from './components/Navbar';
import ShiftMatrixTable from './components/ShiftMatrixTable';
import GuestSignupModal from './components/GuestSignupModal';
import SkillsModal from './components/SkillsModal';
import AuthModal from './components/AuthModal';
import HomePage from './pages/HomePage';
import CalendarPage from './pages/CalendarPage';
import AdminPage from './pages/AdminPage';
import EventEditorPage from './pages/EventEditorPage';
import TemplatesPage from './pages/TemplatesPage';
import { apiFetch, getAuthToken, setAuthToken } from './api/client';
import { Plus, CheckCircle2 } from 'lucide-react';
export default function App() {
const [user, setUser] = useState(null);
const [branding, setBranding] = useState(null);
const [skills, setSkills] = useState([]);
const [events, setEvents] = useState([]);
const [selectedEventId, setSelectedEventId] = useState(null);
const [selectedEventData, setSelectedEventData] = useState(null);
const [loading, setLoading] = useState(true);
// Active Navigation Tab: 'home' | 'calendar' | 'schedule' | 'admin' | 'event-editor' | 'templates'
const [activeTab, setActiveTab] = useState('home');
// Modals state (Authentication & Guest Signup)
const [showAuthModal, setShowAuthModal] = useState(false);
const [showGuestModal, setShowGuestModal] = useState(false);
const [guestTargetShift, setGuestTargetShift] = useState(null);
const [showSkillsModal, setShowSkillsModal] = useState(false);
const [showProfileModal, setShowProfileModal] = useState(false);
// Theme Mode: 'dark' | 'light' | 'auto'
const [themeMode, setThemeMode] = useState(
localStorage.getItem('theme_mode') || 'auto'
);
// Toast / notification state
const [toastMessage, setToastMessage] = useState(null);
// PWA install prompt
const [deferredPrompt, setDeferredPrompt] = useState(null);
useEffect(() => {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
setDeferredPrompt(e);
});
initApp();
}, []);
useEffect(() => {
localStorage.setItem('theme_mode', themeMode);
const applyTheme = () => {
let activeTheme = themeMode;
if (themeMode === 'auto') {
activeTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', activeTheme);
};
applyTheme();
if (themeMode === 'auto') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => applyTheme();
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
}, [themeMode]);
useEffect(() => {
if (branding?.primary_color) {
document.documentElement.style.setProperty('--brand-primary', branding.primary_color);
document.documentElement.style.setProperty(
'--brand-secondary',
branding.secondary_color || branding.primary_color
);
}
}, [branding]);
const [claimToken, setClaimToken] = useState(null);
const [prefilledGuestName, setPrefilledGuestName] = useState(null);
const showNotification = (msg) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 5000);
};
const initApp = async () => {
setLoading(true);
try {
// 1. Fetch Branding
try {
const brandData = await apiFetch('/branding/');
setBranding(brandData);
} catch (e) {}
// 2. Fetch User profile if token exists
if (getAuthToken()) {
try {
const userData = await apiFetch('/users/me/');
setUser(userData);
} catch (e) {
setAuthToken(null);
setUser(null);
}
}
// 3. Check URL parameters for guest account claim link
const urlParams = new URLSearchParams(window.location.search);
const cToken = urlParams.get('claim_token');
const gName = urlParams.get('guest_name');
if (cToken && gName) {
setClaimToken(cToken);
setPrefilledGuestName(gName);
if (!getAuthToken()) {
setShowAuthModal(true);
}
}
// 4. Fetch Skills & Events
await refreshSkills();
await refreshEvents();
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleGenerateClaimLink = async (signup) => {
try {
const data = await apiFetch(`/users/signups/${signup.id}/generate-claim-link/`, {
method: 'POST'
});
const claimUrl = `${window.location.origin}/?claim_token=${data.token}&guest_name=${encodeURIComponent(data.guest_name)}`;
if (navigator.clipboard) {
await navigator.clipboard.writeText(claimUrl);
showNotification(`✅ Einladungs-Link für ${data.guest_name} in Zwischenablage kopiert!`);
} else {
prompt(`Einladungs-Link für ${data.guest_name} kopieren:`, claimUrl);
}
} catch (err) {
alert(err.message || 'Fehler beim Erstellen des Links.');
}
};
const refreshSkills = async () => {
try {
const data = await apiFetch('/skills/');
setSkills(data.results || data);
} catch (e) {}
};
const refreshEvents = async () => {
try {
const data = await apiFetch('/events/');
const list = data.results || data;
setEvents(list);
if (list.length > 0 && !selectedEventId) {
setSelectedEventId(list[0].id);
fetchEventMatrix(list[0].id);
} else if (selectedEventId) {
fetchEventMatrix(selectedEventId);
}
} catch (e) {}
};
const fetchEventMatrix = async (eventId) => {
try {
const matrixData = await apiFetch(`/events/${eventId}/matrix/`);
setSelectedEventData(matrixData);
} catch (e) {}
};
const handleSelectEvent = (id) => {
setSelectedEventId(id);
fetchEventMatrix(id);
setActiveTab('schedule');
};
const handleLogout = () => {
setAuthToken(null);
setUser(null);
showNotification('Erfolgreich abgemeldet.');
if (selectedEventId) fetchEventMatrix(selectedEventId);
};
const handleAuthSuccess = (userData, message) => {
setUser(userData);
showNotification(message || 'Erfolgreich angemeldet!');
if (selectedEventId) fetchEventMatrix(selectedEventId);
};
// Shift Signup Handler
const handleShiftSignupClick = async (shift) => {
if (!user) {
setGuestTargetShift(shift);
setShowGuestModal(true);
} else {
try {
const res = await apiFetch(`/shifts/${shift.id}/signup/`, {
method: 'POST'
});
showNotification(res.message || 'Erfolgreich für Schicht eingetragen!');
if (selectedEventId) fetchEventMatrix(selectedEventId);
} catch (err) {
showNotification(`Fehler: ${err.message}`);
}
}
};
const handleGuestSubmit = async (guestData) => {
if (!guestTargetShift) return;
const res = await apiFetch(`/shifts/${guestTargetShift.id}/signup/`, {
method: 'POST',
body: JSON.stringify(guestData)
});
showNotification(res.message || 'Als Gast eingetragen!');
if (selectedEventId) fetchEventMatrix(selectedEventId);
};
const handleCancelSignup = async (shift) => {
try {
const res = await apiFetch(`/shifts/${shift.id}/signup/`, {
method: 'DELETE'
});
showNotification(res.message || 'Eintragung storniert.');
if (selectedEventId) fetchEventMatrix(selectedEventId);
} catch (err) {
showNotification(`Fehler: ${err.message}`);
}
};
const [eventToEdit, setEventToEdit] = useState(null);
const handleSaveEventSubmit = async (eventPayload) => {
let targetEventId = eventPayload.id;
if (targetEventId) {
// Edit existing event
await apiFetch(`/events/${targetEventId}/`, {
method: 'PATCH',
body: JSON.stringify({
title: eventPayload.title,
description: eventPayload.description,
location: eventPayload.location,
start_date: eventPayload.start_date,
end_date: eventPayload.end_date
})
});
} else {
// Create new event
const created = await apiFetch('/events/', {
method: 'POST',
body: JSON.stringify({
title: eventPayload.title,
description: eventPayload.description,
location: eventPayload.location,
start_date: eventPayload.start_date,
end_date: eventPayload.end_date
})
});
targetEventId = created.id;
}
for (let taData of eventPayload.task_areas) {
if (!taData.name) continue;
let taId = taData.id;
if (taId) {
await apiFetch(`/task-areas/${taId}/`, {
method: 'PATCH',
body: JSON.stringify({
name: taData.name,
description: taData.description || ''
})
});
} else {
const createdTa = await apiFetch('/task-areas/', {
method: 'POST',
body: JSON.stringify({
event: targetEventId,
name: taData.name,
description: taData.description || ''
})
});
taId = createdTa.id;
}
for (let sData of taData.shifts) {
if (sData.id) {
await apiFetch(`/shifts/${sData.id}/`, {
method: 'PATCH',
body: JSON.stringify({
title: sData.title,
start_time: sData.start_time,
end_time: sData.end_time,
max_participants: sData.max_participants,
required_skill_ids: sData.required_skill_ids || []
})
});
} else {
await apiFetch('/shifts/', {
method: 'POST',
body: JSON.stringify({
task_area: taId,
title: sData.title,
start_time: sData.start_time,
end_time: sData.end_time,
max_participants: sData.max_participants,
required_skill_ids: sData.required_skill_ids || []
})
});
}
}
}
showNotification(eventPayload.id ? 'Veranstaltung & Schichten aktualisiert!' : 'Veranstaltung erfolgreich erstellt!');
await refreshEvents();
handleSelectEvent(targetEventId);
};
const handleInstantiateTemplate = async (templateId, payload) => {
const res = await apiFetch(`/templates/${templateId}/instantiate/`, {
method: 'POST',
body: JSON.stringify(payload)
});
showNotification('Veranstaltung aus Vorlage erstellt!');
await refreshEvents();
if (res.id) handleSelectEvent(res.id);
};
const handleExportPdf = () => {
if (!selectedEventId) return;
window.open(`/api/events/${selectedEventId}/export_pdf/`, '_blank');
};
const handlePrintView = () => {
window.print();
};
const handleInstallPwa = () => {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult.outcome === 'accepted') {
showNotification('PWA Installation gestartet!');
}
setDeferredPrompt(null);
});
}
};
return (
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-surface-hover transition-colors duration-200">
{/* Toast Notification */}
{toastMessage && (
<div className="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-2.5 rounded-sm shadow-2xl font-mono text-xs flex items-center gap-2 border border-emerald-400 animate-in fade-in slide-in-from-bottom-3 duration-200">
<CheckCircle2 className="w-4 h-4" />
<span>{toastMessage}</span>
</div>
)}
{/* Navigation Masthead */}
<Navbar
branding={branding}
user={user}
activeTab={activeTab}
onChangeTab={setActiveTab}
themeMode={themeMode}
onChangeThemeMode={setThemeMode}
onLogout={handleLogout}
onOpenAuth={() => setShowAuthModal(true)}
onOpenCreateEvent={() => { setEventToEdit(null); setActiveTab('event-editor'); }}
onOpenTemplates={() => setActiveTab('templates')}
onOpenProfile={() => setShowProfileModal(true)}
onOpenAdmin={() => setActiveTab('admin')}
pwaInstallPrompt={Boolean(deferredPrompt)}
onInstallPwa={handleInstallPwa}
/>
{/* Main Content Area */}
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{loading ? (
<div className="hallmark-panel p-16 text-center text-xs text-muted font-mono border border-grid">
Lade Daten...
</div>
) : activeTab === 'home' ? (
<HomePage
events={events}
user={user}
onSelectEvent={handleSelectEvent}
onOpenCreateEvent={() => { setEventToEdit(null); setActiveTab('event-editor'); }}
onToggleEventActive={async (evt) => {
try {
await apiFetch(`/events/${evt.id}/`, {
method: 'PATCH',
body: JSON.stringify({ is_active: evt.is_active === false ? true : false })
});
refreshEvents();
} catch (err) {
alert(err.message || 'Aktion fehlgeschlagen.');
}
}}
onEditEvent={(evt) => {
setEventToEdit(evt);
setActiveTab('event-editor');
}}
branding={branding}
/>
) : activeTab === 'calendar' ? (
<CalendarPage
events={events}
onSelectEvent={handleSelectEvent}
branding={branding}
/>
) : activeTab === 'admin' ? (
<AdminPage
branding={branding}
onRefreshBranding={async () => {
const b = await apiFetch('/branding/');
setBranding(b);
}}
onRefreshEvents={refreshEvents}
skills={skills}
onRefreshSkills={refreshSkills}
/>
) : activeTab === 'event-editor' ? (
<EventEditorPage
eventToEdit={eventToEdit}
skills={skills}
onBack={() => setActiveTab('schedule')}
onSubmit={handleSaveEventSubmit}
/>
) : activeTab === 'templates' ? (
<TemplatesPage
onBack={() => setActiveTab('schedule')}
onInstantiateTemplate={handleInstantiateTemplate}
/>
) : (
<div className="space-y-6">
{/* Event Selector Sub-Bar in Schedule View */}
<div className="flex items-center justify-between gap-4 overflow-x-auto pb-2 no-scrollbar font-mono">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted uppercase tracking-wider whitespace-nowrap">
Ausgewähltes Event:
</span>
{events.map((evt) => {
const isActive = selectedEventId === evt.id;
return (
<button
key={evt.id}
onClick={() => handleSelectEvent(evt.id)}
style={{
backgroundColor: isActive ? (branding?.primary_color || 'var(--brand-primary)') : undefined,
borderColor: isActive ? (branding?.primary_color || 'var(--brand-primary)') : undefined
}}
className={`px-3.5 py-1.5 rounded-sm text-xs font-bold transition whitespace-nowrap border ${
isActive
? 'text-white shadow-sm'
: 'bg-subtle text-muted border-grid hover:text-main'
}`}
>
{evt.title}
</button>
);
})}
</div>
{user && (
<button
onClick={() => { setEventToEdit(null); setActiveTab('event-editor'); }}
className="px-3.5 py-1.5 rounded-sm text-xs font-bold bg-subtle hover:bg-surface-hover text-main border border-grid transition flex items-center gap-1.5 shrink-0"
>
<Plus className="w-4 h-4" /> Neues Event
</button>
)}
</div>
<ShiftMatrixTable
event={selectedEventData}
user={user}
onSignupClick={handleShiftSignupClick}
onCancelClick={handleCancelSignup}
onRemoveUserFromShift={async (shift, signupId) => {
try {
const res = await apiFetch(`/shifts/${shift.id}/signup/${signupId}/`, {
method: 'DELETE'
});
showNotification(res.message || 'Eintragung entfernt.');
refreshEvents();
if (selectedEventId) {
handleSelectEvent(selectedEventId);
}
} catch (err) {
alert(err.message || 'Entfernen der Person fehlgeschlagen.');
}
}}
onGenerateClaimLink={handleGenerateClaimLink}
onExportPdf={handleExportPdf}
onPrintView={handlePrintView}
onEditEvent={(evt) => {
setEventToEdit(evt);
setActiveTab('event-editor');
}}
/>
</div>
)}
</main>
{/* Footer */}
<footer className="border-t border-grid py-6 text-center text-xs text-muted font-mono no-print">
<p>{branding?.app_name || "Veranstaltungsschichtplaner"} PWA Enabled PostgreSQL & Docker Ready</p>
</footer>
{/* Modals */}
{showAuthModal && (
<AuthModal
onClose={() => { setShowAuthModal(false); setClaimToken(null); setPrefilledGuestName(null); }}
onSuccess={handleAuthSuccess}
claimToken={claimToken}
prefilledGuestName={prefilledGuestName}
/>
)}
{showGuestModal && (
<GuestSignupModal
shift={guestTargetShift}
onClose={() => setShowGuestModal(false)}
onSubmit={handleGuestSubmit}
/>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
const API_BASE = '/api';
export const getAuthToken = () => localStorage.getItem('auth_token');
export const setAuthToken = (token) => {
if (token) {
localStorage.setItem('auth_token', token);
} else {
localStorage.removeItem('auth_token');
}
};
export const apiFetch = async (endpoint, options = {}) => {
const token = getAuthToken();
const headers = {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Token ${token}` } : {}),
...options.headers,
};
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
});
const contentType = response.headers.get('content-type');
let data = null;
if (contentType && contentType.includes('application/json')) {
data = await response.json();
}
if (!response.ok) {
let errorMsg = 'Ein Fehler ist aufgetreten.';
if (data) {
if (typeof data.error === 'string') errorMsg = data.error;
else if (typeof data.detail === 'string') errorMsg = data.detail;
else if (data.email) errorMsg = Array.isArray(data.email) ? data.email[0] : data.email;
else if (data.username) errorMsg = Array.isArray(data.username) ? data.username[0] : data.username;
else if (data.non_field_errors) errorMsg = data.non_field_errors[0];
}
throw new Error(errorMsg);
}
return data;
};
@@ -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>
);
}
+149
View File
@@ -0,0 +1,149 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
/* Hallmark · component: theme-engine · genre: editorial · theme: Atelier (Dark & Light) */
:root {
--font-display: 'Space Grotesk', system-ui, sans-serif;
--font-body: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--color-accent: #E05A47;
--brand-primary: var(--color-accent);
}
/* Dark Theme Tokens (Default) */
:root, [data-theme="dark"] {
--color-paper: #0E0F12;
--color-surface: #15161A;
--color-surface-hover: #1C1D23;
--color-border-grid: #282932;
--color-border-subtle: #1F2026;
--color-text: #F0F0EE;
--color-text-muted: #9E9FAA;
--color-text-dim: #5C5D66;
--color-bg-subtle: #090A0C;
}
/* Light Theme Tokens (Warm Paper Craft) */
[data-theme="light"] {
--color-paper: #F8F7F4;
--color-surface: #FFFFFF;
--color-surface-hover: #F0EEE8;
--color-border-grid: #D8D5CB;
--color-border-subtle: #E8E5DC;
--color-text: #141518;
--color-text-muted: #555660;
--color-text-dim: #888994;
--color-bg-subtle: #EFECE5;
}
body {
background-color: var(--color-paper);
color: var(--color-text);
font-family: var(--font-body);
background-image: radial-gradient(rgba(120, 120, 120, 0.08) 1px, transparent 1px);
background-size: 28px 28px;
overflow-x: clip;
transition: background-color 0.15s ease, color 0.15s ease;
}
/* Force Custom Font Utilities over Tailwind Defaults */
.font-display, .font-serif {
font-family: var(--font-display) !important;
font-style: normal !important;
}
.font-mono {
font-family: var(--font-mono) !important;
}
.font-sans {
font-family: var(--font-body) !important;
}
/* Helper Utility Classes bound strictly to CSS variables */
.bg-canvas, .bg-paper { background-color: var(--color-paper) !important; }
.bg-surface { background-color: var(--color-surface) !important; }
.bg-surface-hover { background-color: var(--color-surface-hover) !important; }
.bg-subtle { background-color: var(--color-bg-subtle) !important; }
.text-main { color: var(--color-text) !important; }
.text-muted { color: var(--color-text-muted) !important; }
.text-dim { color: var(--color-text-dim) !important; }
.border-grid { border-color: var(--color-border-grid) !important; }
.border-subtle { border-color: var(--color-border-subtle) !important; }
/* Form Input Semantic Helper */
.input-field {
background-color: var(--color-bg-subtle) !important;
color: var(--color-text) !important;
border-color: var(--color-border-grid) !important;
}
.input-field:focus {
border-color: var(--color-text-muted) !important;
outline: none !important;
}
/* Hallmark Structural Panels & Cards */
.hallmark-panel {
background-color: var(--color-surface);
border: 1px solid var(--color-border-grid);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.hallmark-card {
background-color: var(--color-surface);
border: 1px solid var(--color-border-grid);
}
.hallmark-card:hover {
border-color: var(--color-text-muted);
background-color: var(--color-surface-hover);
}
/* Dynamic Branding Helper Utilities */
.bg-brand-primary {
background-color: var(--brand-primary) !important;
}
.text-brand-primary {
color: var(--brand-primary) !important;
}
.border-brand-primary {
border-color: var(--brand-primary) !important;
}
/* Print Styles */
@media print {
body {
background: #ffffff !important;
color: #000000 !important;
background-image: none !important;
}
.no-print {
display: none !important;
}
.print-only {
display: block !important;
}
.hallmark-panel, .hallmark-card {
background: none !important;
border: 1px solid #000 !important;
box-shadow: none !important;
}
table {
width: 100% !important;
border-collapse: collapse !important;
}
th, td {
border: 1px solid #000 !important;
padding: 6px !important;
color: #000 !important;
}
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+877
View File
@@ -0,0 +1,877 @@
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
/* Hallmark · component: AdminPage · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
import React, { useState, useEffect } from 'react';
import {
Shield, Settings, Users, Calendar, Layers, Palette, Plus, Trash2, Edit3,
UserCheck, UserX, Clock, CheckCircle2, AlertCircle, Layout, Search, Eye, EyeOff
} from 'lucide-react';
import { apiFetch } from '../api/client';
export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents, skills, onRefreshSkills }) {
const [activeAdminTab, setActiveAdminTab] = useState('users'); // 'users' | 'events' | 'templates' | 'branding'
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// 1. Users & Security State
const [usersList, setUsersList] = useState([]);
const [userSearchQuery, setUserSearchQuery] = useState('');
const [pendingUsers, setPendingUsers] = useState([]);
const [restrictionEnabled, setRestrictionEnabled] = useState(false);
const [requireApproval, setRequireApproval] = useState(false);
const [domainRules, setDomainRules] = useState([]);
const [newDomain, setNewDomain] = useState('');
// 2. Events & Shifts State
const [eventsList, setEventsList] = useState([]);
// 3. Templates & Skills State
const [templatesList, setTemplatesList] = useState([]);
const [newSkillName, setNewSkillName] = useState('');
const [newSkillDesc, setNewSkillDesc] = useState('');
const [newSkillColor, setNewSkillColor] = useState('#E05A47');
// 4. Branding & Layout 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 || '');
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.');
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);
useEffect(() => {
loadAllAdminData();
}, []);
const loadAllAdminData = async () => {
setLoading(true);
setError('');
try {
try {
const users = await apiFetch('/users/manage-users/');
setUsersList(users.results || users);
} catch (e) {}
try {
const pending = await apiFetch('/users/pending/');
setPendingUsers(pending.results || pending);
} catch (e) {}
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);
} catch (e) {}
try {
const evts = await apiFetch('/events/');
setEventsList(evts.results || evts);
} catch (e) {}
try {
const tmpls = await apiFetch('/templates/');
setTemplatesList(tmpls.results || tmpls);
} catch (e) {}
} catch (err) {
setError(err.message || 'Laden der Admin-Daten fehlgeschlagen.');
} finally {
setLoading(false);
}
};
const showNotif = (msg) => {
setSuccess(msg);
setTimeout(() => setSuccess(''), 4000);
};
// --- USER ACTIONS ---
const handleToggleUserAdmin = async (user) => {
try {
const updated = await apiFetch(`/users/manage-users/${user.id}/`, {
method: 'PATCH',
body: JSON.stringify({ is_admin_user: !user.is_admin_user })
});
setUsersList(usersList.map(u => u.id === user.id ? updated : u));
showNotif(`Admin-Rechte für ${user.username} aktualisiert.`);
} catch (e) {
setError(e.message || 'Fehler beim Aktualisieren.');
}
};
const handleToggleUserActive = async (user) => {
try {
const updated = await apiFetch(`/users/manage-users/${user.id}/`, {
method: 'PATCH',
body: JSON.stringify({ is_active: !user.is_active })
});
setUsersList(usersList.map(u => u.id === user.id ? updated : u));
showNotif(`Status für ${user.username} auf ${updated.is_active ? 'AKTIV' : 'DEAKTIVIERT'} gesetzt.`);
} catch (e) {
setError(e.message || 'Fehler beim Aktualisieren.');
}
};
const handleDeleteUser = async (user) => {
if (!window.confirm(`Benutzer ${user.username} wirklich löschen?`)) return;
try {
await apiFetch(`/users/manage-users/${user.id}/`, { method: 'DELETE' });
setUsersList(usersList.filter(u => u.id !== user.id));
showNotif(`Benutzer ${user.username} gelöscht.`);
} catch (e) {
setError(e.message || 'Löschen fehlgeschlagen.');
}
};
const handleApproveUser = async (userId) => {
try {
const res = await apiFetch(`/users/${userId}/approve/`, { method: 'POST' });
setPendingUsers(pendingUsers.filter(u => u.id !== userId));
loadAllAdminData();
showNotif(res.message || 'Nutzer 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));
showNotif(res.message || 'Registrierung abgelehnt.');
} catch (err) {
setError(err.message || 'Ablehnen fehlgeschlagen.');
}
};
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);
showNotif(`Admin-Freischaltung ist jetzt ${updated.require_admin_approval ? 'AKTIV' : 'INAKTIV'}`);
} catch (err) {
setError(err.message || 'Fehler beim Umschalten.');
}
};
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);
showNotif(`Domain-Beschränkung ist jetzt ${updated.is_restriction_enabled ? '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('');
showNotif('Domain 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));
showNotif('Domain entfernt.');
} catch (err) {
setError(err.message || 'Löschen fehlgeschlagen.');
}
};
// --- SKILL ACTIONS ---
const handleCreateSkill = async (e) => {
e.preventDefault();
if (!newSkillName.trim()) return;
try {
await apiFetch('/skills/', {
method: 'POST',
body: JSON.stringify({
name: newSkillName.trim(),
description: newSkillDesc.trim(),
color: newSkillColor
})
});
setNewSkillName('');
setNewSkillDesc('');
onRefreshSkills();
showNotif('Qualifikation angelegt!');
} catch (e) {
setError(e.message || 'Fehler beim Anlegen.');
}
};
const handleDeleteSkill = async (id) => {
if (!window.confirm('Qualifikation wirklich löschen?')) return;
try {
await apiFetch(`/skills/${id}/`, { method: 'DELETE' });
onRefreshSkills();
showNotif('Qualifikation gelöscht.');
} catch (e) {
setError(e.message || 'Löschen fehlgeschlagen.');
}
};
// --- EVENT & TEMPLATE ACTIONS ---
const handleToggleEventActive = async (evt) => {
try {
const updated = await apiFetch(`/events/${evt.id}/`, {
method: 'PATCH',
body: JSON.stringify({ is_active: evt.is_active === false ? true : false })
});
setEventsList(eventsList.map(e => e.id === evt.id ? updated : e));
onRefreshEvents();
showNotif(`Status für "${evt.title}" aktualisiert.`);
} catch (e) {
setError(e.message || 'Fehler beim Umschalten.');
}
};
const handleDeleteEvent = async (id) => {
if (!window.confirm('Veranstaltung mit allen Schichten wirklich löschen?')) return;
try {
await apiFetch(`/events/${id}/`, { method: 'DELETE' });
setEventsList(eventsList.filter(e => e.id !== id));
onRefreshEvents();
showNotif('Veranstaltung gelöscht.');
} catch (e) {
setError(e.message || 'Löschen fehlgeschlagen.');
}
};
const handleDeleteTemplate = async (id) => {
if (!window.confirm('Vorlage wirklich löschen?')) return;
try {
await apiFetch(`/templates/${id}/`, { method: 'DELETE' });
setTemplatesList(templatesList.filter(t => t.id !== id));
showNotif('Vorlage gelöscht.');
} catch (e) {
setError(e.message || 'Löschen fehlgeschlagen.');
}
};
// --- BRANDING ACTIONS ---
const handleSaveBranding = async (e) => {
e.preventDefault();
setSavingBranding(true);
setError('');
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
})
});
showNotif('Branding & Einstellungen erfolgreich gespeichert!');
onRefreshBranding();
} catch (err) {
setError(err.message || 'Speichern fehlgeschlagen.');
} finally {
setSavingBranding(false);
}
};
const filteredUsers = usersList.filter(u =>
u.username.toLowerCase().includes(userSearchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(userSearchQuery.toLowerCase()) ||
(u.display_name && u.display_name.toLowerCase().includes(userSearchQuery.toLowerCase()))
);
return (
<div className="space-y-8 max-w-6xl mx-auto animate-in fade-in duration-200">
{/* Hallmark Masthead Header & Ledger Bar */}
<div className="hallmark-panel rounded-sm p-6 sm:p-8 border border-grid space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-grid pb-4">
<div className="space-y-1">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted border border-grid px-2.5 py-0.5 rounded-sm inline-block">
SYSTEM CONTROL & GOVERNANCE
</span>
<h2 className="font-serif text-3xl sm:text-4xl font-bold uppercase tracking-tight text-main">
Administration & System-Zentrale
</h2>
</div>
<div className="flex items-center gap-2 font-mono text-xs shrink-0">
<div className="bg-subtle px-3 py-2 rounded-sm border border-grid text-right space-y-0.5">
<div className="font-bold text-emerald-500">[ SYSTEM NORMAL ]</div>
<div className="text-[10px] text-muted">{usersList.length} KONTEN {eventsList.length} EVENTS</div>
</div>
</div>
</div>
{/* Tab Controls Bar */}
<div className="flex items-center gap-2 overflow-x-auto font-mono text-xs no-scrollbar pt-2">
<button
onClick={() => setActiveAdminTab('users')}
className={`px-4 py-2 rounded-sm transition flex items-center gap-2 border ${
activeAdminTab === 'users'
? 'bg-surface-hover text-main font-bold border-grid shadow-sm'
: 'bg-subtle text-muted border-transparent hover:text-main'
}`}
>
<Users className="w-3.5 h-3.5 text-blue-400" /> [ 01: BENUTZER & SICHERHEIT ({usersList.length}) ]
</button>
<button
onClick={() => setActiveAdminTab('events')}
className={`px-4 py-2 rounded-sm transition flex items-center gap-2 border ${
activeAdminTab === 'events'
? 'bg-surface-hover text-main font-bold border-grid shadow-sm'
: 'bg-subtle text-muted border-transparent hover:text-main'
}`}
>
<Calendar className="w-3.5 h-3.5 text-emerald-400" /> [ 02: VERANSTALTUNGEN ({eventsList.length}) ]
</button>
<button
onClick={() => setActiveAdminTab('templates')}
className={`px-4 py-2 rounded-sm transition flex items-center gap-2 border ${
activeAdminTab === 'templates'
? 'bg-surface-hover text-main font-bold border-grid shadow-sm'
: 'bg-subtle text-muted border-transparent hover:text-main'
}`}
>
<Layers className="w-3.5 h-3.5 text-amber-400" /> [ 03: VORLAGEN & SKILLS ]
</button>
<button
onClick={() => setActiveAdminTab('branding')}
className={`px-4 py-2 rounded-sm transition flex items-center gap-2 border ${
activeAdminTab === 'branding'
? 'bg-surface-hover text-main font-bold border-grid shadow-sm'
: 'bg-subtle text-muted border-transparent hover:text-main'
}`}
>
<Palette className="w-3.5 h-3.5 text-indigo-400" /> [ 04: BRANDING & SYSTEM ]
</button>
</div>
</div>
{/* Notifications */}
{error && (
<div className="p-4 rounded-sm bg-red-500/10 border border-red-500/20 text-red-500 text-xs font-mono flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{success && (
<div className="p-4 rounded-sm bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 text-xs font-mono flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>{success}</span>
</div>
)}
{/* TAB 1: BENUTZER & SICHERHEIT */}
{activeAdminTab === 'users' && (
<div className="space-y-6 animate-in fade-in duration-150">
{/* Pending Registrations section if any */}
{pendingUsers.length > 0 && (
<div className="hallmark-panel rounded-sm p-5 border border-amber-500/40 space-y-3 font-mono">
<h3 className="font-serif text-lg font-bold uppercase text-amber-400 flex items-center gap-2">
<Clock className="w-4 h-4" /> Ausstehende Freischalt-Anfragen ({pendingUsers.length})
</h3>
<div className="space-y-2">
{pendingUsers.map(pUser => (
<div key={pUser.id} className="flex items-center justify-between p-3 rounded-sm bg-subtle 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>
)}
{/* Registration Rules Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 font-mono">
{/* Require Admin Approval */}
<div className="hallmark-panel rounded-sm p-5 border border-grid space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-bold text-xs uppercase text-main flex items-center gap-2">
<UserCheck className="w-4 h-4 text-blue-400" /> Admin-Freischaltung Pflicht
</h4>
<button
onClick={handleToggleRequireApproval}
className={`px-3 py-1 rounded-sm text-xs font-bold transition border ${
requireApproval ? 'bg-blue-600 text-white border-blue-500' : 'bg-subtle text-muted border-grid'
}`}
>
{requireApproval ? '[ AKTIV ]' : '[ INAKTIV ]'}
</button>
</div>
<p className="text-[11px] text-muted">Neue Registrierungen müssen manuell freigeschaltet werden.</p>
</div>
{/* Email Domain Restrictions */}
<div className="hallmark-panel rounded-sm p-5 border border-grid space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-bold text-xs uppercase text-main flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-400" /> E-Mail Domain-Beschränkung
</h4>
<button
onClick={handleToggleRestriction}
className={`px-3 py-1 rounded-sm text-xs font-bold transition border ${
restrictionEnabled ? 'bg-emerald-600 text-white border-emerald-500' : 'bg-subtle text-muted border-grid'
}`}
>
{restrictionEnabled ? '[ AKTIV ]' : '[ INAKTIV ]'}
</button>
</div>
<p className="text-[11px] text-muted">Nur freigegebene E-Mail-Domains erlauben.</p>
</div>
</div>
{/* Domain Rules Manager */}
{restrictionEnabled && (
<div className="hallmark-panel rounded-sm p-5 border border-grid space-y-4 font-mono">
<h4 className="font-bold text-xs uppercase text-main">Freigegebene E-Mail-Domains</h4>
<form onSubmit={handleAddDomain} className="flex gap-2">
<input
type="text"
placeholder="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 text-white hover:bg-blue-500">
<Plus className="w-3.5 h-3.5 inline" /> Domain Hinzufügen
</button>
</form>
<div className="space-y-1.5">
{domainRules.map(r => (
<div key={r.id} className="flex items-center justify-between p-2 rounded-sm bg-subtle border border-grid text-xs">
<span className="font-bold text-main">{r.domain}</span>
<button onClick={() => handleDeleteDomain(r.id)} className="text-muted hover:text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* User Accounts Management Table */}
<div className="hallmark-panel rounded-sm border border-grid overflow-hidden">
<div className="p-4 bg-subtle border-b border-grid flex flex-col sm:flex-row sm:items-center justify-between gap-3 font-mono">
<h3 className="font-serif text-lg font-bold uppercase text-main">
Alle Benutzerkonten ({filteredUsers.length} von {usersList.length})
</h3>
<div className="relative w-full sm:w-64">
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-muted" />
<input
type="text"
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
placeholder="Benutzer suchen..."
className="w-full pl-9 pr-3 py-1 rounded-sm input-field text-xs font-mono"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse font-sans text-xs">
<thead>
<tr className="bg-subtle border-b border-grid text-[11px] font-mono text-muted uppercase tracking-wider">
<th className="py-3 px-4">Nutzer & E-Mail</th>
<th className="py-3 px-4">Anzeigename</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Rolle</th>
<th className="py-3 px-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-grid font-mono">
{filteredUsers.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-muted italic text-xs">
[ Keine Benutzerkonten gefunden ]
</td>
</tr>
) : (
filteredUsers.map((u) => (
<tr key={u.id} className="hover:bg-surface-hover">
<td className="py-3 px-4">
<div className="font-bold text-main">{u.username}</div>
<div className="text-[11px] text-muted">{u.email}</div>
</td>
<td className="py-3 px-4 text-main">{u.display_name || u.username}</td>
<td className="py-3 px-4">
<button
onClick={() => handleToggleUserActive(u)}
className={`px-2 py-0.5 rounded-sm text-[10px] font-bold border transition ${
u.is_active ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
}`}
>
{u.is_active ? '[ AKTIV ]' : '[ DEAKTIVIERT ]'}
</button>
</td>
<td className="py-3 px-4">
<button
onClick={() => handleToggleUserAdmin(u)}
className={`px-2 py-0.5 rounded-sm text-[10px] font-bold border transition ${
u.is_admin_user ? 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30' : 'bg-subtle text-muted border-grid'
}`}
>
{u.is_admin_user ? '👑 ADMIN' : '👤 USER'}
</button>
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleDeleteUser(u)}
className="p-1.5 rounded-sm text-muted hover:text-red-400 hover:bg-red-500/10 transition"
title="Benutzer löschen"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* TAB 2: VERANSTALTUNGEN & SCHICHTEN */}
{activeAdminTab === 'events' && (
<div className="space-y-6 animate-in fade-in duration-150">
<div className="hallmark-panel rounded-sm border border-grid overflow-hidden font-mono">
<div className="p-4 bg-subtle border-b border-grid flex items-center justify-between">
<h3 className="font-serif text-lg font-bold uppercase text-main">Veranstaltungen verwalten ({eventsList.length})</h3>
</div>
<div className="divide-y divide-grid">
{eventsList.map((evt) => (
<div key={evt.id} className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-surface-hover transition">
<div className="space-y-1">
<div className="flex items-center gap-2 text-[11px] text-muted">
<span className="bg-subtle px-2 py-0.5 rounded-sm border border-grid font-bold">
{evt.start_date} bis {evt.end_date}
</span>
{evt.location && <span> {evt.location}</span>}
{evt.is_active === false && (
<span className="text-amber-500 font-bold px-1.5 py-0.5 rounded-sm bg-amber-500/10 border border-amber-500/20">
[ DEAKTIVIERT ]
</span>
)}
</div>
<h4 className="font-serif text-base font-bold text-main uppercase">{evt.title}</h4>
{evt.description && <p className="text-xs text-muted font-sans line-clamp-1">{evt.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
<button
onClick={() => handleToggleEventActive(evt)}
className={`px-3 py-1 rounded-sm border font-mono font-bold transition flex items-center gap-1 ${
evt.is_active !== false
? 'bg-amber-500/10 text-amber-500 border-amber-500/20 hover:bg-amber-500/20'
: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/20'
}`}
>
{evt.is_active !== false ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
{evt.is_active !== false ? 'DEAKTIVIEREN' : 'AKTIVIEREN'}
</button>
<button
onClick={() => handleDeleteEvent(evt.id)}
className="px-3 py-1 rounded-sm bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 transition flex items-center gap-1 font-bold"
>
<Trash2 className="w-3.5 h-3.5" /> LÖSCHEN
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 3: VORLAGEN & QUALIFIKATIONEN */}
{activeAdminTab === 'templates' && (
<div className="space-y-6 animate-in fade-in duration-150 font-mono">
{/* Skills Management */}
<div className="hallmark-panel rounded-sm p-5 border border-grid space-y-4">
<h3 className="font-serif text-lg font-bold uppercase text-main border-b border-grid pb-2">
Erforderliche Qualifikationen (Skills)
</h3>
<form onSubmit={handleCreateSkill} className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input
type="text"
placeholder="Skill Name (z. B. Bar-Erfahrung)"
required
value={newSkillName}
onChange={(e) => setNewSkillName(e.target.value)}
className="px-3 py-1.5 rounded-sm input-field text-xs font-mono"
/>
<input
type="text"
placeholder="Beschreibung (optional)"
value={newSkillDesc}
onChange={(e) => setNewSkillDesc(e.target.value)}
className="px-3 py-1.5 rounded-sm input-field text-xs font-mono"
/>
<div className="flex gap-2">
<input
type="color"
value={newSkillColor}
onChange={(e) => setNewSkillColor(e.target.value)}
className="w-8 h-8 rounded-sm border-0 cursor-pointer bg-subtle p-0.5"
/>
<button type="submit" className="flex-1 px-3 py-1.5 rounded-sm bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs">
<Plus className="w-3.5 h-3.5 inline" /> Anlegen
</button>
</div>
</form>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
{skills.map(s => (
<div key={s.id} className="p-3 rounded-sm bg-subtle border border-grid flex items-center justify-between text-xs">
<div>
<span style={{ color: s.color }} className="font-bold">{s.name}</span>
{s.description && <p className="text-[11px] text-muted">{s.description}</p>}
</div>
<button onClick={() => handleDeleteSkill(s.id)} className="text-muted hover:text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
{/* Event Templates */}
<div className="hallmark-panel rounded-sm p-5 border border-grid space-y-4">
<h3 className="font-serif text-lg font-bold uppercase text-main border-b border-grid pb-2">
Gespeicherte Event-Vorlagen ({templatesList.length})
</h3>
<div className="space-y-2">
{templatesList.map(t => (
<div key={t.id} className="p-3 rounded-sm bg-subtle border border-grid flex items-center justify-between text-xs">
<div>
<div className="font-bold text-main">{t.name}</div>
<div className="text-[11px] text-muted">{t.description || 'Keine Beschreibung'}</div>
</div>
<button onClick={() => handleDeleteTemplate(t.id)} className="text-muted hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 4: BRANDING & STARTSEITE */}
{activeAdminTab === 'branding' && (
<div className="space-y-6 animate-in fade-in duration-150 font-mono">
<form onSubmit={handleSaveBranding} className="hallmark-panel rounded-sm p-6 border border-grid space-y-6">
<h3 className="font-serif text-lg font-bold uppercase text-main border-b border-grid pb-2">
App Branding & Aussehen
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 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"
/>
</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 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"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-muted mb-1">Ankündigung (Banner-Text)</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"
/>
</div>
</div>
<h4 className="font-serif text-base font-bold uppercase text-main pt-4 border-t border-grid">
Startseiten Sidebar 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-bold text-xs uppercase text-main">📌 Verein & Infos Box</span>
<button
type="button"
onClick={() => setShowCommunityInfoBox(!showCommunityInfoBox)}
className={`px-3 py-1 rounded-sm text-xs font-bold transition border ${
showCommunityInfoBox ? 'bg-emerald-600 text-white border-emerald-500' : 'bg-surface text-muted border-grid'
}`}
>
{showCommunityInfoBox ? '[ AN ]' : '[ AUS ]'}
</button>
</div>
{showCommunityInfoBox && (
<div className="space-y-3 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"
/>
</div>
<div>
<label className="block text-muted mb-1">Inhalt</label>
<textarea
rows={3}
value={communityInfoText}
onChange={(e) => setCommunityInfoText(e.target.value)}
className="w-full px-3 py-1.5 rounded-sm input-field"
/>
</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-bold text-xs uppercase text-main"> Unterstützen Box</span>
<button
type="button"
onClick={() => setShowSupportBox(!showSupportBox)}
className={`px-3 py-1 rounded-sm text-xs font-bold transition border ${
showSupportBox ? 'bg-emerald-600 text-white border-emerald-500' : 'bg-surface text-muted border-grid'
}`}
>
{showSupportBox ? '[ AN ]' : '[ AUS ]'}
</button>
</div>
{showSupportBox && (
<div className="space-y-3 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"
/>
</div>
<div>
<label className="block text-muted mb-1">Inhalt</label>
<textarea
rows={3}
value={supportBoxText}
onChange={(e) => setSupportBoxText(e.target.value)}
className="w-full px-3 py-1.5 rounded-sm input-field"
/>
</div>
</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-6 py-2.5 rounded-sm text-xs font-mono font-bold text-white transition hover:brightness-110 shadow-sm"
>
{savingBranding ? 'Speichern...' : 'Einstellungen speichern'}
</button>
</div>
</form>
</div>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
/* Hallmark · component: calendar-page · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
import React, { useState } from 'react';
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, ArrowRight, MapPin, Clock, Layers } from 'lucide-react';
export default function CalendarPage({ events, onSelectEvent, branding }) {
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedCalendarEvent, setSelectedCalendarEvent] = useState(null);
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const monthNames = [
'JANUAR', 'FEBRUAR', 'MÄRZ', 'APRIL', 'MAI', 'JUNI',
'JULI', 'AUGUST', 'SEPTEMBER', 'OKTOBER', 'NOVEMBER', 'DEZEMBER'
];
const prevMonth = () => setCurrentDate(new Date(year, month - 1, 1));
const nextMonth = () => setCurrentDate(new Date(year, month + 1, 1));
const todayMonth = () => setCurrentDate(new Date());
const firstDayOfMonth = new Date(year, month, 1).getDay();
const startOffset = (firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const formatDateString = (day) => {
const mStr = String(month + 1).padStart(2, '0');
const dStr = String(day).padStart(2, '0');
return `${year}-${mStr}-${dStr}`;
};
const getEventsForDay = (day) => {
const dateStr = formatDateString(day);
return events.filter((evt) => dateStr >= evt.start_date && dateStr <= evt.end_date);
};
const primaryColor = branding?.primary_color || 'var(--brand-primary)';
return (
<div className="space-y-6 max-w-5xl mx-auto animate-in fade-in duration-200">
{/* Calendar Masthead Bar */}
<div className="hallmark-panel rounded-sm p-6 sm:p-8 border border-grid flex flex-col sm:flex-row sm:items-center justify-between gap-4 font-mono">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-sm bg-subtle text-main border border-grid flex items-center justify-center font-bold">
<CalendarIcon className="w-4 h-4 text-amber-500" />
</div>
<div>
<h2 className="font-serif text-3xl sm:text-4xl font-bold uppercase tracking-tight text-main leading-none">
{monthNames[month]} {year}
</h2>
<p className="text-[10px] text-muted uppercase tracking-widest mt-1">
INITIATIVE E.V. TERMIN- & SCHICHTÜBERSICHT
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={todayMonth}
className="px-3.5 py-1.5 rounded-sm text-xs font-mono font-bold bg-surface hover:bg-surface-hover text-main border border-grid transition"
>
[ HEUTE ]
</button>
<button
onClick={prevMonth}
className="p-1.5 rounded-sm text-muted hover:text-main bg-surface border border-grid transition"
title="Vorheriger Monat"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={nextMonth}
className="p-1.5 rounded-sm text-muted hover:text-main bg-surface border border-grid transition"
title="Nächster Monat"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{/* Calendar Grid Container */}
<div className="hallmark-panel rounded-sm border border-grid overflow-hidden">
{/* Days Header */}
<div className="grid grid-cols-7 bg-subtle border-b border-grid text-center text-xs font-mono text-muted font-bold py-3 uppercase">
<div>MO</div><div>DI</div><div>MI</div><div>DO</div><div>FR</div><div>SA</div><div>SO</div>
</div>
{/* Days Cells */}
<div className="grid grid-cols-7 auto-rows-fr divide-x divide-y divide-grid bg-surface text-xs font-mono">
{Array.from({ length: startOffset }).map((_, i) => (
<div key={`offset-${i}`} className="min-h-[100px] sm:min-h-[110px] p-2 bg-subtle/50 text-muted opacity-30"></div>
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const dayNum = i + 1;
const dayEvents = getEventsForDay(dayNum);
const isToday =
new Date().getFullYear() === year &&
new Date().getMonth() === month &&
new Date().getDate() === dayNum;
return (
<div
key={`day-${dayNum}`}
className={`min-h-[100px] sm:min-h-[110px] p-2 flex flex-col justify-between transition ${
isToday ? 'bg-surface-hover/80 font-bold' : 'hover:bg-surface-hover/50'
}`}
>
<div className="flex items-center justify-between mb-1">
<span
className={`w-5 h-5 rounded-sm flex items-center justify-center font-mono text-xs ${
isToday
? 'bg-main text-paper font-bold shadow-sm'
: 'text-muted'
}`}
>
{dayNum}
</span>
{dayEvents.length > 0 && (
<span className="text-[9px] font-mono text-muted">
{dayEvents.length} {dayEvents.length === 1 ? 'Event' : 'Events'}
</span>
)}
</div>
<div className="space-y-1.5 overflow-y-auto max-h-[70px] no-scrollbar">
{dayEvents.map((evt) => {
const isSelected = selectedCalendarEvent?.id === evt.id;
return (
<button
key={`evt-${evt.id}`}
onClick={() => setSelectedCalendarEvent(evt)}
style={{
backgroundColor: isSelected ? primaryColor : `${primaryColor}15`,
borderColor: `${primaryColor}40`,
color: isSelected ? '#ffffff' : primaryColor
}}
className={`w-full text-left px-2 py-1 rounded-sm text-[10px] font-mono font-bold border truncate block hover:brightness-110 transition shadow-sm ${
evt.is_active === false ? 'opacity-50 line-through' : ''
}`}
>
{evt.title}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Selected Event Detail Drawer */}
{selectedCalendarEvent && (
<div className="hallmark-panel rounded-sm p-6 border border-grid flex flex-col sm:flex-row sm:items-center justify-between gap-4 font-mono animate-in fade-in duration-200">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
<span className="flex items-center gap-1 bg-subtle px-2 py-0.5 rounded-sm border border-grid font-bold text-main">
<Clock className="w-3 h-3 text-muted" />
{new Date(selectedCalendarEvent.start_date).toLocaleDateString('de-DE')} {new Date(selectedCalendarEvent.end_date).toLocaleDateString('de-DE')}
</span>
{selectedCalendarEvent.location && (
<span className="flex items-center gap-1 text-muted">
<MapPin className="w-3 h-3" />
{selectedCalendarEvent.location}
</span>
)}
</div>
<h3 className="font-serif text-2xl font-bold uppercase text-main">
{selectedCalendarEvent.title}
</h3>
{selectedCalendarEvent.description && (
<p className="text-xs font-sans text-muted max-w-xl">
{selectedCalendarEvent.description}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => onSelectEvent(selectedCalendarEvent.id)}
style={{ backgroundColor: primaryColor }}
className="px-5 py-2 rounded-sm text-xs font-mono font-bold text-white transition flex items-center gap-1.5 hover:brightness-110 shadow-sm"
>
SCHICHTPLAN ÖFFNEN <ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
);
}
+393
View File
@@ -0,0 +1,393 @@
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
/* Hallmark · component: EventEditorPage · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
import React, { useState, useEffect } from 'react';
import { ArrowLeft, Calendar, Layers, Plus, Trash2, Edit3, CheckCircle2, AlertCircle } from 'lucide-react';
export default function EventEditorPage({ eventToEdit, skills, onBack, 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([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = 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 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
});
} catch (err) {
setError(err.message || 'Speichern der Veranstaltung fehlgeschlagen.');
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6 max-w-4xl mx-auto animate-in fade-in duration-200 font-sans">
{/* Navigation Header */}
<div className="flex items-center justify-between font-mono text-xs">
<button
onClick={onBack}
className="px-3.5 py-1.5 rounded-sm bg-subtle hover:bg-surface-hover text-main border border-grid transition flex items-center gap-2 font-bold"
>
<ArrowLeft className="w-4 h-4" /> [ ZURÜCK ZUR ÜBERSICHT ]
</button>
<span className="text-muted border border-grid px-2.5 py-0.5 rounded-sm">
MODE: {eventToEdit ? 'EVENT EDIT' : 'NEW EVENT'}
</span>
</div>
{/* Page Title Panel */}
<div className="hallmark-panel rounded-sm p-6 sm:p-8 border border-grid space-y-2">
<div className="flex items-center gap-3">
<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>
<h2 className="font-serif text-3xl font-bold uppercase tracking-tight text-main">
{eventToEdit ? 'Veranstaltung & Schichten Bearbeiten' : 'Neue Veranstaltung Erstellen'}
</h2>
<p className="text-xs text-muted font-mono mt-0.5">
Konfiguriere Stammdaten, Aufgabenfelder, Schichtzeiten & Qualifikationen
</p>
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-sm bg-red-500/10 border border-red-500/20 text-red-500 text-xs font-mono flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Main Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Section 1: Stammdaten */}
<div className="hallmark-panel rounded-sm p-6 border border-grid space-y-4 font-mono text-xs">
<h3 className="font-serif text-lg font-bold uppercase text-main border-b border-grid pb-2">
1. Stammdaten der Veranstaltung
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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.5 py-2 rounded-sm input-field text-sm"
/>
</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.5 py-2 rounded-sm input-field text-sm"
/>
</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.5 py-2 rounded-sm input-field text-sm"
/>
</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.5 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.5 py-2 rounded-sm input-field text-xs font-sans"
/>
</div>
</div>
</div>
{/* Section 2: Task Areas & Shifts */}
<div className="hallmark-panel rounded-sm p-6 border border-grid space-y-4 font-mono text-xs">
<div className="flex items-center justify-between border-b border-grid pb-2">
<h3 className="font-serif text-lg font-bold uppercase text-main flex items-center gap-2">
<Layers className="w-4 h-4 text-blue-500" /> 2. Aufgabenfelder & Schichten
</h3>
<button
type="button"
onClick={addTaskArea}
className="px-3.5 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>
{/* Action Controls */}
<div className="hallmark-panel rounded-sm p-4 border border-grid flex items-center justify-between font-mono text-xs">
<button
type="button"
onClick={onBack}
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-6 py-2.5 rounded-sm text-xs font-mono font-bold text-white transition shadow-sm hover:brightness-110 flex items-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
{submitting ? 'Speichern...' : eventToEdit ? 'Änderungen Speichern' : 'Veranstaltung Jetzt Erstellen'}
</button>
</div>
</form>
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
/* Hallmark · redesign: HomePage · studied-DNA: https://inihaus.de · macrostructure: Newsstand Feed */
import React, { useState } from 'react';
import { Search, Calendar, MapPin, Clock, ArrowRight, Plus, EyeOff, CheckCircle2, Edit3, ShieldAlert } from 'lucide-react';
export default function HomePage({
events,
user,
onSelectEvent,
onOpenCreateEvent,
onToggleEventActive,
onEditEvent,
branding
}) {
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState('all');
const nowStr = new Date().toISOString().split('T')[0];
const filteredEvents = events.filter((evt) => {
const matchesSearch =
evt.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
(evt.description && evt.description.toLowerCase().includes(searchQuery.toLowerCase())) ||
(evt.location && evt.location.toLowerCase().includes(searchQuery.toLowerCase()));
if (!matchesSearch) return false;
if (filterMode === 'upcoming') return evt.end_date >= nowStr;
return true;
});
const primaryColor = branding?.primary_color || 'var(--brand-primary)';
const showCommunityBox = branding?.show_community_info_box ?? true;
const showSupportBox = branding?.show_support_box ?? true;
const hasSidebar = showCommunityBox || showSupportBox;
const isUserAdmin = user && (user.is_admin_user || user.is_staff || user.is_superuser);
return (
<div className="space-y-8 animate-in fade-in duration-200">
{/* Hero Banner Masthead */}
<div className="hallmark-panel rounded-sm p-6 sm:p-10 space-y-6 max-w-5xl mx-auto relative border border-grid">
<div className="space-y-2 border-b border-grid pb-6">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted border border-grid px-2.5 py-1 rounded-sm inline-block">
{branding?.app_name || "SCHICHT- & EVENTPORTAL"}
</span>
{branding?.custom_banner_text && (
<span className="font-mono text-[10px] uppercase tracking-widest text-amber-500 bg-amber-500/10 border border-amber-500/20 px-2.5 py-1 rounded-sm inline-block font-bold">
📢 {branding.custom_banner_text}
</span>
)}
</div>
<h2 className="font-serif text-3xl sm:text-5xl font-bold uppercase tracking-tight text-main leading-tight">
Veranstaltungen & Schichtkoordination
</h2>
<p className="font-sans text-xs sm:text-sm text-muted max-w-2xl">
Hier findest du aktuelle Termine, Arbeitsgruppen, Bar- & Tresendienste und Schichtpläne der Initiative.
</p>
</div>
{/* Search & Filter Bar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3.5 top-3 text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Veranstaltung, Ort oder Suchbegriff..."
className="w-full pl-10 pr-16 py-2 rounded-sm bg-subtle border border-grid text-main text-xs font-mono focus:outline-none focus:border-muted"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-2.5 text-[10px] font-mono text-muted hover:text-main"
>
[ CLEAR ]
</button>
)}
</div>
<div className="flex items-center gap-1.5 font-mono text-xs shrink-0">
<button
onClick={() => setFilterMode('all')}
className={`px-3.5 py-1.5 rounded-sm transition border ${
filterMode === 'all'
? 'bg-surface-hover text-main border-grid font-bold shadow-sm'
: 'bg-subtle text-muted border-grid hover:text-main'
}`}
>
ALLE PROGRAMME ({events.length})
</button>
<button
onClick={() => setFilterMode('upcoming')}
className={`px-3.5 py-1.5 rounded-sm transition border ${
filterMode === 'upcoming'
? 'bg-surface-hover text-main border-grid font-bold shadow-sm'
: 'bg-subtle text-muted border-grid hover:text-main'
}`}
>
ANSTEHEND ({events.filter(e => e.end_date >= nowStr).length})
</button>
</div>
</div>
</div>
{/* Main Content Stream & Sidebar Widget */}
<div className="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Events Grid Stream */}
<div className={`${hasSidebar ? 'lg:col-span-3' : 'lg:col-span-4'} space-y-4`}>
<div className="flex items-center justify-between border-b border-grid pb-3 font-mono">
<h3 className="font-serif text-xl font-bold uppercase tracking-wide text-main flex items-center gap-2">
<Calendar className="w-4.5 h-4.5 text-muted" /> Termine & Schichten
</h3>
{user && (
<button
onClick={onOpenCreateEvent}
style={{ backgroundColor: primaryColor }}
className="px-3.5 py-1.5 rounded-sm text-xs font-mono font-bold text-white transition flex items-center gap-1 hover:brightness-110 shadow-sm"
>
<Plus className="w-3.5 h-3.5" /> EVENT ANLEGEN
</button>
)}
</div>
{filteredEvents.length === 0 ? (
<div className="hallmark-panel rounded-sm p-12 text-center text-xs font-mono text-muted border border-grid">
{searchQuery
? `[ Keinen Eintrag für "${searchQuery}" gefunden ]`
: '[ Keine Veranstaltungen vorhanden ]'}
</div>
) : (
<div className={`grid grid-cols-1 sm:grid-cols-2 ${hasSidebar ? '' : 'lg:grid-cols-3'} gap-4`}>
{filteredEvents.map((evt) => {
const taskAreaCount = evt.task_areas?.length || 0;
const totalShifts = evt.task_areas?.reduce((acc, ta) => acc + (ta.shifts?.length || 0), 0) || 0;
const isCreator = user && (evt.created_by === user.id || evt.created_by?.id === user.id);
const canManage = isUserAdmin || isCreator;
return (
<div
key={evt.id}
className={`hallmark-card rounded-sm p-5 border transition flex flex-col justify-between space-y-4 group overflow-hidden ${
evt.is_active === false ? 'border-amber-500/40 opacity-80 bg-subtle' : 'border-grid'
}`}
>
<div className="space-y-3">
<div className="flex items-center justify-between text-[11px] font-mono text-muted gap-2">
<span className="flex items-center gap-1 bg-subtle px-2 py-0.5 rounded-sm border border-grid font-bold shrink-0">
<Clock className="w-3 h-3 text-muted" />
{new Date(evt.start_date).toLocaleDateString('de-DE')}
</span>
{evt.is_active === false ? (
<span className="text-amber-500 font-bold px-1.5 py-0.5 rounded-sm bg-amber-500/10 border border-amber-500/20 shrink-0">
[ DEAKTIVIERT ]
</span>
) : evt.location ? (
<span className="flex items-center gap-1 text-muted truncate max-w-[130px]">
<MapPin className="w-3 h-3 shrink-0" />
<span className="truncate">{evt.location}</span>
</span>
) : null}
</div>
<h4 className="font-serif text-lg font-bold uppercase text-main group-hover:text-amber-500 transition line-clamp-2">
{evt.title}
</h4>
{evt.description && (
<p className="text-xs text-muted font-sans line-clamp-2">{evt.description}</p>
)}
<div className="text-[10px] font-mono text-muted flex items-center gap-1.5 pt-1">
<span>{taskAreaCount} Bereiche</span>
<span>/</span>
<span>{totalShifts} Schichten</span>
</div>
</div>
<div className="pt-3 border-t border-grid space-y-2 font-mono">
<div className="flex flex-wrap items-center gap-1.5">
<button
onClick={() => onSelectEvent(evt.id)}
className="flex-1 min-w-[110px] py-1.5 px-3 rounded-sm text-xs font-mono font-bold bg-surface hover:bg-surface-hover text-main border border-grid transition flex items-center justify-center gap-1"
>
Schichtplan <ArrowRight className="w-3.5 h-3.5" />
</button>
{canManage && onEditEvent && (
<button
onClick={() => onEditEvent(evt)}
className="py-1.5 px-2.5 rounded-sm text-[10px] font-mono font-bold bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 hover:bg-indigo-500/20 transition flex items-center gap-1 shrink-0"
title="Veranstaltung & Schichten bearbeiten"
>
<Edit3 className="w-3.5 h-3.5" /> BEARBEITEN
</button>
)}
{canManage && onToggleEventActive && (
<button
onClick={() => onToggleEventActive(evt)}
className={`py-1.5 px-2 rounded-sm text-[10px] font-mono font-bold transition flex items-center gap-1 border shrink-0 ${
evt.is_active !== false
? 'bg-amber-500/10 text-amber-500 border-amber-500/20 hover:bg-amber-500/20'
: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/20'
}`}
title={evt.is_active !== false ? 'Deaktivieren' : 'Aktivieren'}
>
{evt.is_active !== false ? <EyeOff className="w-3.5 h-3.5" /> : <CheckCircle2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Sidebar Info & Community Links Widget */}
{hasSidebar && (
<div className="space-y-4 font-mono text-xs">
{showCommunityBox && (
<div className="hallmark-panel rounded-sm p-4 border border-grid space-y-3">
<h4 className="font-serif text-sm font-bold uppercase tracking-wide text-main border-b border-grid pb-2">
{branding?.community_info_title || "📌 Verein & Infos"}
</h4>
<div className="space-y-2 text-muted text-[11px] whitespace-pre-line">
{branding?.community_info_text || "Initiative e.V. Hausverein\nOffene Angebote, DIY-Kultur & engagierte Schichten."}
</div>
</div>
)}
{showSupportBox && (
<div className="hallmark-panel rounded-sm p-4 border border-grid space-y-3">
<h4 className="font-serif text-sm font-bold uppercase tracking-wide text-main border-b border-grid pb-2">
{branding?.support_box_title || "❤️ Unterstützen"}
</h4>
<p className="text-[11px] text-muted whitespace-pre-line">
{branding?.support_box_text || "Hilf mit als Helfer*in an der Bar, beim Essen kochen oder beim Auf- & Abbau."}
</p>
</div>
)}
</div>
)}
</div>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
/* Hallmark · component: TemplatesPage · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
import React, { useState, useEffect } from 'react';
import { ArrowLeft, Layers, Play, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react';
import { apiFetch } from '../api/client';
export default function TemplatesPage({ onBack, 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 () => {
setLoading(true);
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
});
} catch (err) {
setError(err.message || 'Erstellen der Veranstaltung aus Vorlage fehlgeschlagen.');
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6 max-w-4xl mx-auto animate-in fade-in duration-200 font-sans">
{/* Navigation Header */}
<div className="flex items-center justify-between font-mono text-xs">
<button
onClick={onBack}
className="px-3.5 py-1.5 rounded-sm bg-subtle hover:bg-surface-hover text-main border border-grid transition flex items-center gap-2 font-bold"
>
<ArrowLeft className="w-4 h-4" /> [ ZURÜCK ZUR ÜBERSICHT ]
</button>
<span className="text-muted border border-grid px-2.5 py-0.5 rounded-sm uppercase">
SYSTEM VORLAGEN ({templates.length})
</span>
</div>
{/* Page Title Panel */}
<div className="hallmark-panel rounded-sm p-6 sm:p-8 border border-grid space-y-2">
<div className="flex items-center gap-3">
<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 font-bold">
<Layers className="w-5 h-5" />
</div>
<div>
<h2 className="font-serif text-3xl font-bold uppercase tracking-tight text-main">
Veranstaltungs-Vorlagen Zentrale
</h2>
<p className="text-xs text-muted font-mono mt-0.5">
Erstelle neue Veranstaltungen im Handumdrehen aus vorgefertigten Struktur-Vorlagen
</p>
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-sm bg-red-500/10 border border-red-500/20 text-red-500 text-xs font-mono flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Templates Grid */}
<div className="space-y-4 font-mono text-xs">
<h3 className="font-serif text-lg font-bold uppercase text-main">Verfügbare Vorlagen</h3>
{loading ? (
<div className="hallmark-panel p-8 text-center text-muted">Lade Vorlagen...</div>
) : templates.length === 0 ? (
<div className="hallmark-panel p-8 text-center text-muted italic">
[ Noch keine Vorlagen gespeichert. Du kannst in der Admin-Zentrale Vorlagen anlegen. ]
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{templates.map((tpl) => (
<div
key={tpl.id}
onClick={() => handleSelectTemplate(tpl)}
className={`hallmark-panel p-5 rounded-sm border cursor-pointer transition space-y-2 ${
selectedTemplate?.id === tpl.id
? 'bg-indigo-500/10 border-indigo-500 shadow-md'
: 'bg-surface border-grid hover:border-muted'
}`}
>
<div className="flex items-center justify-between">
<h4 className="font-serif text-base font-bold text-main uppercase">{tpl.name}</h4>
{selectedTemplate?.id === tpl.id && (
<span className="text-[10px] font-mono text-indigo-400 bg-indigo-500/20 px-2 py-0.5 rounded-sm border border-indigo-500/30 font-bold">
[ AUSGEWÄHLT ]
</span>
)}
</div>
{tpl.description && <p className="text-xs font-sans text-muted">{tpl.description}</p>}
<div className="text-[10px] text-muted pt-2 border-t border-grid flex items-center justify-between">
<span>Erstellt von: <strong>{tpl.created_by_name || 'Admin'}</strong></span>
<span className="text-indigo-400 font-bold">Klick zum Auswählen</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Instantiation Form */}
{selectedTemplate && (
<form onSubmit={handleInstantiate} className="hallmark-panel rounded-sm p-6 border border-grid space-y-4 font-mono text-xs animate-in fade-in duration-200">
<h3 className="font-serif text-lg font-bold uppercase text-indigo-400 border-b border-grid pb-2">
Neue Veranstaltung aus Vorlage "{selectedTemplate.name}" Erstellen
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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)}
className="w-full px-3.5 py-2 rounded-sm input-field text-sm"
/>
</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.5 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.5 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. Großer Saal"
className="w-full px-3.5 py-2 rounded-sm input-field text-xs"
/>
</div>
</div>
<div className="pt-3 flex justify-end border-t border-grid">
<button
type="submit"
disabled={submitting}
style={{ backgroundColor: 'var(--brand-primary)' }}
className="px-6 py-2.5 rounded-sm text-xs font-mono font-bold text-white transition hover:brightness-110 shadow-sm flex items-center gap-2"
>
<Play className="w-4 h-4 fill-white" />
{submitting ? 'Erstellen...' : 'Veranstaltung Jetzt Aus Vorlage Erstellen'}
</button>
</div>
</form>
)}
</div>
);
}