platz zu begrenzt im aufnahmemodus - vollbildmodus möglich? Menü und Kopfleiste ausblenden? Bild aus eigener galerie auswählen - Upload schlägt fehl (zu groß? evtl fehlende Rechte - aber browser hat rechte auf bilder und dateien!) hochgeladene bilder tauchen in der galerie nicht beim filter "Meine Bilder" auf - fotos werden auch nicht gezählt in den stats und achievements zeigen keinen fortschriftt. geteilte fotos: ruft man den Link auf, bekommt man die meldung "Link abgelaufen" der im startbildschirm gewählte name mit Umlauten (Sören) ist nach erneutem aufruf der pwa ohne umlaut (Sren). Aufgabenseite verbessert (Zwischenstand)
1593 lines
67 KiB
TypeScript
1593 lines
67 KiB
TypeScript
// @ts-nocheck
|
|
import React from 'react';
|
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
import { ArrowLeft, Layers, Loader2, PlusCircle, Search, Sparkles, Pencil, Check, X } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import toast from 'react-hot-toast';
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
DragEndEvent,
|
|
DragOverlay,
|
|
useDraggable,
|
|
useDroppable,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
CSS,
|
|
} from '@dnd-kit/utilities';
|
|
|
|
import { AdminLayout } from '../components/AdminLayout';
|
|
import {
|
|
assignTasksToEvent,
|
|
detachTasksFromEvent,
|
|
getEvent,
|
|
getEventTasks,
|
|
createTask,
|
|
getTasks,
|
|
getTaskCollections,
|
|
importTaskCollection,
|
|
getEmotions,
|
|
updateEvent,
|
|
updateTask,
|
|
TenantEvent,
|
|
TenantTask,
|
|
TenantTaskCollection,
|
|
TenantEmotion,
|
|
} from '../api';
|
|
import { EmotionsSection } from './EmotionsPage';
|
|
import { isAuthError } from '../auth/tokens';
|
|
import { ADMIN_EVENTS_PATH, buildEngagementTabPath } from '../constants';
|
|
import { filterEmotionsByEventType } from '../lib/emotions';
|
|
import { buildEventTabs } from '../lib/eventTabs';
|
|
import { Trash2 } from 'lucide-react';
|
|
|
|
export default function EventTasksPage() {
|
|
const { t } = useTranslation('management', { keyPrefix: 'eventTasks' });
|
|
const { t: tDashboard } = useTranslation('dashboard');
|
|
const params = useParams<{ slug?: string }>();
|
|
const [searchParams] = useSearchParams();
|
|
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
|
const navigate = useNavigate();
|
|
|
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
|
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
|
|
const [availableTasks, setAvailableTasks] = React.useState<TenantTask[]>([]);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [saving, setSaving] = React.useState(false);
|
|
const [modeSaving, setModeSaving] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [tab, setTab] = React.useState<'tasks' | 'packs' | 'emotions'>('packs');
|
|
const [taskSearch, setTaskSearch] = React.useState('');
|
|
const [debouncedTaskSearch, setDebouncedTaskSearch] = React.useState('');
|
|
const [difficultyFilter, setDifficultyFilter] = React.useState<TenantTask['difficulty'] | ''>('');
|
|
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
|
|
const [collectionsLoading, setCollectionsLoading] = React.useState(false);
|
|
const [collectionsError, setCollectionsError] = React.useState<string | null>(null);
|
|
const [importingCollectionId, setImportingCollectionId] = React.useState<number | null>(null);
|
|
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
|
const [emotionsLoading, setEmotionsLoading] = React.useState(false);
|
|
const [emotionsError, setEmotionsError] = React.useState<string | null>(null);
|
|
const [emotionFilter, setEmotionFilter] = React.useState<number[]>([]);
|
|
const [emotionsModalOpen, setEmotionsModalOpen] = React.useState(false);
|
|
const [newTaskTitle, setNewTaskTitle] = React.useState('');
|
|
const [newTaskDescription, setNewTaskDescription] = React.useState('');
|
|
const [newTaskEmotionId, setNewTaskEmotionId] = React.useState<number | null>(null);
|
|
const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>('');
|
|
const [creatingTask, setCreatingTask] = React.useState(false);
|
|
const [quickAddOpen, setQuickAddOpen] = React.useState(false);
|
|
const [draggingId, setDraggingId] = React.useState<number | null>(null);
|
|
const [selectedAssignedIds, setSelectedAssignedIds] = React.useState<number[]>([]);
|
|
const [selectedAvailableIds, setSelectedAvailableIds] = React.useState<number[]>([]);
|
|
const [batchSaving, setBatchSaving] = React.useState(false);
|
|
const [inlineSavingId, setInlineSavingId] = React.useState<number | null>(null);
|
|
const [emotionFilterOpen, setEmotionFilterOpen] = React.useState(false);
|
|
const libraryRef = React.useRef<HTMLDivElement | null>(null);
|
|
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([
|
|
getEventTasks(targetEvent.id, 1),
|
|
getTasks({ per_page: 200 }),
|
|
]);
|
|
const assignedIds = new Set(refreshed.data.map((task) => task.id));
|
|
setAssignedTasks(refreshed.data);
|
|
const eventTypeId = targetEvent.event_type_id ?? null;
|
|
const filteredLibraryTasks = libraryTasks.data.filter((task) => {
|
|
if (assignedIds.has(task.id)) {
|
|
return false;
|
|
}
|
|
if (eventTypeId && task.event_type_id && task.event_type_id !== eventTypeId) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
setAvailableTasks(filteredLibraryTasks);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(t('errors.assign', 'Tasks konnten nicht geladen werden.'));
|
|
}
|
|
}
|
|
}, [t]);
|
|
|
|
const relevantEmotions = React.useMemo(() => {
|
|
const filtered = filterEmotionsByEventType(emotions, event?.event_type_id ?? event?.event_type?.id ?? null);
|
|
return filtered.length > 0 ? filtered : emotions;
|
|
}, [emotions, event?.event_type_id, event?.event_type?.id]);
|
|
const emotionChips = React.useMemo(() => {
|
|
const map: Record<number, TenantEmotion> = {};
|
|
assignedTasks.forEach((task) => {
|
|
if (task.emotion) {
|
|
map[task.emotion.id] = {
|
|
...task.emotion,
|
|
name_translations: task.emotion.name_translations ?? {},
|
|
description: null,
|
|
description_translations: {},
|
|
sort_order: 0,
|
|
is_active: true,
|
|
tenant_id: null,
|
|
is_global: false,
|
|
event_types: [],
|
|
created_at: null,
|
|
updated_at: null,
|
|
} as TenantEmotion;
|
|
}
|
|
});
|
|
return Object.values(map);
|
|
}, [assignedTasks]);
|
|
|
|
React.useEffect(() => {
|
|
if (!slug) {
|
|
setError(t('errors.missingSlug', 'Kein Event-Slug angegeben.'));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const eventData = await getEvent(slug);
|
|
const [eventTasksResponse, libraryTasks] = await Promise.all([
|
|
getEventTasks(eventData.id, 1),
|
|
getTasks({ per_page: 200 }),
|
|
]);
|
|
if (cancelled) return;
|
|
setEvent(eventData);
|
|
const assignedIds = new Set(eventTasksResponse.data.map((task) => task.id));
|
|
setAssignedTasks(eventTasksResponse.data);
|
|
const eventTypeId = eventData.event_type_id ?? null;
|
|
const filteredLibraryTasks = libraryTasks.data.filter((task) => {
|
|
if (assignedIds.has(task.id)) {
|
|
return false;
|
|
}
|
|
if (eventTypeId && task.event_type_id && task.event_type_id !== eventTypeId) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
setAvailableTasks(filteredLibraryTasks);
|
|
setError(null);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(t('errors.load', 'Event-Tasks konnten nicht geladen werden.'));
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [slug, t]);
|
|
|
|
const filteredAssignedTasks = React.useMemo(() => {
|
|
let list = assignedTasks;
|
|
if (emotionFilter.length > 0) {
|
|
const set = new Set(emotionFilter);
|
|
list = list.filter((task) => (task.emotion_id ? set.has(task.emotion_id) : false));
|
|
}
|
|
if (difficultyFilter) {
|
|
list = list.filter((task) => task.difficulty === difficultyFilter);
|
|
}
|
|
if (!debouncedTaskSearch) {
|
|
return list;
|
|
}
|
|
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 } }));
|
|
|
|
const handleAssignSingle = React.useCallback(
|
|
async (taskId: number) => {
|
|
if (!event) return;
|
|
const task = availableTasks.find((t) => t.id === taskId);
|
|
if (task) {
|
|
setAvailableTasks((prev) => prev.filter((t) => t.id !== taskId));
|
|
setAssignedTasks((prev) => [...prev, task]);
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await assignTasksToEvent(event.id, [taskId]);
|
|
toast.success(t('actions.assignedToast', 'Tasks wurden zugewiesen.'));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
toast.error(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
|
|
}
|
|
// revert optimistic change
|
|
if (task) {
|
|
setAssignedTasks((prev) => prev.filter((t) => t.id !== taskId));
|
|
setAvailableTasks((prev) => [...prev, task]);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
},
|
|
[availableTasks, event, t],
|
|
);
|
|
|
|
const handleDetachSingle = React.useCallback(
|
|
async (taskId: number) => {
|
|
if (!event) return;
|
|
const task = assignedTasks.find((t) => t.id === taskId);
|
|
if (task) {
|
|
setAssignedTasks((prev) => prev.filter((t) => t.id !== taskId));
|
|
setAvailableTasks((prev) => [...prev, task]);
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await detachTasksFromEvent(event.id, [taskId]);
|
|
toast.success(t('actions.removedToast', 'Tasks wurden entfernt.'));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
toast.error(t('errors.remove', 'Tasks konnten nicht entfernt werden.'));
|
|
}
|
|
// revert optimistic change
|
|
if (task) {
|
|
setAvailableTasks((prev) => prev.filter((t) => t.id !== taskId));
|
|
setAssignedTasks((prev) => [...prev, task]);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
},
|
|
[assignedTasks, event, t],
|
|
);
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || !active?.data?.current) {
|
|
setDraggingId(null);
|
|
return;
|
|
}
|
|
|
|
const originList = active.data.current.list as 'assigned' | 'library';
|
|
const overList = (over.data?.current?.list as 'assigned' | 'library' | undefined) ?? null;
|
|
const targetList =
|
|
overList ??
|
|
(over.id === 'assigned-dropzone'
|
|
? 'assigned'
|
|
: over.id === 'library-dropzone'
|
|
? 'library'
|
|
: null);
|
|
|
|
setDraggingId(null);
|
|
if (!targetList || targetList === originList) {
|
|
return;
|
|
}
|
|
|
|
const taskId = Number(active.id);
|
|
if (Number.isNaN(taskId)) {
|
|
return;
|
|
}
|
|
|
|
if (targetList === 'assigned') {
|
|
void handleAssignSingle(taskId);
|
|
} else {
|
|
void handleDetachSingle(taskId);
|
|
}
|
|
};
|
|
|
|
const handleCreateQuickTask = React.useCallback(async () => {
|
|
if (!event || !newTaskTitle.trim()) return;
|
|
setCreatingTask(true);
|
|
const emotion = emotions.find((e) => e.id === newTaskEmotionId) ?? null;
|
|
try {
|
|
const created = await createTask({
|
|
title: newTaskTitle.trim(),
|
|
description: newTaskDescription.trim() || null,
|
|
emotion_id: newTaskEmotionId ?? undefined,
|
|
event_type_id: event.event_type_id ?? undefined,
|
|
difficulty: newTaskDifficulty || undefined,
|
|
});
|
|
setAssignedTasks((prev) => [...prev, { ...created, emotion: emotion ?? created.emotion ?? null }]);
|
|
setAvailableTasks((prev) => prev.filter((task) => task.id !== created.id));
|
|
await assignTasksToEvent(event.id, [created.id]);
|
|
toast.success(t('actions.created', 'Aufgabe erstellt und zugewiesen.'));
|
|
setNewTaskTitle('');
|
|
setNewTaskDescription('');
|
|
setNewTaskEmotionId(null);
|
|
setNewTaskDifficulty('');
|
|
setQuickAddOpen(false);
|
|
await hydrateTasks(event);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
toast.error(t('errors.create', 'Aufgabe konnte nicht erstellt werden.'));
|
|
}
|
|
} finally {
|
|
setCreatingTask(false);
|
|
}
|
|
}, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, newTaskDifficulty, emotions, hydrateTasks, t]);
|
|
|
|
const eventTabs = React.useMemo(() => {
|
|
if (!event) {
|
|
return [];
|
|
}
|
|
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const taskBadge = loading ? undefined : assignedTasks.length;
|
|
return buildEventTabs(event, translateMenu, {
|
|
photos: event.photo_count ?? undefined,
|
|
tasks: taskBadge,
|
|
});
|
|
}, [event, assignedTasks.length, t, loading]);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
setCollectionsLoading(true);
|
|
setCollectionsError(null);
|
|
const eventTypeSlug = event?.event_type?.slug ?? null;
|
|
const query = eventTypeSlug
|
|
? { top_picks: true, limit: 6, event_type: eventTypeSlug }
|
|
: { top_picks: true, limit: 6 };
|
|
|
|
getTaskCollections(query)
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
setCollections(result.data);
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
if (!isAuthError(err)) {
|
|
setCollectionsError(t('collections.error', 'Kollektionen konnten nicht geladen werden.'));
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setCollectionsLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [event?.event_type?.slug, t]);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
setEmotionsLoading(true);
|
|
setEmotionsError(null);
|
|
getEmotions()
|
|
.then((list) => {
|
|
if (!cancelled) {
|
|
setEmotions(list);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
if (!isAuthError(err)) {
|
|
setEmotionsError(t('tasks.emotions.error', 'Emotionen konnten nicht geladen werden.'));
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setEmotionsLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [t]);
|
|
|
|
const handleImportCollection = React.useCallback(async (collection: TenantTaskCollection) => {
|
|
if (!slug || !event) {
|
|
return;
|
|
}
|
|
setImportingCollectionId(collection.id);
|
|
try {
|
|
await importTaskCollection(collection.id, slug);
|
|
toast.success(
|
|
t('collections.imported', {
|
|
defaultValue: 'Aufgaben-Set "{{name}}" importiert.',
|
|
name: collection.name,
|
|
}),
|
|
);
|
|
setTab('tasks');
|
|
await hydrateTasks(event);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
toast.error(t('collections.importFailed', 'Aufgaben-Set konnte nicht importiert werden.'));
|
|
}
|
|
} finally {
|
|
setImportingCollectionId(null);
|
|
}
|
|
}, [event, hydrateTasks, slug, t]);
|
|
|
|
const tasksEnabled = React.useMemo(() => {
|
|
const settingsMode =
|
|
event?.settings && typeof event.settings === 'object' && 'engagement_mode' in (event.settings as Record<string, unknown>)
|
|
? (event.settings as { engagement_mode?: string }).engagement_mode
|
|
: undefined;
|
|
const mode = event?.engagement_mode ?? settingsMode;
|
|
return mode !== 'photo_only';
|
|
}, [event?.engagement_mode, event?.settings]);
|
|
|
|
const hasSelection = selectedAssignedIds.length > 0 || selectedAvailableIds.length > 0;
|
|
const tasksFirst = assignedTasks.length > 0;
|
|
const tabOrder: Array<'tasks' | 'packs' | 'emotions'> = tasksFirst ? ['tasks', 'packs', 'emotions'] : ['packs', 'tasks', 'emotions'];
|
|
const prevAssignedRef = React.useRef(assignedTasks.length);
|
|
|
|
React.useEffect(() => {
|
|
if (prevAssignedRef.current === 0 && assignedTasks.length > 0) {
|
|
setTab('tasks');
|
|
}
|
|
prevAssignedRef.current = assignedTasks.length;
|
|
}, [assignedTasks.length, setTab]);
|
|
|
|
async function handleModeChange(checked: boolean) {
|
|
if (!event || !slug) return;
|
|
|
|
setModeSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const nextMode = checked ? 'tasks' : 'photo_only';
|
|
const updated = await updateEvent(slug, {
|
|
settings: {
|
|
...(event.settings ?? {}),
|
|
engagement_mode: nextMode,
|
|
},
|
|
});
|
|
setEvent((prev) => ({
|
|
...(prev ?? updated),
|
|
...(updated ?? {}),
|
|
engagement_mode: updated?.engagement_mode ?? nextMode,
|
|
settings: {
|
|
...(prev?.settings ?? {}),
|
|
...(updated?.settings ?? {}),
|
|
engagement_mode: nextMode,
|
|
},
|
|
}));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(
|
|
checked
|
|
? t('errors.photoOnlyEnable', 'Foto-Modus konnte nicht aktiviert werden.')
|
|
: t('errors.photoOnlyDisable', 'Foto-Modus konnte nicht deaktiviert werden.'),
|
|
);
|
|
}
|
|
} finally {
|
|
setModeSaving(false);
|
|
}
|
|
}
|
|
|
|
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<TenantTask> = {
|
|
...payload,
|
|
title: payload.title ?? optimistic.title,
|
|
difficulty: payload.difficulty || null,
|
|
} as Partial<TenantTask>;
|
|
|
|
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 = (
|
|
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
{t('actions.back', 'Zurück zur Übersicht')}
|
|
</Button>
|
|
);
|
|
|
|
return (
|
|
<AdminLayout
|
|
title={t('title', 'Aufgaben & Missionen')}
|
|
subtitle={t('subtitle', 'Stelle Mission Cards und Aufgaben für dieses Event zusammen.')}
|
|
actions={actions}
|
|
tabs={eventTabs}
|
|
currentTabKey="tasks"
|
|
>
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{loading ? (
|
|
<TaskSkeleton />
|
|
) : !event ? (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
|
|
<AlertDescription>{t('alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
|
|
</Alert>
|
|
) : (
|
|
<>
|
|
<Tabs value={tab} onValueChange={(value) => setTab(value as 'tasks' | 'packs' | 'emotions')} className="space-y-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<TabsList className="grid flex-1 gap-2 rounded-2xl bg-slate-100/80 p-1 sm:grid-cols-3">
|
|
{tabOrder.map((key) => (
|
|
<TabsTrigger key={key} value={key}>
|
|
{key === 'packs'
|
|
? t('tabs.packs', 'Vorlagen / Aufgaben-Bundles')
|
|
: key === 'tasks'
|
|
? t('tabs.tasks', 'Aufgaben')
|
|
: t('tabs.emotions', 'Emotionen')}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
<Button
|
|
variant="outline"
|
|
className="border-emerald-200 text-emerald-700"
|
|
onClick={() => navigate(buildEngagementTabPath('tasks'))}
|
|
>
|
|
{t('library.open', 'Aufgaben-Bibliothek öffnen')}
|
|
</Button>
|
|
</div>
|
|
<TabsContent value="tasks" className="space-y-6">
|
|
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
|
<CardHeader className="space-y-4">
|
|
<div className="flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-semibold text-slate-900">
|
|
{t('modes.title', 'Aufgaben & Foto-Modus')}
|
|
</p>
|
|
<p className="text-xs text-slate-600">
|
|
{tasksEnabled
|
|
? t('modes.tasksHint', 'Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.')
|
|
: t('modes.photoOnlyHint', 'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.')}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge className="rounded-full bg-slate-900 text-white">
|
|
{t('summary.assigned', 'Zugeordnete Tasks')} · {assignedTasks.length}
|
|
</Badge>
|
|
<Badge className="rounded-full bg-emerald-600/90 text-white">
|
|
{t('summary.library', 'Bibliothek')} · {availableTasks.length}
|
|
</Badge>
|
|
<Badge className="rounded-full bg-pink-500/90 text-white">
|
|
{t('summary.mode', 'Aktiver Modus')} ·{' '}
|
|
{tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-start gap-2 lg:items-end">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs uppercase tracking-wide text-slate-500">
|
|
{tasksEnabled ? t('modes.tasks', 'Aufgaben aktiv') : t('modes.photoOnly', 'Foto-Modus')}
|
|
</span>
|
|
<Switch
|
|
checked={tasksEnabled}
|
|
onCheckedChange={handleModeChange}
|
|
disabled={modeSaving || (!tasksEnabled && assignedTasks.length === 0)}
|
|
aria-label={t('modes.switchLabel', 'Aufgaben aktivieren/deaktivieren')}
|
|
/>
|
|
</div>
|
|
{modeSaving ? (
|
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t('modes.updating', 'Einstellung wird gespeichert ...')}
|
|
</div>
|
|
) : null}
|
|
{!tasksEnabled && assignedTasks.length === 0 ? (
|
|
<p className="text-[11px] text-slate-500">
|
|
{t('modes.needTasks', 'Aktiviere Aufgaben, sobald mindestens eine Aufgabe zugewiesen ist.')}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
|
onClick={() => setQuickAddOpen(true)}
|
|
>
|
|
<PlusCircle className="mr-2 h-4 w-4" />
|
|
{t('actions.addCustom', 'Eigene Aufgabe hinzufügen')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={(event) => setDraggingId(Number(event.active.id))}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<CardContent className="grid gap-4 lg:grid-cols-2">
|
|
<section className="space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
|
<Sparkles className="h-4 w-4 text-pink-500" />
|
|
{t('sections.assigned.title', 'Zugeordnete Tasks')}
|
|
</h3>
|
|
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1">
|
|
<Checkbox
|
|
checked={
|
|
filteredAssignedTasks.length > 0 && filteredAssignedTasks.every((task) => selectedAssignedIds.includes(task.id))
|
|
? true
|
|
: selectedAssignedIds.some((id) => filteredAssignedTasks.some((task) => task.id === id))
|
|
? 'indeterminate'
|
|
: false
|
|
}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setSelectedAssignedIds((prev) => {
|
|
const next = new Set(prev);
|
|
filteredAssignedTasks.forEach((task) => next.add(task.id));
|
|
return Array.from(next);
|
|
});
|
|
} else {
|
|
const visibleIds = new Set(filteredAssignedTasks.map((task) => task.id));
|
|
setSelectedAssignedIds((prev) => prev.filter((id) => !visibleIds.has(id)));
|
|
}
|
|
}}
|
|
aria-label={t('sections.assigned.selectAll', 'Alle sichtbaren Aufgaben auswählen')}
|
|
/>
|
|
<span className="text-xs text-slate-600">
|
|
{t('sections.assigned.selectedCount', {
|
|
defaultValue: '{{count}} ausgewählt',
|
|
count: selectedAssignedIds.length,
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1">
|
|
<Search className="h-4 w-4 text-slate-500" />
|
|
<Input
|
|
value={taskSearch}
|
|
onChange={(event) => setTaskSearch(event.target.value)}
|
|
placeholder={t('sections.assigned.search', 'Aufgaben suchen...')}
|
|
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700">
|
|
<span>{t('sections.assigned.results', { defaultValue: '{{count}} Treffer', count: filteredAssignedTasks.length })}</span>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant={difficultyFilter ? 'default' : 'outline'}
|
|
className="rounded-full"
|
|
onClick={() => setDifficultyFilter((prev) => (prev ? '' : 'medium'))}
|
|
aria-expanded={difficultyFilter ? true : false}
|
|
>
|
|
{difficultyFilter
|
|
? t('sections.assigned.difficulty.active', { defaultValue: 'Schwierigkeit: ' }) +
|
|
t(
|
|
difficultyFilter === 'easy'
|
|
? 'sections.assigned.difficulty.easy'
|
|
: difficultyFilter === 'medium'
|
|
? 'sections.assigned.difficulty.medium'
|
|
: 'sections.assigned.difficulty.hard',
|
|
difficultyFilter === 'easy' ? 'Leicht' : difficultyFilter === 'medium' ? 'Mittel' : 'Schwer',
|
|
)
|
|
: t('sections.assigned.difficulty.filter', 'Nach Schwierigkeit filtern')}
|
|
</Button>
|
|
{difficultyFilter ? (
|
|
<div className="flex flex-wrap gap-2 rounded-xl border border-slate-200 bg-white/80 p-2">
|
|
{(['', 'easy', 'medium', 'hard'] as Array<TenantTask['difficulty'] | ''>).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 (
|
|
<Button
|
|
key={value || 'all'}
|
|
size="sm"
|
|
variant={active ? 'default' : 'outline'}
|
|
className="rounded-full"
|
|
onClick={() => setDifficultyFilter(value)}
|
|
>
|
|
{label}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{emotionChips.length > 0 ? (
|
|
<Button
|
|
size="sm"
|
|
variant={emotionFilterOpen ? 'default' : 'outline'}
|
|
className="rounded-full"
|
|
onClick={() => setEmotionFilterOpen((prev) => !prev)}
|
|
>
|
|
{emotionFilterOpen
|
|
? t('filters.emotionsHide', 'Emotionen ausblenden')
|
|
: t('filters.emotionsShow', 'Emotionen filtern')}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void handleDetachSelected()}
|
|
disabled={selectedAssignedIds.length === 0 || batchSaving || saving}
|
|
>
|
|
{t('actions.removeSelected', 'Auswahl entfernen')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{emotionChips.length > 0 && emotionFilterOpen ? (
|
|
<div className="flex flex-wrap gap-2 rounded-xl border border-slate-200 bg-white/80 p-2">
|
|
<Button
|
|
size="sm"
|
|
variant={emotionFilter.length === 0 ? 'default' : 'outline'}
|
|
onClick={() => setEmotionFilter([])}
|
|
className="rounded-full"
|
|
>
|
|
{t('filters.allEmotions', 'Alle Emotionen')}
|
|
</Button>
|
|
{emotionChips.map((emotion) => {
|
|
const active = emotionFilter.includes(emotion.id);
|
|
return (
|
|
<Button
|
|
key={emotion.id}
|
|
size="sm"
|
|
variant={active ? 'default' : 'outline'}
|
|
onClick={() =>
|
|
setEmotionFilter((prev) =>
|
|
active ? prev.filter((id) => id !== emotion.id) : [...prev, emotion.id]
|
|
)
|
|
}
|
|
className="rounded-full"
|
|
style={
|
|
active
|
|
? { backgroundColor: emotion.color ?? '#e0f2fe', color: '#0f172a' }
|
|
: { borderColor: emotion.color ?? undefined, color: '#0f172a' }
|
|
}
|
|
>
|
|
{emotion.icon ? <span className="mr-1">{emotion.icon}</span> : null}
|
|
{emotion.name}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
<DropZone id="assigned-dropzone">
|
|
{filteredAssignedTasks.length === 0 ? (
|
|
<EmptyState
|
|
message={
|
|
taskSearch.trim()
|
|
? t('sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
|
|
: t('sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filteredAssignedTasks.map((task) => (
|
|
<DraggableTaskCard
|
|
key={task.id}
|
|
task={task}
|
|
origin="assigned"
|
|
onRemove={() => void handleDetachSingle(task.id)}
|
|
showCheckbox
|
|
checked={selectedAssignedIds.includes(task.id)}
|
|
onCheckedChange={(checked) => {
|
|
setSelectedAssignedIds((prev) => {
|
|
if (checked) {
|
|
if (prev.includes(task.id)) return prev;
|
|
return [...prev, task.id];
|
|
}
|
|
return prev.filter((id) => id !== task.id);
|
|
});
|
|
}}
|
|
disabled={batchSaving || saving}
|
|
onInlineUpdate={handleInlineUpdate}
|
|
inlineSaving={inlineSavingId === task.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</DropZone>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2" ref={libraryRef} id="library-section">
|
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
|
<PlusCircle className="h-4 w-4 text-emerald-500" />
|
|
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
|
|
</h3>
|
|
</div>
|
|
<p className="text-xs text-slate-600">
|
|
{t('sections.library.helper', 'Suche, filtere und füge einzelne Aufgaben hinzu. Eigene Aufgaben legst du über den Dialog an.')}
|
|
</p>
|
|
<DropZone id="library-dropzone">
|
|
<div className="space-y-2 max-h-80 overflow-y-auto">
|
|
{availableTasks.length === 0 ? (
|
|
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
|
|
) : (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2 pb-1">
|
|
<div className="flex items-center gap-2 text-xs text-slate-700">
|
|
<Checkbox
|
|
checked={
|
|
availableTasks.length > 0 && availableTasks.every((task) => selectedAvailableIds.includes(task.id))
|
|
? true
|
|
: selectedAvailableIds.some((id) => availableTasks.some((task) => task.id === id))
|
|
? 'indeterminate'
|
|
: false
|
|
}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setSelectedAvailableIds((prev) => {
|
|
const next = new Set(prev);
|
|
availableTasks.forEach((task) => next.add(task.id));
|
|
return Array.from(next);
|
|
});
|
|
} else {
|
|
setSelectedAvailableIds([]);
|
|
}
|
|
}}
|
|
aria-label={t('sections.library.selectAll', 'Alle Bibliotheks-Tasks auswählen')}
|
|
/>
|
|
<span>
|
|
{t('sections.library.selectedCount', {
|
|
defaultValue: '{{count}} ausgewählt',
|
|
count: selectedAvailableIds.length,
|
|
})}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void handleAssignSelected()}
|
|
disabled={selectedAvailableIds.length === 0 || !tasksEnabled || batchSaving || saving}
|
|
>
|
|
{t('actions.assignSelected', 'Auswahl zuweisen')}
|
|
</Button>
|
|
</div>
|
|
{availableTasks.map((task) => (
|
|
<DraggableTaskCard
|
|
key={task.id}
|
|
task={task}
|
|
origin="library"
|
|
onAdd={() => void handleAssignSingle(task.id)}
|
|
disabled={!tasksEnabled || saving || batchSaving}
|
|
showCheckbox
|
|
checked={selectedAvailableIds.includes(task.id)}
|
|
onCheckedChange={(checked) => {
|
|
setSelectedAvailableIds((prev) => {
|
|
if (checked) {
|
|
if (prev.includes(task.id)) return prev;
|
|
return [...prev, task.id];
|
|
}
|
|
return prev.filter((id) => id !== task.id);
|
|
});
|
|
}}
|
|
onInlineUpdate={handleInlineUpdate}
|
|
inlineSaving={inlineSavingId === task.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DropZone>
|
|
</section>
|
|
</CardContent>
|
|
<DragOverlay>
|
|
{draggingId ? (
|
|
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/90 px-4 py-3 shadow-sm opacity-80">
|
|
{filteredAssignedTasks.find((t) => t.id === draggingId)?.title ??
|
|
availableTasks.find((t) => t.id === draggingId)?.title}
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</Card>
|
|
{hasSelection ? (
|
|
<div className="fixed inset-x-0 bottom-4 z-30 flex justify-center px-4">
|
|
<div className="pointer-events-auto flex w-full max-w-4xl flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl">
|
|
<div className="flex flex-1 flex-wrap items-center gap-2 text-sm text-slate-700">
|
|
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
|
{t('sections.bulk.assignedSelected', {
|
|
defaultValue: '{{count}} ausgewählt (Zugeordnet)',
|
|
count: selectedAssignedIds.length,
|
|
})}
|
|
</Badge>
|
|
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
|
{t('sections.bulk.librarySelected', {
|
|
defaultValue: '{{count}} ausgewählt (Bibliothek)',
|
|
count: selectedAvailableIds.length,
|
|
})}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void handleAssignSelected()}
|
|
disabled={selectedAvailableIds.length === 0 || !tasksEnabled || batchSaving || saving}
|
|
>
|
|
{t('actions.assignSelected', 'Auswahl zuweisen')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void handleDetachSelected()}
|
|
disabled={selectedAssignedIds.length === 0 || batchSaving || saving}
|
|
>
|
|
{t('actions.removeSelected', 'Auswahl entfernen')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setSelectedAssignedIds([]);
|
|
setSelectedAvailableIds([]);
|
|
}}
|
|
>
|
|
{t('sections.bulk.clear', 'Auswahl aufheben')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</TabsContent>
|
|
<TabsContent value="packs">
|
|
<MissionPackGrid
|
|
collections={collections}
|
|
loading={collectionsLoading}
|
|
error={collectionsError}
|
|
onImport={handleImportCollection}
|
|
importingId={importingCollectionId}
|
|
onViewAll={() => navigate(buildEngagementTabPath('collections'))}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="emotions">
|
|
<EmotionsCard
|
|
emotions={relevantEmotions}
|
|
emotionsLoading={emotionsLoading}
|
|
emotionsError={emotionsError}
|
|
onOpenEmotions={() => setEmotionsModalOpen(true)}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</>
|
|
)}
|
|
|
|
<Dialog open={quickAddOpen} onOpenChange={setQuickAddOpen}>
|
|
<DialogContent className="max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('sections.library.quickCreate', 'Eigene Aufgabe hinzufügen')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<p className="text-xs text-slate-600">
|
|
{t('sections.library.quickHelper', 'Titel eingeben, optional beschreiben und sofort zum Event zuweisen.')}
|
|
</p>
|
|
<Input
|
|
value={newTaskTitle}
|
|
onChange={(e) => setNewTaskTitle(e.target.value)}
|
|
placeholder={t('sections.library.quickTitle', 'Titel der Aufgabe')}
|
|
disabled={!tasksEnabled || creatingTask}
|
|
/>
|
|
<Textarea
|
|
value={newTaskDescription}
|
|
onChange={(e) => setNewTaskDescription(e.target.value)}
|
|
placeholder={t('sections.library.quickDescription', 'Beschreibung (optional)')}
|
|
disabled={!tasksEnabled || creatingTask}
|
|
className="min-h-[90px]"
|
|
/>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-slate-700">{t('sections.library.quickEmotion', 'Emotion')}</label>
|
|
<select
|
|
className="h-9 w-full rounded-lg border border-slate-200 bg-white px-2 text-sm"
|
|
value={newTaskEmotionId ?? ''}
|
|
onChange={(e) => setNewTaskEmotionId(e.target.value ? Number(e.target.value) : null)}
|
|
disabled={!tasksEnabled || creatingTask}
|
|
>
|
|
<option value="">{t('sections.library.quickEmotionNone', 'Keine')}</option>
|
|
{relevantEmotions.map((emotion) => (
|
|
<option key={emotion.id} value={emotion.id}>
|
|
{emotion.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-slate-700">{t('sections.library.quickDifficulty', 'Schwierigkeit')}</label>
|
|
<select
|
|
className="h-9 w-full rounded-lg border border-slate-200 bg-white px-2 text-sm"
|
|
disabled={!tasksEnabled || creatingTask}
|
|
value={newTaskDifficulty}
|
|
onChange={(e) => setNewTaskDifficulty(e.target.value as TenantTask['difficulty'] | '')}
|
|
>
|
|
<option value="">{t('sections.library.quickDifficultyNone', 'Keine')}</option>
|
|
<option value="easy">{t('sections.library.difficulty.easy', 'Leicht')}</option>
|
|
<option value="medium">{t('sections.library.difficulty.medium', 'Mittel')}</option>
|
|
<option value="hard">{t('sections.library.difficulty.hard', 'Schwer')}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setQuickAddOpen(false);
|
|
setNewTaskTitle('');
|
|
setNewTaskDescription('');
|
|
setNewTaskEmotionId(null);
|
|
setNewTaskDifficulty('');
|
|
}}
|
|
>
|
|
{t('actions.cancel', 'Abbrechen')}
|
|
</Button>
|
|
<Button
|
|
onClick={() => void handleCreateQuickTask()}
|
|
disabled={!newTaskTitle.trim() || creatingTask || !tasksEnabled}
|
|
>
|
|
{creatingTask ? <Loader2 className="h-4 w-4 animate-spin" /> : t('sections.library.quickCreateCta', 'Erstellen & zuweisen')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={emotionsModalOpen} onOpenChange={setEmotionsModalOpen}>
|
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('tasks.emotions.manage', 'Emotionen verwalten')}</DialogTitle>
|
|
</DialogHeader>
|
|
<EmotionsSection embedded />
|
|
</DialogContent>
|
|
</Dialog>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ message }: { message: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-6 text-center">
|
|
<p className="text-xs text-slate-600">{message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskSkeleton() {
|
|
return (
|
|
<div className="space-y-4">
|
|
{Array.from({ length: 2 }).map((_, index) => (
|
|
<div key={index} className="h-48 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DropZone({ id, children }: { id: string; children: React.ReactNode }) {
|
|
const zone = id === 'assigned-dropzone' ? 'assigned' : 'library';
|
|
const { setNodeRef, isOver } = useDroppable({ id, data: { list: zone } });
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
className={`rounded-2xl border border-dashed p-2 ${isOver ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200/70'}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DraggableTaskCard({
|
|
task,
|
|
origin,
|
|
onRemove,
|
|
onAdd,
|
|
disabled,
|
|
showCheckbox,
|
|
checked,
|
|
onCheckedChange,
|
|
onInlineUpdate,
|
|
inlineSaving,
|
|
}: {
|
|
task: TenantTask;
|
|
origin: 'assigned' | 'library';
|
|
onRemove?: () => void;
|
|
onAdd?: () => void;
|
|
disabled?: boolean;
|
|
showCheckbox?: boolean;
|
|
checked?: boolean;
|
|
onCheckedChange?: (checked: boolean) => void;
|
|
onInlineUpdate?: (taskId: number, payload: { title?: string; difficulty?: TenantTask['difficulty'] | '' }) => void;
|
|
inlineSaving?: boolean;
|
|
}) {
|
|
const { t } = useTranslation('management');
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({
|
|
id: task.id,
|
|
data: { list: origin },
|
|
});
|
|
|
|
const [editing, setEditing] = React.useState(false);
|
|
const [titleValue, setTitleValue] = React.useState(task.title ?? '');
|
|
const [difficultyValue, setDifficultyValue] = React.useState<TenantTask['difficulty'] | ''>(task.difficulty ?? '');
|
|
|
|
React.useEffect(() => {
|
|
setTitleValue(task.title ?? '');
|
|
setDifficultyValue(task.difficulty ?? '');
|
|
}, [task.title, task.difficulty]);
|
|
|
|
const style = {
|
|
transform: transform ? CSS.Translate.toString(transform) : undefined,
|
|
transition: transition || undefined,
|
|
opacity: isDragging ? 0.8 : 1,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm"
|
|
onDoubleClick={() => setEditing(true)}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex items-center gap-2 pt-1">
|
|
{showCheckbox ? (
|
|
<Checkbox
|
|
checked={checked ?? false}
|
|
onCheckedChange={(state) => onCheckedChange?.(Boolean(state))}
|
|
aria-label={t('actions.selectTask', 'Task auswählen')}
|
|
/>
|
|
) : null}
|
|
<button
|
|
className="h-7 w-7 rounded-md border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 disabled:opacity-50"
|
|
{...listeners}
|
|
{...attributes}
|
|
disabled={disabled}
|
|
aria-label={t('library.dragHandle', 'Task verschieben')}
|
|
>
|
|
⋮⋮
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 space-y-2 min-w-0">
|
|
<div className="space-y-1 min-w-0 max-w-full">
|
|
{editing ? (
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
value={titleValue}
|
|
onChange={(e) => setTitleValue(e.target.value)}
|
|
disabled={inlineSaving}
|
|
className="min-h-[64px]"
|
|
/>
|
|
<select
|
|
className="h-9 w-full rounded-lg border border-slate-200 bg-white px-2 text-sm"
|
|
value={difficultyValue}
|
|
onChange={(e) => setDifficultyValue(e.target.value as TenantTask['difficulty'] | '')}
|
|
disabled={inlineSaving}
|
|
>
|
|
<option value="">{t('sections.assigned.difficulty.all', 'Alle Stufen')}</option>
|
|
<option value="easy">{t('sections.library.difficulty.easy', 'Leicht')}</option>
|
|
<option value="medium">{t('sections.library.difficulty.medium', 'Mittel')}</option>
|
|
<option value="hard">{t('sections.library.difficulty.hard', 'Schwer')}</option>
|
|
</select>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="icon"
|
|
variant="secondary"
|
|
className="h-8 w-8"
|
|
disabled={!titleValue.trim() || inlineSaving}
|
|
onClick={() => {
|
|
if (!onInlineUpdate) return;
|
|
onInlineUpdate(task.id, { title: titleValue.trim(), difficulty: difficultyValue });
|
|
setEditing(false);
|
|
}}
|
|
>
|
|
{inlineSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8"
|
|
disabled={inlineSaving}
|
|
onClick={() => {
|
|
setTitleValue(task.title ?? '');
|
|
setDifficultyValue(task.difficulty ?? '');
|
|
setEditing(false);
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-slate-900 break-words">{task.title}</p>
|
|
{task.description ? <p className="text-xs text-slate-600 break-words">{task.description}</p> : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{!editing && task.emotion ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-transparent text-[11px]"
|
|
style={{
|
|
backgroundColor: `${task.emotion.color ?? '#eef2ff'}20`,
|
|
color: task.emotion.color ?? '#4338ca',
|
|
}}
|
|
>
|
|
{task.emotion.icon ? <span className="mr-1">{task.emotion.icon}</span> : null}
|
|
{task.emotion.name}
|
|
</Badge>
|
|
) : null}
|
|
{!editing ? (
|
|
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
|
{mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))}
|
|
</Badge>
|
|
) : null}
|
|
{task.tenant_id !== null ? (
|
|
<Badge variant="outline" className="border-emerald-200 text-emerald-700">
|
|
{t('tasks.customBadge', 'Eigene Aufgabe')}
|
|
</Badge>
|
|
) : null}
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => setEditing((prev) => !prev)}
|
|
aria-label={t('actions.edit', 'Bearbeiten')}
|
|
disabled={inlineSaving}
|
|
>
|
|
{editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4 text-slate-500" />}
|
|
</Button>
|
|
{origin === 'assigned' ? (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={onRemove}
|
|
disabled={disabled}
|
|
aria-label={t('actions.remove', 'Vom Event entfernen')}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-slate-500" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={onAdd}
|
|
disabled={disabled}
|
|
aria-label={t('actions.assign', 'Zum Event hinzufügen')}
|
|
>
|
|
<PlusCircle className="h-4 w-4 text-emerald-600" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MissionPackGrid({
|
|
collections,
|
|
loading,
|
|
error,
|
|
onImport,
|
|
importingId,
|
|
onViewAll,
|
|
}: {
|
|
collections: TenantTaskCollection[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
onImport: (collection: TenantTaskCollection) => void;
|
|
importingId: number | null;
|
|
onViewAll: () => void;
|
|
}) {
|
|
const { t } = useTranslation('management', { keyPrefix: 'eventTasks.collections' });
|
|
|
|
return (
|
|
<Card className="border border-slate-200 bg-white/90">
|
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2 text-base text-slate-900">
|
|
<Layers className="h-5 w-5 text-pink-500" />
|
|
{t('title', 'Vorlagen / Aufgaben-Bundles')}
|
|
</CardTitle>
|
|
<CardDescription className="text-sm text-slate-600">
|
|
{t('subtitle', 'Importiere Aufgaben-Sets, die zu deinem Event passen.')}
|
|
</CardDescription>
|
|
</div>
|
|
<Button variant="outline" onClick={onViewAll}>
|
|
{t('viewAll', 'Alle Sets ansehen')}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{error ? (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('errorTitle', 'Kollektionen nicht verfügbar')}</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<div key={index} className="h-24 animate-pulse rounded-2xl bg-slate-100/60" />
|
|
))}
|
|
</div>
|
|
) : collections.length === 0 ? (
|
|
<EmptyState message={t('empty', 'Keine empfohlenen Kollektionen gefunden.')} />
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{collections.map((collection) => (
|
|
<div key={collection.id} className="flex flex-col rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
<p className="text-sm font-semibold text-slate-900">{collection.name}</p>
|
|
{collection.description ? (
|
|
<p className="text-xs text-slate-500">{collection.description}</p>
|
|
) : null}
|
|
<Badge variant="outline" className="w-fit border-slate-200 text-slate-600">
|
|
{t('tasksCount', {
|
|
defaultValue: '{{count}} Aufgaben',
|
|
count: collection.tasks_count,
|
|
})}
|
|
</Badge>
|
|
</div>
|
|
<div className="mt-4 flex justify-between text-xs text-slate-500">
|
|
<span>{collection.event_type?.name ?? t('genericType', 'Allgemein')}</span>
|
|
<span>{collection.is_global ? t('global', 'Global') : t('custom', 'Custom')}</span>
|
|
</div>
|
|
<Button
|
|
className="mt-4 rounded-full bg-brand-rose text-white"
|
|
disabled={importingId === collection.id}
|
|
onClick={() => onImport(collection)}
|
|
>
|
|
{importingId === collection.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
t('importCta', 'Aufgaben-Set importieren')
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
type EmotionsCardProps = {
|
|
emotions: TenantEmotion[];
|
|
emotionsLoading: boolean;
|
|
emotionsError: string | null;
|
|
onOpenEmotions: () => void;
|
|
};
|
|
|
|
function EmotionsCard({ emotions, emotionsLoading, emotionsError, onOpenEmotions }: EmotionsCardProps) {
|
|
const { t } = useTranslation('management');
|
|
const spotlightEmotions = emotions.slice(0, 6);
|
|
|
|
return (
|
|
<Card className="border border-rose-100 bg-rose-50/70 shadow-sm">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base text-rose-900">
|
|
<Sparkles className="h-5 w-5 text-rose-500" />
|
|
{t('tasks.story.emotionsTitle', 'Emotionen')}
|
|
</CardTitle>
|
|
<CardDescription className="text-sm text-rose-800">
|
|
{t('tasks.story.description', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="outline" className="border-rose-200 text-rose-700">
|
|
{t('tasks.story.emotionsCount', { defaultValue: '{{count}} aktiv', count: emotions.length })}
|
|
</Badge>
|
|
<Button
|
|
size="sm"
|
|
className="bg-rose-600 text-white hover:bg-rose-700"
|
|
onClick={onOpenEmotions}
|
|
>
|
|
{t('tasks.story.emotionsCta', 'Emotionen verwalten')}
|
|
</Button>
|
|
</div>
|
|
{emotionsLoading ? (
|
|
<div className="h-10 animate-pulse rounded-xl bg-white/70" />
|
|
) : emotionsError ? (
|
|
<p className="text-xs text-rose-900/70">{emotionsError}</p>
|
|
) : spotlightEmotions.length ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
{spotlightEmotions.map((emotion) => (
|
|
<span
|
|
key={emotion.id}
|
|
className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
|
|
style={{
|
|
backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
|
|
color: emotion.color ?? '#be123c',
|
|
}}
|
|
>
|
|
{emotion.icon ? <span>{emotion.icon}</span> : null}
|
|
{emotion.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-rose-900/70">
|
|
{t('tasks.story.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string {
|
|
switch (priority) {
|
|
case 'low':
|
|
return translate('management.eventTasks.priorities.low', 'Niedrig');
|
|
case 'high':
|
|
return translate('management.eventTasks.priorities.high', 'Hoch');
|
|
case 'urgent':
|
|
return translate('management.eventTasks.priorities.urgent', 'Dringend');
|
|
default:
|
|
return translate('management.eventTasks.priorities.medium', 'Mittel');
|
|
}
|
|
}
|