Refactor event tasks tabs
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-22 20:33:02 +01:00
parent fba9714ede
commit db5fea9f2a
4 changed files with 249 additions and 296 deletions

View File

@@ -10,7 +10,6 @@ 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 { Switch } from '@tamagui/switch';
import { Checkbox } from '@tamagui/checkbox';
import { Tabs } from 'tamagui';
@@ -47,8 +46,7 @@ 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 { type TaskSectionKey } from './lib/taskSectionCounts';
import { withAlpha } from './components/colors';
import { useAdminTheme } from './theme';
import { resolveEngagementMode } from '../lib/events';
@@ -97,7 +95,7 @@ export default function MobileEventTasksPage() {
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 [activeTab, setActiveTab] = React.useState<TaskSectionKey>('assigned');
const [showTaskSheet, setShowTaskSheet] = React.useState(false);
const [newTask, setNewTask] = React.useState({
id: null as number | null,
@@ -131,19 +129,10 @@ export default function MobileEventTasksPage() {
const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
const [showTaskDetails, setShowTaskDetails] = 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(
@@ -151,7 +140,6 @@ export default function MobileEventTasksPage() {
[isMember, memberPermissions]
);
const tasksEnabled = resolveEngagementMode(permissionSource ?? null) !== 'photo_only';
const sectionCounts = React.useMemo(() => buildTaskSectionCounts(summary), [summary]);
const maxTasks = React.useMemo(() => {
const limit = eventRecord?.limits?.tasks?.limit;
return typeof limit === 'number' && Number.isFinite(limit) ? limit : null;
@@ -187,34 +175,6 @@ export default function MobileEventTasksPage() {
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 handleQuickNavPress = (key: TaskSectionKey) => {
if (quickNavSelection === key) {
handleQuickNav(key);
}
};
const load = React.useCallback(async () => {
if (!slug) {
try {
@@ -654,7 +614,7 @@ export default function MobileEventTasksPage() {
<CTAButton
label={t('events.tasks.emptyActionPack', 'Import photo task pack')}
tone="ghost"
onPress={() => setShowCollectionSheet(true)}
onPress={() => setActiveTab('collections')}
disabled={!canAddTasks}
fullWidth={false}
/>
@@ -708,7 +668,7 @@ export default function MobileEventTasksPage() {
toast.error(limitReachedMessage);
return;
}
setShowCollectionSheet(true);
setActiveTab('collections');
}}
title={
<XStack alignItems="center" space="$2">
@@ -741,7 +701,6 @@ export default function MobileEventTasksPage() {
</YStack>
) : (
<YStack space="$2">
<YStack ref={assignedRef} />
<XStack alignItems="center" flexWrap="wrap" space="$2">
<Text fontSize="$sm" color={muted}>
{t('events.tasks.count', '{{count}} photo tasks', { count: filteredTasks.length })}
@@ -845,29 +804,41 @@ export default function MobileEventTasksPage() {
</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')}
</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>
<Pressable onPress={() => setShowCollectionSheet(true)}>
<Pressable onPress={() => setActiveTab('collections')}>
<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')}
{!canAddTasks ? (
<Text fontSize={12} fontWeight="600" color={dangerText}>
{limitReachedMessage}
{limitReachedHint ? ` ${limitReachedHint}` : ''}
</Text>
</Pressable>
) : 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', 'Keine weiteren Fotoaufgaben verfügbar.')}
{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, idx, arr) => (
{(expandedLibrary ? library : library.slice(0, 6)).map((task) => (
<YGroup.Item key={`lib-${task.id}`}>
<ListItem
hoverTheme
@@ -915,6 +886,134 @@ export default function MobileEventTasksPage() {
</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">
<Pressable
onPress={() => {
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
importCollection(collection.id);
}}
>
<Text fontSize={12} fontWeight="600" color={canAddTasks ? primary : muted}>
{t('events.tasks.import', 'Import')}
</Text>
</Pressable>
<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>
<CTAButton
label={t('events.tasks.addEmotion', 'Add emotion')}
fullWidth={false}
onPress={() => {
setEditingEmotion(null);
setEmotionForm({ name: '', color: border });
setShowEmotionSheet(true);
}}
/>
</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"
@@ -1024,212 +1123,80 @@ export default function MobileEventTasksPage() {
))}
</YStack>
) : (
<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}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TaskSectionKey)}>
<Tabs.List>
<Tabs.Tab value="assigned">{t('events.tasks.tabs.tasks', 'Tasks')}</Tabs.Tab>
<Tabs.Tab value="library">{t('events.tasks.tabs.library', 'Task Library')}</Tabs.Tab>
<Tabs.Tab value="emotions">{t('events.tasks.tabs.emotions', 'Emotions')}</Tabs.Tab>
<Tabs.Tab value="collections">{t('events.tasks.tabs.collections', 'Collections')}</Tabs.Tab>
</Tabs.List>
<Tabs.Content value="assigned" paddingTop="$2">
<YStack space="$2">
<YStack className="admin-sticky-toolbar">
<Card
borderRadius={20}
borderWidth={2}
borderColor={stickyBorder}
backgroundColor={stickySurface}
padding="$3"
shadowColor={stickyShadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('events.tasks.quickNav', 'Quick jump')}
</Text>
</XStack>
</XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Tabs
value={quickNavSelection}
onValueChange={(next) => {
const key = next as TaskSectionKey | '';
if (!key) {
return;
}
setQuickNavSelection(key);
handleQuickNav(key);
}}
orientation="horizontal"
>
<Tabs.List backgroundColor="transparent" borderWidth={0} paddingVertical="$1" gap="$2">
{sectionCounts.map((section) => {
const isActive = quickNavSelection === section.key;
const activeBorder = withAlpha(primary, 0.45);
const activeBackground = withAlpha(primary, 0.16);
return (
<Tabs.Tab
key={section.key}
value={section.key}
unstyled
onPress={() => handleQuickNavPress(section.key)}
borderRadius={999}
borderWidth={1}
borderColor={isActive ? activeBorder : border}
backgroundColor={isActive ? activeBackground : surface}
paddingHorizontal="$3"
paddingVertical="$2"
height={36}
pressStyle={{ backgroundColor: isActive ? activeBackground : surfaceMuted }}
>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
{t(`events.tasks.sections.${section.key}`, section.key)}
</Text>
<XStack
paddingHorizontal="$2"
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
borderColor={isActive ? activeBorder : border}
backgroundColor={surfaceMuted}
>
<Text fontSize={10} fontWeight="800" color={textStrong}>
{section.count}
</Text>
</XStack>
</XStack>
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>
</ScrollView>
</YStack>
</Card>
<div className="admin-sticky-toolbar">
<Card
borderRadius={20}
borderWidth={2}
borderColor={stickyBorder}
backgroundColor={stickySurface}
padding="$3"
shadowColor={stickyShadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<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>
</div>
{taskPanel}
</YStack>
)}
<MobileSheet
open={showCollectionSheet}
onClose={() => setShowCollectionSheet(false)}
title={t('events.tasks.import', 'Fotoaufgabenpaket importieren')}
footer={null}
>
<YStack space="$2">
{!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', '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}
<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>
) : null
}
iconAfter={
<XStack space="$1.5" alignItems="center">
<Pressable
onPress={() => {
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
importCollection(collection.id);
}}
>
<Text fontSize={12} fontWeight="600" color={canAddTasks ? primary : muted}>
{t('events.tasks.import', 'Import')}
</Text>
</Pressable>
<ChevronRight size={14} color={subtle} />
<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>
}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</YGroup.Item>
))}
</YGroup>
)}
</YStack>
</MobileSheet>
</Pressable>
</XStack>
</Card>
</YStack>
{taskPanel}
</YStack>
</Tabs.Content>
<Tabs.Content value="library" paddingTop="$2">
{libraryPanel}
</Tabs.Content>
<Tabs.Content value="emotions" paddingTop="$2">
{emotionsPanel}
</Tabs.Content>
<Tabs.Content value="collections" paddingTop="$2">
{collectionsPanel}
</Tabs.Content>
</Tabs>
)}
<MobileSheet
open={showTaskSheet}
@@ -1355,39 +1322,6 @@ export default function MobileEventTasksPage() {
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>
@@ -1619,7 +1553,7 @@ export default function MobileEventTasksPage() {
}
onPress={() => {
setShowFabMenu(false);
setShowEmotionSheet(true);
setActiveTab('emotions');
}}
paddingVertical="$2"
paddingHorizontal="$3"