diff --git a/resources/js/admin/pages/EventTasksPage.tsx b/resources/js/admin/pages/EventTasksPage.tsx index 0775d74..c6d4d6d 100644 --- a/resources/js/admin/pages/EventTasksPage.tsx +++ b/resources/js/admin/pages/EventTasksPage.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, Layers, Loader2, PlusCircle, Search, Sparkles } from 'lucide-react'; +import { ArrowLeft, Layers, Loader2, PlusCircle, Search, Sparkles, Pencil, Check, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import toast from 'react-hot-toast'; @@ -12,8 +12,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Input } from '@/components/ui/input'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DndContext, closestCenter, @@ -41,6 +42,7 @@ import { importTaskCollection, getEmotions, updateEvent, + updateTask, TenantEvent, TenantTask, TenantTaskCollection, @@ -48,7 +50,7 @@ import { } from '../api'; import { EmotionsSection } from './EmotionsPage'; import { isAuthError } from '../auth/tokens'; -import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants'; +import { ADMIN_EVENTS_PATH, buildEngagementTabPath } from '../constants'; import { filterEmotionsByEventType } from '../lib/emotions'; import { buildEventTabs } from '../lib/eventTabs'; import { Trash2 } from 'lucide-react'; @@ -70,6 +72,8 @@ export default function EventTasksPage() { const [error, setError] = React.useState(null); const [tab, setTab] = React.useState<'tasks' | 'packs'>('tasks'); const [taskSearch, setTaskSearch] = React.useState(''); + const [debouncedTaskSearch, setDebouncedTaskSearch] = React.useState(''); + const [difficultyFilter, setDifficultyFilter] = React.useState(''); const [collections, setCollections] = React.useState([]); const [collectionsLoading, setCollectionsLoading] = React.useState(false); const [collectionsError, setCollectionsError] = React.useState(null); @@ -85,6 +89,15 @@ export default function EventTasksPage() { const [newTaskDifficulty, setNewTaskDifficulty] = React.useState(''); const [creatingTask, setCreatingTask] = React.useState(false); const [draggingId, setDraggingId] = React.useState(null); + const [selectedAssignedIds, setSelectedAssignedIds] = React.useState([]); + const [selectedAvailableIds, setSelectedAvailableIds] = React.useState([]); + const [batchSaving, setBatchSaving] = React.useState(false); + const [inlineSavingId, setInlineSavingId] = React.useState(null); + const [emotionFilterOpen, setEmotionFilterOpen] = React.useState(false); + React.useEffect(() => { + const handle = window.setTimeout(() => setDebouncedTaskSearch(taskSearch.trim().toLowerCase()), 180); + return () => window.clearTimeout(handle); + }, [taskSearch]); const hydrateTasks = React.useCallback(async (targetEvent: TenantEvent) => { try { const [refreshed, libraryTasks] = await Promise.all([ @@ -191,12 +204,22 @@ export default function EventTasksPage() { const set = new Set(emotionFilter); list = list.filter((task) => (task.emotion_id ? set.has(task.emotion_id) : false)); } - if (!taskSearch.trim()) { + if (difficultyFilter) { + list = list.filter((task) => task.difficulty === difficultyFilter); + } + if (!debouncedTaskSearch) { return list; } - const term = taskSearch.toLowerCase(); - return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term)); - }, [assignedTasks, taskSearch, emotionFilter]); + return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(debouncedTaskSearch)); + }, [assignedTasks, debouncedTaskSearch, emotionFilter, difficultyFilter]); + + React.useEffect(() => { + setSelectedAssignedIds((prev) => prev.filter((id) => assignedTasks.some((task) => task.id === id))); + }, [assignedTasks]); + + React.useEffect(() => { + setSelectedAvailableIds((prev) => prev.filter((id) => availableTasks.some((task) => task.id === id))); + }, [availableTasks]); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); @@ -225,7 +248,7 @@ export default function EventTasksPage() { setSaving(false); } }, - [availableTasks, event, hydrateTasks, t], + [availableTasks, event, t], ); const handleDetachSingle = React.useCallback( @@ -253,7 +276,7 @@ export default function EventTasksPage() { setSaving(false); } }, - [assignedTasks, event, hydrateTasks, t], + [assignedTasks, event, t], ); const handleDragEnd = (event: DragEndEvent) => { @@ -318,7 +341,7 @@ export default function EventTasksPage() { } finally { setCreatingTask(false); } - }, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, hydrateTasks, t]); + }, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, newTaskDifficulty, emotions, hydrateTasks, t]); const eventTabs = React.useMemo(() => { if (!event) { @@ -416,7 +439,11 @@ export default function EventTasksPage() { }, [event, hydrateTasks, slug, t]); const tasksEnabled = React.useMemo(() => { - const mode = event?.engagement_mode ?? (event?.settings as any)?.engagement_mode; + const settingsMode = + event?.settings && typeof event.settings === 'object' && 'engagement_mode' in (event.settings as Record) + ? (event.settings as { engagement_mode?: string }).engagement_mode + : undefined; + const mode = event?.engagement_mode ?? settingsMode; return mode !== 'photo_only'; }, [event?.engagement_mode, event?.settings]); @@ -482,6 +509,140 @@ export default function EventTasksPage() { } } + const handleAssignSelected = React.useCallback(async () => { + if (!event || selectedAvailableIds.length === 0) return; + const ids = selectedAvailableIds; + const move = availableTasks.filter((task) => ids.includes(task.id)); + if (!move.length) return; + + const nextAvailableSet = new Set(ids); + const prevAssigned = assignedTasks; + const prevAvailable = availableTasks; + + setAssignedTasks((prev) => [...prev, ...move]); + setAvailableTasks((prev) => prev.filter((task) => !nextAvailableSet.has(task.id))); + setSelectedAvailableIds([]); + setBatchSaving(true); + try { + await assignTasksToEvent(event.id, ids); + toast.success(t('actions.assignedToast', 'Tasks wurden zugewiesen.')); + } catch (err) { + if (!isAuthError(err)) { + toast.error(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.')); + } + setAssignedTasks(prevAssigned); + setAvailableTasks(prevAvailable); + } finally { + setBatchSaving(false); + } + }, [event, selectedAvailableIds, availableTasks, assignedTasks, t]); + + const handleDetachSelected = React.useCallback(async () => { + if (!event || selectedAssignedIds.length === 0) return; + const ids = selectedAssignedIds; + const move = assignedTasks.filter((task) => ids.includes(task.id)); + if (!move.length) return; + + const nextAssignedSet = new Set(ids); + const prevAssigned = assignedTasks; + const prevAvailable = availableTasks; + + setAssignedTasks((prev) => prev.filter((task) => !nextAssignedSet.has(task.id))); + setAvailableTasks((prev) => [...prev, ...move]); + setSelectedAssignedIds([]); + setBatchSaving(true); + try { + await detachTasksFromEvent(event.id, ids); + toast.success(t('actions.removedToast', 'Tasks wurden entfernt.')); + } catch (err) { + if (!isAuthError(err)) { + toast.error(t('errors.remove', 'Tasks konnten nicht entfernt werden.')); + } + setAssignedTasks(prevAssigned); + setAvailableTasks(prevAvailable); + } finally { + setBatchSaving(false); + } + }, [event, selectedAssignedIds, assignedTasks, availableTasks, t]); + + const handleInlineUpdate = React.useCallback( + async (taskId: number, payload: { title?: string; difficulty?: TenantTask['difficulty'] | '' }) => { + if (!event) return; + + const prevAssigned = assignedTasks; + const prevAvailable = availableTasks; + const existingAssigned = assignedTasks.find((task) => task.id === taskId); + const existingAvailable = availableTasks.find((task) => task.id === taskId); + const optimistic = existingAssigned ?? existingAvailable; + if (!optimistic) { + return; + } + + const patch: Partial = { + ...payload, + title: payload.title ?? optimistic.title, + difficulty: payload.difficulty || null, + } as Partial; + + if (existingAssigned) { + setAssignedTasks((prev) => prev.map((task) => (task.id === taskId ? { ...task, ...patch } : task))); + } + if (existingAvailable) { + setAvailableTasks((prev) => prev.map((task) => (task.id === taskId ? { ...task, ...patch } : task))); + } + + setInlineSavingId(taskId); + const duplicateAndAssign = async () => { + const created = await createTask({ + title: patch.title, + difficulty: patch.difficulty ?? undefined, + description: optimistic.description ?? null, + priority: optimistic.priority ?? undefined, + emotion_id: optimistic.emotion_id ?? undefined, + event_type_id: optimistic.event_type_id ?? undefined, + }); + await assignTasksToEvent(event.id, [created.id]); + setAssignedTasks((prev) => { + const withoutOld = prev.filter((task) => task.id !== taskId); + return [...withoutOld, { ...created, emotion: optimistic.emotion ?? created.emotion ?? null }]; + }); + setAvailableTasks((prev) => prev.filter((task) => task.id !== taskId)); + toast.success(t('actions.created', 'Aufgabe erstellt und zugewiesen.')); + }; + + try { + if (optimistic.tenant_id === null) { + await duplicateAndAssign(); + } else { + const updated = await updateTask(taskId, { + title: patch.title, + difficulty: payload.difficulty || undefined, + description: optimistic.description ?? undefined, + priority: optimistic.priority ?? undefined, + emotion_id: optimistic.emotion_id ?? undefined, + }); + + if (existingAssigned) { + setAssignedTasks((prev) => prev.map((task) => (task.id === taskId ? { ...task, ...updated } : task))); + } + if (existingAvailable) { + setAvailableTasks((prev) => prev.map((task) => (task.id === taskId ? { ...task, ...updated } : task))); + } + toast.success(t('actions.updated', 'Task aktualisiert.')); + } + } catch (err) { + if (!isAuthError(err)) { + toast.error(t('errors.update', 'Task konnte nicht aktualisiert werden.')); + } + setAssignedTasks(prevAssigned); + setAvailableTasks(prevAvailable); + } finally { + setInlineSavingId(null); + } + }, + [event, assignedTasks, availableTasks, t], + ); + const actions = ( + {difficultyFilter ? ( +
+ {(['', 'easy', 'medium', 'hard'] as Array).map((value) => { + const active = difficultyFilter === value; + const label = + value === 'easy' + ? t('sections.assigned.difficulty.easy', 'Leicht') + : value === 'medium' + ? t('sections.assigned.difficulty.medium', 'Mittel') + : value === 'hard' + ? t('sections.assigned.difficulty.hard', 'Schwer') + : t('sections.assigned.difficulty.all', 'Alle Stufen'); + return ( + + ); + })} +
+ ) : null} + +
+ {emotionChips.length > 0 ? ( + + ) : null} +
- - {emotionChips.length > 0 ? ( -
+ + {emotionChips.length > 0 && emotionFilterOpen ? ( +
@@ -702,10 +979,10 @@ export default function EventTasksPage() {