Add tasks toggle card

This commit is contained in:
Codex Agent
2026-01-16 14:58:24 +01:00
parent 1517eb8631
commit 9e4ea3dafb
4 changed files with 137 additions and 1 deletions

View File

@@ -2070,6 +2070,18 @@
"tasks": { "tasks": {
"disabledTitle": "Task-Modus ist für dieses Event aus", "disabledTitle": "Task-Modus ist für dieses Event aus",
"disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.", "disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.",
"toggle": {
"title": "1. Aufgaben aktivieren",
"description": "Aktiviere Aufgaben, damit Gäste Challenges und Hinweise in der App sehen.",
"active": "AKTIV",
"inactive": "INAKTIV",
"onLabel": "Gäste sehen Aufgaben-Prompts",
"offLabel": "Gäste sehen nur Fotos",
"switchLabel": "Aufgaben aktiv",
"enabled": "Aufgaben aktiviert",
"disabled": "Aufgaben deaktiviert",
"permissionHint": "Du hast keine Berechtigung, Aufgaben zu ändern."
},
"title": "Tasks & Checklisten", "title": "Tasks & Checklisten",
"quickNav": "Schnellzugriff", "quickNav": "Schnellzugriff",
"sections": { "sections": {

View File

@@ -2074,6 +2074,18 @@
"tasks": { "tasks": {
"disabledTitle": "Task mode is off for this event", "disabledTitle": "Task mode is off for this event",
"disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.", "disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.",
"toggle": {
"title": "1. Activate tasks",
"description": "Enable tasks so guests see challenges and prompts in the app.",
"active": "ACTIVE",
"inactive": "INACTIVE",
"onLabel": "Guests see task prompts",
"offLabel": "Guest app shows photos only",
"switchLabel": "Tasks enabled",
"enabled": "Tasks activated",
"disabled": "Tasks disabled",
"permissionHint": "You do not have permission to change tasks."
},
"title": "Tasks & checklists", "title": "Tasks & checklists",
"quickNav": "Quick jump", "quickNav": "Quick jump",
"sections": { "sections": {

View File

@@ -12,13 +12,15 @@ import { Button } from '@tamagui/button';
import { AlertDialog } from '@tamagui/alert-dialog'; import { AlertDialog } from '@tamagui/alert-dialog';
import { ScrollView } from '@tamagui/scroll-view'; import { ScrollView } from '@tamagui/scroll-view';
import { ToggleGroup } from '@tamagui/toggle-group'; import { ToggleGroup } from '@tamagui/toggle-group';
import { Switch } from '@tamagui/switch';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives'; import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton, PillBadge } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { import {
getEvent, getEvent,
getEvents, getEvents,
getEventTasks, getEventTasks,
updateEvent,
updateTask, updateTask,
TenantTask, TenantTask,
TenantEvent, TenantEvent,
@@ -48,6 +50,19 @@ import { buildTaskSummary } from './lib/taskSummary';
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts'; import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
import { withAlpha } from './components/colors'; import { withAlpha } from './components/colors';
import { ADMIN_ACTION_COLORS, useAdminTheme } from './theme'; import { ADMIN_ACTION_COLORS, useAdminTheme } from './theme';
import { resolveEngagementMode } from '../lib/events';
import { useAuth } from '../auth/context';
function allowPermission(permissions: string[], permission: string): boolean {
if (permissions.includes('*') || permissions.includes(permission)) {
return true;
}
if (permission.includes(':')) {
const [prefix] = permission.split(':');
return permissions.includes(`${prefix}:*`);
}
return false;
}
function TaskSummaryCard({ summary }: { summary: ReturnType<typeof buildTaskSummary> }) { function TaskSummaryCard({ summary }: { summary: ReturnType<typeof buildTaskSummary> }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -238,7 +253,9 @@ export default function MobileEventTasksPage() {
const slug = slugParam ?? activeEvent?.slug ?? null; const slug = slugParam ?? activeEvent?.slug ?? null;
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { user } = useAuth();
const { textStrong, muted, subtle, border, primary, danger, surface, surfaceMuted, dangerBg, dangerText, overlay } = useAdminTheme(); const { textStrong, muted, subtle, border, primary, danger, surface, surfaceMuted, dangerBg, dangerText, overlay } = useAdminTheme();
const isMember = user?.role === 'member';
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]); const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
const [library, setLibrary] = React.useState<TenantTask[]>([]); const [library, setLibrary] = React.useState<TenantTask[]>([]);
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]); const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
@@ -271,6 +288,8 @@ export default function MobileEventTasksPage() {
const [savingEmotion, setSavingEmotion] = React.useState(false); const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false); const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
const [quickNavSelection, setQuickNavSelection] = React.useState<TaskSectionKey | ''>(''); const [quickNavSelection, setQuickNavSelection] = React.useState<TaskSectionKey | ''>('');
const [eventRecord, setEventRecord] = React.useState<TenantEvent | null>(null);
const [tasksToggleBusy, setTasksToggleBusy] = React.useState(false);
const text = textStrong; const text = textStrong;
const assignedRef = React.useRef<HTMLDivElement>(null); const assignedRef = React.useRef<HTMLDivElement>(null);
const libraryRef = React.useRef<HTMLDivElement>(null); const libraryRef = React.useRef<HTMLDivElement>(null);
@@ -281,6 +300,13 @@ export default function MobileEventTasksPage() {
collections: collections.length, collections: collections.length,
emotions: emotions.length, emotions: emotions.length,
}); });
const permissionSource = eventRecord ?? activeEvent;
const memberPermissions = Array.isArray(permissionSource?.member_permissions) ? permissionSource?.member_permissions ?? [] : [];
const canManageTasks = React.useMemo(
() => (isMember ? allowPermission(memberPermissions, 'tasks:manage') : true),
[isMember, memberPermissions]
);
const tasksEnabled = resolveEngagementMode(permissionSource ?? null) !== 'photo_only';
const sectionCounts = React.useMemo(() => buildTaskSectionCounts(summary), [summary]); const sectionCounts = React.useMemo(() => buildTaskSectionCounts(summary), [summary]);
React.useEffect(() => { React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) { if (slugParam && activeEvent?.slug !== slugParam) {
@@ -339,6 +365,7 @@ export default function MobileEventTasksPage() {
try { try {
const event = await getEvent(slug); const event = await getEvent(slug);
setEventId(event.id); setEventId(event.id);
setEventRecord(event);
const [result, libraryTasks] = await Promise.all([ const [result, libraryTasks] = await Promise.all([
getEventTasks(event.id, 1), getEventTasks(event.id, 1),
getTasks({ per_page: 200 }), getTasks({ per_page: 200 }),
@@ -574,6 +601,30 @@ export default function MobileEventTasksPage() {
} }
} }
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', 'Tasks activated')
: t('events.tasks.toggle.disabled', 'Tasks disabled')
);
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('events.errors.toggleFailed', 'Status could not be updated.'));
}
} finally {
setTasksToggleBusy(false);
}
}
return ( return (
<MobileShell <MobileShell
activeTab="tasks" activeTab="tasks"
@@ -601,6 +652,53 @@ export default function MobileEventTasksPage() {
</MobileCard> </MobileCard>
) : null} ) : null}
{!loading ? (
<MobileCard space="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('events.tasks.toggle.title', '1. Activate tasks')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'events.tasks.toggle.description',
'Enable tasks so guests see challenges and prompts in the app.'
)}
</Text>
</YStack>
<XStack alignItems="center" justifyContent="space-between" space="$3" flexWrap="wrap">
<PillBadge tone={tasksEnabled ? 'success' : 'warning'}>
{tasksEnabled
? t('events.tasks.toggle.active', 'ACTIVE')
: t('events.tasks.toggle.inactive', 'INACTIVE')}
</PillBadge>
<Text fontSize="$xs" color={muted}>
{tasksEnabled
? t('events.tasks.toggle.onLabel', 'Guests see task prompts')
: t('events.tasks.toggle.offLabel', 'Guest app shows photos only')}
</Text>
</XStack>
<XStack alignItems="center" justifyContent="space-between" marginTop="$2">
<Text fontSize="$xs" color={text} fontWeight="600">
{t('events.tasks.toggle.switchLabel', 'Tasks enabled')}
</Text>
<Switch
size="$4"
checked={tasksEnabled}
onCheckedChange={handleTasksToggle}
aria-label={t('events.tasks.toggle.switchLabel', 'Tasks enabled')}
disabled={!canManageTasks || tasksToggleBusy}
>
<Switch.Thumb />
</Switch>
</XStack>
{isMember && !canManageTasks ? (
<Text fontSize="$xs" color={muted}>
{t('events.tasks.toggle.permissionHint', 'You do not have permission to change tasks.')}
</Text>
) : null}
</MobileCard>
) : null}
{!loading ? ( {!loading ? (
<TaskSummaryCard summary={summary} /> <TaskSummaryCard summary={summary} />
) : null} ) : null}

View File

@@ -57,6 +57,10 @@ vi.mock('../../context/EventContext', () => ({
}), }),
})); }));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ user: { role: 'tenant_admin' } }),
}));
vi.mock('../../api', () => ({ vi.mock('../../api', () => ({
getEvent: vi.fn().mockResolvedValue(fixtures.event), getEvent: vi.fn().mockResolvedValue(fixtures.event),
getEvents: vi.fn().mockResolvedValue([fixtures.event]), getEvents: vi.fn().mockResolvedValue([fixtures.event]),
@@ -65,6 +69,7 @@ vi.mock('../../api', () => ({
getTaskCollections: vi.fn().mockResolvedValue({ data: fixtures.collections }), getTaskCollections: vi.fn().mockResolvedValue({ data: fixtures.collections }),
getEmotions: vi.fn().mockResolvedValue(fixtures.emotions), getEmotions: vi.fn().mockResolvedValue(fixtures.emotions),
assignTasksToEvent: vi.fn(), assignTasksToEvent: vi.fn(),
updateEvent: vi.fn().mockResolvedValue(fixtures.event),
updateTask: vi.fn(), updateTask: vi.fn(),
importTaskCollection: vi.fn(), importTaskCollection: vi.fn(),
createTask: vi.fn(), createTask: vi.fn(),
@@ -110,6 +115,13 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
})); }));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{ Thumb: () => <div /> },
),
}));
vi.mock('@tamagui/list-item', () => ({ vi.mock('@tamagui/list-item', () => ({
ListItem: ({ title, subTitle, iconAfter }: { title?: React.ReactNode; subTitle?: React.ReactNode; iconAfter?: React.ReactNode }) => ( ListItem: ({ title, subTitle, iconAfter }: { title?: React.ReactNode; subTitle?: React.ReactNode; iconAfter?: React.ReactNode }) => (
<div> <div>
@@ -162,6 +174,7 @@ vi.mock('../components/MobileShell', () => ({
vi.mock('../components/Primitives', () => ({ vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => ( CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
<button type="button" onClick={onPress}> <button type="button" onClick={onPress}>
{label} {label}
@@ -215,6 +228,7 @@ describe('MobileEventTasksPage', () => {
it('renders the task overview summary and quick jump chips', async () => { it('renders the task overview summary and quick jump chips', async () => {
render(<MobileEventTasksPage />); render(<MobileEventTasksPage />);
expect(await screen.findByText('1. Activate tasks')).toBeInTheDocument();
expect(await screen.findByText('Task overview')).toBeInTheDocument(); expect(await screen.findByText('Task overview')).toBeInTheDocument();
expect(screen.getByText('Tasks total')).toBeInTheDocument(); expect(screen.getByText('Tasks total')).toBeInTheDocument();
expect(screen.getByText('Quick jump')).toBeInTheDocument(); expect(screen.getByText('Quick jump')).toBeInTheDocument();