Files
fotospiel-app/resources/js/admin/mobile/EventTasksPage.tsx
Codex Agent e1221e0466
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Clarify photo task wording in admin UI
2026-01-20 08:49:34 +01:00

1470 lines
54 KiB
TypeScript

import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight, Check } 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 (
<ToggleGroup.Item
value={value}
onPress={onPress}
borderRadius={999}
borderWidth={1}
borderColor={isActive ? activeBorder : border}
backgroundColor={isActive ? activeBackground : surface}
paddingHorizontal="$3"
paddingVertical="$2"
height={36}
>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack
paddingHorizontal="$2"
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
borderColor={isActive ? activeBorder : border}
backgroundColor={surfaceMuted}
>
<Text fontSize={10} fontWeight="800" color={textStrong}>
{count}
</Text>
</XStack>
</XStack>
</ToggleGroup.Item>
);
}
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<TenantTask[]>([]);
const [library, setLibrary] = React.useState<TenantTask[]>([]);
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
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<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null);
const [assigningId, setAssigningId] = React.useState<number | null>(null);
const [deleteCandidate, setDeleteCandidate] = React.useState<TenantTask | null>(null);
const [eventId, setEventId] = React.useState<number | null>(null);
const [searchTerm, setSearchTerm] = React.useState('');
const [emotionFilter, setEmotionFilter] = React.useState<string>('');
const [expandedLibrary, setExpandedLibrary] = React.useState(false);
const [expandedCollections, setExpandedCollections] = React.useState(false);
const [selectionMode, setSelectionMode] = React.useState(false);
const [selectedTaskIds, setSelectedTaskIds] = React.useState<Set<number>>(new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
const [bulkDeleteBusy, setBulkDeleteBusy] = React.useState(false);
const longPressTimer = React.useRef<number | null>(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<TenantEmotion | null>(null);
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
const [quickNavSelection, setQuickNavSelection] = React.useState<TaskSectionKey | ''>('');
const [eventRecord, setEventRecord] = React.useState<TenantEvent | null>(null);
const [tasksToggleBusy, setTasksToggleBusy] = React.useState(false);
const text = textStrong;
const assignedRef = React.useRef<HTMLDivElement>(null);
const libraryRef = React.useRef<HTMLDivElement>(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]);
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<HTMLDivElement | null>) => {
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;
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;
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;
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;
const lines = bulkLines
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return;
try {
for (const line of lines) {
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);
}
}
return (
<MobileShell
activeTab="tasks"
title={t('events.tasks.title', 'Photo tasks & checklists')}
onBack={back}
headerActions={
<XStack space="$2">
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} />
</HeaderActionButton>
</XStack>
}
>
{error ? (
<MobileCard>
<Text fontSize={13} fontWeight="600" color={danger}>
{error}
</Text>
<CTAButton
label={t('common.retry', 'Retry')}
tone="ghost"
fullWidth={false}
onPress={() => load()}
/>
</MobileCard>
) : null}
{!loading ? (
<MobileCard space="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('events.tasks.toggle.title', 'Photo tasks for this event')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'events.tasks.toggle.description',
'Give guests optional photo tasks and prompts.'
)}
</Text>
</YStack>
<XStack alignItems="center" justifyContent="space-between" space="$3" flexWrap="wrap">
<PillBadge tone={tasksEnabled ? 'success' : 'warning'}>
{tasksEnabled
? t('events.tasks.toggle.active', 'ACTIVE')
: t('events.tasks.toggle.inactive', 'INACTIVE')}
</PillBadge>
<Text fontSize="$xs" color={muted}>
{tasksEnabled
? t('events.tasks.toggle.onLabel', 'Guests see photo tasks')
: t('events.tasks.toggle.offLabel', 'Guest app shows photos only')}
</Text>
</XStack>
<XStack alignItems="center" justifyContent="space-between" marginTop="$2">
<Text fontSize="$xs" color={text} fontWeight="600">
{t('events.tasks.toggle.switchLabel', 'Photo tasks enabled')}
</Text>
<Switch
size="$4"
checked={tasksEnabled}
onCheckedChange={handleTasksToggle}
aria-label={t('events.tasks.toggle.switchLabel', 'Photo tasks enabled')}
disabled={!canManageTasks || tasksToggleBusy}
>
<Switch.Thumb />
</Switch>
</XStack>
{isMember && !canManageTasks ? (
<Text fontSize="$xs" color={muted}>
{t('events.tasks.toggle.permissionHint', 'You do not have permission to change photo tasks.')}
</Text>
) : null}
</MobileCard>
) : null}
{!loading ? (
<YStack space="$2">
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('events.tasks.quickNav', 'Quick jump')}
</Text>
</XStack>
</XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<ToggleGroup
type="single"
value={quickNavSelection}
onValueChange={(next) => {
const key = next as TaskSectionKey | '';
if (!key) {
return;
}
setQuickNavSelection(key);
handleQuickNav(key);
}}
>
<XStack space="$2" paddingVertical="$1">
{sectionCounts.map((section) => (
<QuickNavChip
key={section.key}
value={section.key}
label={t(`events.tasks.sections.${section.key}`, section.key)}
count={section.count}
onPress={() => {
setQuickNavSelection(section.key);
handleQuickNav(section.key);
}}
isActive={quickNavSelection === section.key}
/>
))}
</XStack>
</ToggleGroup>
</ScrollView>
</YStack>
</Card>
<Card
borderRadius={20}
borderWidth={2}
borderColor={stickyBorder}
backgroundColor={stickySurface}
padding="$3"
shadowColor={stickyShadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
style={{
position: 'sticky',
top: 'calc(env(safe-area-inset-top, 0px) + 76px)',
zIndex: 45,
}}
>
<XStack alignItems="center" space="$2">
<XStack flex={1}>
<MobileInput
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('events.tasks.search', 'Search photo tasks')}
compact
/>
</XStack>
<Pressable onPress={() => setShowEmotionFilterSheet(true)}>
<XStack
alignItems="center"
space="$1.5"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
>
<Text fontSize={11} fontWeight="700" color={text}>
{t('events.tasks.emotionFilterShort', 'Emotion')}
</Text>
<Text fontSize={11} color={muted}>
{emotionFilter
? emotions.find((e) => String(e.id) === emotionFilter)?.name ?? t('events.tasks.customEmotion', 'Custom emotion')
: t('events.tasks.allEmotions', 'All')}
</Text>
<ChevronDown size={14} color={muted} />
</XStack>
</Pressable>
</XStack>
</Card>
</YStack>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<SkeletonCard key={`tsk-${idx}`} height={70} />
))}
</YStack>
) : assignedTasks.length === 0 ? (
<YStack space="$2">
<MobileCard space="$2">
<Text fontSize={13} fontWeight="700" color={text}>
{t('events.tasks.emptyTitle', 'No photo tasks yet')}
</Text>
<Text fontSize={12} color={muted}>
{t('events.tasks.emptyBody', 'Create photo tasks or import a pack for your event.')}
</Text>
<XStack space="$2">
<CTAButton
label={t('events.tasks.emptyActionTask', 'Add photo task')}
onPress={() => setShowTaskSheet(true)}
fullWidth={false}
/>
<CTAButton
label={t('events.tasks.emptyActionPack', 'Import photo task pack')}
tone="ghost"
onPress={() => setShowCollectionSheet(true)}
fullWidth={false}
/>
</XStack>
</MobileCard>
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
<YGroup.Item>
<ListItem
hoverTheme
pressTheme
onPress={() => setShowTaskSheet(true)}
title={
<XStack alignItems="center" space="$2">
<YStack
width={28}
height={28}
borderRadius={14}
backgroundColor={primary}
alignItems="center"
justifyContent="center"
>
<Plus size={14} color={surface} />
</YStack>
<Text fontSize={12.5} fontWeight="700" color={text}>
{t('events.tasks.addTask', 'Fotoaufgabe hinzufügen')}
</Text>
</XStack>
}
subTitle={
<Text fontSize={11.5} color={muted}>
{t('events.tasks.addTaskHint', 'Erstelle eine neue Fotoaufgabe für dieses Event.')}
</Text>
}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={16} color={muted} />}
/>
</YGroup.Item>
<YGroup.Item>
<ListItem
hoverTheme
pressTheme
onPress={() => setShowCollectionSheet(true)}
title={
<XStack alignItems="center" space="$2">
<YStack
width={28}
height={28}
borderRadius={14}
backgroundColor={primary}
alignItems="center"
justifyContent="center"
>
<Plus size={14} color={surface} />
</YStack>
<Text fontSize={12.5} fontWeight="700" color={text}>
{t('events.tasks.import', 'Fotoaufgabenpaket importieren')}
</Text>
</XStack>
}
subTitle={
<Text fontSize={11.5} color={muted}>
{t('events.tasks.importHint', 'Nutze vordefinierte Pakete für deinen Event-Typ.')}
</Text>
}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={16} color={muted} />}
/>
</YGroup.Item>
</YGroup>
</YStack>
) : (
<YStack space="$2">
<YStack ref={assignedRef} />
<Text fontSize="$sm" color={muted}>
{t('events.tasks.count', '{{count}} photo tasks', { count: filteredTasks.length })}
</Text>
{selectionMode ? (
<MobileCard padding="$2.5" space="$2">
<Text fontSize={12} fontWeight="700" color={text}>
{t('events.tasks.selectionCount', '{{count}} ausgewählt', { count: selectedTaskIds.size })}
</Text>
<XStack space="$2">
<CTAButton
label={t('events.tasks.bulkRemove', 'Auswahl löschen')}
tone="danger"
fullWidth={false}
disabled={selectedTaskIds.size === 0 || bulkDeleteBusy}
onPress={() => setBulkDeleteOpen(true)}
/>
<CTAButton
label={t('events.tasks.bulkCancel', 'Auswahl beenden')}
tone="ghost"
fullWidth={false}
onPress={() => clearSelection()}
/>
</XStack>
</MobileCard>
) : null}
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
{filteredTasks.map((task, idx) => (
<YGroup.Item key={task.id}>
<ListItem
hoverTheme
pressTheme
onPress={() => handleTaskPress(task)}
onPointerDown={() => startLongPress(task.id)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
title={
<XStack alignItems="center" space="$2">
{selectionMode ? (
<Checkbox
size="$3"
checked={selectedTaskIds.has(task.id)}
onCheckedChange={() => toggleSelectedTask(task.id)}
onPress={(event: any) => event?.stopPropagation?.()}
aria-label={t('events.tasks.select', 'Select photo task')}
>
<Checkbox.Indicator>
<Check size={14} color={text} />
</Checkbox.Indicator>
</Checkbox>
) : null}
<Text fontSize={12.5} fontWeight="600" color={text}>
{task.title}
</Text>
</XStack>
}
subTitle={
task.description ? (
<Text fontSize={11.5} fontWeight="400" color={subtle}>
{task.description}
</Text>
) : null
}
iconAfter={
selectionMode ? null : (
<XStack space="$2" alignItems="center">
{task.emotion ? (
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
) : null}
<Button
size="$2"
circular
backgroundColor={dangerBg}
borderWidth={1}
borderColor={`${danger}33`}
icon={<Trash2 size={14} color={dangerText} />}
aria-label={t('events.tasks.remove', 'Remove photo task')}
disabled={busyId === task.id}
onPress={(event: any) => {
event?.stopPropagation?.();
setDeleteCandidate(task);
}}
/>
<ChevronRight size={14} color={subtle} />
</XStack>
)
}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</YGroup.Item>
))}
</YGroup>
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
<YStack ref={libraryRef} />
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.library', 'Weitere Fotoaufgaben')}
</Text>
<Pressable onPress={() => setShowCollectionSheet(true)}>
<Text fontSize={12} fontWeight="600" color={primary}>
{t('events.tasks.import', 'Import photo task pack')}
</Text>
</Pressable>
</XStack>
<Pressable onPress={() => setExpandedLibrary((prev) => !prev)}>
<Text fontSize={12} fontWeight="600" color={primary}>
{expandedLibrary ? t('events.tasks.hideLibrary', 'Hide library') : t('events.tasks.viewAllLibrary', 'View all')}
</Text>
</Pressable>
{library.length === 0 ? (
<Text fontSize={12} fontWeight="500" color={subtle}>
{t('events.tasks.libraryEmpty', 'Keine weiteren Fotoaufgaben verfügbar.')}
</Text>
) : (
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
<YGroup.Item key={`lib-${task.id}`}>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{task.title}
</Text>
}
subTitle={
task.description ? (
<Text fontSize={11.5} fontWeight="400" color={subtle}>
{task.description}
</Text>
) : null
}
iconAfter={
<XStack space="$1.5" alignItems="center">
<Pressable onPress={() => quickAssign(task.id)}>
<XStack alignItems="center" space="$1">
<Plus size={14} color={primary} />
<Text fontSize={12} fontWeight="600" color={primary}>
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
</Text>
</XStack>
</Pressable>
<ChevronRight size={14} color={subtle} />
</XStack>
}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</YGroup.Item>
))}
</YGroup>
)}
</YStack>
)}
<MobileSheet
open={showCollectionSheet}
onClose={() => setShowCollectionSheet(false)}
title={t('events.tasks.import', 'Fotoaufgabenpaket importieren')}
footer={null}
>
<YStack space="$2">
{collections.length > 6 ? (
<Pressable onPress={() => setExpandedCollections((prev) => !prev)}>
<Text fontSize={12} fontWeight="600" color={primary}>
{expandedCollections ? t('events.tasks.hideCollections', 'Hide collections') : t('events.tasks.showCollections', 'Show all')}
</Text>
</Pressable>
) : null}
{collections.length === 0 ? (
<Text fontSize={13} fontWeight="500" color={muted}>
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
</Text>
) : (
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
<YGroup.Item key={collection.id}>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{collection.name}
</Text>
}
subTitle={
collection.description ? (
<Text fontSize={11.5} fontWeight="400" color={subtle}>
{collection.description}
</Text>
) : null
}
iconAfter={
<XStack space="$1.5" alignItems="center">
<Pressable onPress={() => importCollection(collection.id)}>
<Text fontSize={12} fontWeight="600" color={primary}>
{t('events.tasks.import', 'Import')}
</Text>
</Pressable>
<ChevronRight size={14} color={subtle} />
</XStack>
}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</YGroup.Item>
))}
</YGroup>
)}
</YStack>
</MobileSheet>
<MobileSheet
open={showTaskSheet}
onClose={() => setShowTaskSheet(false)}
title={t('events.tasks.addTask', 'Fotoaufgabe hinzufügen')}
footer={
<CTAButton label={t('events.tasks.saveTask', 'Fotoaufgabe speichern')} onPress={() => createNewTask()} />
}
>
<YStack space="$2">
<MobileField label={t('events.tasks.titleLabel', 'Titel')}>
<MobileInput
type="text"
value={newTask.title}
onChange={(e) => setNewTask((prev) => ({ ...prev, title: e.target.value }))}
placeholder={t('events.tasks.titlePlaceholder', 'z.B. Erstes Gruppenfoto')}
/>
</MobileField>
<MobileField label={t('events.tasks.description', 'Beschreibung')}>
<MobileTextArea
value={newTask.description}
onChange={(e) => setNewTask((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t('events.tasks.descriptionPlaceholder', 'Optionale Hinweise')}
compact
style={{ minHeight: 80 }}
/>
</MobileField>
<MobileField label={t('events.tasks.emotion', 'Emotion')}>
<MobileSelect
value={newTask.emotion_id}
onChange={(e) => setNewTask((prev) => ({ ...prev, emotion_id: e.target.value }))}
>
<option value="">{t('events.tasks.emotionNone', 'Keine')}</option>
{emotions.map((emotion) => (
<option key={emotion.id} value={emotion.id}>
{emotion.name}
</option>
))}
</MobileSelect>
</MobileField>
</YStack>
</MobileSheet>
<MobileSheet
open={showBulkSheet}
onClose={() => setShowBulkSheet(false)}
title={t('events.tasks.bulkAdd', 'Bulk add')}
footer={<CTAButton label={t('events.tasks.saveTask', 'Fotoaufgabe speichern')} onPress={() => handleBulkAdd()} />}
>
<YStack space="$2">
<Text fontSize={12} color={muted}>
{t('events.tasks.bulkHint', 'One photo task per line. These will be created and added to the event.')}
</Text>
<MobileTextArea
value={bulkLines}
onChange={(e) => setBulkLines(e.target.value)}
placeholder={t('events.tasks.bulkPlaceholder', 'e.g.\nBride & groom portrait\nGroup photo main guests')}
style={{ minHeight: 140, fontSize: 12.5 }}
/>
</YStack>
</MobileSheet>
<MobileSheet
open={showEmotionSheet}
onClose={() => {
setShowEmotionSheet(false);
setEditingEmotion(null);
setEmotionForm({ name: '', color: border });
}}
title={t('events.tasks.manageEmotions', 'Manage emotions')}
footer={
<CTAButton
label={savingEmotion ? t('common.saving', 'Saving...') : t('events.tasks.saveEmotion', 'Emotion speichern')}
onPress={() => saveEmotion()}
/>
}
>
<YStack space="$2">
<MobileField label={t('events.tasks.emotionName', 'Name')}>
<MobileInput
type="text"
value={emotionForm.name}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('events.tasks.emotionNamePlaceholder', 'z.B. Joy')}
/>
</MobileField>
<MobileField label={t('events.tasks.emotionColor', 'Farbe')}>
<MobileInput
type="color"
value={emotionForm.color}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, color: e.target.value }))}
style={{ padding: 0 }}
/>
</MobileField>
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
{emotions.map((em, idx) => (
<YGroup.Item key={`emo-${em.id}`}>
<ListItem
hoverTheme
pressTheme
title={
<XStack alignItems="center" space="$2">
<Tag label={em.name ?? ''} color={em.color ?? border} />
</XStack>
}
iconAfter={
<XStack space="$2">
<Pressable
onPress={() => {
setEditingEmotion(em);
setEmotionForm({ name: em.name ?? '', color: em.color ?? border });
}}
>
<Pencil size={14} color={primary} />
</Pressable>
<Pressable onPress={() => removeEmotion(em.id)}>
<Trash2 size={14} color={danger} />
</Pressable>
<ChevronRight size={14} color={subtle} />
</XStack>
}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</YGroup.Item>
))}
</YGroup>
</YStack>
</MobileSheet>
<MobileSheet
open={showEmotionFilterSheet}
onClose={() => setShowEmotionFilterSheet(false)}
title={t('events.tasks.emotionFilter', 'Emotion filter')}
footer={
<CTAButton label={t('common.close', 'Close')} onPress={() => setShowEmotionFilterSheet(false)} />
}
>
<RadioGroup
value={emotionFilter}
onValueChange={(val) => {
setEmotionFilter(val);
setShowEmotionFilterSheet(false);
}}
>
<YStack space="$2">
<XStack alignItems="center" space="$2">
<RadioGroup.Item value="">
<RadioGroup.Indicator />
</RadioGroup.Item>
<Text fontSize={12.5} color={text}>
{t('events.tasks.allEmotions', 'All')}
</Text>
</XStack>
{emotions.map((emotion) => (
<XStack key={`emo-filter-${emotion.id}`} alignItems="center" space="$2">
<RadioGroup.Item value={String(emotion.id)}>
<RadioGroup.Indicator />
</RadioGroup.Item>
<Text fontSize={12.5} color={emotion.color ?? text}>
{emotion.name ?? ''}
</Text>
</XStack>
))}
</YStack>
</RadioGroup>
</MobileSheet>
<AlertDialog
open={Boolean(deleteCandidate)}
onOpenChange={(open) => {
if (!open) {
setDeleteCandidate(null);
}
}}
>
<AlertDialog.Portal>
<AlertDialog.Overlay backgroundColor={`${overlay}66` as any} />
<AlertDialog.Content
{...({ borderRadius: 20 } as any)}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
padding="$4"
maxWidth={420}
width="90%"
>
<YStack space="$3">
<AlertDialog.Title>
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.tasks.removeTitle', 'Remove photo task?')}
</Text>
</AlertDialog.Title>
<AlertDialog.Description>
<Text fontSize="$sm" color={muted}>
{deleteCandidate
? t('events.tasks.removeBody', 'This will remove "{{title}}" from the event.', { title: deleteCandidate.title })
: t('events.tasks.removeBodyFallback', 'This will remove the photo task from the event.')}
</Text>
</AlertDialog.Description>
<XStack space="$2" justifyContent="flex-end">
<AlertDialog.Cancel asChild>
<CTAButton
label={t('common.cancel', 'Cancel')}
tone="ghost"
fullWidth={false}
onPress={() => setDeleteCandidate(null)}
/>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<CTAButton
label={t('events.tasks.remove', 'Remove')}
tone="danger"
fullWidth={false}
onPress={() => confirmDeleteTask()}
/>
</AlertDialog.Action>
</XStack>
</YStack>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog>
<AlertDialog
open={bulkDeleteOpen}
onOpenChange={(open) => {
if (!open) {
setBulkDeleteOpen(false);
}
}}
>
<AlertDialog.Portal>
<AlertDialog.Overlay backgroundColor={`${overlay}66` as any} />
<AlertDialog.Content
{...({ borderRadius: 20 } as any)}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
padding="$4"
maxWidth={420}
width="90%"
>
<YStack space="$3">
<AlertDialog.Title>
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.tasks.bulkRemoveTitle', 'Auswahl löschen')}
</Text>
</AlertDialog.Title>
<AlertDialog.Description>
<Text fontSize="$sm" color={muted}>
{t('events.tasks.bulkRemoveBody', 'This will remove the selected photo tasks from the event.')}
</Text>
</AlertDialog.Description>
<XStack space="$2" justifyContent="flex-end">
<AlertDialog.Cancel asChild>
<CTAButton
label={t('common.cancel', 'Cancel')}
tone="ghost"
fullWidth={false}
onPress={() => setBulkDeleteOpen(false)}
/>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<CTAButton
label={t('events.tasks.remove', 'Remove')}
tone="danger"
fullWidth={false}
onPress={() => confirmBulkDelete()}
disabled={bulkDeleteBusy}
/>
</AlertDialog.Action>
</XStack>
</YStack>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog>
<FloatingActionButton
onPress={() => setShowFabMenu(true)}
label={t('events.tasks.add', 'Add')}
icon={Plus}
/>
<MobileSheet
open={showFabMenu}
onClose={() => setShowFabMenu(false)}
title={t('events.tasks.actions', 'Aktionen')}
footer={null}
>
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
<YGroup.Item>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.addTask', 'Fotoaufgabe hinzufügen')}
</Text>
}
onPress={() => {
setShowFabMenu(false);
setShowTaskSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={14} color={subtle} />}
/>
</YGroup.Item>
<YGroup.Item>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.bulkAdd', 'Bulk add')}
</Text>
}
onPress={() => {
setShowFabMenu(false);
setShowBulkSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={14} color={subtle} />}
/>
</YGroup.Item>
<YGroup.Item>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.manageEmotions', 'Manage emotions')}
</Text>
}
subTitle={
<Text fontSize={11.5} fontWeight="400" color={subtle}>
{t('events.tasks.manageEmotionsHint', 'Filter and keep your taxonomy tidy.')}
</Text>
}
onPress={() => {
setShowFabMenu(false);
setShowEmotionSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={14} color={subtle} />}
/>
</YGroup.Item>
</YGroup>
</MobileSheet>
</MobileShell>
);
}