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