feat: add task multi-select on long-press
This commit is contained in:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user