feat: Add preset shift templates, admin SMTP settings, test email dispatch, and email verification opt-out option

This commit is contained in:
Richard
2026-07-31 10:26:55 +02:00
parent 6ffcc58a4c
commit 7c377937ea
26 changed files with 1434 additions and 642 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<title>Schichtplaner — Veranstaltungsschichtpläne</title>
<script type="module" crossorigin src="/assets/index-BHd9ZwUB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-VOnznzhX.css">
<script type="module" crossorigin src="/assets/index-COhsF1I-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DyS9UMYS.css">
</head>
<body class="bg-paper text-main font-sans antialiased min-h-screen">
<div id="root"></div>
+2
View File
@@ -469,6 +469,8 @@ export default function App() {
<TemplatesPage
onBack={() => setActiveTab('schedule')}
onInstantiateTemplate={handleInstantiateTemplate}
user={user}
skills={skills}
/>
) : (
<div className="space-y-6">
File diff suppressed because it is too large Load Diff
+57 -10
View File
@@ -2,6 +2,7 @@
/* 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';
import { apiFetch } from '../api/client';
export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit }) {
const [title, setTitle] = useState(eventToEdit?.title || '');
@@ -369,7 +370,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
</div>
{/* Action Controls */}
<div className="hallmark-panel rounded-sm p-4 border border-grid flex items-center justify-between font-mono text-xs">
<div className="hallmark-panel rounded-sm p-4 border border-grid flex flex-wrap items-center justify-between gap-3 font-mono text-xs">
<button
type="button"
onClick={onBack}
@@ -377,15 +378,61 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
>
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 className="flex items-center gap-2">
<button
type="button"
disabled={submitting}
onClick={async () => {
if (!title.trim()) {
setError('Bitte gib einen Titel an.');
return;
}
setSubmitting(true);
try {
await apiFetch('/templates/', {
method: 'POST',
body: JSON.stringify({
name: title,
description: description || `Vorlage mit ${taskAreas.length} Aufgabenbereichen`,
template_data: {
task_areas: taskAreas.map(ta => ({
name: ta.name,
description: ta.description,
shifts: (ta.shifts || []).map(s => ({
title: s.title,
start_time: s.start_time,
end_time: s.end_time,
max_participants: s.max_participants,
required_skill_ids: s.required_skill_ids
}))
}))
}
})
});
alert(`✅ Vorlage "${title}" inklusive aller voreingestellten Schichten erfolgreich gespeichert!`);
} catch (err) {
setError(err.message || 'Speichern der Vorlage fehlgeschlagen.');
} finally {
setSubmitting(false);
}
}}
className="px-4 py-2.5 rounded-sm text-xs 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.5"
title="Diese Konfiguration mit allen Schichten als Vorlage abspeichern"
>
<Layers className="w-4 h-4" /> ALS VORLAGE SPEICHERN
</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>
</div>
</form>
</div>
+301 -17
View File
@@ -1,10 +1,10 @@
/* 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 { ArrowLeft, Layers, Play, CheckCircle2, AlertCircle, Plus, Trash2, Clock, Users, Award } from 'lucide-react';
import { apiFetch } from '../api/client';
export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
export default function TemplatesPage({ onBack, onInstantiateTemplate, user, skills }) {
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -17,6 +17,21 @@ export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
const [location, setLocation] = useState('');
const [submitting, setSubmitting] = useState(false);
// State for creating a new template with preset shifts
const [showCreateTemplate, setShowCreateTemplate] = useState(false);
const [newTplName, setNewTplName] = useState('');
const [newTplDesc, setNewTplDesc] = useState('');
const [newTplTaskAreas, setNewTplTaskAreas] = useState([
{
name: 'Tresendienst',
description: 'Getränkeausschank und Bar',
shifts: [
{ title: 'Frühschicht', start_time: '18:00', end_time: '22:00', max_participants: 2, required_skill_ids: [] },
{ title: 'Spätschicht', start_time: '22:00', end_time: '02:00', max_participants: 3, required_skill_ids: [] }
]
}
]);
useEffect(() => {
fetchTemplates();
}, []);
@@ -57,6 +72,72 @@ export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
}
};
const handleCreateTemplate = async (e) => {
e.preventDefault();
if (!newTplName.trim()) {
setError('Bitte gib einen Vorlagen-Namen an.');
return;
}
setSubmitting(true);
try {
const payload = {
name: newTplName,
description: newTplDesc,
template_data: {
task_areas: newTplTaskAreas.map(ta => ({
name: ta.name,
description: ta.description,
shifts: (ta.shifts || []).map(s => ({
title: s.title,
start_time: s.start_time,
end_time: s.end_time,
max_participants: s.max_participants,
required_skill_ids: s.required_skill_ids
}))
}))
}
};
await apiFetch('/templates/', {
method: 'POST',
body: JSON.stringify(payload)
});
setShowCreateTemplate(false);
setNewTplName('');
setNewTplDesc('');
fetchTemplates();
} catch (err) {
setError(err.message || 'Erstellen der Vorlage fehlgeschlagen.');
} finally {
setSubmitting(false);
}
};
const addTplTaskArea = () => {
setNewTplTaskAreas([
...newTplTaskAreas,
{
name: 'Neuer Bereich',
description: '',
shifts: [{ title: 'Schicht 1', start_time: '14:00', end_time: '18:00', max_participants: 2, required_skill_ids: [] }]
}
]);
};
const addTplShift = (taIdx) => {
const updated = [...newTplTaskAreas];
updated[taIdx].shifts.push({
title: `Schicht ${updated[taIdx].shifts.length + 1}`,
start_time: '18:00',
end_time: '22:00',
max_participants: 2,
required_skill_ids: []
});
setNewTplTaskAreas(updated);
};
return (
<div className="space-y-6 max-w-4xl mx-auto animate-in fade-in duration-200 font-sans">
{/* Navigation Header */}
@@ -74,19 +155,31 @@ export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
</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 className="hallmark-panel rounded-sm p-6 sm:p-8 border border-grid space-y-4">
<div className="flex items-center justify-between gap-3">
<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 mit voreingestellten Schichten und Aufgabenbereichen
</p>
</div>
</div>
{user && (
<button
onClick={() => setShowCreateTemplate(!showCreateTemplate)}
className="px-3.5 py-2 rounded-sm text-xs 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.5 shrink-0"
>
<Plus className="w-4 h-4" />
{showCreateTemplate ? 'Schließen' : 'Neue Vorlage Erstellen'}
</button>
)}
</div>
</div>
@@ -97,6 +190,171 @@ export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
</div>
)}
{/* Creator Form for new Template with Preset Shifts */}
{showCreateTemplate && (
<form onSubmit={handleCreateTemplate} 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 flex items-center justify-between">
<span>Neue Vorlage mit voreingestellten Schichten anlegen</span>
<button
type="button"
onClick={addTplTaskArea}
className="text-xs font-mono font-bold bg-indigo-500/20 text-indigo-300 px-3 py-1 rounded-sm border border-indigo-500/30 flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" /> Bereich Hinzufügen
</button>
</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">Vorlagen Name</label>
<input
type="text"
required
placeholder="z. B. Standard Kneipenabend / Bar-Event"
value={newTplName}
onChange={(e) => setNewTplName(e.target.value)}
className="w-full px-3.5 py-2 rounded-sm input-field text-sm font-bold"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-main mb-1 font-bold">Beschreibung (optional)</label>
<input
type="text"
placeholder="z. B. Inklusive 2 Barschichten und Aufbau"
value={newTplDesc}
onChange={(e) => setNewTplDesc(e.target.value)}
className="w-full px-3.5 py-2 rounded-sm input-field text-xs"
/>
</div>
</div>
{/* Task Areas & Preset Shifts Builder */}
<div className="space-y-4 pt-2">
{newTplTaskAreas.map((ta, taIdx) => (
<div key={taIdx} className="p-4 rounded-sm bg-subtle border border-grid space-y-3">
<div className="flex items-center justify-between gap-2">
<input
type="text"
required
placeholder="Bereichs-Name (z. B. Bar)"
value={ta.name}
onChange={(e) => {
const updated = [...newTplTaskAreas];
updated[taIdx].name = e.target.value;
setNewTplTaskAreas(updated);
}}
className="flex-1 px-3 py-1 rounded-sm input-field text-xs font-bold"
/>
<button
type="button"
onClick={() => {
setNewTplTaskAreas(newTplTaskAreas.filter((_, i) => i !== taIdx));
}}
className="text-muted hover:text-red-400 p-1"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="pl-3 border-l-2 border-grid space-y-2">
<div className="flex items-center justify-between text-muted text-[11px] font-bold">
<span>Voreingestellte Schichten</span>
<button
type="button"
onClick={() => addTplShift(taIdx)}
className="text-indigo-400 hover:underline flex items-center gap-1"
>
<Plus className="w-3 h-3" /> Schicht hinzufügen
</button>
</div>
{ta.shifts.map((sh, sIdx) => (
<div key={sIdx} className="p-2.5 rounded-sm bg-surface border border-grid grid grid-cols-1 sm:grid-cols-12 gap-2 items-center text-xs">
<input
type="text"
placeholder="Schichtname"
value={sh.title}
onChange={(e) => {
const updated = [...newTplTaskAreas];
updated[taIdx].shifts[sIdx].title = e.target.value;
setNewTplTaskAreas(updated);
}}
className="sm:col-span-4 px-2 py-1 rounded-sm input-field text-xs"
/>
<input
type="text"
placeholder="Start (18:00)"
value={sh.start_time}
onChange={(e) => {
const updated = [...newTplTaskAreas];
updated[taIdx].shifts[sIdx].start_time = e.target.value;
setNewTplTaskAreas(updated);
}}
className="sm:col-span-2 px-2 py-1 rounded-sm input-field text-xs"
/>
<input
type="text"
placeholder="Ende (22:00)"
value={sh.end_time}
onChange={(e) => {
const updated = [...newTplTaskAreas];
updated[taIdx].shifts[sIdx].end_time = e.target.value;
setNewTplTaskAreas(updated);
}}
className="sm:col-span-2 px-2 py-1 rounded-sm input-field text-xs"
/>
<div className="sm:col-span-3 flex items-center gap-1">
<span className="text-[10px] text-muted">Plätze:</span>
<input
type="number"
min="1"
value={sh.max_participants}
onChange={(e) => {
const updated = [...newTplTaskAreas];
updated[taIdx].shifts[sIdx].max_participants = parseInt(e.target.value) || 1;
setNewTplTaskAreas(updated);
}}
className="w-full px-2 py-1 rounded-sm input-field text-xs font-bold"
/>
</div>
<button
type="button"
onClick={() => {
const updated = [...newTplTaskAreas];
updated[taIdx].shifts = updated[taIdx].shifts.filter((_, i) => i !== sIdx);
setNewTplTaskAreas(updated);
}}
className="sm:col-span-1 p-1 text-muted hover:text-red-400 text-center"
>
<Trash2 className="w-3.5 h-3.5 mx-auto" />
</button>
</div>
))}
</div>
</div>
))}
</div>
<div className="pt-3 flex justify-end gap-2 border-t border-grid">
<button
type="button"
onClick={() => setShowCreateTemplate(false)}
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-mono font-bold text-white transition hover:brightness-110 shadow-sm flex items-center gap-1.5"
>
<CheckCircle2 className="w-4 h-4" /> Vorlage Speichern
</button>
</div>
</form>
)}
{/* 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>
@@ -105,7 +363,7 @@ export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
<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. ]
[ Noch keine Vorlagen gespeichert. Klicke oben auf "Neue Vorlage Erstellen" um Vorlagen mit voreingestellten Schichten anzulegen. ]
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -113,7 +371,7 @@ export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
<div
key={tpl.id}
onClick={() => handleSelectTemplate(tpl)}
className={`hallmark-panel p-5 rounded-sm border cursor-pointer transition space-y-2 ${
className={`hallmark-panel p-5 rounded-sm border cursor-pointer transition space-y-3 ${
selectedTemplate?.id === tpl.id
? 'bg-indigo-500/10 border-indigo-500 shadow-md'
: 'bg-surface border-grid hover:border-muted'
@@ -127,7 +385,33 @@ export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
</span>
)}
</div>
{tpl.description && <p className="text-xs font-sans text-muted">{tpl.description}</p>}
{/* Render Preset Task Areas & Preset Shifts */}
{tpl.template_data?.task_areas && tpl.template_data.task_areas.length > 0 && (
<div className="space-y-2 pt-2 border-t border-grid text-[11px]">
<div className="font-bold text-muted uppercase text-[10px]">📌 Voreingestellte Schichten:</div>
{tpl.template_data.task_areas.map((ta, idx) => (
<div key={idx} className="bg-subtle p-2 rounded-sm border border-grid space-y-1">
<div className="font-bold text-main flex items-center justify-between">
<span>{ta.name}</span>
<span className="text-[10px] text-muted font-normal">{ta.shifts?.length || 0} Schichten</span>
</div>
<div className="flex flex-wrap gap-1">
{(ta.shifts || []).map((sh, sIdx) => (
<span key={sIdx} className="px-2 py-0.5 rounded-sm bg-surface border border-grid text-[10px] flex items-center gap-1 font-mono">
<Clock className="w-3 h-3 text-muted shrink-0" />
<strong className="text-main">{sh.title}</strong>
<span className="text-muted">({sh.start_time}-{sh.end_time}, {sh.max_participants} Plätze)</span>
</span>
))}
</div>
</div>
))}
</div>
)}
<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>
@@ -153,7 +437,7 @@ export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
required
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3.5 py-2 rounded-sm input-field text-sm"
className="w-full px-3.5 py-2 rounded-sm input-field text-sm font-bold"
/>
</div>