feat: Add event co-managers permissions and allow managers/admins to signup guests on skill-restricted shifts
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
@@ -18,9 +18,19 @@ class Event(models.Model):
|
|||||||
end_date = models.DateField()
|
end_date = models.DateField()
|
||||||
is_active = models.BooleanField(default=True, help_text="Aktiv/Deaktiviert Status")
|
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')
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=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):
|
def __str__(self):
|
||||||
return f"{self.title} ({'Aktiv' if self.is_active else 'Deaktiviert'})"
|
return f"{self.title} ({'Aktiv' if self.is_active else 'Deaktiviert'})"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
|
from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
|
||||||
|
from apps.users.serializers import UserSerializer
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
class SkillSerializer(serializers.ModelSerializer):
|
class SkillSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -65,12 +69,17 @@ class TaskAreaSerializer(serializers.ModelSerializer):
|
|||||||
class EventSerializer(serializers.ModelSerializer):
|
class EventSerializer(serializers.ModelSerializer):
|
||||||
task_areas = TaskAreaSerializer(many=True, read_only=True)
|
task_areas = TaskAreaSerializer(many=True, read_only=True)
|
||||||
created_by_name = serializers.SerializerMethodField()
|
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:
|
class Meta:
|
||||||
model = Event
|
model = Event
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'title', 'description', 'location',
|
'id', 'title', 'description', 'location',
|
||||||
'start_date', 'end_date', 'is_active', 'created_by', 'created_by_name',
|
'start_date', 'end_date', 'is_active', 'created_by', 'created_by_name',
|
||||||
|
'co_managers', 'co_manager_ids',
|
||||||
'task_areas', 'created_at', 'updated_at'
|
'task_areas', 'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['created_by', 'created_at', 'updated_at']
|
read_only_fields = ['created_by', 'created_at', 'updated_at']
|
||||||
|
|||||||
@@ -111,3 +111,34 @@ class EventAndShiftTests(TestCase):
|
|||||||
res_ok = self.client.delete(f'/api/shifts/{shift.id}/signup/', {'signup_id': signup.id}, format='json')
|
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.assertEqual(res_ok.status_code, status.HTTP_200_OK)
|
||||||
self.assertFalse(ShiftSignup.objects.filter(pk=signup.id).exists())
|
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
@@ -29,31 +29,19 @@ class IsAdminOrEventCreator(permissions.BasePermission):
|
|||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
if request.method in permissions.SAFE_METHODS:
|
if request.method in permissions.SAFE_METHODS:
|
||||||
return True
|
return True
|
||||||
if not request.user or not request.user.is_authenticated:
|
return obj.is_user_manager_or_admin(request.user)
|
||||||
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
|
|
||||||
|
|
||||||
class IsTaskAreaOwnerOrAdmin(permissions.BasePermission):
|
class IsTaskAreaOwnerOrAdmin(permissions.BasePermission):
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
if request.method in permissions.SAFE_METHODS:
|
if request.method in permissions.SAFE_METHODS:
|
||||||
return True
|
return True
|
||||||
if not request.user or not request.user.is_authenticated:
|
return obj.event.is_user_manager_or_admin(request.user)
|
||||||
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
|
|
||||||
|
|
||||||
class IsShiftOwnerOrAdmin(permissions.BasePermission):
|
class IsShiftOwnerOrAdmin(permissions.BasePermission):
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
if request.method in permissions.SAFE_METHODS:
|
if request.method in permissions.SAFE_METHODS:
|
||||||
return True
|
return True
|
||||||
if not request.user or not request.user.is_authenticated:
|
return obj.task_area.event.is_user_manager_or_admin(request.user)
|
||||||
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
|
|
||||||
|
|
||||||
class EventViewSet(viewsets.ModelViewSet):
|
class EventViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = EventSerializer
|
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):
|
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')
|
return Event.objects.all().order_by('-start_date', '-id')
|
||||||
if user.is_authenticated:
|
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')
|
return Event.objects.filter(is_active=True).order_by('-start_date', '-id')
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
@@ -73,110 +61,128 @@ class EventViewSet(viewsets.ModelViewSet):
|
|||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
def matrix(self, request, pk=None):
|
def matrix(self, request, pk=None):
|
||||||
event = self.get_object()
|
event = self.get_object()
|
||||||
serializer = EventSerializer(event, context={'request': request})
|
task_areas = event.task_areas.prefetch_related('shifts__required_skills', 'shifts__signups__user').all()
|
||||||
return Response(serializer.data)
|
serializer = TaskAreaSerializer(task_areas, many=True, context={'request': request})
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
user_signups = []
|
||||||
def save_as_template(self, request, pk=None):
|
if request.user.is_authenticated:
|
||||||
event = self.get_object()
|
signups = ShiftSignup.objects.filter(shift__task_area__event=event, user=request.user)
|
||||||
name = request.data.get('name', f"Vorlage von {event.title}")
|
user_signups = list(signups.values_list('shift_id', flat=True))
|
||||||
description = request.data.get('description', '')
|
|
||||||
|
|
||||||
# Build template structure
|
return Response({
|
||||||
template_task_areas = []
|
'event': EventSerializer(event).data,
|
||||||
for ta in event.task_areas.all():
|
'task_areas': serializer.data,
|
||||||
shifts_data = []
|
'user_signups': user_signups
|
||||||
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)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
def export_pdf(self, request, pk=None):
|
def pdf(self, request, pk=None):
|
||||||
event = self.get_object()
|
event = self.get_object()
|
||||||
|
task_areas = event.task_areas.prefetch_related('shifts__required_skills', 'shifts__signups__user').all()
|
||||||
|
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
doc = SimpleDocTemplate(
|
doc = SimpleDocTemplate(
|
||||||
buffer, pagesize=landscape(letter),
|
buffer,
|
||||||
rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30
|
pagesize=landscape(letter),
|
||||||
|
rightMargin=30,
|
||||||
|
leftMargin=30,
|
||||||
|
topMargin=30,
|
||||||
|
bottomMargin=30
|
||||||
)
|
)
|
||||||
elements = []
|
elements = []
|
||||||
|
|
||||||
styles = getSampleStyleSheet()
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
title_style = ParagraphStyle(
|
title_style = ParagraphStyle(
|
||||||
'DocTitle',
|
'EventTitle',
|
||||||
parent=styles['Heading1'],
|
parent=styles['Heading1'],
|
||||||
fontSize=20,
|
fontSize=18,
|
||||||
leading=24,
|
leading=22,
|
||||||
textColor=colors.HexColor('#1e293b'),
|
textColor=colors.HexColor('#0f172a'),
|
||||||
spaceAfter=6
|
spaceAfter=6
|
||||||
)
|
)
|
||||||
sub_style = ParagraphStyle(
|
|
||||||
'DocSub',
|
subtitle_style = ParagraphStyle(
|
||||||
|
'EventSubtitle',
|
||||||
parent=styles['Normal'],
|
parent=styles['Normal'],
|
||||||
fontSize=10,
|
fontSize=10,
|
||||||
leading=12,
|
leading=14,
|
||||||
textColor=colors.HexColor('#64748b'),
|
textColor=colors.HexColor('#475569'),
|
||||||
spaceAfter=15
|
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))
|
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
|
date_str = f"{event.start_date.strftime('%d.%m.%Y')} - {event.end_date.strftime('%d.%m.%Y')}"
|
||||||
table_data = []
|
loc_str = f" | Ort: {event.location}" if event.location else ""
|
||||||
headers = ["Bereich", "Schicht & Zeit", "Benötigte Qualifikationen", "Eingetragene Personen"]
|
elements.append(Paragraph(f"Datum: {date_str}{loc_str}", subtitle_style))
|
||||||
table_data.append([Paragraph(f"<b>{h}</b>", styles['Normal']) for h in headers])
|
|
||||||
|
|
||||||
for ta in event.task_areas.all():
|
table_data = [[
|
||||||
for shift in ta.shifts.all():
|
Paragraph("Aufgabenbereich", cell_title_style),
|
||||||
skills_list = ", ".join([s.name for s in shift.required_skills.all()]) or "Keine"
|
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()
|
for area in task_areas:
|
||||||
signups_list = []
|
shifts = area.shifts.all()
|
||||||
for su in signups:
|
if not shifts:
|
||||||
if request.user.is_authenticated:
|
table_data.append([
|
||||||
display = su.user.get_display_name() if su.user else (su.guest_name or "Gast")
|
Paragraph(area.name, cell_title_style),
|
||||||
else:
|
Paragraph("—", cell_text_style),
|
||||||
display = "Belegt (Anonym)"
|
Paragraph("—", cell_text_style),
|
||||||
signups_list.append(display)
|
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 = shift.signups.all()
|
||||||
signups_list.append("[ Offen ]")
|
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 = [
|
participants_html = "<br/>".join(signup_names) or "<i>[ Frei ]</i>"
|
||||||
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)
|
|
||||||
|
|
||||||
if len(table_data) == 1:
|
date_prefix = f"{shift.date.strftime('%d.%m.')} " if shift.date else ""
|
||||||
table_data.append([Paragraph("Keine Schichten angelegt", styles['Normal']), "", "", ""])
|
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([
|
pdf_table.setStyle(TableStyle([
|
||||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1f5f9')),
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1f5f9')),
|
||||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#0f172a')),
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#0f172a')),
|
||||||
@@ -203,9 +209,7 @@ class TaskAreaViewSet(viewsets.ModelViewSet):
|
|||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
event = serializer.validated_data.get('event')
|
event = serializer.validated_data.get('event')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
if not user.is_authenticated:
|
if not event or not event.is_user_manager_or_admin(user):
|
||||||
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)):
|
|
||||||
raise permissions.PermissionDenied("Keine Berechtigung, Bereiche für dieses Event hinzuzufügen.")
|
raise permissions.PermissionDenied("Keine Berechtigung, Bereiche für dieses Event hinzuzufügen.")
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
@@ -217,9 +221,7 @@ class ShiftViewSet(viewsets.ModelViewSet):
|
|||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
task_area = serializer.validated_data.get('task_area')
|
task_area = serializer.validated_data.get('task_area')
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
if not user.is_authenticated:
|
if not task_area or not task_area.event.is_user_manager_or_admin(user):
|
||||||
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)):
|
|
||||||
raise permissions.PermissionDenied("Keine Berechtigung, Schichten für dieses Event hinzuzufügen.")
|
raise permissions.PermissionDenied("Keine Berechtigung, Schichten für dieses Event hinzuzufügen.")
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
@@ -228,19 +230,36 @@ class ShiftSignupView(views.APIView):
|
|||||||
|
|
||||||
def post(self, request, shift_id):
|
def post(self, request, shift_id):
|
||||||
shift = get_object_or_404(Shift, pk=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
|
# Check capacity
|
||||||
if shift.signups.count() >= shift.max_participants:
|
if shift.signups.count() >= shift.max_participants:
|
||||||
return Response({'error': 'Diese Schicht ist bereits vollständig belegt.'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': 'Diese Schicht ist bereits vollständig belegt.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Authenticated User flow
|
guest_name = request.data.get('guest_name', '').strip()
|
||||||
if request.user.is_authenticated:
|
|
||||||
if ShiftSignup.objects.filter(shift=shift, user=request.user).exists():
|
# 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)
|
return Response({'error': 'Du bist für diese Schicht bereits eingetragen.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
required_skills = shift.required_skills.all()
|
required_skills = shift.required_skills.all()
|
||||||
if required_skills.exists():
|
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]
|
missing_skills = [s.name for s in required_skills if s not in user_skills]
|
||||||
if missing_skills:
|
if missing_skills:
|
||||||
missing_str = ", ".join(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}'
|
'error': f'Dir fehlen folgende erforderliche Fähigkeiten für diese Schicht: {missing_str}'
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
}, 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)
|
return Response({'message': 'Erfolgreich für Schicht eingetragen.', 'signup_id': signup.id}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
# Guest flow
|
# Guest flow for visitors
|
||||||
else:
|
else:
|
||||||
guest_name = request.data.get('guest_name', '').strip()
|
|
||||||
captcha_token = request.data.get('captcha_token', '').strip()
|
|
||||||
|
|
||||||
if not guest_name:
|
if not guest_name:
|
||||||
return Response({'error': 'Bitte gib einen Namen an.'}, status=status.HTTP_400_BAD_REQUEST)
|
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)
|
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()])
|
required_names = ", ".join([s.name for s in shift.required_skills.all()])
|
||||||
return Response({
|
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.'
|
'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
|
guest_session_key=session_key
|
||||||
)
|
)
|
||||||
return Response({
|
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
|
'signup_id': signup.id
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
@@ -300,7 +318,7 @@ class ShiftSignupView(views.APIView):
|
|||||||
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
user = request.user
|
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:
|
if signup_id and is_manager:
|
||||||
signup = ShiftSignup.objects.filter(shift=shift, pk=signup_id).first()
|
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')
|
end_date = request.data.get('end_date')
|
||||||
location = request.data.get('location', '')
|
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(
|
event = Event.objects.create(
|
||||||
title=title,
|
title=title,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ from .views import (
|
|||||||
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
|
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
|
||||||
PendingUsersView, ApproveUserView, AdminUserViewSet,
|
PendingUsersView, ApproveUserView, AdminUserViewSet,
|
||||||
GenerateClaimLinkView, ClaimInfoView,
|
GenerateClaimLinkView, ClaimInfoView,
|
||||||
SMTPSettingView, TestSMTPEmailView
|
SMTPSettingView, TestSMTPEmailView, UserListView
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -17,6 +17,7 @@ urlpatterns = [
|
|||||||
path('verify-email/', VerifyEmailView.as_view(), name='verify-email'),
|
path('verify-email/', VerifyEmailView.as_view(), name='verify-email'),
|
||||||
path('login/', LoginView.as_view(), name='login'),
|
path('login/', LoginView.as_view(), name='login'),
|
||||||
path('me/', MeView.as_view(), name='me'),
|
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('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'),
|
||||||
path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'),
|
path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'),
|
||||||
path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'),
|
path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'),
|
||||||
|
|||||||
@@ -388,3 +388,10 @@ class ApproveUserView(views.APIView):
|
|||||||
name = user.get_display_name()
|
name = user.get_display_name()
|
||||||
user.delete()
|
user.delete()
|
||||||
return Response({'message': f'Registrierung von {name} wurde abgelehnt und gelöscht.'})
|
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)
|
||||||
|
|||||||
-272
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+272
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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>
|
<title>Schichtplaner — Veranstaltungsschichtpläne</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BTg0Pu2I.js"></script>
|
<script type="module" crossorigin src="/assets/index-KtQZvLw-.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DyS9UMYS.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CGy_Z7Jy.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-paper text-main font-sans antialiased min-h-screen">
|
<body class="bg-paper text-main font-sans antialiased min-h-screen">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -278,7 +278,8 @@ export default function App() {
|
|||||||
description: eventPayload.description,
|
description: eventPayload.description,
|
||||||
location: eventPayload.location,
|
location: eventPayload.location,
|
||||||
start_date: eventPayload.start_date,
|
start_date: eventPayload.start_date,
|
||||||
end_date: eventPayload.end_date
|
end_date: eventPayload.end_date,
|
||||||
|
co_manager_ids: eventPayload.co_manager_ids || []
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -290,7 +291,8 @@ export default function App() {
|
|||||||
description: eventPayload.description,
|
description: eventPayload.description,
|
||||||
location: eventPayload.location,
|
location: eventPayload.location,
|
||||||
start_date: eventPayload.start_date,
|
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;
|
targetEventId = created.id;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function ShiftMatrixTable({
|
|||||||
<Clock className="w-8 h-8 text-muted mx-auto mb-3" />
|
<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>
|
<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>
|
<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
|
<button
|
||||||
onClick={() => onEditEvent(event)}
|
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"
|
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 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
|
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
|
||||||
/* Hallmark · component: EventEditorPage · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
|
/* Hallmark · component: EventEditorPage · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
|
||||||
import React, { useState, useEffect } from 'react';
|
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';
|
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 [title, setTitle] = useState(eventToEdit?.title || '');
|
||||||
const [description, setDescription] = useState(eventToEdit?.description || '');
|
const [description, setDescription] = useState(eventToEdit?.description || '');
|
||||||
const [location, setLocation] = useState(eventToEdit?.location || '');
|
const [location, setLocation] = useState(eventToEdit?.location || '');
|
||||||
const [startDate, setStartDate] = useState(eventToEdit?.start_date || new Date().toISOString().split('T')[0]);
|
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]);
|
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
|
// Task areas state
|
||||||
const [taskAreas, setTaskAreas] = useState([]);
|
const [taskAreas, setTaskAreas] = useState([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
|
||||||
if (eventToEdit && eventToEdit.task_areas && eventToEdit.task_areas.length > 0) {
|
if (eventToEdit && eventToEdit.task_areas && eventToEdit.task_areas.length > 0) {
|
||||||
const formattedAreas = eventToEdit.task_areas.map(ta => ({
|
const formattedAreas = eventToEdit.task_areas.map(ta => ({
|
||||||
id: ta.id,
|
id: ta.id,
|
||||||
@@ -54,6 +62,13 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
|||||||
}
|
}
|
||||||
}, [eventToEdit]);
|
}, [eventToEdit]);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/users/all/');
|
||||||
|
setAllUsers(data.results || data);
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
const addTaskArea = () => {
|
const addTaskArea = () => {
|
||||||
setTaskAreas([
|
setTaskAreas([
|
||||||
...taskAreas,
|
...taskAreas,
|
||||||
@@ -100,13 +115,13 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
|||||||
setTaskAreas(updated);
|
setTaskAreas(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleShiftSkill = (areaIndex, shiftIndex, skillId) => {
|
const toggleSkillRequirement = (areaIndex, shiftIndex, skillId) => {
|
||||||
const updated = [...taskAreas];
|
const updated = [...taskAreas];
|
||||||
const currentSkills = updated[areaIndex].shifts[shiftIndex].required_skill_ids || [];
|
const current = updated[areaIndex].shifts[shiftIndex].required_skill_ids || [];
|
||||||
if (currentSkills.includes(skillId)) {
|
if (current.includes(skillId)) {
|
||||||
updated[areaIndex].shifts[shiftIndex].required_skill_ids = currentSkills.filter(id => id !== skillId);
|
updated[areaIndex].shifts[shiftIndex].required_skill_ids = current.filter(id => id !== skillId);
|
||||||
} else {
|
} else {
|
||||||
updated[areaIndex].shifts[shiftIndex].required_skill_ids = [...currentSkills, skillId];
|
updated[areaIndex].shifts[shiftIndex].required_skill_ids = [...current, skillId];
|
||||||
}
|
}
|
||||||
setTaskAreas(updated);
|
setTaskAreas(updated);
|
||||||
};
|
};
|
||||||
@@ -129,6 +144,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
|||||||
location,
|
location,
|
||||||
start_date: startDate,
|
start_date: startDate,
|
||||||
end_date: endDate,
|
end_date: endDate,
|
||||||
|
co_manager_ids: coManagerIds,
|
||||||
task_areas: taskAreas
|
task_areas: taskAreas
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -165,7 +181,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
|||||||
{eventToEdit ? 'Veranstaltung & Schichten Bearbeiten' : 'Neue Veranstaltung Erstellen'}
|
{eventToEdit ? 'Veranstaltung & Schichten Bearbeiten' : 'Neue Veranstaltung Erstellen'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted font-mono mt-0.5">
|
<p className="text-xs text-muted font-mono mt-0.5">
|
||||||
Konfiguriere Stammdaten, Aufgabenfelder, Schichtzeiten & Qualifikationen
|
Konfiguriere Stammdaten, Co-Verwalter, Aufgabenfelder, Schichtzeiten & Qualifikationen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +199,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
|||||||
{/* Section 1: Stammdaten */}
|
{/* Section 1: Stammdaten */}
|
||||||
<div className="hallmark-panel rounded-sm p-6 border border-grid space-y-4 font-mono text-xs">
|
<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">
|
<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>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<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"
|
className="w-full px-3.5 py-2 rounded-sm input-field text-xs font-sans"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -347,30 +401,29 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Required Skills selection */}
|
{/* Skill requirements selector */}
|
||||||
{skills && skills.length > 0 && (
|
{skills && skills.length > 0 && (
|
||||||
<div className="pt-1.5 border-t border-grid text-[11px]">
|
<div className="pt-2 border-t border-grid flex flex-wrap items-center gap-1.5">
|
||||||
<span className="text-muted block mb-1">Erforderliche Qualifikationen:</span>
|
<span className="text-[10px] text-muted font-bold mr-1">Erforderlich:</span>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
{skills.map(sk => {
|
||||||
{skills.map(sk => {
|
const isReq = (shift.required_skill_ids || []).includes(sk.id);
|
||||||
const isSelected = (shift.required_skill_ids || []).includes(sk.id);
|
return (
|
||||||
return (
|
<button
|
||||||
<button
|
key={sk.id}
|
||||||
key={sk.id}
|
type="button"
|
||||||
type="button"
|
onClick={() => toggleSkillRequirement(aIdx, sIdx, sk.id)}
|
||||||
onClick={() => toggleShiftSkill(aIdx, sIdx, sk.id)}
|
style={{
|
||||||
style={{
|
backgroundColor: isReq ? `${sk.color}25` : 'transparent',
|
||||||
backgroundColor: isSelected ? sk.color : `${sk.color}15`,
|
borderColor: isReq ? sk.color : 'var(--color-border-grid)',
|
||||||
borderColor: sk.color,
|
color: isReq ? sk.color : 'var(--color-text-muted)'
|
||||||
color: isSelected ? '#ffffff' : sk.color
|
}}
|
||||||
}}
|
className="px-2 py-0.5 rounded-sm text-[10px] border font-bold transition flex items-center gap-1"
|
||||||
className="px-2 py-0.5 rounded-sm border text-[10px] font-bold transition"
|
>
|
||||||
>
|
<span>{sk.name}</span>
|
||||||
{sk.name} {isSelected ? '✓' : ''}
|
{isReq && <CheckCircle2 className="w-3 h-3" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -380,70 +433,24 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Controls */}
|
{/* Submit Actions */}
|
||||||
<div className="hallmark-panel rounded-sm p-4 border border-grid flex flex-wrap items-center justify-between gap-3 font-mono text-xs">
|
<div className="flex items-center justify-end gap-3 pt-2 font-mono">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
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
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
<div className="flex items-center gap-2">
|
type="submit"
|
||||||
<button
|
disabled={submitting}
|
||||||
type="button"
|
style={{ backgroundColor: 'var(--brand-primary)' }}
|
||||||
disabled={submitting}
|
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"
|
||||||
onClick={async () => {
|
>
|
||||||
if (!title.trim()) {
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
setError('Bitte gib einen Titel an.');
|
{submitting ? 'Speichern...' : 'Veranstaltung Speichern'}
|
||||||
return;
|
</button>
|
||||||
}
|
|
||||||
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ export default function HomePage({
|
|||||||
{filteredEvents.map((evt) => {
|
{filteredEvents.map((evt) => {
|
||||||
const taskAreaCount = evt.task_areas?.length || 0;
|
const taskAreaCount = evt.task_areas?.length || 0;
|
||||||
const totalShifts = evt.task_areas?.reduce((acc, ta) => acc + (ta.shifts?.length || 0), 0) || 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;
|
const canManage = isUserAdmin || isCreator;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user