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