Enforce task limits and update event form
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-21 09:49:30 +01:00
parent 0b1430e64d
commit 1c5412e82c
15 changed files with 491 additions and 52 deletions

View File

@@ -2,14 +2,14 @@ import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { CalendarDays, ChevronDown, MapPin, Save, Check } from 'lucide-react';
import { CalendarDays, MapPin, Save, Check } from 'lucide-react';
import { isPast, isSameDay, parseISO, startOfDay } from 'date-fns';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, FloatingActionButton } from './components/Primitives';
import { MobileDateTimeInput, MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { MobileDateInput, MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import {
createEvent,
@@ -31,7 +31,6 @@ import { getApiErrorMessage, getApiValidationMessage, isApiError } from '../lib/
import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import { withAlpha } from './components/colors';
import { useAuth } from '../auth/context';
import { useEventContext } from '../context/EventContext';
@@ -138,7 +137,7 @@ export default function MobileEventFormPage() {
const handleDateChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isEventCompleted) return;
setForm((prev) => ({ ...prev, date: event.target.value }));
setForm((prev) => ({ ...prev, date: normalizeEventDateTime(event.target.value, prev.date) }));
},
[isEventCompleted],
);
@@ -258,11 +257,12 @@ export default function MobileEventFormPage() {
async function handleSubmit() {
setSaving(true);
setError(null);
const normalizedEventDate = normalizeEventDateTime(form.date);
try {
if (isEdit && slug) {
const updated = await updateEvent(slug, {
name: form.name,
event_date: form.date || undefined,
event_date: normalizedEventDate || undefined,
event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft',
settings: {
@@ -280,7 +280,7 @@ export default function MobileEventFormPage() {
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
event_date: normalizedEventDate || undefined,
status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
service_package_slug: form.servicePackageSlug ?? undefined,
@@ -314,7 +314,7 @@ export default function MobileEventFormPage() {
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
event_date: normalizedEventDate || undefined,
status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
service_package_slug: form.servicePackageSlug ?? undefined,
@@ -374,6 +374,20 @@ export default function MobileEventFormPage() {
}
}
const requiredLabel = React.useCallback(
(label: string) => (
<XStack alignItems="center" space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{label}
</Text>
<Text fontSize="$sm" fontWeight="800" color={danger}>
*
</Text>
</XStack>
),
[danger, text],
);
return (
<MobileShell
activeTab="home"
@@ -389,7 +403,7 @@ export default function MobileEventFormPage() {
) : null}
<MobileCard space="$3">
<MobileField label={t('eventForm.fields.name.label', 'Event name')}>
<MobileField label={requiredLabel(t('eventForm.fields.name.label', 'Event name'))}>
<MobileInput
type="text"
value={form.name}
@@ -451,10 +465,10 @@ export default function MobileEventFormPage() {
</MobileField>
) : null}
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<MobileField label={requiredLabel(t('eventForm.fields.date.label', 'Date & time'))}>
<XStack alignItems="center" space="$2">
<MobileDateTimeInput
value={form.date}
<MobileDateInput
value={extractDateValue(form.date)}
onChange={handleDateChange}
style={{ flex: 1 }}
disabled={isEventCompleted}
@@ -463,7 +477,7 @@ export default function MobileEventFormPage() {
</XStack>
</MobileField>
<MobileField label={t('eventForm.fields.type.label', 'Event type')}>
<MobileField label={requiredLabel(t('eventForm.fields.type.label', 'Event type'))}>
{typesLoading ? (
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
) : eventTypes.length === 0 ? (
@@ -589,9 +603,9 @@ export default function MobileEventFormPage() {
<YStack space="$2" paddingBottom="$10">
{!isEdit ? (
<CTAButton
label={t('eventForm.actions.saveDraft', 'Save as draft')}
label={t('eventForm.actions.create', 'Create event')}
tone="ghost"
onPress={back}
onPress={() => handleSubmit()}
/>
) : null}
</YStack>
@@ -663,6 +677,30 @@ function isWaiverRequiredError(error: unknown): boolean {
return 'accepted_waiver' in metaErrors;
}
const DEFAULT_EVENT_TIME = '12:00';
function normalizeEventDateTime(value: string, previousValue?: string): string {
if (!value) {
return '';
}
const [datePart, timePartRaw] = value.split('T');
if (!datePart) {
return value;
}
const nextTime = timePartRaw?.slice(0, 5);
if (!nextTime || nextTime === '00:00') {
const previousTime = previousValue?.split('T')[1]?.slice(0, 5);
if (previousTime && previousTime !== '00:00') {
return `${datePart}T${previousTime}`;
}
return `${datePart}T${DEFAULT_EVENT_TIME}`;
}
return `${datePart}T${nextTime}`;
}
function toDateTimeLocal(value?: string | null): string {
if (!value) return '';
@@ -674,6 +712,14 @@ function toDateTimeLocal(value?: string | null): string {
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
}
function extractDateValue(value?: string | null): string {
if (!value) {
return '';
}
return value.split('T')[0] ?? '';
}
function resolveLocation(event: TenantEvent): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =

View File

@@ -201,6 +201,25 @@ export default function MobileEventTasksPage() {
);
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;
}, [eventRecord?.limits?.tasks?.limit]);
const remainingTasks = React.useMemo(() => {
if (maxTasks === null) {
return null;
}
return Math.max(0, maxTasks - assignedTasks.length);
}, [assignedTasks.length, maxTasks]);
const canAddTasks = maxTasks === null || (remainingTasks ?? 0) > 0;
const limitReachedMessage = t('events.tasks.limitReached', 'Photo task limit reached.');
const limitReachedHint =
maxTasks === null
? null
: t('events.tasks.limitReachedHint', {
count: maxTasks,
defaultValue: 'This event allows up to {{count}} photo tasks.',
});
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
@@ -310,6 +329,10 @@ export default function MobileEventTasksPage() {
async function quickAssign(taskId: number) {
if (!eventId) return;
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
setAssigningId(taskId);
try {
await assignTasksToEvent(eventId, [taskId]);
@@ -329,6 +352,10 @@ export default function MobileEventTasksPage() {
async function importCollection(collectionId: number) {
if (!slug || !eventId) return;
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
try {
await importTaskCollection(collectionId, slug);
const result = await getEventTasks(eventId, 1);
@@ -346,6 +373,10 @@ export default function MobileEventTasksPage() {
async function createNewTask() {
if (!eventId || !newTask.title.trim()) return;
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
try {
if (newTask.id) {
if (!Number.isFinite(Number(newTask.id))) {
@@ -536,13 +567,32 @@ export default function MobileEventTasksPage() {
async function handleBulkAdd() {
if (!eventId || !bulkLines.trim()) return;
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
const lines = bulkLines
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return;
const capacity = remainingTasks === null ? lines.length : Math.max(0, remainingTasks);
const slicedLines = lines.slice(0, capacity);
if (!slicedLines.length) {
toast.error(limitReachedMessage);
return;
}
try {
for (const line of lines) {
if (slicedLines.length < lines.length) {
toast.error(
t('events.tasks.limitSkipped', {
count: lines.length - slicedLines.length,
defaultValue: 'Skipped {{count}} tasks due to limit.',
}),
);
}
for (const line of slicedLines) {
const created = await createTask({ title: line } as any);
await assignTasksToEvent(eventId, [created.id]);
}
@@ -627,16 +677,24 @@ export default function MobileEventTasksPage() {
<Text fontSize={12} color={muted}>
{t('events.tasks.emptyBody', 'Create photo tasks or import a pack for your event.')}
</Text>
{!canAddTasks ? (
<Text fontSize={12} fontWeight="600" color={dangerText}>
{limitReachedMessage}
{limitReachedHint ? ` ${limitReachedHint}` : ''}
</Text>
) : null}
<XStack space="$2">
<CTAButton
label={t('events.tasks.emptyActionTask', 'Add photo task')}
onPress={() => setShowTaskSheet(true)}
disabled={!canAddTasks}
fullWidth={false}
/>
<CTAButton
label={t('events.tasks.emptyActionPack', 'Import photo task pack')}
tone="ghost"
onPress={() => setShowCollectionSheet(true)}
disabled={!canAddTasks}
fullWidth={false}
/>
</XStack>
@@ -646,18 +704,24 @@ export default function MobileEventTasksPage() {
<ListItem
hoverTheme
pressTheme
onPress={() => setShowTaskSheet(true)}
onPress={() => {
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
setShowTaskSheet(true);
}}
title={
<XStack alignItems="center" space="$2">
<YStack
width={28}
height={28}
borderRadius={14}
backgroundColor={primary}
backgroundColor={canAddTasks ? primary : withAlpha(border, 0.4)}
alignItems="center"
justifyContent="center"
>
<Plus size={14} color={surface} />
<Plus size={14} color={canAddTasks ? surface : muted} />
</YStack>
<Text fontSize={12.5} fontWeight="700" color={text}>
{t('events.tasks.addTask', 'Fotoaufgabe hinzufügen')}
@@ -678,18 +742,24 @@ export default function MobileEventTasksPage() {
<ListItem
hoverTheme
pressTheme
onPress={() => setShowCollectionSheet(true)}
onPress={() => {
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
setShowCollectionSheet(true);
}}
title={
<XStack alignItems="center" space="$2">
<YStack
width={28}
height={28}
borderRadius={14}
backgroundColor={primary}
backgroundColor={canAddTasks ? primary : withAlpha(border, 0.4)}
alignItems="center"
justifyContent="center"
>
<Plus size={14} color={surface} />
<Plus size={14} color={canAddTasks ? surface : muted} />
</YStack>
<Text fontSize={12.5} fontWeight="700" color={text}>
{t('events.tasks.import', 'Fotoaufgabenpaket importieren')}
@@ -845,10 +915,18 @@ export default function MobileEventTasksPage() {
}
iconAfter={
<XStack space="$1.5" alignItems="center">
<Pressable onPress={() => quickAssign(task.id)}>
<Pressable
onPress={() => {
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
quickAssign(task.id);
}}
>
<XStack alignItems="center" space="$1">
<Plus size={14} color={primary} />
<Text fontSize={12} fontWeight="600" color={primary}>
<Plus size={14} color={canAddTasks ? primary : muted} />
<Text fontSize={12} fontWeight="600" color={canAddTasks ? primary : muted}>
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
</Text>
</XStack>
@@ -1087,6 +1165,12 @@ export default function MobileEventTasksPage() {
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}>
@@ -1119,8 +1203,16 @@ export default function MobileEventTasksPage() {
}
iconAfter={
<XStack space="$1.5" alignItems="center">
<Pressable onPress={() => importCollection(collection.id)}>
<Text fontSize={12} fontWeight="600" color={primary}>
<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>
@@ -1142,10 +1234,20 @@ export default function MobileEventTasksPage() {
onClose={() => setShowTaskSheet(false)}
title={t('events.tasks.addTask', 'Fotoaufgabe hinzufügen')}
footer={
<CTAButton label={t('events.tasks.saveTask', 'Fotoaufgabe speichern')} onPress={() => createNewTask()} />
<CTAButton
label={t('events.tasks.saveTask', 'Fotoaufgabe speichern')}
onPress={() => createNewTask()}
disabled={!canAddTasks}
/>
}
>
<YStack space="$2">
{!canAddTasks ? (
<Text fontSize={12} fontWeight="600" color={dangerText}>
{limitReachedMessage}
{limitReachedHint ? ` ${limitReachedHint}` : ''}
</Text>
) : null}
<MobileField label={t('events.tasks.titleLabel', 'Titel')}>
<MobileInput
type="text"
@@ -1183,9 +1285,30 @@ export default function MobileEventTasksPage() {
open={showBulkSheet}
onClose={() => setShowBulkSheet(false)}
title={t('events.tasks.bulkAdd', 'Bulk add')}
footer={<CTAButton label={t('events.tasks.saveTask', 'Fotoaufgabe speichern')} onPress={() => handleBulkAdd()} />}
footer={
<CTAButton
label={t('events.tasks.saveTask', 'Fotoaufgabe speichern')}
onPress={() => handleBulkAdd()}
disabled={!canAddTasks}
/>
}
>
<YStack space="$2">
{!canAddTasks ? (
<Text fontSize={12} fontWeight="600" color={dangerText}>
{limitReachedMessage}
{limitReachedHint ? ` ${limitReachedHint}` : ''}
</Text>
) : null}
{maxTasks !== null ? (
<Text fontSize={11} color={muted}>
{t('events.tasks.limitRemaining', {
count: remainingTasks ?? 0,
total: maxTasks,
defaultValue: '{{count}} of {{total}} photo tasks remaining.',
})}
</Text>
) : null}
<Text fontSize={12} color={muted}>
{t('events.tasks.bulkHint', 'One photo task per line. These will be created and added to the event.')}
</Text>
@@ -1414,7 +1537,13 @@ export default function MobileEventTasksPage() {
</AlertDialog>
<FloatingActionButton
onPress={() => setShowFabMenu(true)}
onPress={() => {
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
setShowFabMenu(true);
}}
label={t('events.tasks.add', 'Add')}
icon={Plus}
/>
@@ -1436,6 +1565,10 @@ export default function MobileEventTasksPage() {
</Text>
}
onPress={() => {
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
setShowFabMenu(false);
setShowTaskSheet(true);
}}
@@ -1454,6 +1587,10 @@ export default function MobileEventTasksPage() {
</Text>
}
onPress={() => {
if (!canAddTasks) {
toast.error(limitReachedMessage);
return;
}
setShowFabMenu(false);
setShowBulkSheet(true);
}}

View File

@@ -60,8 +60,8 @@ vi.mock('../components/Primitives', () => ({
vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileDateTimeInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => (
<input type="datetime-local" {...props} />
MobileDateInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => (
<input type="date" {...props} />
),
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
@@ -116,7 +116,7 @@ vi.mock('../../context/EventContext', () => ({
}),
}));
import { getEvent, getEventTypes } from '../../api';
import { createEvent, getEvent, getEventTypes } from '../../api';
import MobileEventFormPage from '../EventFormPage';
describe('MobileEventFormPage', () => {
@@ -124,16 +124,21 @@ describe('MobileEventFormPage', () => {
paramsState.slug = undefined;
});
it('renders a save draft button when creating a new event', async () => {
it('renders a create button when creating a new event', async () => {
paramsState.slug = undefined;
vi.mocked(createEvent).mockResolvedValueOnce({
event: { id: 1, slug: 'new-event' },
} as any);
await act(async () => {
render(<MobileEventFormPage />);
});
const saveDraft = screen.getByText('eventForm.actions.saveDraft');
fireEvent.click(saveDraft);
const buttons = screen.getAllByText('eventForm.actions.create');
await act(async () => {
fireEvent.click(buttons[0]);
});
expect(backMock).toHaveBeenCalled();
expect(createEvent).toHaveBeenCalled();
});
it('defaults event type to wedding when available', async () => {
@@ -167,7 +172,7 @@ describe('MobileEventFormPage', () => {
render(<MobileEventFormPage />);
const dateInput = await screen.findByDisplayValue('2020-01-01T10:00');
const dateInput = await screen.findByDisplayValue('2020-01-01');
expect(dateInput).toBeDisabled();
});
});

View File

@@ -8,7 +8,7 @@ import { withAlpha } from './colors';
import { useAdminTheme } from '../theme';
type FieldProps = {
label: string;
label: React.ReactNode;
hint?: string;
error?: string | null;
children: React.ReactNode;
@@ -19,9 +19,13 @@ export function MobileField({ label, hint, error, children }: FieldProps) {
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{label}
</Text>
{typeof label === 'string' || typeof label === 'number' ? (
<Text fontSize="$sm" fontWeight="800" color={text}>
{label}
</Text>
) : (
label
)}
{children}
{hint ? (
<Text fontSize="$xs" color={muted}>
@@ -127,6 +131,46 @@ export const MobileDateTimeInput = React.forwardRef<
);
});
export const MobileDateInput = React.forwardRef<
HTMLInputElement,
React.ComponentPropsWithoutRef<'input'> & ControlProps
>(function MobileDateInput({ hasError = false, style, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const borderColor = hasError ? danger : border;
return (
<input
ref={ref}
type="date"
{...props}
style={{
width: '100%',
height: 44,
padding: '0 12px',
borderRadius: 12,
borderWidth: 1,
borderStyle: 'solid',
borderColor,
backgroundColor: surface,
color: text,
fontSize: 14,
outline: 'none',
boxShadow: `0 0 0 0 ${ringColor}`,
...style,
}}
onFocus={(event) => {
event.currentTarget.style.boxShadow = `0 0 0 3px ${ringColor}`;
props.onFocus?.(event);
}}
onBlur={(event) => {
event.currentTarget.style.boxShadow = `0 0 0 0 ${ringColor}`;
props.onBlur?.(event);
}}
/>
);
});
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();