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
+9 -4
View File
@@ -374,9 +374,14 @@ class EventTemplateViewSet(viewsets.ModelViewSet):
max_participants=shift_data.get('max_participants', 1)
)
skill_ids = shift_data.get('required_skill_ids', [])
skill_names = shift_data.get('required_skill_names', [])
if skill_names:
matching_skills = Skill.objects.filter(name__in=skill_names)
shift.required_skills.set(matching_skills)
if skill_ids:
shift.required_skills.set(Skill.objects.filter(id__in=skill_ids))
elif skill_names:
shift.required_skills.set(Skill.objects.filter(name__in=skill_names))
return Response(EventSerializer(event).data, status=status.HTTP_201_CREATED)
return Response({
'message': f'Veranstaltung "{event.title}" wurde erfolgreich aus der Vorlage erstellt.',
'event_id': event.id
}, status=status.HTTP_201_CREATED)
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,27 @@
# Generated by Django 6.0.7 on 2026-07-31 08:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0004_user_is_email_verified_alter_user_is_approved_and_more'),
]
operations = [
migrations.CreateModel(
name='SMTPSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_active', models.BooleanField(default=False, help_text='SMTP-Versand aktivieren')),
('host', models.CharField(default='smtp.example.com', max_length=255)),
('port', models.IntegerField(default=587)),
('username', models.CharField(blank=True, default='', max_length=255)),
('password', models.CharField(blank=True, default='', max_length=255)),
('use_tls', models.BooleanField(default=True)),
('use_ssl', models.BooleanField(default=False)),
('from_email', models.EmailField(default='noreply@schichtplaner.local', max_length=254)),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-07-31 08:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0005_smtpsetting'),
]
operations = [
migrations.AddField(
model_name='registrationrestrictionsetting',
name='require_email_verification',
field=models.BooleanField(default=True, help_text='E-Mail Bestätigungslink für neue Konten erforderlich'),
),
]
+20 -1
View File
@@ -39,6 +39,7 @@ class RegistrationDomainRule(models.Model):
class RegistrationRestrictionSetting(models.Model):
is_restriction_enabled = models.BooleanField(default=False)
require_admin_approval = models.BooleanField(default=False, help_text="Neue Registrierungen müssen von einem Admin freigeschaltet werden")
require_email_verification = models.BooleanField(default=True, help_text="E-Mail Bestätigungslink für neue Konten erforderlich")
@classmethod
def get_solo(cls):
@@ -46,7 +47,25 @@ class RegistrationRestrictionSetting(models.Model):
return obj
def __str__(self):
return f"Domain-Einschränkung: {self.is_restriction_enabled}, Admin-Freischaltung: {self.require_admin_approval}"
return f"Domain-Einschränkung: {self.is_restriction_enabled}, Admin-Freischaltung: {self.require_admin_approval}, E-Mail-Pflicht: {self.require_email_verification}"
class SMTPSetting(models.Model):
is_active = models.BooleanField(default=False, help_text="SMTP-Versand aktivieren")
host = models.CharField(max_length=255, default="smtp.example.com")
port = models.IntegerField(default=587)
username = models.CharField(max_length=255, blank=True, default="")
password = models.CharField(max_length=255, blank=True, default="")
use_tls = models.BooleanField(default=True)
use_ssl = models.BooleanField(default=False)
from_email = models.EmailField(default="noreply@schichtplaner.local")
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return f"SMTP {self.host}:{self.port} ({'Aktiv' if self.is_active else 'Inaktiv'})"
class EmailVerificationToken(models.Model):
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
+7 -2
View File
@@ -1,6 +1,6 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import RegistrationDomainRule, RegistrationRestrictionSetting
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, SMTPSetting
from apps.events.models import Skill
User = get_user_model()
@@ -77,4 +77,9 @@ class RegistrationDomainRuleSerializer(serializers.ModelSerializer):
class RegistrationRestrictionSettingSerializer(serializers.ModelSerializer):
class Meta:
model = RegistrationRestrictionSetting
fields = ['is_restriction_enabled', 'require_admin_approval']
fields = ['is_restriction_enabled', 'require_admin_approval', 'require_email_verification']
class SMTPSettingSerializer(serializers.ModelSerializer):
class Meta:
model = SMTPSetting
fields = ['is_active', 'host', 'port', 'username', 'password', 'use_tls', 'use_ssl', 'from_email']
+47 -1
View File
@@ -2,7 +2,7 @@ from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, EmailVerificationToken
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, EmailVerificationToken, SMTPSetting
from apps.events.models import Event, TaskArea, Shift, ShiftSignup
User = get_user_model()
@@ -74,6 +74,30 @@ class UserRegistrationTests(TestCase):
self.assertEqual(res_login_ok.status_code, status.HTTP_200_OK)
self.assertIn('token', res_login_ok.data)
def test_email_verification_opt_out(self):
# Set require_email_verification to False (Opt-Out)
setting = RegistrationRestrictionSetting.get_solo()
setting.require_email_verification = False
setting.save()
# Register user with Opt-Out
res_reg = self.client.post('/api/users/register/', {
'username': 'optout_user',
'email': 'optout@example.com',
'password': 'password123',
'display_name': 'Opt-Out User'
})
self.assertEqual(res_reg.status_code, status.HTTP_201_CREATED)
self.assertIn('token', res_reg.data)
self.assertFalse(res_reg.data.get('requires_verification'))
# Immediate login works
res_login = self.client.post('/api/users/login/', {
'username': 'optout_user',
'password': 'password123'
})
self.assertEqual(res_login.status_code, status.HTTP_200_OK)
def test_guest_shift_claiming_on_registration(self):
# Create event, task area, shift
event = Event.objects.create(title="Test Event", start_date="2026-08-01", end_date="2026-08-02")
@@ -169,3 +193,25 @@ class UserRegistrationTests(TestCase):
self.assertIsNotNone(signup.user)
self.assertEqual(signup.user.username, 'erika_muster')
self.assertIsNone(signup.guest_name)
def test_smtp_settings_configuration(self):
admin = User.objects.create_superuser(username='smtp_admin', email='smtp@test.com', password='password123')
self.client.force_authenticate(user=admin)
# GET SMTP setting
res_get = self.client.get('/api/users/smtp-setting/')
self.assertEqual(res_get.status_code, status.HTTP_200_OK)
self.assertFalse(res_get.data['is_active'])
# UPDATE SMTP setting
res_post = self.client.post('/api/users/smtp-setting/', {
'is_active': True,
'host': 'smtp.mailtrap.io',
'port': 2525,
'username': 'test_user',
'password': 'test_password',
'from_email': 'noreply@test.de'
}, format='json')
self.assertEqual(res_post.status_code, status.HTTP_200_OK)
self.assertTrue(res_post.data['is_active'])
self.assertEqual(res_post.data['host'], 'smtp.mailtrap.io')
+4 -1
View File
@@ -4,7 +4,8 @@ from .views import (
RegisterView, VerifyEmailView, LoginView, MeView,
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
PendingUsersView, ApproveUserView, AdminUserViewSet,
GenerateClaimLinkView, ClaimInfoView
GenerateClaimLinkView, ClaimInfoView,
SMTPSettingView, TestSMTPEmailView
)
router = DefaultRouter()
@@ -17,6 +18,8 @@ urlpatterns = [
path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'),
path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'),
path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'),
path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'),
path('pending/', PendingUsersView.as_view(), name='pending-users'),
path('<int:user_id>/approve/', ApproveUserView.as_view(), name='approve-user'),
path('signups/<int:signup_id>/generate-claim-link/', GenerateClaimLinkView.as_view(), name='generate-claim-link'),
+139 -6
View File
@@ -3,15 +3,67 @@ from rest_framework.response import Response
from rest_framework.authtoken.models import Token
from django.contrib.auth import authenticate, get_user_model
from django.shortcuts import get_object_or_404
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken, EmailVerificationToken
from django.core.mail import get_connection, EmailMultiAlternatives
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken, EmailVerificationToken, SMTPSetting
from .serializers import (
UserSerializer, AdminUserSerializer, RegisterSerializer,
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer, SMTPSettingSerializer
)
from apps.events.models import ShiftSignup
User = get_user_model()
def send_verification_email_to_user(user, v_token_str, request_host=None):
smtp = SMTPSetting.get_solo()
host_str = request_host or 'localhost:3000'
verify_url = f"http://{host_str}/?verify_email={v_token_str}"
subject = "E-Mail-Adresse bestätigen — Veranstaltungsschichtplaner"
text_content = f"Hallo {user.get_display_name()},\n\nbitte bestätige deine E-Mail-Adresse durch Aufruf des folgenden Links:\n{verify_url}\n\nVielen Dank!"
html_content = f"""
<div style="font-family: sans-serif; padding: 20px; line-height: 1.6; color: #1f2937;">
<h2 style="color: #4f46e5;">Willkommen beim Schichtplaner!</h2>
<p>Hallo <strong>{user.get_display_name()}</strong>,</p>
<p>vielen Dank für deine Registrierung. Bitte bestätige deine E-Mail-Adresse durch Klick auf den folgenden Button:</p>
<p style="margin: 25px 0;">
<a href="{verify_url}" style="background: #4f46e5; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">
E-Mail-Adresse Bestätigen
</a>
</p>
<p style="color: #6b7280; font-size: 12px;">Oder kopiere diesen Link in deinen Browser:<br><a href="{verify_url}">{verify_url}</a></p>
</div>
"""
print("==================================================")
print(f"[E-MAIL BESTÄTIGUNG]: {verify_url}")
print("==================================================")
if smtp.is_active and smtp.host:
try:
connection = get_connection(
host=smtp.host,
port=smtp.port,
username=smtp.username,
password=smtp.password,
use_tls=smtp.use_tls,
use_ssl=smtp.use_ssl,
fail_silently=False,
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=smtp.from_email or 'noreply@schichtplaner.local',
to=[user.email],
connection=connection
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return True, "E-Mail erfolgreich versendet."
except Exception as e:
return False, f"SMTP-Fehler: {str(e)}"
return True, "Bestätigungslink in Konsole ausgegeben."
class AdminUserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by('-date_joined')
serializer_class = AdminUserSerializer
@@ -70,10 +122,15 @@ class RegisterView(views.APIView):
signup.save()
assigned_count += 1
if setting.require_email_verification:
user.is_approved = False
user.is_email_verified = False
user.is_active = True
user.save()
v_token = EmailVerificationToken.objects.create(user=user)
print("==================================================")
print(f"[E-MAIL BESTÄTIGUNG]: http://localhost:3000/?verify_email={v_token.token}")
print("==================================================")
req_host = request.get_host()
send_verification_email_to_user(user, str(v_token.token), request_host=req_host)
return Response({
'requires_verification': True,
@@ -82,6 +139,32 @@ class RegisterView(views.APIView):
'message': 'Konto erfolgreich erstellt! Bitte bestätige deine E-Mail-Adresse über den Link in der Bestätigungs-E-Mail oder warte auf die Admin-Freischaltung.'
}, status=status.HTTP_201_CREATED)
# Opt-Out: Email verification not required!
user.is_email_verified = True
if setting.require_admin_approval:
user.is_approved = False
user.is_active = False
user.save()
return Response({
'requires_approval': True,
'claimed_shifts_count': assigned_count,
'message': 'Konto erfolgreich registriert! Ein Administrator muss dein Konto vor der ersten Anmeldung freischalten.'
}, status=status.HTTP_201_CREATED)
user.is_approved = True
user.is_active = True
user.save()
token, _ = Token.objects.get_or_create(user=user)
user_data = UserSerializer(user).data
return Response({
'token': token.key,
'user': user_data,
'claimed_shifts_count': assigned_count,
'message': f'Konto erfolgreich erstellt! {assigned_count} Gast-Schichten wurden deinem Konto zugewiesen.' if assigned_count > 0 else 'Konto erfolgreich erstellt!'
}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class VerifyEmailView(views.APIView):
@@ -218,6 +301,7 @@ class RegistrationRestrictionSettingView(views.APIView):
return Response({
'is_restriction_enabled': setting.is_restriction_enabled,
'require_admin_approval': setting.require_admin_approval,
'require_email_verification': setting.require_email_verification,
'active_domains': active_domains
})
@@ -227,12 +311,61 @@ class RegistrationRestrictionSettingView(views.APIView):
setting.is_restriction_enabled = request.data['is_restriction_enabled']
if 'require_admin_approval' in request.data:
setting.require_admin_approval = request.data['require_admin_approval']
if 'require_email_verification' in request.data:
setting.require_email_verification = request.data['require_email_verification']
setting.save()
return Response({
'is_restriction_enabled': setting.is_restriction_enabled,
'require_admin_approval': setting.require_admin_approval
'require_admin_approval': setting.require_admin_approval,
'require_email_verification': setting.require_email_verification
})
class SMTPSettingView(views.APIView):
permission_classes = [permissions.IsAdminUser]
def get(self, request):
setting = SMTPSetting.get_solo()
return Response(SMTPSettingSerializer(setting).data)
def post(self, request):
setting = SMTPSetting.get_solo()
serializer = SMTPSettingSerializer(setting, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class TestSMTPEmailView(views.APIView):
permission_classes = [permissions.IsAdminUser]
def post(self, request):
smtp = SMTPSetting.get_solo()
target_email = request.data.get('email') or request.user.email
if not target_email:
return Response({'error': 'E-Mail-Adresse erforderlich.'}, status=status.HTTP_400_BAD_REQUEST)
try:
connection = get_connection(
host=smtp.host,
port=smtp.port,
username=smtp.username,
password=smtp.password,
use_tls=smtp.use_tls,
use_ssl=smtp.use_ssl,
fail_silently=False,
)
msg = EmailMultiAlternatives(
subject="Test E-Mail — SMTP Konfiguration",
body=f"Hallo,\n\ndies ist eine Test-E-Mail zur Überprüfung der SMTP-Einstellungen für {request.user.get_display_name()}.",
from_email=smtp.from_email or 'noreply@schichtplaner.local',
to=[target_email],
connection=connection
)
msg.send()
return Response({'message': f'Test E-Mail erfolgreich an {target_email} gesendet!'})
except Exception as e:
return Response({'error': f'SMTP-Verbindungsfehler: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST)
class PendingUsersView(views.APIView):
permission_classes = [permissions.IsAdminUser]
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
+48 -1
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,6 +378,51 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
>
Abbrechen
</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}
@@ -387,6 +433,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
{submitting ? 'Speichern...' : eventToEdit ? 'Änderungen Speichern' : 'Veranstaltung Jetzt Erstellen'}
</button>
</div>
</div>
</form>
</div>
);
+291 -7
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,7 +155,8 @@ 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="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" />
@@ -84,10 +166,21 @@ export default function TemplatesPage({ onBack, onInstantiateTemplate }) {
Veranstaltungs-Vorlagen Zentrale
</h2>
<p className="text-xs text-muted font-mono mt-0.5">
Erstelle neue Veranstaltungen im Handumdrehen aus vorgefertigten Struktur-Vorlagen
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>
{error && (
@@ -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>