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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user