feat: Add event co-managers permissions and allow managers/admins to signup guests on skill-restricted shifts

This commit is contained in:
Richard
2026-07-31 11:06:47 +02:00
parent 821e14fcab
commit e2b311b634
23 changed files with 589 additions and 485 deletions
@@ -0,0 +1,20 @@
# Generated by Django 6.0.7 on 2026-07-31 09:05
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0004_shift_date'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='event',
name='co_managers',
field=models.ManyToManyField(blank=True, help_text='Weitere Verwalter dieser Veranstaltung', related_name='managed_events', to=settings.AUTH_USER_MODEL),
),
]
+10
View File
@@ -18,9 +18,19 @@ class Event(models.Model):
end_date = models.DateField()
is_active = models.BooleanField(default=True, help_text="Aktiv/Deaktiviert Status")
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='created_events')
co_managers = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, related_name='managed_events', help_text="Weitere Verwalter dieser Veranstaltung")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def is_user_manager_or_admin(self, user):
if not user or not user.is_authenticated:
return False
if user.is_admin_user or user.is_staff or user.is_superuser:
return True
if self.created_by_id == user.id:
return True
return self.co_managers.filter(id=user.id).exists()
def __str__(self):
return f"{self.title} ({'Aktiv' if self.is_active else 'Deaktiviert'})"
+9
View File
@@ -1,5 +1,9 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
from apps.users.serializers import UserSerializer
User = get_user_model()
class SkillSerializer(serializers.ModelSerializer):
class Meta:
@@ -65,12 +69,17 @@ class TaskAreaSerializer(serializers.ModelSerializer):
class EventSerializer(serializers.ModelSerializer):
task_areas = TaskAreaSerializer(many=True, read_only=True)
created_by_name = serializers.SerializerMethodField()
co_managers = UserSerializer(many=True, read_only=True)
co_manager_ids = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), many=True, write_only=True, required=False, source='co_managers'
)
class Meta:
model = Event
fields = [
'id', 'title', 'description', 'location',
'start_date', 'end_date', 'is_active', 'created_by', 'created_by_name',
'co_managers', 'co_manager_ids',
'task_areas', 'created_at', 'updated_at'
]
read_only_fields = ['created_by', 'created_at', 'updated_at']
+31
View File
@@ -111,3 +111,34 @@ class EventAndShiftTests(TestCase):
res_ok = self.client.delete(f'/api/shifts/{shift.id}/signup/', {'signup_id': signup.id}, format='json')
self.assertEqual(res_ok.status_code, status.HTTP_200_OK)
self.assertFalse(ShiftSignup.objects.filter(pk=signup.id).exists())
def test_co_manager_permissions_and_guest_skill_bypass(self):
owner = User.objects.create_user(username="owner_user", email="owner@test.de", password="password123")
co_manager = User.objects.create_user(username="comanager", email="comanager@test.de", password="password123")
event = Event.objects.create(title="CoManaged Event", start_date="2026-10-01", end_date="2026-10-02", created_by=owner)
event.co_managers.add(co_manager)
ta = TaskArea.objects.create(event=event, name="Einlass")
shift = Shift.objects.create(task_area=ta, title="Spätschicht", start_time="20:00", end_time="24:00", max_participants=5)
shift.required_skills.add(self.skill)
# Co-manager can edit event
self.client.force_authenticate(user=co_manager)
res_edit = self.client.patch(f'/api/events/{event.id}/', {'location': 'Haupteingang'})
self.assertEqual(res_edit.status_code, status.HTTP_200_OK)
self.assertEqual(res_edit.data['location'], 'Haupteingang')
# Co-manager CAN register a guest on skill-required shift (skill check bypassed for manager!)
res_guest = self.client.post(f'/api/shifts/{shift.id}/signup/', {'guest_name': 'VIP Gast'}, format='json')
self.assertEqual(res_guest.status_code, status.HTTP_201_CREATED)
self.assertIn('ohne Qualifikationsprüfung', res_guest.data['message'])
# Check signup exists
signup_id = res_guest.data['signup_id']
signup = ShiftSignup.objects.get(pk=signup_id)
self.assertEqual(signup.guest_name, 'VIP Gast')
# Co-manager can delete any signup
res_del = self.client.delete(f'/api/shifts/{shift.id}/signup/', {'signup_id': signup_id}, format='json')
self.assertEqual(res_del.status_code, status.HTTP_200_OK)
+129 -114
View File
@@ -29,31 +29,19 @@ class IsAdminOrEventCreator(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
if not request.user or not request.user.is_authenticated:
return False
if request.user.is_admin_user or request.user.is_staff or request.user.is_superuser:
return True
return obj.created_by == request.user
return obj.is_user_manager_or_admin(request.user)
class IsTaskAreaOwnerOrAdmin(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
if not request.user or not request.user.is_authenticated:
return False
if request.user.is_admin_user or request.user.is_staff or request.user.is_superuser:
return True
return obj.event.created_by == request.user
return obj.event.is_user_manager_or_admin(request.user)
class IsShiftOwnerOrAdmin(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
if not request.user or not request.user.is_authenticated:
return False
if request.user.is_admin_user or request.user.is_staff or request.user.is_superuser:
return True
return obj.task_area.event.created_by == request.user
return obj.task_area.event.is_user_manager_or_admin(request.user)
class EventViewSet(viewsets.ModelViewSet):
serializer_class = EventSerializer
@@ -64,7 +52,7 @@ class EventViewSet(viewsets.ModelViewSet):
if user.is_authenticated and (user.is_admin_user or user.is_staff or user.is_superuser):
return Event.objects.all().order_by('-start_date', '-id')
if user.is_authenticated:
return Event.objects.filter(Q(is_active=True) | Q(created_by=user)).order_by('-start_date', '-id')
return Event.objects.filter(Q(is_active=True) | Q(created_by=user) | Q(co_managers=user)).distinct().order_by('-start_date', '-id')
return Event.objects.filter(is_active=True).order_by('-start_date', '-id')
def perform_create(self, serializer):
@@ -73,110 +61,128 @@ class EventViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get'])
def matrix(self, request, pk=None):
event = self.get_object()
serializer = EventSerializer(event, context={'request': request})
return Response(serializer.data)
task_areas = event.task_areas.prefetch_related('shifts__required_skills', 'shifts__signups__user').all()
serializer = TaskAreaSerializer(task_areas, many=True, context={'request': request})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def save_as_template(self, request, pk=None):
event = self.get_object()
name = request.data.get('name', f"Vorlage von {event.title}")
description = request.data.get('description', '')
user_signups = []
if request.user.is_authenticated:
signups = ShiftSignup.objects.filter(shift__task_area__event=event, user=request.user)
user_signups = list(signups.values_list('shift_id', flat=True))
# Build template structure
template_task_areas = []
for ta in event.task_areas.all():
shifts_data = []
for shift in ta.shifts.all():
shifts_data.append({
'title': shift.title,
'start_time': shift.start_time,
'end_time': shift.end_time,
'max_participants': shift.max_participants,
'required_skill_names': list(shift.required_skills.values_list('name', flat=True))
})
template_task_areas.append({
'name': ta.name,
'description': ta.description,
'order': ta.order,
'shifts': shifts_data
})
template = EventTemplate.objects.create(
name=name,
description=description,
template_data={'task_areas': template_task_areas},
created_by=request.user
)
return Response(EventTemplateSerializer(template).data, status=status.HTTP_201_CREATED)
return Response({
'event': EventSerializer(event).data,
'task_areas': serializer.data,
'user_signups': user_signups
})
@action(detail=True, methods=['get'])
def export_pdf(self, request, pk=None):
def pdf(self, request, pk=None):
event = self.get_object()
task_areas = event.task_areas.prefetch_related('shifts__required_skills', 'shifts__signups__user').all()
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer, pagesize=landscape(letter),
rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30
buffer,
pagesize=landscape(letter),
rightMargin=30,
leftMargin=30,
topMargin=30,
bottomMargin=30
)
elements = []
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'DocTitle',
'EventTitle',
parent=styles['Heading1'],
fontSize=20,
leading=24,
textColor=colors.HexColor('#1e293b'),
fontSize=18,
leading=22,
textColor=colors.HexColor('#0f172a'),
spaceAfter=6
)
sub_style = ParagraphStyle(
'DocSub',
subtitle_style = ParagraphStyle(
'EventSubtitle',
parent=styles['Normal'],
fontSize=10,
leading=12,
textColor=colors.HexColor('#64748b'),
leading=14,
textColor=colors.HexColor('#475569'),
spaceAfter=15
)
cell_title_style = ParagraphStyle(
'CellTitle',
parent=styles['Normal'],
fontSize=9,
leading=11,
textColor=colors.HexColor('#0f172a'),
fontName='Helvetica-Bold'
)
cell_text_style = ParagraphStyle(
'CellText',
parent=styles['Normal'],
fontSize=8,
leading=10,
textColor=colors.HexColor('#334155')
)
elements.append(Paragraph(f"Schichtplan: {event.title}", title_style))
loc_str = f" • Ort: {event.location}" if event.location else ""
elements.append(Paragraph(f"Zeitraum: {event.start_date} bis {event.end_date}{loc_str}", sub_style))
# Build Table Data
table_data = []
headers = ["Bereich", "Schicht & Zeit", "Benötigte Qualifikationen", "Eingetragene Personen"]
table_data.append([Paragraph(f"<b>{h}</b>", styles['Normal']) for h in headers])
date_str = f"{event.start_date.strftime('%d.%m.%Y')} - {event.end_date.strftime('%d.%m.%Y')}"
loc_str = f" | Ort: {event.location}" if event.location else ""
elements.append(Paragraph(f"Datum: {date_str}{loc_str}", subtitle_style))
for ta in event.task_areas.all():
for shift in ta.shifts.all():
skills_list = ", ".join([s.name for s in shift.required_skills.all()]) or "Keine"
table_data = [[
Paragraph("Aufgabenbereich", cell_title_style),
Paragraph("Schicht", cell_title_style),
Paragraph("Zeitfenster", cell_title_style),
Paragraph("Erforderlich", cell_title_style),
Paragraph("Eingetragene Personen / Plätze", cell_title_style)
]]
signups = shift.signups.all()
signups_list = []
for su in signups:
if request.user.is_authenticated:
display = su.user.get_display_name() if su.user else (su.guest_name or "Gast")
else:
display = "Belegt (Anonym)"
signups_list.append(display)
for area in task_areas:
shifts = area.shifts.all()
if not shifts:
table_data.append([
Paragraph(area.name, cell_title_style),
Paragraph("", cell_text_style),
Paragraph("", cell_text_style),
Paragraph("", cell_text_style),
Paragraph("Keine Schichten definiert", cell_text_style)
])
else:
for idx, shift in enumerate(shifts):
area_name = area.name if idx == 0 else ""
skills_str = ", ".join([s.name for s in shift.required_skills.all()]) or "Keine"
while len(signups_list) < shift.max_participants:
signups_list.append("[ Offen ]")
signups = shift.signups.all()
signup_names = []
for su in signups:
if su.user:
signup_names.append(su.user.get_display_name())
elif su.guest_name:
signup_names.append(f"{su.guest_name} (Gast)")
signups_str = "<br/>".join(signups_list)
needed = shift.max_participants - len(signup_names)
if needed > 0:
signup_names.extend([f"<i>[ {needed} Platz frei ]</i>"] * 1)
row = [
Paragraph(f"<b>{ta.name}</b>", styles['Normal']),
Paragraph(f"{shift.title}<br/><font color='#64748b'>{shift.start_time} - {shift.end_time}</font>", styles['Normal']),
Paragraph(skills_list, styles['Normal']),
Paragraph(signups_str, styles['Normal'])
]
table_data.append(row)
participants_html = "<br/>".join(signup_names) or "<i>[ Frei ]</i>"
if len(table_data) == 1:
table_data.append([Paragraph("Keine Schichten angelegt", styles['Normal']), "", "", ""])
date_prefix = f"{shift.date.strftime('%d.%m.')} " if shift.date else ""
time_range = f"{date_prefix}{shift.start_time} - {shift.end_time} Uhr"
pdf_table = Table(table_data, colWidths=[150, 160, 160, 250])
table_data.append([
Paragraph(area_name, cell_title_style),
Paragraph(shift.title, cell_text_style),
Paragraph(time_range, cell_text_style),
Paragraph(skills_str, cell_text_style),
Paragraph(participants_html, cell_text_style)
])
col_widths = [130, 110, 110, 110, 240]
pdf_table = Table(table_data, colWidths=col_widths, repeatRows=1)
pdf_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1f5f9')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#0f172a')),
@@ -203,9 +209,7 @@ class TaskAreaViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
event = serializer.validated_data.get('event')
user = self.request.user
if not user.is_authenticated:
raise permissions.PermissionDenied("Nicht angemeldet.")
if not (user.is_admin_user or user.is_staff or user.is_superuser or (event and event.created_by == user)):
if not event or not event.is_user_manager_or_admin(user):
raise permissions.PermissionDenied("Keine Berechtigung, Bereiche für dieses Event hinzuzufügen.")
serializer.save()
@@ -217,9 +221,7 @@ class ShiftViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
task_area = serializer.validated_data.get('task_area')
user = self.request.user
if not user.is_authenticated:
raise permissions.PermissionDenied("Nicht angemeldet.")
if not (user.is_admin_user or user.is_staff or user.is_superuser or (task_area and task_area.event.created_by == user)):
if not task_area or not task_area.event.is_user_manager_or_admin(user):
raise permissions.PermissionDenied("Keine Berechtigung, Schichten für dieses Event hinzuzufügen.")
serializer.save()
@@ -228,19 +230,36 @@ class ShiftSignupView(views.APIView):
def post(self, request, shift_id):
shift = get_object_or_404(Shift, pk=shift_id)
event = shift.task_area.event
user = request.user if request.user.is_authenticated else None
is_manager = event.is_user_manager_or_admin(user)
# Check capacity
if shift.signups.count() >= shift.max_participants:
return Response({'error': 'Diese Schicht ist bereits vollständig belegt.'}, status=status.HTTP_400_BAD_REQUEST)
# Authenticated User flow
if request.user.is_authenticated:
if ShiftSignup.objects.filter(shift=shift, user=request.user).exists():
guest_name = request.data.get('guest_name', '').strip()
# Manager/Owner/Admin registering a guest manually -> Skill check bypassed!
if is_manager and guest_name:
signup = ShiftSignup.objects.create(
shift=shift,
guest_name=guest_name,
guest_session_key='added_by_manager'
)
return Response({
'message': f'Gast "{guest_name}" wurde von dir erfolgreich eingetragen (ohne Qualifikationsprüfung).',
'signup_id': signup.id
}, status=status.HTTP_201_CREATED)
# Authenticated User flow (self-signup)
if user and not guest_name:
if ShiftSignup.objects.filter(shift=shift, user=user).exists():
return Response({'error': 'Du bist für diese Schicht bereits eingetragen.'}, status=status.HTTP_400_BAD_REQUEST)
required_skills = shift.required_skills.all()
if required_skills.exists():
user_skills = request.user.skills.all()
user_skills = user.skills.all()
missing_skills = [s.name for s in required_skills if s not in user_skills]
if missing_skills:
missing_str = ", ".join(missing_skills)
@@ -248,21 +267,20 @@ class ShiftSignupView(views.APIView):
'error': f'Dir fehlen folgende erforderliche Fähigkeiten für diese Schicht: {missing_str}'
}, status=status.HTTP_400_BAD_REQUEST)
signup = ShiftSignup.objects.create(shift=shift, user=request.user)
signup = ShiftSignup.objects.create(shift=shift, user=user)
return Response({'message': 'Erfolgreich für Schicht eingetragen.', 'signup_id': signup.id}, status=status.HTTP_201_CREATED)
# Guest flow
# Guest flow for visitors
else:
guest_name = request.data.get('guest_name', '').strip()
captcha_token = request.data.get('captcha_token', '').strip()
if not guest_name:
return Response({'error': 'Bitte gib einen Namen an.'}, status=status.HTTP_400_BAD_REQUEST)
if not captcha_token:
captcha_token = request.data.get('captcha_token', '').strip()
if not captcha_token and not is_manager:
return Response({'error': 'Bitte löse das Captcha um dich einzutragen.'}, status=status.HTTP_400_BAD_REQUEST)
if shift.required_skills.exists():
# If NOT a manager, enforce required skills check for guest signups
if not is_manager and shift.required_skills.exists():
required_names = ", ".join([s.name for s in shift.required_skills.all()])
return Response({
'error': f'Gäste können keine Fähigkeiten haben. Diese Schicht erfordert: {required_names}. Bitte registriere ein Konto mit den passenden Fähigkeiten.'
@@ -282,7 +300,7 @@ class ShiftSignupView(views.APIView):
guest_session_key=session_key
)
return Response({
'message': 'Als Gast erfolgreich eingetragen! Dein Name wurde in deiner Sitzung gespeichert.',
'message': f'Gast "{guest_name}" erfolgreich eingetragen!',
'signup_id': signup.id
}, status=status.HTTP_201_CREATED)
@@ -300,7 +318,7 @@ class ShiftSignupView(views.APIView):
if request.user.is_authenticated:
user = request.user
is_manager = user.is_admin_user or user.is_staff or user.is_superuser or (shift.task_area.event.created_by == user)
is_manager = shift.task_area.event.is_user_manager_or_admin(user)
if signup_id and is_manager:
signup = ShiftSignup.objects.filter(shift=shift, pk=signup_id).first()
@@ -344,9 +362,6 @@ class EventTemplateViewSet(viewsets.ModelViewSet):
end_date = request.data.get('end_date')
location = request.data.get('location', '')
if not start_date or not end_date:
return Response({'error': 'start_date und end_date sind erforderlich.'}, status=status.HTTP_400_BAD_REQUEST)
event = Event.objects.create(
title=title,
description=template.description,
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -5,7 +5,7 @@ from .views import (
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
PendingUsersView, ApproveUserView, AdminUserViewSet,
GenerateClaimLinkView, ClaimInfoView,
SMTPSettingView, TestSMTPEmailView
SMTPSettingView, TestSMTPEmailView, UserListView
)
router = DefaultRouter()
@@ -17,6 +17,7 @@ urlpatterns = [
path('verify-email/', VerifyEmailView.as_view(), name='verify-email'),
path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'),
path('all/', UserListView.as_view(), name='all-users'),
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'),
+7
View File
@@ -388,3 +388,10 @@ class ApproveUserView(views.APIView):
name = user.get_display_name()
user.delete()
return Response({'message': f'Registrierung von {name} wurde abgelehnt und gelöscht.'})
class UserListView(views.APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
users = User.objects.filter(is_active=True).order_by('username')
return Response(UserSerializer(users, many=True).data)
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-BTg0Pu2I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DyS9UMYS.css">
<script type="module" crossorigin src="/assets/index-KtQZvLw-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGy_Z7Jy.css">
</head>
<body class="bg-paper text-main font-sans antialiased min-h-screen">
<div id="root"></div>
+4 -2
View File
@@ -278,7 +278,8 @@ export default function App() {
description: eventPayload.description,
location: eventPayload.location,
start_date: eventPayload.start_date,
end_date: eventPayload.end_date
end_date: eventPayload.end_date,
co_manager_ids: eventPayload.co_manager_ids || []
})
});
} else {
@@ -290,7 +291,8 @@ export default function App() {
description: eventPayload.description,
location: eventPayload.location,
start_date: eventPayload.start_date,
end_date: eventPayload.end_date
end_date: eventPayload.end_date,
co_manager_ids: eventPayload.co_manager_ids || []
})
});
targetEventId = created.id;
+3 -2
View File
@@ -20,7 +20,7 @@ export default function ShiftMatrixTable({
<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 && (
{user && (user.is_admin_user || user.is_staff || user.is_superuser || event?.created_by === user.id || event?.created_by?.id === user.id || (event?.co_managers && event.co_managers.some(cm => (typeof cm === 'object' ? cm.id === user.id : cm === 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"
@@ -33,7 +33,8 @@ export default function ShiftMatrixTable({
}
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);
const isCoManager = user && event?.co_managers && event.co_managers.some(cm => (typeof cm === 'object' ? cm.id === user.id : cm === user.id));
const isManager = user && (user.is_admin_user || user.is_staff || user.is_superuser || event?.created_by === user.id || event?.created_by?.id === user.id || isCoManager);
return (
<div className="space-y-6">
+97 -90
View File
@@ -1,22 +1,30 @@
/* 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';
import { ArrowLeft, Calendar, Layers, Plus, Trash2, Edit3, CheckCircle2, AlertCircle, Users, UserCheck } from 'lucide-react';
import { apiFetch } from '../api/client';
export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit }) {
export default function EventEditorPage({ eventToEdit, skills, user, 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]);
// Co-managers state
const [coManagerIds, setCoManagerIds] = useState(
eventToEdit?.co_managers ? eventToEdit.co_managers.map(u => u.id) : []
);
const [allUsers, setAllUsers] = useState([]);
// Task areas state
const [taskAreas, setTaskAreas] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetchUsers();
if (eventToEdit && eventToEdit.task_areas && eventToEdit.task_areas.length > 0) {
const formattedAreas = eventToEdit.task_areas.map(ta => ({
id: ta.id,
@@ -54,6 +62,13 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
}
}, [eventToEdit]);
const fetchUsers = async () => {
try {
const data = await apiFetch('/users/all/');
setAllUsers(data.results || data);
} catch (e) {}
};
const addTaskArea = () => {
setTaskAreas([
...taskAreas,
@@ -100,13 +115,13 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
setTaskAreas(updated);
};
const toggleShiftSkill = (areaIndex, shiftIndex, skillId) => {
const toggleSkillRequirement = (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);
const current = updated[areaIndex].shifts[shiftIndex].required_skill_ids || [];
if (current.includes(skillId)) {
updated[areaIndex].shifts[shiftIndex].required_skill_ids = current.filter(id => id !== skillId);
} else {
updated[areaIndex].shifts[shiftIndex].required_skill_ids = [...currentSkills, skillId];
updated[areaIndex].shifts[shiftIndex].required_skill_ids = [...current, skillId];
}
setTaskAreas(updated);
};
@@ -129,6 +144,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
location,
start_date: startDate,
end_date: endDate,
co_manager_ids: coManagerIds,
task_areas: taskAreas
});
} catch (err) {
@@ -165,7 +181,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
{eventToEdit ? 'Veranstaltung & Schichten Bearbeiten' : 'Neue Veranstaltung Erstellen'}
</h2>
<p className="text-xs text-muted font-mono mt-0.5">
Konfiguriere Stammdaten, Aufgabenfelder, Schichtzeiten & Qualifikationen
Konfiguriere Stammdaten, Co-Verwalter, Aufgabenfelder, Schichtzeiten & Qualifikationen
</p>
</div>
</div>
@@ -183,7 +199,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
{/* 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
1. Stammdaten & Verwalter der Veranstaltung
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -242,6 +258,44 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
className="w-full px-3.5 py-2 rounded-sm input-field text-xs font-sans"
/>
</div>
{/* Co-Managers Selection */}
{allUsers.length > 0 && (
<div className="sm:col-span-2 pt-3 border-t border-grid space-y-2">
<label className="block text-main font-bold flex items-center gap-1.5">
<Users className="w-4 h-4 text-indigo-400" /> Co-Verwalter / Mit-Organisatoren (optional)
</label>
<p className="text-[11px] text-muted font-sans">
Wähle registrierte Benutzer aus, die dieses Event ebenfalls verwalten, bearbeiten und Gäste eintragen dürfen:
</p>
<div className="flex flex-wrap gap-2 pt-1">
{allUsers.map((u) => {
const isSelected = coManagerIds.includes(u.id);
return (
<button
key={u.id}
type="button"
onClick={() => {
if (isSelected) {
setCoManagerIds(coManagerIds.filter(id => id !== u.id));
} else {
setCoManagerIds([...coManagerIds, u.id]);
}
}}
className={`px-3 py-1.5 rounded-sm text-xs font-mono font-bold transition flex items-center gap-1.5 border ${
isSelected
? 'bg-indigo-500/20 text-indigo-300 border-indigo-500/50 shadow-sm'
: 'bg-subtle text-muted border-grid hover:text-main'
}`}
>
<UserCheck className={`w-3.5 h-3.5 ${isSelected ? 'text-indigo-400' : 'opacity-40'}`} />
<span>{u.display_name || u.username}</span>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
@@ -347,30 +401,29 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
</button>
</div>
{/* Required Skills selection */}
{/* Skill requirements selector */}
{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 className="pt-2 border-t border-grid flex flex-wrap items-center gap-1.5">
<span className="text-[10px] text-muted font-bold mr-1">Erforderlich:</span>
{skills.map(sk => {
const isReq = (shift.required_skill_ids || []).includes(sk.id);
return (
<button
key={sk.id}
type="button"
onClick={() => toggleSkillRequirement(aIdx, sIdx, sk.id)}
style={{
backgroundColor: isReq ? `${sk.color}25` : 'transparent',
borderColor: isReq ? sk.color : 'var(--color-border-grid)',
color: isReq ? sk.color : 'var(--color-text-muted)'
}}
className="px-2 py-0.5 rounded-sm text-[10px] border font-bold transition flex items-center gap-1"
>
<span>{sk.name}</span>
{isReq && <CheckCircle2 className="w-3 h-3" />}
</button>
);
})}
</div>
)}
</div>
@@ -380,70 +433,24 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
))}
</div>
{/* Action Controls */}
<div className="hallmark-panel rounded-sm p-4 border border-grid flex flex-wrap items-center justify-between gap-3 font-mono text-xs">
{/* Submit Actions */}
<div className="flex items-center justify-end gap-3 pt-2 font-mono">
<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"
className="px-5 py-2.5 rounded-sm text-xs font-semibold text-muted hover:bg-surface-hover transition border border-grid"
>
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}
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>
<button
type="submit"
disabled={submitting}
style={{ backgroundColor: 'var(--brand-primary)' }}
className="px-6 py-2.5 rounded-sm text-xs font-bold text-white transition hover:brightness-110 shadow-sm flex items-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
{submitting ? 'Speichern...' : 'Veranstaltung Speichern'}
</button>
</div>
</form>
</div>
+2 -1
View File
@@ -136,7 +136,8 @@ export default function HomePage({
{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 isCoManager = user && evt.co_managers && evt.co_managers.some(cm => (typeof cm === 'object' ? cm.id === user.id : cm === user.id));
const isCreator = user && (evt.created_by === user.id || evt.created_by?.id === user.id || isCoManager);
const canManage = isUserAdmin || isCreator;
return (