feat: add task multi-select on long-press
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-19 18:49:40 +01:00
parent 6f6d8901ec
commit fbd48afbd6
4 changed files with 312 additions and 40 deletions

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
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';
@@ -13,6 +13,7 @@ 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';
@@ -279,6 +280,12 @@ export default function MobileEventTasksPage() {
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('');
@@ -366,11 +373,15 @@ export default function MobileEventTasksPage() {
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 });
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;
@@ -383,6 +394,8 @@ export default function MobileEventTasksPage() {
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', 'Tasks konnten nicht geladen werden.'));
@@ -503,6 +516,15 @@ export default function MobileEventTasksPage() {
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', 'Aufgabe entfernt'));
} catch (err) {
if (!isAuthError(err)) {
@@ -521,6 +543,29 @@ export default function MobileEventTasksPage() {
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', 'Aufgabe entfernt'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('events.errors.saveFailed', 'Aufgabe konnte nicht entfernt werden.'));
}
} finally {
setBulkDeleteBusy(false);
}
}
const startEdit = (task: TenantTask) => {
setNewTask({
id: task.id,
@@ -541,6 +586,68 @@ export default function MobileEventTasksPage() {
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
@@ -898,17 +1005,58 @@ export default function MobileEventTasksPage() {
<Text fontSize="$sm" color={muted}>
{t('events.tasks.count', '{{count}} 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={() => startEdit(task)}
onPress={() => handleTaskPress(task)}
onPointerDown={() => startLongPress(task.id)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{task.title}
</Text>
<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 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 ? (
@@ -918,26 +1066,28 @@ export default function MobileEventTasksPage() {
) : null
}
iconAfter={
<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 task')}
disabled={busyId === task.id}
onPress={(event: any) => {
event?.stopPropagation?.();
setDeleteCandidate(task);
}}
/>
<ChevronRight size={14} color={subtle} />
</XStack>
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 task')}
disabled={busyId === task.id}
onPress={(event: any) => {
event?.stopPropagation?.();
setDeleteCandidate(task);
}}
/>
<ChevronRight size={14} color={subtle} />
</XStack>
)
}
paddingVertical="$2"
paddingHorizontal="$3"
@@ -1283,6 +1433,60 @@ export default function MobileEventTasksPage() {
</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 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')}