Fix demo task readiness and gate event creation
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-02-05 11:26:07 +01:00
parent 7262617897
commit 04c399aeb6
14 changed files with 318 additions and 36 deletions

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, Bell, CalendarDays, Camera, CheckCircle2, ChevronRight, Circle, Download, Heart, Image as ImageIcon, Layout, ListTodo, Megaphone, QrCode, Settings, ShieldCheck, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
import { AlertCircle, Bell, CalendarDays, Camera, CheckCircle2, ChevronRight, Circle, Download, Heart, Image as ImageIcon, Layout, ListTodo, Megaphone, Plus, QrCode, Settings, ShieldCheck, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
import { Button } from '@tamagui/button';
import { Card } from '@tamagui/card';
import { YGroup } from '@tamagui/group';
@@ -19,7 +19,7 @@ import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell';
import { ADMIN_EVENTS_PATH, adminPath } from '../constants';
import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEventPhotos, TenantPhoto, updateEvent, getLiveShowQueue } from '../api';
import { getEventStats, EventStats, TenantEvent, getEventPhotos, TenantPhoto, updateEvent, getLiveShowQueue, getTenantPackagesOverview, type TenantPackageSummary } from '../api';
import { formatEventDate } from '../lib/events';
import { useAuth } from '../auth/context';
import { ADMIN_ACTION_COLORS, useAdminTheme } from './theme';
@@ -37,6 +37,59 @@ function translateLimits(t: any) {
return (key: string, options?: any) => t(`management:limits.${key}`, key, options);
}
function collectActivePackages(
overview: { packages: TenantPackageSummary[]; activePackage: TenantPackageSummary | null } | null
): TenantPackageSummary[] {
if (!overview) {
return [];
}
const packages = overview.packages ?? [];
const activePackage = overview.activePackage;
const activeList = packages.filter((pkg) => pkg.active);
if (activePackage && !activeList.some((pkg) => pkg.id === activePackage.id) && activePackage.active) {
return [activePackage, ...activeList];
}
return activeList;
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function resellerHasRemainingEvents(pkg: TenantPackageSummary): boolean {
if (pkg.package_type !== 'reseller') {
return false;
}
const remaining = toNumber(pkg.remaining_events);
if (remaining !== null) {
return remaining > 0;
}
const limits = (pkg.package_limits ?? {}) as Record<string, unknown>;
const limitMaxEvents = toNumber(limits.max_events_per_year);
if (limitMaxEvents === null) {
return false;
}
const usedEvents = toNumber(pkg.used_events) ?? 0;
return limitMaxEvents > usedEvents;
}
// --- TAMAGUI-ALIGNED PRIMITIVES ---
function DashboardCard({
@@ -129,6 +182,7 @@ export default function MobileDashboardPage() {
const { user } = useAuth();
const theme = useAdminTheme();
const isMember = user?.role === 'member';
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
// --- LOGIC ---
const memberPermissions = React.useMemo(() => {
@@ -178,6 +232,29 @@ export default function MobileDashboardPage() {
},
});
const { data: packagesOverview, isLoading: packagesLoading } = useQuery({
queryKey: ['mobile', 'dashboard', 'packages-overview'],
enabled: !isMember && !isSuperAdmin,
queryFn: () => getTenantPackagesOverview({ force: true }),
});
const canCreateEvent = React.useMemo(() => {
if (!canManageEvents) {
return false;
}
if (isSuperAdmin) {
return true;
}
if (isMember || packagesLoading) {
return false;
}
const activePackages = collectActivePackages(packagesOverview ?? null);
return activePackages.some((pkg) => resellerHasRemainingEvents(pkg));
}, [canManageEvents, isSuperAdmin, isMember, packagesLoading, packagesOverview]);
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
React.useEffect(() => {
@@ -265,6 +342,7 @@ export default function MobileDashboardPage() {
permissions={memberPermissions}
isMember={isMember}
isCompleted={isCompleted}
canCreateEvent={canCreateEvent}
/>
{/* 5. RECENT PHOTOS */}
@@ -627,7 +705,7 @@ function PulseStrip({ event, stats, liveShowApprovedCount }: any) {
return <KpiStrip items={items} />;
}
function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }: any) {
function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted, canCreateEvent }: any) {
const theme = useAdminTheme();
const { t } = useTranslation(['management', 'dashboard']);
const slug = event?.slug;
@@ -649,6 +727,7 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
].filter(Boolean) as ToolItem[];
const adminItems: ToolItem[] = [
canCreateEvent ? { label: t('management:events.list.actions.create', 'Create Event'), icon: Plus, path: `/mobile/events/new`, color: theme.primary } : null,
{ label: t('management:mobileDashboard.shortcutAnalytics', 'Analytics'), icon: TrendingUp, path: `/mobile/events/${slug}/analytics`, color: ADMIN_ACTION_COLORS.analytics },
!isCompleted ? { label: t('events.recap.exportTitleShort', 'Exports'), icon: Download, path: `/mobile/exports`, color: ADMIN_ACTION_COLORS.recap } : null,
{ label: t('management:mobileProfile.settings', 'Settings'), icon: Settings, path: `/mobile/events/${slug}/edit`, color: ADMIN_ACTION_COLORS.settings },

View File

@@ -66,7 +66,7 @@ function allowPermission(permissions: string[], permission: string): boolean {
export default function MobileEventTasksPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const { activeEvent, selectEvent } = useEventContext();
const { activeEvent, selectEvent, refetch } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
@@ -258,6 +258,7 @@ export default function MobileEventTasksPage() {
const result = await getEventTasks(eventId, 1);
setAssignedTasks(result.data);
setLibrary((prev) => prev.filter((t) => t.id !== taskId));
refetch();
toast.success(t('events.tasks.assigned', 'Fotoaufgabe hinzugefügt'));
} catch (err) {
if (!isAuthError(err)) {
@@ -281,6 +282,7 @@ export default function MobileEventTasksPage() {
const assignedIds = new Set(result.data.map((t) => t.id));
setAssignedTasks(result.data);
setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id)));
refetch();
toast.success(t('events.tasks.imported', 'Fotoaufgabenpaket importiert'));
} catch (err) {
if (!isAuthError(err)) {
@@ -337,6 +339,7 @@ export default function MobileEventTasksPage() {
setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id)));
setShowTaskSheet(false);
setNewTask({ id: null, title: '', description: '', emotion_id: '', tenant_id: null });
refetch();
toast.success(t('events.tasks.created', 'Fotoaufgabe gespeichert'));
} catch (err) {
if (!isAuthError(err)) {
@@ -361,6 +364,7 @@ export default function MobileEventTasksPage() {
}
return next;
});
refetch();
toast.success(t('events.tasks.removed', 'Fotoaufgabe entfernt'));
} catch (err) {
if (!isAuthError(err)) {
@@ -392,6 +396,7 @@ export default function MobileEventTasksPage() {
setAssignedTasks((prev) => prev.filter((task) => !selectedTaskIds.has(task.id)));
setSelectedTaskIds(new Set());
setSelectionMode(false);
refetch();
toast.success(t('events.tasks.removed', 'Fotoaufgabe entfernt'));
} catch (err) {
if (!isAuthError(err)) {
@@ -519,6 +524,7 @@ export default function MobileEventTasksPage() {
setAssignedTasks(result.data);
setBulkLines('');
setShowBulkSheet(false);
refetch();
toast.success(t('events.tasks.created', 'Fotoaufgabe gespeichert'));
} catch (err) {
if (!isAuthError(err)) {

View File

@@ -28,6 +28,7 @@ export default function MobileEventsPage() {
const navigate = useNavigate();
const { user } = useAuth();
const isMember = user?.role === 'member';
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
@@ -85,13 +86,21 @@ export default function MobileEventsPage() {
}, [isMember]);
const canCreateEvent = React.useMemo(() => {
if (isMember || packagesLoading) {
if (isMember) {
return false;
}
if (isSuperAdmin) {
return true;
}
if (packagesLoading) {
return false;
}
const activePackages = collectActivePackages(packagesOverview);
return activePackages.some((pkg) => packageHasRemainingEvents(pkg));
}, [isMember, packagesLoading, packagesOverview]);
return activePackages.some((pkg) => resellerHasRemainingEvents(pkg));
}, [isMember, isSuperAdmin, packagesLoading, packagesOverview]);
return (
<MobileShell
@@ -540,18 +549,13 @@ function toNumber(value: unknown): number | null {
return null;
}
function packageHasRemainingEvents(pkg: TenantPackageSummary): boolean {
const remaining = toNumber(pkg.remaining_events);
function resellerHasRemainingEvents(pkg: TenantPackageSummary): boolean {
if (pkg.package_type !== 'reseller') {
if (remaining !== null) {
return remaining > 0;
}
const usedEvents = toNumber(pkg.used_events) ?? 0;
return usedEvents < 1;
return false;
}
const remaining = toNumber(pkg.remaining_events);
if (remaining !== null) {
return remaining > 0;
}

View File

@@ -399,7 +399,17 @@ describe('MobileDashboardPage', () => {
expect(screen.getByText('Experience')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.queryByText('Create Event')).not.toBeInTheDocument();
authState.user = { role: 'tenant_admin' };
});
it('shows create event action for reseller packages', () => {
fixtures.activePackage.package_type = 'reseller';
fixtures.activePackage.remaining_events = 2;
render(<MobileDashboardPage />);
expect(screen.getByText('Create Event')).toBeInTheDocument();
});
});

View File

@@ -63,11 +63,13 @@ vi.mock('../hooks/useBackNavigation', () => ({
}));
const selectEventMock = vi.fn();
const refetchMock = vi.fn();
vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({
activeEvent: fixtures.event,
selectEvent: selectEventMock,
refetch: refetchMock,
}),
}));
@@ -245,7 +247,12 @@ vi.mock('../components/FormControls', () => ({
}));
vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileSheet: ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => (
<div>
{children}
{footer}
</div>
),
}));
vi.mock('../components/Tag', () => ({
@@ -336,4 +343,23 @@ describe('MobileEventTasksPage', () => {
expect(screen.queryByLabelText('Add')).not.toBeInTheDocument();
});
});
it('refetches events after creating a task', async () => {
refetchMock.mockClear();
(api.createTask as unknown as { mockResolvedValueOnce: (value: any) => void }).mockResolvedValueOnce({ id: 99 });
render(<MobileEventTasksPage />);
expect(await screen.findByText('Photo tasks for guests')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('z.B. Erstes Gruppenfoto'), {
target: { value: 'New Task' },
});
fireEvent.click(screen.getAllByRole('button', { name: 'Fotoaufgabe speichern' })[0]);
await waitFor(() => {
expect(refetchMock).toHaveBeenCalled();
});
});
});

View File

@@ -255,6 +255,41 @@ describe('MobileEventsPage', () => {
});
it('shows the create button when an active package has remaining events', async () => {
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
packages: [
{
id: 2,
package_id: 2,
package_name: 'Reseller',
package_type: 'reseller',
included_package_slug: 'standard',
active: true,
used_events: 1,
remaining_events: 2,
price: 240,
currency: 'EUR',
purchased_at: null,
expires_at: null,
package_limits: { max_events_per_year: 5 },
},
],
activePackage: {
id: 2,
package_id: 2,
package_name: 'Reseller',
package_type: 'reseller',
included_package_slug: 'standard',
active: true,
used_events: 1,
remaining_events: 2,
price: 240,
currency: 'EUR',
purchased_at: null,
expires_at: null,
package_limits: { max_events_per_year: 5 },
},
});
render(<MobileEventsPage />);
expect(await screen.findByText('New event')).toBeInTheDocument();

View File

@@ -16,11 +16,13 @@ export function EventSwitcherSheet({
onClose,
events,
activeSlug,
canCreateEvent,
}: {
open: boolean;
onClose: () => void;
events: TenantEvent[];
activeSlug: string | null;
canCreateEvent: boolean;
}) {
const { t, i18n } = useTranslation(['management', 'mobile']);
const navigate = useNavigate();
@@ -73,13 +75,15 @@ export function EventSwitcherSheet({
);
})}
<Pressable onPress={() => { onClose(); navigate(adminPath('/mobile/events/new')); }}>
<XStack padding="$3" justifyContent="center">
<Text fontSize="$sm" fontWeight="700" color={theme.primary}>
{t('mobile:header.createEvent', 'Create Event')}
</Text>
</XStack>
</Pressable>
{canCreateEvent ? (
<Pressable onPress={() => { onClose(); navigate(adminPath('/mobile/events/new')); }}>
<XStack padding="$3" justifyContent="center">
<Text fontSize="$sm" fontWeight="700" color={theme.primary}>
{t('mobile:header.createEvent', 'Create Event')}
</Text>
</XStack>
</Pressable>
) : null}
</YStack>
</MobileSheet>
);

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeft, Bell, QrCode, ChevronsUpDown, Search } from 'lucide-react';
import { YStack, XStack, SizableText as Text, Image } from 'tamagui';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -12,7 +13,7 @@ import { MobileCard, CTAButton } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api';
import { TenantEvent, getEvents, getTenantPackagesOverview, type TenantPackageSummary } from '../../api';
import { setTabHistory } from '../lib/tabHistory';
import { loadPhotoQueue } from '../lib/photoModerationQueue';
import { countQueuedPhotoActions } from '../lib/queueStatus';
@@ -31,6 +32,59 @@ type MobileShellProps = {
headerActions?: React.ReactNode;
};
function collectActivePackages(
overview: { packages: TenantPackageSummary[]; activePackage: TenantPackageSummary | null } | null
): TenantPackageSummary[] {
if (!overview) {
return [];
}
const packages = overview.packages ?? [];
const activePackage = overview.activePackage;
const activeList = packages.filter((pkg) => pkg.active);
if (activePackage && !activeList.some((pkg) => pkg.id === activePackage.id) && activePackage.active) {
return [activePackage, ...activeList];
}
return activeList;
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function resellerHasRemainingEvents(pkg: TenantPackageSummary): boolean {
if (pkg.package_type !== 'reseller') {
return false;
}
const remaining = toNumber(pkg.remaining_events);
if (remaining !== null) {
return remaining > 0;
}
const limits = (pkg.package_limits ?? {}) as Record<string, unknown>;
const limitMaxEvents = toNumber(limits.max_events_per_year);
if (limitMaxEvents === null) {
return false;
}
const usedEvents = toNumber(pkg.used_events) ?? 0;
return limitMaxEvents > usedEvents;
}
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
const { events, activeEvent, selectEvent } = useEventContext();
const { user } = useAuth();
@@ -40,6 +94,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const { t } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
useDocumentTitle(title);
@@ -144,6 +199,29 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
};
const showQr = Boolean(effectiveActive?.slug) && allowPermission('join-tokens:manage');
const { data: packagesOverview, isLoading: packagesLoading } = useQuery({
queryKey: ['mobile', 'header', 'packages-overview'],
enabled: !isMember && !isSuperAdmin,
queryFn: () => getTenantPackagesOverview({ force: true }),
});
const canCreateEvent = React.useMemo(() => {
if (isMember) {
return false;
}
if (isSuperAdmin) {
return true;
}
if (packagesLoading) {
return false;
}
const activePackages = collectActivePackages(packagesOverview ?? null);
return activePackages.some((pkg) => resellerHasRemainingEvents(pkg));
}, [isMember, isSuperAdmin, packagesLoading, packagesOverview]);
// --- CONTEXT PILL ---
const EventContextPill = () => {
if (!effectiveActive || isEventsIndex || isCompactHeader) {
@@ -387,10 +465,11 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
<BottomNav active={activeTab} onNavigate={go} />
<EventSwitcherSheet
open={switcherOpen}
onClose={() => setSwitcherOpen(false)}
events={effectiveEvents}
activeSlug={effectiveActive?.slug ?? null}
open={switcherOpen}
onClose={() => setSwitcherOpen(false)}
events={effectiveEvents}
activeSlug={effectiveActive?.slug ?? null}
canCreateEvent={canCreateEvent}
/>
<UserMenuSheet
open={userMenuOpen}

View File

@@ -112,6 +112,11 @@ vi.mock('../../hooks/useOnlineStatus', () => ({
vi.mock('../../../api', () => ({
getEvents: vi.fn().mockResolvedValue([]),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
}));
vi.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: { packages: [], activePackage: null }, isLoading: false }),
}));
vi.mock('../../lib/tabHistory', () => ({

View File

@@ -14,8 +14,7 @@ import React from 'react';
import { usePage } from '@inertiajs/react';
import { useEffect } from 'react';
import { Sentry, initSentry } from './lib/sentry';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
import { buildPageTitle } from './lib/appTitle';
const InertiaFallback: React.FC = () => (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Oberfläche konnte nicht geladen werden.
@@ -48,7 +47,7 @@ const LocaleSync: React.FC<{ children: React.ReactNode }> = ({ children }) => {
};
createInertiaApp({
title: (title) => title ? `${title} - ${appName}` : appName,
title: (title) => buildPageTitle(title),
resolve: (name) =>
resolvePageComponent(
`./pages/${name}.tsx`,

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { buildPageTitle, getAppName } from '../appTitle';
describe('app title helpers', () => {
it('falls back to Fotospiel when VITE_APP_NAME is missing', () => {
const original = import.meta.env.VITE_APP_NAME;
// @ts-expect-error - import.meta.env is mutable in vitest
import.meta.env.VITE_APP_NAME = '';
expect(getAppName()).toBe('Fotospiel');
expect(buildPageTitle('Demo')).toBe('Demo - Fotospiel');
// @ts-expect-error - restore original value
import.meta.env.VITE_APP_NAME = original;
});
it('uses the configured VITE_APP_NAME when available', () => {
const original = import.meta.env.VITE_APP_NAME;
// @ts-expect-error - import.meta.env is mutable in vitest
import.meta.env.VITE_APP_NAME = 'Fotospiel App';
expect(getAppName()).toBe('Fotospiel App');
expect(buildPageTitle('Demo')).toBe('Demo - Fotospiel App');
// @ts-expect-error - restore original value
import.meta.env.VITE_APP_NAME = original;
});
});

View File

@@ -0,0 +1,8 @@
export function getAppName(): string {
return import.meta.env.VITE_APP_NAME || 'Fotospiel';
}
export function buildPageTitle(title?: string): string {
const appName = getAppName();
return title ? `${title} - ${appName}` : appName;
}

View File

@@ -4,14 +4,13 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import ReactDOMServer from 'react-dom/server';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
import { buildPageTitle } from './lib/appTitle';
createServer((page) =>
createInertiaApp({
page,
render: ReactDOMServer.renderToString,
title: (title) => (title ? `${title} - ${appName}` : appName),
title: (title) => buildPageTitle(title),
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
setup: ({ App, props }) => {
const locale = (props.initialPage?.props as Record<string, unknown> | undefined)?.locale as string | undefined;

View File

@@ -28,7 +28,7 @@
})();
</script>
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<title inertia>{{ config('app.name', 'Fotospiel') }}</title>
<link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">