import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight, Check, Info } from 'lucide-react'; import { Card } from '@tamagui/card'; import { YStack, XStack } from '@tamagui/stacks'; import { YGroup } from '@tamagui/group'; import { SizableText as Text } from '@tamagui/text'; import { ListItem } from '@tamagui/list-item'; import { Pressable } from '@tamagui/react-native-web-lite'; import { Button } from '@tamagui/button'; import { AlertDialog } from '@tamagui/alert-dialog'; import { ScrollView } from '@tamagui/scroll-view'; import { ToggleGroup } from '@tamagui/toggle-group'; import { Switch } from '@tamagui/switch'; import { Checkbox } from '@tamagui/checkbox'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton, PillBadge } from './components/Primitives'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { getEvent, getEvents, getEventTasks, updateEvent, updateTask, TenantTask, TenantEvent, assignTasksToEvent, getTasks, getTaskCollections, importTaskCollection, createTask, TenantTaskCollection, getEmotions, TenantEmotion, detachTasksFromEvent, createEmotion, updateEmotion as updateEmotionApi, deleteEmotion as deleteEmotionApi, } from '../api'; import { adminPath } from '../constants'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import toast from 'react-hot-toast'; import { MobileSheet } from './components/Sheet'; import { Tag } from './components/Tag'; import { useEventContext } from '../context/EventContext'; import { RadioGroup } from '@tamagui/radio-group'; import { useBackNavigation } from './hooks/useBackNavigation'; import { buildTaskSummary } from './lib/taskSummary'; import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts'; import { withAlpha } from './components/colors'; import { useAdminTheme } from './theme'; import { resolveEngagementMode } from '../lib/events'; import { useAuth } from '../auth/context'; function allowPermission(permissions: string[], permission: string): boolean { if (permissions.includes('*') || permissions.includes(permission)) { return true; } if (permission.includes(':')) { const [prefix] = permission.split(':'); return permissions.includes(`${prefix}:*`); } return false; } function QuickNavChip({ value, label, count, onPress, isActive = false, }: { value: TaskSectionKey; label: string; count: number; onPress: () => void; isActive?: boolean; }) { const { textStrong, border, surface, surfaceMuted, primary } = useAdminTheme(); const activeBorder = withAlpha(primary, 0.45); const activeBackground = withAlpha(primary, 0.16); return ( {label} {count} ); } export default function MobileEventTasksPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const { activeEvent, selectEvent } = useEventContext(); const slug = slugParam ?? activeEvent?.slug ?? null; const navigate = useNavigate(); const { t } = useTranslation('management'); const { user } = useAuth(); const { textStrong, muted, subtle, border, primary, danger, surface, surfaceMuted, dangerBg, dangerText, overlay, shadow, glassSurface, glassBorder, glassShadow, } = useAdminTheme(); const stickySurface = glassSurface ?? surface; const stickyBorder = glassBorder ?? border; const stickyShadow = glassShadow ?? shadow; const isMember = user?.role === 'member'; const [assignedTasks, setAssignedTasks] = React.useState([]); const [library, setLibrary] = React.useState([]); const [collections, setCollections] = React.useState([]); const [emotions, setEmotions] = React.useState([]); const [showCollectionSheet, setShowCollectionSheet] = React.useState(false); const [showTaskSheet, setShowTaskSheet] = React.useState(false); const [newTask, setNewTask] = React.useState({ id: null as number | null, title: '', description: '', emotion_id: '' as string | '', tenant_id: null as number | null, }); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [busyId, setBusyId] = React.useState(null); const [assigningId, setAssigningId] = React.useState(null); const [deleteCandidate, setDeleteCandidate] = React.useState(null); const [eventId, setEventId] = React.useState(null); const [searchTerm, setSearchTerm] = React.useState(''); const [emotionFilter, setEmotionFilter] = React.useState(''); const [expandedLibrary, setExpandedLibrary] = React.useState(false); const [expandedCollections, setExpandedCollections] = React.useState(false); const [selectionMode, setSelectionMode] = React.useState(false); const [selectedTaskIds, setSelectedTaskIds] = React.useState>(new Set()); const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false); const [bulkDeleteBusy, setBulkDeleteBusy] = React.useState(false); const longPressTimer = React.useRef(null); const longPressTriggered = React.useRef(false); const [showFabMenu, setShowFabMenu] = React.useState(false); const [showBulkSheet, setShowBulkSheet] = React.useState(false); const [bulkLines, setBulkLines] = React.useState(''); const [showEmotionSheet, setShowEmotionSheet] = React.useState(false); const [editingEmotion, setEditingEmotion] = React.useState(null); const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) }); const [savingEmotion, setSavingEmotion] = React.useState(false); const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false); const [showTaskDetails, setShowTaskDetails] = React.useState(false); const [quickNavSelection, setQuickNavSelection] = React.useState(''); const [eventRecord, setEventRecord] = React.useState(null); const [tasksToggleBusy, setTasksToggleBusy] = React.useState(false); const text = textStrong; const assignedRef = React.useRef(null); const libraryRef = React.useRef(null); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const summary = buildTaskSummary({ assigned: assignedTasks.length, library: library.length, collections: collections.length, emotions: emotions.length, }); const permissionSource = eventRecord ?? activeEvent; const memberPermissions = Array.isArray(permissionSource?.member_permissions) ? permissionSource?.member_permissions ?? [] : []; const canManageTasks = React.useMemo( () => (isMember ? allowPermission(memberPermissions, 'tasks:manage') : true), [isMember, memberPermissions] ); const tasksEnabled = resolveEngagementMode(permissionSource ?? null) !== 'photo_only'; const sectionCounts = React.useMemo(() => buildTaskSectionCounts(summary), [summary]); const maxTasks = React.useMemo(() => { const limit = eventRecord?.limits?.tasks?.limit; return typeof limit === 'number' && Number.isFinite(limit) ? limit : null; }, [eventRecord?.limits?.tasks?.limit]); const remainingTasks = React.useMemo(() => { if (maxTasks === null) { return null; } return Math.max(0, maxTasks - assignedTasks.length); }, [assignedTasks.length, maxTasks]); const canAddTasks = maxTasks === null || (remainingTasks ?? 0) > 0; const limitReachedMessage = t('events.tasks.limitReached', 'Photo task limit reached.'); const limitReachedHint = maxTasks === null ? null : t('events.tasks.limitReachedHint', { count: maxTasks, defaultValue: 'This event allows up to {{count}} photo tasks.', }); React.useEffect(() => { if (slugParam && activeEvent?.slug !== slugParam) { selectEvent(slugParam); } }, [slugParam, activeEvent?.slug, selectEvent]); // Reset filters when switching events to avoid empty lists due to stale filters. React.useEffect(() => { setEmotionFilter(''); setSearchTerm(''); }, [slug]); const scrollToSection = (ref: React.RefObject) => { if (ref.current) { ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }; const handleQuickNav = (key: TaskSectionKey) => { if (key === 'assigned') { scrollToSection(assignedRef); return; } if (key === 'library') { scrollToSection(libraryRef); return; } if (key === 'collections') { setShowCollectionSheet(true); return; } setShowEmotionSheet(true); }; const load = React.useCallback(async () => { if (!slug) { try { const available = await getEvents({ force: true }); if (available.length) { const target = available[0]; selectEvent(target.slug ?? null); navigate(adminPath(`/mobile/events/${target.slug ?? ''}/tasks`)); return; } } catch { // ignore } finally { setError(t('events.errors.missingSlug', 'Kein Event-Slug angegeben.')); setLoading(false); } return; } setLoading(true); setError(null); try { const event = await getEvent(slug); setEventId(event.id); setEventRecord(event); const eventTypeSlug = event.event_type?.slug ?? null; const [result, libraryTasks] = await Promise.all([ getEventTasks(event.id, 1), getTasks({ per_page: 200 }), ]); const collectionList = await getTaskCollections({ per_page: 50, event_type: eventTypeSlug ?? undefined, }); const emotionList = await getEmotions(); const assignedIds = new Set(result.data.map((t) => t.id)); const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null; const filteredLibrary = 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; }); setAssignedTasks(result.data); setLibrary(filteredLibrary); setCollections(collectionList.data ?? []); setEmotions(emotionList ?? []); setSelectionMode(false); setSelectedTaskIds(new Set()); } catch (err) { if (!isAuthError(err)) { const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Fotoaufgaben konnten nicht geladen werden.')); setError(message); toast.error(message); // If the current slug is invalid, attempt to recover to a valid event to avoid empty lists. try { const available = await getEvents({ force: true }); const fallback = available.find((e: TenantEvent) => e.slug !== slug) ?? available[0]; if (fallback?.slug) { selectEvent(fallback.slug); navigate(adminPath(`/mobile/events/${fallback.slug}/tasks`)); } } catch { // ignore } } } finally { setLoading(false); } }, [slug, t, navigate, selectEvent]); React.useEffect(() => { void load(); }, [load]); async function quickAssign(taskId: number) { if (!eventId) return; if (!canAddTasks) { toast.error(limitReachedMessage); return; } setAssigningId(taskId); try { await assignTasksToEvent(eventId, [taskId]); const result = await getEventTasks(eventId, 1); setAssignedTasks(result.data); setLibrary((prev) => prev.filter((t) => t.id !== taskId)); toast.success(t('events.tasks.assigned', 'Fotoaufgabe hinzugefügt')); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Fotoaufgabe konnte nicht zugewiesen werden.'))); toast.error(t('events.tasks.updateFailed', 'Fotoaufgabe konnte nicht zugewiesen werden.')); } } finally { setAssigningId(null); } } async function importCollection(collectionId: number) { if (!slug || !eventId) return; if (!canAddTasks) { toast.error(limitReachedMessage); return; } try { await importTaskCollection(collectionId, slug); const result = await getEventTasks(eventId, 1); const assignedIds = new Set(result.data.map((t) => t.id)); setAssignedTasks(result.data); setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id))); toast.success(t('events.tasks.imported', 'Fotoaufgabenpaket importiert')); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Paket konnte nicht importiert werden.'))); toast.error(t('events.errors.saveFailed', 'Paket konnte nicht importiert werden.')); } } } async function createNewTask() { if (!eventId || !newTask.title.trim()) return; if (!canAddTasks) { toast.error(limitReachedMessage); return; } try { if (newTask.id) { if (!Number.isFinite(Number(newTask.id))) { toast.error(t('events.tasks.updateFailed', 'Fotoaufgabe konnte nicht gespeichert werden (ID fehlt).')); return; } const isGlobal = !newTask.tenant_id; // Global tasks must not be edited in place: clone and replace. if (isGlobal) { const cloned = await createTask({ title: newTask.title.trim(), description: newTask.description.trim() || null, emotion_id: newTask.emotion_id ? Number(newTask.emotion_id) : undefined, } as any); await assignTasksToEvent(eventId, [cloned.id]); await detachTasksFromEvent(eventId, [Number(newTask.id)]); } else { // Tenant-owned task: update in place. await updateTask(Number(newTask.id), { id: Number(newTask.id), title: newTask.title.trim(), description: newTask.description.trim() || null, emotion_id: newTask.emotion_id ? Number(newTask.emotion_id) : undefined, } as any); } } else { const created = await createTask({ title: newTask.title.trim(), description: newTask.description.trim() || null, emotion_id: newTask.emotion_id ? Number(newTask.emotion_id) : undefined, } as any); await assignTasksToEvent(eventId, [created.id]); } const result = await getEventTasks(eventId, 1); const assignedIds = new Set(result.data.map((t) => t.id)); setAssignedTasks(result.data); setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id))); setShowTaskSheet(false); setNewTask({ id: null, title: '', description: '', emotion_id: '', tenant_id: null }); toast.success(t('events.tasks.created', 'Fotoaufgabe gespeichert')); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Fotoaufgabe konnte nicht erstellt werden.'))); toast.error(t('events.errors.saveFailed', 'Fotoaufgabe konnte nicht erstellt werden.')); } } } async function detachTask(taskId: number) { if (!eventId) return; setBusyId(taskId); try { await detachTasksFromEvent(eventId, [taskId]); setAssignedTasks((prev) => prev.filter((task) => task.id !== taskId)); setSelectedTaskIds((prev) => { if (!prev.has(taskId)) return prev; const next = new Set(prev); next.delete(taskId); if (next.size === 0) { setSelectionMode(false); } return next; }); toast.success(t('events.tasks.removed', 'Fotoaufgabe entfernt')); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Fotoaufgabe konnte nicht entfernt werden.'))); toast.error(t('events.errors.saveFailed', 'Fotoaufgabe konnte nicht entfernt werden.')); } } finally { setBusyId(null); } } async function confirmDeleteTask() { if (!deleteCandidate) return; const taskId = deleteCandidate.id; setDeleteCandidate(null); await detachTask(taskId); } async function confirmBulkDelete() { if (!eventId || selectedTaskIds.size === 0) { setBulkDeleteOpen(false); return; } const ids = Array.from(selectedTaskIds); setBulkDeleteOpen(false); setBulkDeleteBusy(true); try { await detachTasksFromEvent(eventId, ids); setAssignedTasks((prev) => prev.filter((task) => !selectedTaskIds.has(task.id))); setSelectedTaskIds(new Set()); setSelectionMode(false); toast.success(t('events.tasks.removed', 'Fotoaufgabe entfernt')); } catch (err) { if (!isAuthError(err)) { toast.error(t('events.errors.saveFailed', 'Fotoaufgabe konnte nicht entfernt werden.')); } } finally { setBulkDeleteBusy(false); } } const startEdit = (task: TenantTask) => { setNewTask({ id: task.id, title: task.title, description: task.description ?? '', emotion_id: task.emotion?.id ? String(task.emotion.id) : '', tenant_id: (task as any).tenant_id ?? null, }); setShowTaskSheet(true); }; const filteredTasks = assignedTasks.filter((task) => { const matchText = !searchTerm || task.title.toLowerCase().includes(searchTerm.toLowerCase()) || (task.description ?? '').toLowerCase().includes(searchTerm.toLowerCase()); const matchEmotion = !emotionFilter || task.emotion?.id === Number(emotionFilter); return matchText && matchEmotion; }); const toggleSelectedTask = React.useCallback((taskId: number) => { setSelectedTaskIds((prev) => { const next = new Set(prev); if (next.has(taskId)) { next.delete(taskId); } else { next.add(taskId); } if (next.size === 0) { setSelectionMode(false); } return next; }); }, []); const clearSelection = React.useCallback(() => { setSelectedTaskIds(new Set()); setSelectionMode(false); }, []); const startLongPress = React.useCallback( (taskId: number) => { if (selectionMode) return; if (longPressTimer.current) { window.clearTimeout(longPressTimer.current); } longPressTriggered.current = false; longPressTimer.current = window.setTimeout(() => { longPressTriggered.current = true; setSelectionMode(true); toggleSelectedTask(taskId); }, 450); }, [selectionMode, toggleSelectedTask] ); const cancelLongPress = React.useCallback(() => { if (longPressTimer.current) { window.clearTimeout(longPressTimer.current); longPressTimer.current = null; } }, []); const handleTaskPress = React.useCallback( (task: TenantTask) => { if (longPressTriggered.current) { longPressTriggered.current = false; return; } if (selectionMode) { toggleSelectedTask(task.id); return; } startEdit(task); }, [selectionMode, startEdit, toggleSelectedTask] ); React.useEffect(() => { return () => cancelLongPress(); }, [cancelLongPress]); async function handleBulkAdd() { if (!eventId || !bulkLines.trim()) return; if (!canAddTasks) { toast.error(limitReachedMessage); return; } const lines = bulkLines .split('\n') .map((l) => l.trim()) .filter(Boolean); if (!lines.length) return; const capacity = remainingTasks === null ? lines.length : Math.max(0, remainingTasks); const slicedLines = lines.slice(0, capacity); if (!slicedLines.length) { toast.error(limitReachedMessage); return; } try { if (slicedLines.length < lines.length) { toast.error( t('events.tasks.limitSkipped', { count: lines.length - slicedLines.length, defaultValue: 'Skipped {{count}} tasks due to limit.', }), ); } for (const line of slicedLines) { const created = await createTask({ title: line } as any); await assignTasksToEvent(eventId, [created.id]); } const result = await getEventTasks(eventId, 1); setAssignedTasks(result.data); setBulkLines(''); setShowBulkSheet(false); toast.success(t('events.tasks.created', 'Fotoaufgabe gespeichert')); } catch (err) { if (!isAuthError(err)) { toast.error(t('events.errors.saveFailed', 'Fotoaufgabe konnte nicht erstellt werden.')); } } } async function saveEmotion() { if (!emotionForm.name.trim()) return; setSavingEmotion(true); try { if (editingEmotion) { const updated = await updateEmotionApi(editingEmotion.id, { name: emotionForm.name.trim(), color: emotionForm.color }); setEmotions((prev) => prev.map((em) => (em.id === editingEmotion.id ? updated : em))); } else { const created = await createEmotion({ name: emotionForm.name.trim(), color: emotionForm.color }); setEmotions((prev) => [...prev, created]); } setShowEmotionSheet(false); setEditingEmotion(null); setEmotionForm({ name: '', color: border }); toast.success(t('events.tasks.emotionSaved', 'Emotion gespeichert')); } catch (err) { if (!isAuthError(err)) { toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Konnte nicht gespeichert werden.'))); } } finally { setSavingEmotion(false); } } async function removeEmotion(emotionId: number) { try { await deleteEmotionApi(emotionId); setEmotions((prev) => prev.filter((em) => em.id !== emotionId)); toast.success(t('events.tasks.emotionRemoved', 'Emotion entfernt')); } catch (err) { if (!isAuthError(err)) { toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Konnte nicht gespeichert werden.'))); } } } async function handleTasksToggle(nextEnabled: boolean) { if (!slug || tasksToggleBusy || !canManageTasks) return; setTasksToggleBusy(true); try { const updated = await updateEvent(slug, { settings: { engagement_mode: nextEnabled ? 'tasks' : 'photo_only', }, }); setEventRecord(updated); toast.success( nextEnabled ? t('events.tasks.toggle.enabled', 'Photo tasks activated') : t('events.tasks.toggle.disabled', 'Photo tasks disabled') ); } catch (err) { if (!isAuthError(err)) { toast.error(t('events.errors.toggleFailed', 'Status could not be updated.')); } } finally { setTasksToggleBusy(false); } } const taskPanel = assignedTasks.length === 0 ? ( {t('events.tasks.emptyTitle', 'No photo tasks yet')} {t('events.tasks.emptyBody', 'Create photo tasks or import a pack for your event.')} {!canAddTasks ? ( {limitReachedMessage} {limitReachedHint ? ` ${limitReachedHint}` : ''} ) : null} setShowTaskSheet(true)} disabled={!canAddTasks} fullWidth={false} /> setShowCollectionSheet(true)} disabled={!canAddTasks} fullWidth={false} /> { if (!canAddTasks) { toast.error(limitReachedMessage); return; } setShowTaskSheet(true); }} title={ {t('events.tasks.addTask', 'Fotoaufgabe hinzufügen')} } subTitle={ {t('events.tasks.addTaskHint', 'Erstelle eine neue Fotoaufgabe für dieses Event.')} } paddingVertical="$2" paddingHorizontal="$3" iconAfter={} /> { if (!canAddTasks) { toast.error(limitReachedMessage); return; } setShowCollectionSheet(true); }} title={ {t('events.tasks.import', 'Fotoaufgabenpaket importieren')} } subTitle={ {t('events.tasks.importHint', 'Nutze vordefinierte Pakete für deinen Event-Typ.')} } paddingVertical="$2" paddingHorizontal="$3" iconAfter={} /> ) : ( {t('events.tasks.count', '{{count}} photo tasks', { count: filteredTasks.length })} {selectionMode ? ( {t('events.tasks.selectionCount', '{{count}} ausgewählt', { count: selectedTaskIds.size })} setBulkDeleteOpen(true)} /> clearSelection()} /> ) : null} {filteredTasks.map((task, idx) => ( handleTaskPress(task)} onPointerDown={() => startLongPress(task.id)} onPointerUp={cancelLongPress} onPointerLeave={cancelLongPress} onPointerCancel={cancelLongPress} title={ {selectionMode ? ( toggleSelectedTask(task.id)} onPress={(event: any) => event?.stopPropagation?.()} aria-label={t('events.tasks.select', 'Select photo task')} > ) : null} {task.title} } subTitle={ task.description ? ( {task.description} ) : null } iconAfter={ selectionMode ? null : ( {task.emotion ? ( ) : null}