feat: Add event co-managers permissions and allow managers/admins to signup guests on skill-restricted shifts
This commit is contained in:
-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 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user