Add tasks toggle card
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user