1640 lines
61 KiB
TypeScript
1640 lines
61 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, 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 { Switch } from '@tamagui/switch';
|
|
import { Checkbox } from '@tamagui/checkbox';
|
|
import { Tabs } from 'tamagui';
|
|
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 { 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;
|
|
}
|
|
|
|
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 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 [activeTab, setActiveTab] = React.useState<TaskSectionKey>('assigned');
|
|
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 [showTaskDetails, setShowTaskDetails] = React.useState(false);
|
|
const [eventRecord, setEventRecord] = React.useState<TenantEvent | null>(null);
|
|
const [tasksToggleBusy, setTasksToggleBusy] = React.useState(false);
|
|
const text = textStrong;
|
|
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
|
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 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(() => {
|
|
const remaining = eventRecord?.limits?.tasks?.remaining;
|
|
if (typeof remaining === 'number' && Number.isFinite(remaining)) {
|
|
return Math.max(0, remaining);
|
|
}
|
|
if (maxTasks === null) {
|
|
return null;
|
|
}
|
|
return Math.max(0, maxTasks - assignedTasks.length);
|
|
}, [assignedTasks.length, eventRecord?.limits?.tasks?.remaining, 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 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 ? (
|
|
<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>
|
|
{!canAddTasks ? (
|
|
<Text fontSize={12} fontWeight="600" color={dangerText}>
|
|
{limitReachedMessage}
|
|
{limitReachedHint ? ` ${limitReachedHint}` : ''}
|
|
</Text>
|
|
) : null}
|
|
<XStack space="$2">
|
|
<CTAButton
|
|
label={t('events.tasks.emptyActionTask', 'Add photo task')}
|
|
onPress={() => setShowTaskSheet(true)}
|
|
disabled={!canAddTasks}
|
|
fullWidth={false}
|
|
/>
|
|
<CTAButton
|
|
label={t('events.tasks.emptyActionPack', 'Import photo task pack')}
|
|
tone="ghost"
|
|
onPress={() => setActiveTab('collections')}
|
|
disabled={!canAddTasks}
|
|
fullWidth={false}
|
|
/>
|
|
</XStack>
|
|
</MobileCard>
|
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
|
<YGroup.Item>
|
|
<ListItem
|
|
hoverTheme
|
|
pressTheme
|
|
onPress={() => {
|
|
if (!canAddTasks) {
|
|
toast.error(limitReachedMessage);
|
|
return;
|
|
}
|
|
setShowTaskSheet(true);
|
|
}}
|
|
title={
|
|
<XStack alignItems="center" space="$2">
|
|
<YStack
|
|
width={28}
|
|
height={28}
|
|
borderRadius={14}
|
|
backgroundColor={canAddTasks ? primary : withAlpha(border, 0.4)}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
>
|
|
<Plus size={14} color={canAddTasks ? surface : muted} />
|
|
</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={() => {
|
|
if (!canAddTasks) {
|
|
toast.error(limitReachedMessage);
|
|
return;
|
|
}
|
|
setActiveTab('collections');
|
|
}}
|
|
title={
|
|
<XStack alignItems="center" space="$2">
|
|
<YStack
|
|
width={28}
|
|
height={28}
|
|
borderRadius={14}
|
|
backgroundColor={canAddTasks ? primary : withAlpha(border, 0.4)}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
>
|
|
<Plus size={14} color={canAddTasks ? surface : muted} />
|
|
</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">
|
|
<XStack alignItems="baseline" justifyContent="space-between" flexWrap="wrap" space="$2">
|
|
<XStack alignItems="baseline" space="$2" flexWrap="wrap">
|
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
|
{t('events.tasks.assignedTitle', 'Task list')}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('events.tasks.count', '{{count}} photo tasks', { count: filteredTasks.length })}
|
|
</Text>
|
|
</XStack>
|
|
{typeof remainingTasks === 'number' && typeof maxTasks === 'number' ? (
|
|
<Tag
|
|
label={t('events.tasks.remainingIndicator', '{{count}} / {{total}} tasks remaining', {
|
|
count: remainingTasks,
|
|
total: maxTasks,
|
|
})}
|
|
/>
|
|
) : null}
|
|
</XStack>
|
|
{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>
|
|
</YStack>
|
|
);
|
|
|
|
const libraryPanel = (
|
|
<YStack space="$2">
|
|
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap">
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('events.tasks.tabs.library', 'Task Library')}
|
|
</Text>
|
|
<Button
|
|
size="$2"
|
|
backgroundColor={withAlpha(primary, 0.12)}
|
|
borderWidth={1}
|
|
borderColor={withAlpha(primary, 0.35)}
|
|
onPress={() => setActiveTab('collections')}
|
|
>
|
|
<Text fontSize="$xs" fontWeight="700" color={primary}>
|
|
{t('events.tasks.import', 'Import photo task pack')}
|
|
</Text>
|
|
</Button>
|
|
</XStack>
|
|
{!canAddTasks ? (
|
|
<Text fontSize={12} fontWeight="600" color={dangerText}>
|
|
{limitReachedMessage}
|
|
{limitReachedHint ? ` ${limitReachedHint}` : ''}
|
|
</Text>
|
|
) : null}
|
|
{library.length > 6 ? (
|
|
<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>
|
|
) : null}
|
|
{library.length === 0 ? (
|
|
<Text fontSize={12} fontWeight="500" color={subtle}>
|
|
{t('events.tasks.libraryEmpty', 'No more photo tasks available.')}
|
|
</Text>
|
|
) : (
|
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
|
{(expandedLibrary ? library : library.slice(0, 6)).map((task) => (
|
|
<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={() => {
|
|
if (!canAddTasks) {
|
|
toast.error(limitReachedMessage);
|
|
return;
|
|
}
|
|
quickAssign(task.id);
|
|
}}
|
|
>
|
|
<XStack alignItems="center" space="$1">
|
|
<Plus size={14} color={canAddTasks ? primary : muted} />
|
|
<Text fontSize={12} fontWeight="600" color={canAddTasks ? primary : muted}>
|
|
{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>
|
|
);
|
|
|
|
const collectionsPanel = (
|
|
<YStack space="$2">
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('events.tasks.importHint', 'Use predefined packs for your event type.')}
|
|
</Text>
|
|
{!canAddTasks ? (
|
|
<Text fontSize={12} fontWeight="600" color={dangerText}>
|
|
{limitReachedMessage}
|
|
{limitReachedHint ? ` ${limitReachedHint}` : ''}
|
|
</Text>
|
|
) : null}
|
|
{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', 'No collections available.')}
|
|
</Text>
|
|
) : (
|
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
|
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection) => (
|
|
<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">
|
|
<Button
|
|
size="$2"
|
|
backgroundColor={withAlpha(primary, 0.12)}
|
|
borderWidth={1}
|
|
borderColor={withAlpha(primary, 0.35)}
|
|
onPress={() => {
|
|
if (!canAddTasks) {
|
|
toast.error(limitReachedMessage);
|
|
return;
|
|
}
|
|
importCollection(collection.id);
|
|
}}
|
|
>
|
|
<Text fontSize="$xs" fontWeight="700" color={canAddTasks ? primary : muted}>
|
|
{t('events.tasks.import', 'Import')}
|
|
</Text>
|
|
</Button>
|
|
<ChevronRight size={14} color={subtle} />
|
|
</XStack>
|
|
}
|
|
paddingVertical="$2"
|
|
paddingHorizontal="$3"
|
|
/>
|
|
</YGroup.Item>
|
|
))}
|
|
</YGroup>
|
|
)}
|
|
</YStack>
|
|
);
|
|
|
|
const emotionsPanel = (
|
|
<YStack space="$2">
|
|
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap">
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('events.tasks.tabs.emotions', 'Emotions')}
|
|
</Text>
|
|
<Button
|
|
size="$2"
|
|
backgroundColor={withAlpha(primary, 0.12)}
|
|
borderWidth={1}
|
|
borderColor={withAlpha(primary, 0.35)}
|
|
onPress={() => {
|
|
setEditingEmotion(null);
|
|
setEmotionForm({ name: '', color: border });
|
|
setShowEmotionSheet(true);
|
|
}}
|
|
>
|
|
<Text fontSize="$xs" fontWeight="700" color={primary}>
|
|
{t('events.tasks.addEmotion', 'Add emotion')}
|
|
</Text>
|
|
</Button>
|
|
</XStack>
|
|
{emotions.length === 0 ? (
|
|
<Text fontSize={12} fontWeight="500" color={muted}>
|
|
{t('events.tasks.emotionsEmpty', 'No emotions yet. Add one to help categorize tasks.')}
|
|
</Text>
|
|
) : (
|
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
|
{emotions.map((emotion) => (
|
|
<YGroup.Item key={`emo-${emotion.id}`}>
|
|
<ListItem
|
|
hoverTheme
|
|
pressTheme
|
|
title={
|
|
<XStack alignItems="center" space="$2">
|
|
<Tag label={emotion.name ?? ''} color={emotion.color ?? border} />
|
|
</XStack>
|
|
}
|
|
iconAfter={
|
|
<XStack space="$2">
|
|
<Pressable
|
|
onPress={() => {
|
|
setEditingEmotion(emotion);
|
|
setEmotionForm({ name: emotion.name ?? '', color: emotion.color ?? border });
|
|
setShowEmotionSheet(true);
|
|
}}
|
|
>
|
|
<Pencil size={14} color={primary} />
|
|
</Pressable>
|
|
<Pressable onPress={() => removeEmotion(emotion.id)}>
|
|
<Trash2 size={14} color={danger} />
|
|
</Pressable>
|
|
<ChevronRight size={14} color={subtle} />
|
|
</XStack>
|
|
}
|
|
paddingVertical="$2"
|
|
paddingHorizontal="$3"
|
|
/>
|
|
</YGroup.Item>
|
|
))}
|
|
</YGroup>
|
|
)}
|
|
</YStack>
|
|
);
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="tasks"
|
|
title={t('events.tasks.title', 'Photo tasks for guests')}
|
|
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 ? (
|
|
<YStack space="$2">
|
|
{Array.from({ length: 4 }).map((_, idx) => (
|
|
<SkeletonCard key={`tsk-${idx}`} height={70} />
|
|
))}
|
|
</YStack>
|
|
) : (
|
|
<Card
|
|
borderRadius={24}
|
|
borderWidth={2}
|
|
borderColor={border}
|
|
backgroundColor={surface}
|
|
padding="$3"
|
|
>
|
|
<YStack space="$3">
|
|
<Card
|
|
borderRadius={18}
|
|
borderWidth={1}
|
|
borderColor={border}
|
|
backgroundColor={surfaceMuted}
|
|
padding="$3"
|
|
>
|
|
<YStack space="$2">
|
|
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
|
<Text fontSize="$xs" fontWeight="700" color={text}>
|
|
{t('events.tasks.toggle.title', 'Photo tasks for guests')}
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => setShowTaskDetails((prev) => !prev)}
|
|
aria-label={t(
|
|
'events.tasks.toggle.description',
|
|
'Control whether guests see mission cards and prompts.'
|
|
)}
|
|
>
|
|
<XStack
|
|
width={30}
|
|
height={30}
|
|
borderRadius={10}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
borderWidth={1}
|
|
borderColor={showTaskDetails ? withAlpha(primary, 0.45) : border}
|
|
backgroundColor={showTaskDetails ? withAlpha(primary, 0.12) : surface}
|
|
>
|
|
<Info size={14} color={showTaskDetails ? primary : muted} />
|
|
</XStack>
|
|
</Pressable>
|
|
</XStack>
|
|
<XStack
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
paddingHorizontal="$2"
|
|
paddingVertical="$1.5"
|
|
borderRadius={12}
|
|
borderWidth={1}
|
|
borderColor={border}
|
|
backgroundColor={surface}
|
|
>
|
|
<Text fontSize="$xs" fontWeight="700" color={text}>
|
|
{t('events.tasks.toggle.switchLabel', 'Photo task mode')}
|
|
</Text>
|
|
<XStack alignItems="center" space="$1.5">
|
|
<Switch
|
|
size="$3"
|
|
checked={tasksEnabled}
|
|
onCheckedChange={handleTasksToggle}
|
|
aria-label={t('events.tasks.toggle.switchLabel', 'Photo task mode')}
|
|
disabled={!canManageTasks || tasksToggleBusy}
|
|
>
|
|
<Switch.Thumb />
|
|
</Switch>
|
|
<PillBadge tone={tasksEnabled ? 'success' : 'warning'}>
|
|
{tasksEnabled
|
|
? t('events.tasks.toggle.active', 'ACTIVE')
|
|
: t('events.tasks.toggle.inactive', 'INACTIVE')}
|
|
</PillBadge>
|
|
</XStack>
|
|
</XStack>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{tasksEnabled
|
|
? t('events.tasks.toggle.onLabel', 'Mission cards active')
|
|
: t('events.tasks.toggle.offLabel', 'Photo feed only')}
|
|
</Text>
|
|
{showTaskDetails ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('events.tasks.toggle.description', 'Control whether guests see mission cards and prompts.')}
|
|
</Text>
|
|
) : null}
|
|
{isMember && !canManageTasks ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('events.tasks.toggle.permissionHint', 'You do not have permission to change photo tasks.')}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
</Card>
|
|
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(value) => setActiveTab(value as TaskSectionKey)}
|
|
flexDirection="column"
|
|
alignItems="stretch"
|
|
width="100%"
|
|
>
|
|
<Tabs.List
|
|
borderRadius={16}
|
|
borderWidth={1}
|
|
borderColor={border}
|
|
backgroundColor={surfaceMuted}
|
|
overflow="hidden"
|
|
gap="$0"
|
|
>
|
|
{[
|
|
{ value: 'assigned', label: t('events.tasks.tabs.tasks', 'Tasks') },
|
|
{ value: 'library', label: t('events.tasks.tabs.library', 'Task Library') },
|
|
{ value: 'emotions', label: t('events.tasks.tabs.emotions', 'Emotions') },
|
|
{ value: 'collections', label: t('events.tasks.tabs.collections', 'Collections') },
|
|
].map((tab, index, arr) => {
|
|
const isActive = activeTab === tab.value;
|
|
return (
|
|
<Tabs.Tab
|
|
key={tab.value}
|
|
value={tab.value}
|
|
flex={1}
|
|
unstyled
|
|
paddingVertical="$2.5"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
backgroundColor={isActive ? primary : 'transparent'}
|
|
borderRightWidth={index === arr.length - 1 ? 0 : 1}
|
|
borderRightColor={border}
|
|
pressStyle={{ backgroundColor: isActive ? primary : surface }}
|
|
>
|
|
<Text fontSize="$sm" fontWeight={isActive ? '700' : '500'} color={isActive ? 'white' : text}>
|
|
{tab.label}
|
|
</Text>
|
|
</Tabs.Tab>
|
|
);
|
|
})}
|
|
</Tabs.List>
|
|
|
|
<Tabs.Content value="assigned" paddingTop="$2">
|
|
<Card borderRadius={18} borderWidth={1} borderColor={border} backgroundColor={surface} padding="$3">
|
|
<YStack space="$2">
|
|
<Card
|
|
borderRadius={16}
|
|
borderWidth={1}
|
|
borderColor={border}
|
|
backgroundColor={surfaceMuted}
|
|
padding="$2.5"
|
|
>
|
|
<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>
|
|
{taskPanel}
|
|
</YStack>
|
|
</Card>
|
|
</Tabs.Content>
|
|
|
|
<Tabs.Content value="library" paddingTop="$2">
|
|
<Card borderRadius={18} borderWidth={1} borderColor={border} backgroundColor={surface} padding="$3">
|
|
{libraryPanel}
|
|
</Card>
|
|
</Tabs.Content>
|
|
|
|
<Tabs.Content value="emotions" paddingTop="$2">
|
|
<Card borderRadius={18} borderWidth={1} borderColor={border} backgroundColor={surface} padding="$3">
|
|
{emotionsPanel}
|
|
</Card>
|
|
</Tabs.Content>
|
|
|
|
<Tabs.Content value="collections" paddingTop="$2">
|
|
<Card borderRadius={18} borderWidth={1} borderColor={border} backgroundColor={surface} padding="$3">
|
|
{collectionsPanel}
|
|
</Card>
|
|
</Tabs.Content>
|
|
</Tabs>
|
|
</YStack>
|
|
</Card>
|
|
)}
|
|
|
|
<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()}
|
|
disabled={!canAddTasks}
|
|
/>
|
|
}
|
|
>
|
|
<YStack space="$2">
|
|
{!canAddTasks ? (
|
|
<Text fontSize={12} fontWeight="600" color={dangerText}>
|
|
{limitReachedMessage}
|
|
{limitReachedHint ? ` ${limitReachedHint}` : ''}
|
|
</Text>
|
|
) : null}
|
|
<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()}
|
|
disabled={!canAddTasks}
|
|
/>
|
|
}
|
|
>
|
|
<YStack space="$2">
|
|
{!canAddTasks ? (
|
|
<Text fontSize={12} fontWeight="600" color={dangerText}>
|
|
{limitReachedMessage}
|
|
{limitReachedHint ? ` ${limitReachedHint}` : ''}
|
|
</Text>
|
|
) : null}
|
|
{maxTasks !== null ? (
|
|
<Text fontSize={11} color={muted}>
|
|
{t('events.tasks.limitRemaining', {
|
|
count: remainingTasks ?? 0,
|
|
total: maxTasks,
|
|
defaultValue: '{{count}} of {{total}} photo tasks remaining.',
|
|
})}
|
|
</Text>
|
|
) : null}
|
|
<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>
|
|
</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>
|
|
|
|
{canAddTasks ? (
|
|
<FloatingActionButton
|
|
onPress={() => {
|
|
if (!canAddTasks) {
|
|
toast.error(limitReachedMessage);
|
|
return;
|
|
}
|
|
setShowFabMenu(true);
|
|
}}
|
|
label={t('events.tasks.add', 'Add')}
|
|
icon={Plus}
|
|
/>
|
|
) : null}
|
|
|
|
<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={() => {
|
|
if (!canAddTasks) {
|
|
toast.error(limitReachedMessage);
|
|
return;
|
|
}
|
|
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={() => {
|
|
if (!canAddTasks) {
|
|
toast.error(limitReachedMessage);
|
|
return;
|
|
}
|
|
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);
|
|
setActiveTab('emotions');
|
|
}}
|
|
paddingVertical="$2"
|
|
paddingHorizontal="$3"
|
|
iconAfter={<ChevronRight size={14} color={subtle} />}
|
|
/>
|
|
</YGroup.Item>
|
|
</YGroup>
|
|
</MobileSheet>
|
|
</MobileShell>
|
|
);
|
|
}
|