1682 lines
70 KiB
TypeScript
1682 lines
70 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, CircleOff } 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 [libraryOpen, setLibraryOpen] = React.useState(false);
|
|
const [librarySearch, setLibrarySearch] = React.useState('');
|
|
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;
|
|
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 payload = {
|
|
name: event.name,
|
|
slug: event.slug,
|
|
event_type_id: event.event_type_id ?? event.event_type?.id,
|
|
event_date: event.event_date ?? undefined,
|
|
settings: {
|
|
...(event.settings ?? {}),
|
|
engagement_mode: nextMode,
|
|
},
|
|
};
|
|
const updated = await updateEvent(slug, {
|
|
...payload,
|
|
});
|
|
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([]);
|
|
setLibraryOpen(false);
|
|
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'] | ''; emotion_id?: number | null }) => {
|
|
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,
|
|
emotion_id: payload.emotion_id ?? optimistic.emotion_id ?? null,
|
|
} as Partial<TenantTask>;
|
|
|
|
const nextEmotion =
|
|
typeof payload.emotion_id === 'number'
|
|
? emotions.find((emotion) => emotion.id === payload.emotion_id) ?? null
|
|
: payload.emotion_id === null
|
|
? null
|
|
: optimistic.emotion ?? null;
|
|
|
|
if (existingAssigned) {
|
|
setAssignedTasks((prev) =>
|
|
prev.map((task) => (task.id === taskId ? { ...task, ...patch, emotion: nextEmotion } : task)),
|
|
);
|
|
}
|
|
if (existingAvailable) {
|
|
setAvailableTasks((prev) =>
|
|
prev.map((task) => (task.id === taskId ? { ...task, ...patch, emotion: nextEmotion } : 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: payload.emotion_id ?? optimistic.emotion_id ?? undefined,
|
|
});
|
|
|
|
if (existingAssigned) {
|
|
setAssignedTasks((prev) =>
|
|
prev.map((task) =>
|
|
task.id === taskId ? { ...task, ...updated, emotion: nextEmotion ?? updated.emotion ?? null } : task,
|
|
),
|
|
);
|
|
}
|
|
if (existingAvailable) {
|
|
setAvailableTasks((prev) =>
|
|
prev.map((task) =>
|
|
task.id === taskId ? { ...task, ...updated, emotion: nextEmotion ?? updated.emotion ?? null } : 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-col gap-2">
|
|
<div className="flex justify-end">
|
|
<Button
|
|
variant="outline"
|
|
className="border-emerald-200 text-emerald-700"
|
|
onClick={() => navigate(buildEngagementTabPath('tasks'))}
|
|
>
|
|
{t('library.open', 'Aufgaben-Bibliothek öffnen')}
|
|
</Button>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<TabsList className="inline-flex min-w-fit gap-2 rounded-2xl bg-slate-100/80 p-1">
|
|
{tabOrder.map((key) => (
|
|
<TabsTrigger key={key} value={key} className="px-3 py-1.5 text-sm sm:text-base">
|
|
{key === 'packs'
|
|
? t('tabs.packs', 'Aufgaben-Sets')
|
|
: key === 'tasks'
|
|
? t('tabs.tasks', 'Aufgaben')
|
|
: t('tabs.emotions', 'Emotionen')}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</div>
|
|
</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>
|
|
{!tasksEnabled ? (
|
|
<div className="flex flex-col gap-2 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
|
<div className="flex items-center gap-3">
|
|
<CircleOff className="h-5 w-5" />
|
|
<div>
|
|
<p className="font-semibold">{t('modes.disabledTitle', 'Aufgabenmodus ist aus')}</p>
|
|
<p className="text-xs text-amber-800">
|
|
{t('modes.disabledCopy', 'Gäste sehen keine Mission Cards. Aktivieren, um Aufgaben sichtbar zu machen.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
className="bg-amber-600 text-white hover:bg-amber-700"
|
|
onClick={() => void handleModeChange(true)}
|
|
disabled={modeSaving || assignedTasks.length === 0}
|
|
>
|
|
{modeSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('modes.enable', 'Aufgaben aktivieren')}
|
|
</Button>
|
|
{assignedTasks.length === 0 ? (
|
|
<p className="text-[11px] text-amber-800">
|
|
{t('modes.needTasks', 'Aktiviere Aufgaben, sobald mindestens eine Aufgabe zugewiesen ist.')}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</CardHeader>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={(event) => setDraggingId(Number(event.active.id))}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<CardContent className="relative grid gap-4">
|
|
{!tasksEnabled ? (
|
|
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-2xl bg-white/80 backdrop-blur-sm">
|
|
<div className="flex flex-col items-center gap-2 text-center text-sm text-slate-700">
|
|
<CircleOff className="h-5 w-5 text-amber-700" />
|
|
<p className="font-semibold">{t('modes.disabledTitle', 'Aufgabenmodus ist aus')}</p>
|
|
<p className="text-xs text-slate-600">
|
|
{t('modes.disabledOverlay', 'Aktiviere Aufgaben, um Listen und Aktionen zu nutzen.')}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
className="bg-amber-600 text-white hover:bg-amber-700"
|
|
onClick={() => void handleModeChange(true)}
|
|
disabled={modeSaving || assignedTasks.length === 0}
|
|
>
|
|
{t('modes.enable', 'Aufgaben aktivieren')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<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"
|
|
className="border-emerald-200 text-emerald-700"
|
|
variant="outline"
|
|
onClick={() => setLibraryOpen(true)}
|
|
>
|
|
<PlusCircle className="mr-2 h-4 w-4" />
|
|
{t('actions.openLibrary', 'Aus Bibliothek hinzufügen')}
|
|
</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}
|
|
emotions={relevantEmotions}
|
|
/>
|
|
))}
|
|
</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>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void handleDetachSelected()}
|
|
disabled={selectedAssignedIds.length === 0 || batchSaving || saving}
|
|
>
|
|
{t('actions.removeSelected', 'Ausgewählte Aufgaben entfernen')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setSelectedAssignedIds([]);
|
|
}}
|
|
>
|
|
{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={libraryOpen} onOpenChange={(open) => {
|
|
setLibraryOpen(open);
|
|
if (!open) {
|
|
setSelectedAvailableIds([]);
|
|
setLibrarySearch('');
|
|
}
|
|
}}>
|
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2">
|
|
<Search className="h-4 w-4 text-slate-500" />
|
|
<Input
|
|
value={librarySearch}
|
|
onChange={(e) => setLibrarySearch(e.target.value)}
|
|
placeholder={t('sections.library.search', 'Aufgaben suchen...')}
|
|
className="h-8 border-0 bg-transparent p-0 text-sm focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 text-xs text-slate-600">
|
|
<Badge variant="outline" className="border-slate-200">
|
|
{t('sections.library.selectedCount', { defaultValue: '{{count}} ausgewählt', count: selectedAvailableIds.length })}
|
|
</Badge>
|
|
<Badge variant="outline" className="border-slate-200">
|
|
{t('summary.library', 'Bibliothek')} · {availableTasks.length}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 max-h-[55vh] overflow-y-auto">
|
|
{availableTasks.length === 0 ? (
|
|
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
|
|
) : (
|
|
availableTasks
|
|
.filter((task) =>
|
|
librarySearch.trim()
|
|
? `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(librarySearch.toLowerCase().trim())
|
|
: true,
|
|
)
|
|
.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}
|
|
emotions={relevantEmotions}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{selectedAvailableIds.length > 0 ? (
|
|
<div className="sticky bottom-0 flex flex-wrap items-center gap-2 rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-lg">
|
|
<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.library.selectedCount', { defaultValue: '{{count}} ausgewählt', 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="ghost"
|
|
onClick={() => setSelectedAvailableIds([])}
|
|
>
|
|
{t('sections.bulk.clear', 'Auswahl aufheben')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</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,
|
|
emotions,
|
|
}: {
|
|
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'] | ''; emotion_id?: number | null }) => void;
|
|
inlineSaving?: boolean;
|
|
emotions?: TenantEmotion[];
|
|
}) {
|
|
const { t } = useTranslation('management');
|
|
|
|
const [editing, setEditing] = React.useState(false);
|
|
const [titleValue, setTitleValue] = React.useState(task.title ?? '');
|
|
const [difficultyValue, setDifficultyValue] = React.useState<TenantTask['difficulty'] | ''>(task.difficulty ?? '');
|
|
const [emotionIdValue, setEmotionIdValue] = React.useState<number | ''>(task.emotion_id ?? '');
|
|
|
|
React.useEffect(() => {
|
|
setTitleValue(task.title ?? '');
|
|
setDifficultyValue(task.difficulty ?? '');
|
|
setEmotionIdValue(task.emotion_id ?? '');
|
|
}, [task.title, task.difficulty, task.emotion_id]);
|
|
|
|
return (
|
|
<div
|
|
className="rounded-xl border border-slate-200 bg-white/90 px-3 py-2 shadow-sm"
|
|
onDoubleClick={() => setEditing(true)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<div className="flex min-w-[32px] flex-col items-center gap-1 pt-1">
|
|
{showCheckbox ? (
|
|
<Checkbox
|
|
checked={checked ?? false}
|
|
onCheckedChange={(state) => onCheckedChange?.(Boolean(state))}
|
|
aria-label={t('actions.selectTask', 'Task auswählen')}
|
|
/>
|
|
) : null}
|
|
<div className="flex flex-col items-center gap-1">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => setEditing((prev) => !prev)}
|
|
aria-label={t('actions.edit', 'Bearbeiten')}
|
|
disabled={inlineSaving}
|
|
className="h-8 w-8"
|
|
>
|
|
{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')}
|
|
className="h-8 w-8"
|
|
>
|
|
<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')}
|
|
className="h-8 w-8"
|
|
>
|
|
<PlusCircle className="h-4 w-4 text-emerald-600" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 space-y-1 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>
|
|
{emotions && emotions.length > 0 ? (
|
|
<select
|
|
className="h-9 w-full rounded-lg border border-slate-200 bg-white px-2 text-sm"
|
|
value={emotionIdValue}
|
|
onChange={(e) => setEmotionIdValue(e.target.value ? Number(e.target.value) : '')}
|
|
disabled={inlineSaving}
|
|
>
|
|
<option value="">{t('sections.library.quickEmotionNone', 'Keine')}</option>
|
|
{emotions.map((emotion) => (
|
|
<option key={emotion.id} value={emotion.id}>
|
|
{emotion.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : null}
|
|
<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,
|
|
emotion_id: emotionIdValue === '' ? null : Number(emotionIdValue),
|
|
});
|
|
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 text-xs">
|
|
{!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}
|
|
</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');
|
|
}
|
|
}
|