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
+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 (