From 1c5412e82c36056ce3ec5d01020509dd241dc8da Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 21 Jan 2026 09:49:30 +0100 Subject: [PATCH] Enforce task limits and update event form --- .../Api/Tenant/TaskCollectionController.php | 1 + .../Controllers/Api/Tenant/TaskController.php | 75 +++++++- app/Http/Resources/Tenant/EventResource.php | 15 +- .../Packages/PackageLimitEvaluator.php | 12 +- .../Tenant/TaskCollectionImportService.php | 36 +++- .../js/admin/i18n/locales/de/management.json | 4 + .../js/admin/i18n/locales/en/management.json | 4 + resources/js/admin/lib/limitWarnings.ts | 2 + resources/js/admin/mobile/EventFormPage.tsx | 74 ++++++-- resources/js/admin/mobile/EventTasksPage.tsx | 167 ++++++++++++++++-- .../mobile/__tests__/EventFormPage.test.tsx | 21 ++- .../admin/mobile/components/FormControls.tsx | 52 +++++- resources/lang/de/api.php | 4 + resources/lang/en/api.php | 4 + tests/Feature/Tenant/TaskApiTest.php | 72 ++++++++ 15 files changed, 491 insertions(+), 52 deletions(-) diff --git a/app/Http/Controllers/Api/Tenant/TaskCollectionController.php b/app/Http/Controllers/Api/Tenant/TaskCollectionController.php index 2acf1ee..9cbe95a 100644 --- a/app/Http/Controllers/Api/Tenant/TaskCollectionController.php +++ b/app/Http/Controllers/Api/Tenant/TaskCollectionController.php @@ -110,6 +110,7 @@ class TaskCollectionController extends Controller ), 'created_task_ids' => $result['created_task_ids'], 'attached_task_ids' => $result['attached_task_ids'], + 'skipped_task_ids' => $result['skipped_task_ids'], ]); } diff --git a/app/Http/Controllers/Api/Tenant/TaskController.php b/app/Http/Controllers/Api/Tenant/TaskController.php index ba8cd94..ccb3b78 100644 --- a/app/Http/Controllers/Api/Tenant/TaskController.php +++ b/app/Http/Controllers/Api/Tenant/TaskController.php @@ -10,6 +10,7 @@ use App\Models\Event; use App\Models\Task; use App\Models\TaskCollection; use App\Models\Tenant; +use App\Services\Packages\PackageLimitEvaluator; use App\Support\ApiError; use App\Support\TenantMemberPermissions; use App\Support\TenantRequestResolver; @@ -20,6 +21,8 @@ use Symfony\Component\HttpFoundation\Response; class TaskController extends Controller { + public function __construct(private readonly PackageLimitEvaluator $packageLimitEvaluator) {} + /** * Display a listing of the tenant's tasks. */ @@ -163,7 +166,8 @@ class TaskController extends Controller { TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage'); - $tenantId = $this->currentTenant($request)->id; + $tenant = $this->currentTenant($request); + $tenantId = $tenant->id; if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) { abort(404); @@ -173,6 +177,11 @@ class TaskController extends Controller return response()->json(['message' => 'Task ist bereits diesem Event zugewiesen.'], 409); } + $limitStatus = $this->resolveTaskLimitStatus($event, $tenant); + if ($limitStatus['remaining'] !== null && $limitStatus['remaining'] <= 0) { + return $this->taskLimitExceededResponse($event, $limitStatus); + } + $task->assignedEvents()->attach($event->id); return response()->json([ @@ -187,7 +196,8 @@ class TaskController extends Controller { TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage'); - $tenantId = $this->currentTenant($request)->id; + $tenant = $this->currentTenant($request); + $tenantId = $tenant->id; if ($event->tenant_id !== $tenantId) { abort(404); @@ -203,12 +213,27 @@ class TaskController extends Controller ); } + $taskIds = array_values(array_unique(array_map('intval', $taskIds))); $tasks = Task::whereIn('id', $taskIds) ->where(function ($query) use ($tenantId) { $query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); }) ->get(); + $assignedIds = $event->tasks() + ->whereIn('tasks.id', $taskIds) + ->pluck('tasks.id') + ->all(); + $pendingIds = array_values(array_diff($taskIds, $assignedIds)); + $limitStatus = $this->resolveTaskLimitStatus($event, $tenant); + if ( + $limitStatus['remaining'] !== null + && $pendingIds !== [] + && $limitStatus['remaining'] < count($pendingIds) + ) { + return $this->taskLimitExceededResponse($event, $limitStatus); + } + $attached = 0; foreach ($tasks as $task) { if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) { @@ -330,6 +355,52 @@ class TaskController extends Controller return TenantRequestResolver::resolve($request); } + /** + * @return array{limit: ?int, used: int, remaining: ?int, package_id: ?int} + */ + protected function resolveTaskLimitStatus(Event $event, Tenant $tenant): array + { + $event->loadMissing(['eventPackage.package', 'eventPackages.package']); + + $eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload( + $tenant, + $event->id, + $event + ); + + $limit = $eventPackage?->effectiveLimits()['max_tasks'] ?? null; + $used = $event->tasks()->count(); + $remaining = $limit === null ? null : max(0, (int) $limit - $used); + + return [ + 'limit' => $limit === null ? null : (int) $limit, + 'used' => $used, + 'remaining' => $remaining, + 'package_id' => $eventPackage?->package_id, + ]; + } + + /** + * @param array{limit: ?int, used: int, remaining: ?int, package_id: ?int} $limitStatus + */ + protected function taskLimitExceededResponse(Event $event, array $limitStatus): JsonResponse + { + return ApiError::response( + 'task_limit_exceeded', + __('api.packages.task_limit_exceeded.title'), + __('api.packages.task_limit_exceeded.message'), + Response::HTTP_PAYMENT_REQUIRED, + [ + 'scope' => 'tasks', + 'used' => $limitStatus['used'], + 'limit' => $limitStatus['limit'], + 'remaining' => $limitStatus['remaining'] ?? 0, + 'event_id' => $event->id, + 'package_id' => $limitStatus['package_id'], + ] + ); + } + protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array { if (array_key_exists('title', $data)) { diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index d352029..14a0f05 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -97,13 +97,26 @@ class EventResource extends JsonResource 'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed, ] : null, 'limits' => $eventPackage && $limitEvaluator - ? $limitEvaluator->summarizeEventPackage($eventPackage) + ? $limitEvaluator->summarizeEventPackage($eventPackage, $this->resolveTasksUsed()) : null, 'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [], 'member_permissions' => $memberPermissions, ]; } + protected function resolveTasksUsed(): ?int + { + if (isset($this->tasks_count)) { + return (int) $this->tasks_count; + } + + if ($this->relationLoaded('tasks')) { + return $this->tasks->count(); + } + + return null; + } + /** * @param array $settings * @return array diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php index 0365a22..64674b5 100644 --- a/app/Services/Packages/PackageLimitEvaluator.php +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -223,7 +223,7 @@ class PackageLimitEvaluator return $eventPackage; } - public function summarizeEventPackage(EventPackage $eventPackage): array + public function summarizeEventPackage(EventPackage $eventPackage, ?int $tasksUsed = null): array { $limits = $eventPackage->effectiveLimits(); @@ -244,12 +244,22 @@ class PackageLimitEvaluator config('package-limits.gallery_warning_days', []) ); + $taskSummary = $tasksUsed === null + ? null + : $this->buildUsageSummary( + $tasksUsed, + $limits['max_tasks'], + [] + ); + return [ 'photos' => $photoSummary, 'guests' => $guestSummary, 'gallery' => $gallerySummary, + 'tasks' => $taskSummary, 'can_upload_photos' => $photoSummary['state'] !== 'limit_reached' && $gallerySummary['state'] !== 'expired', 'can_add_guests' => $guestSummary['state'] !== 'limit_reached', + 'can_add_tasks' => $taskSummary ? $taskSummary['state'] !== 'limit_reached' : null, ]; } diff --git a/app/Services/Tenant/TaskCollectionImportService.php b/app/Services/Tenant/TaskCollectionImportService.php index 32b9ce3..92fb946 100644 --- a/app/Services/Tenant/TaskCollectionImportService.php +++ b/app/Services/Tenant/TaskCollectionImportService.php @@ -11,12 +11,10 @@ use RuntimeException; class TaskCollectionImportService { - public function __construct(private readonly DatabaseManager $db) - { - } + public function __construct(private readonly DatabaseManager $db) {} /** - * @return array{collection: TaskCollection, created_task_ids: array, attached_task_ids: array} + * @return array{collection: TaskCollection, created_task_ids: array, attached_task_ids: array, skipped_task_ids: array} */ public function import(TaskCollection $collection, Event $event): array { @@ -33,8 +31,28 @@ class TaskCollectionImportService $createdTaskIds = []; $attachedTaskIds = []; + $skippedTaskIds = []; + + $event->loadMissing(['eventPackage.package', 'eventPackages.package']); + $eventPackage = $event->eventPackage; + if (! $eventPackage && method_exists($event, 'eventPackages')) { + $eventPackage = $event->eventPackages() + ->with('package') + ->orderByDesc('purchased_at') + ->orderByDesc('created_at') + ->first(); + } + + $taskLimit = $eventPackage?->effectiveLimits()['max_tasks'] ?? null; + $remaining = $taskLimit === null ? null : max(0, (int) $taskLimit - $event->tasks()->count()); foreach ($collection->tasks as $task) { + if ($remaining !== null && $remaining <= 0) { + $skippedTaskIds[] = $task->id; + + continue; + } + $tenantTask = $this->resolveTenantTask($task, $targetCollection, $tenantId); if ($tenantTask->wasRecentlyCreated) { @@ -44,6 +62,9 @@ class TaskCollectionImportService if (! $tenantTask->assignedEvents()->where('event_id', $event->id)->exists()) { $tenantTask->assignedEvents()->attach($event->id); $attachedTaskIds[] = $tenantTask->id; + if ($remaining !== null) { + $remaining = max(0, $remaining - 1); + } } } @@ -55,6 +76,7 @@ class TaskCollectionImportService 'collection' => $targetCollection->fresh(), 'created_task_ids' => $createdTaskIds, 'attached_task_ids' => $attachedTaskIds, + 'skipped_task_ids' => $skippedTaskIds, ]; }); } @@ -139,10 +161,10 @@ class TaskCollectionImportService protected function buildCollectionSlug(?string $slug, int $tenantId): string { - $base = Str::slug(($slug ?: 'collection') . '-' . $tenantId); + $base = Str::slug(($slug ?: 'collection').'-'.$tenantId); do { - $candidate = $base . '-' . Str::random(4); + $candidate = $base.'-'.Str::random(4); } while (TaskCollection::where('slug', $candidate)->exists()); return $candidate; @@ -153,7 +175,7 @@ class TaskCollectionImportService $slugBase = Str::slug($base) ?: 'task'; do { - $candidate = $slugBase . '-' . Str::random(6); + $candidate = $slugBase.'-'.Str::random(6); } while (Task::where('slug', $candidate)->exists()); return $candidate; diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index a8a02dd..c2765aa 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -558,6 +558,10 @@ "actions": "Aktionen", "assigned": "Fotoaufgabe hinzugefügt", "updateFailed": "Fotoaufgabe konnte nicht gespeichert werden.", + "limitReached": "Fotoaufgaben-Limit erreicht.", + "limitReachedHint": "Dieses Event erlaubt maximal {{count}} Fotoaufgaben.", + "limitRemaining": "{{count}} von {{total}} Fotoaufgaben verfügbar.", + "limitSkipped": "{{count}} Fotoaufgaben wegen Limit übersprungen.", "created": "Fotoaufgabe gespeichert", "removed": "Fotoaufgabe entfernt", "imported": "Fotoaufgabenpaket importiert", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index ad694ca..85c76f5 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -554,6 +554,10 @@ "actions": "Actions", "assigned": "Photo task added", "updateFailed": "Photo task could not be saved.", + "limitReached": "Photo task limit reached.", + "limitReachedHint": "This event allows up to {{count}} photo tasks.", + "limitRemaining": "{{count}} of {{total}} photo tasks remaining.", + "limitSkipped": "Skipped {{count}} tasks due to limit.", "created": "Photo task saved", "removed": "Photo task removed", "imported": "Photo task pack imported", diff --git a/resources/js/admin/lib/limitWarnings.ts b/resources/js/admin/lib/limitWarnings.ts index 4752049..82ba342 100644 --- a/resources/js/admin/lib/limitWarnings.ts +++ b/resources/js/admin/lib/limitWarnings.ts @@ -32,8 +32,10 @@ export type EventLimitSummary = { photos: LimitUsageSummary; guests: LimitUsageSummary; gallery: GallerySummary; + tasks?: LimitUsageSummary; can_upload_photos: boolean; can_add_guests: boolean; + can_add_tasks?: boolean | null; } | null | undefined; type TranslateFn = (key: string, options?: Record) => string; diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index a1791f5..60e7ef3 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -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) => { 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) => ( + + + {label} + + + * + + + ), + [danger, text], + ); + return ( - + ) : null} - + - - + {typesLoading ? ( {t('eventForm.fields.type.loading', 'Loading event types…')} ) : eventTypes.length === 0 ? ( @@ -589,9 +603,9 @@ export default function MobileEventFormPage() { {!isEdit ? ( handleSubmit()} /> ) : null} @@ -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; const candidate = diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index 997d0e7..4d8b888 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -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() { {t('events.tasks.emptyBody', 'Create photo tasks or import a pack for your event.')} + {!canAddTasks ? ( + + {limitReachedMessage} + {limitReachedHint ? ` ${limitReachedHint}` : ''} + + ) : null} setShowTaskSheet(true)} + disabled={!canAddTasks} fullWidth={false} /> setShowCollectionSheet(true)} + disabled={!canAddTasks} fullWidth={false} /> @@ -646,18 +704,24 @@ export default function MobileEventTasksPage() { setShowTaskSheet(true)} + onPress={() => { + if (!canAddTasks) { + toast.error(limitReachedMessage); + return; + } + setShowTaskSheet(true); + }} title={ - + {t('events.tasks.addTask', 'Fotoaufgabe hinzufügen')} @@ -678,18 +742,24 @@ export default function MobileEventTasksPage() { setShowCollectionSheet(true)} + onPress={() => { + if (!canAddTasks) { + toast.error(limitReachedMessage); + return; + } + setShowCollectionSheet(true); + }} title={ - + {t('events.tasks.import', 'Fotoaufgabenpaket importieren')} @@ -845,10 +915,18 @@ export default function MobileEventTasksPage() { } iconAfter={ - quickAssign(task.id)}> + { + if (!canAddTasks) { + toast.error(limitReachedMessage); + return; + } + quickAssign(task.id); + }} + > - - + + {assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')} @@ -1087,6 +1165,12 @@ export default function MobileEventTasksPage() { footer={null} > + {!canAddTasks ? ( + + {limitReachedMessage} + {limitReachedHint ? ` ${limitReachedHint}` : ''} + + ) : null} {collections.length > 6 ? ( setExpandedCollections((prev) => !prev)}> @@ -1119,8 +1203,16 @@ export default function MobileEventTasksPage() { } iconAfter={ - importCollection(collection.id)}> - + { + if (!canAddTasks) { + toast.error(limitReachedMessage); + return; + } + importCollection(collection.id); + }} + > + {t('events.tasks.import', 'Import')} @@ -1142,10 +1234,20 @@ export default function MobileEventTasksPage() { onClose={() => setShowTaskSheet(false)} title={t('events.tasks.addTask', 'Fotoaufgabe hinzufügen')} footer={ - createNewTask()} /> + createNewTask()} + disabled={!canAddTasks} + /> } > + {!canAddTasks ? ( + + {limitReachedMessage} + {limitReachedHint ? ` ${limitReachedHint}` : ''} + + ) : null} setShowBulkSheet(false)} title={t('events.tasks.bulkAdd', 'Bulk add')} - footer={ handleBulkAdd()} />} + footer={ + handleBulkAdd()} + disabled={!canAddTasks} + /> + } > + {!canAddTasks ? ( + + {limitReachedMessage} + {limitReachedHint ? ` ${limitReachedHint}` : ''} + + ) : null} + {maxTasks !== null ? ( + + {t('events.tasks.limitRemaining', { + count: remainingTasks ?? 0, + total: maxTasks, + defaultValue: '{{count}} of {{total}} photo tasks remaining.', + })} + + ) : null} {t('events.tasks.bulkHint', 'One photo task per line. These will be created and added to the event.')} @@ -1414,7 +1537,13 @@ export default function MobileEventTasksPage() { 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() { } onPress={() => { + if (!canAddTasks) { + toast.error(limitReachedMessage); + return; + } setShowFabMenu(false); setShowTaskSheet(true); }} @@ -1454,6 +1587,10 @@ export default function MobileEventTasksPage() { } onPress={() => { + if (!canAddTasks) { + toast.error(limitReachedMessage); + return; + } setShowFabMenu(false); setShowBulkSheet(true); }} diff --git a/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx b/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx index 5dd715c..949ebc7 100644 --- a/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx @@ -60,8 +60,8 @@ vi.mock('../components/Primitives', () => ({ vi.mock('../components/FormControls', () => ({ MobileField: ({ children }: { children: React.ReactNode }) =>
{children}
, - MobileDateTimeInput: (props: React.InputHTMLAttributes) => ( - + MobileDateInput: (props: React.InputHTMLAttributes) => ( + ), MobileInput: (props: React.InputHTMLAttributes) => , MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => , @@ -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(); }); - 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(); - const dateInput = await screen.findByDisplayValue('2020-01-01T10:00'); + const dateInput = await screen.findByDisplayValue('2020-01-01'); expect(dateInput).toBeDisabled(); }); }); diff --git a/resources/js/admin/mobile/components/FormControls.tsx b/resources/js/admin/mobile/components/FormControls.tsx index 51d2fc2..8739c52 100644 --- a/resources/js/admin/mobile/components/FormControls.tsx +++ b/resources/js/admin/mobile/components/FormControls.tsx @@ -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 ( - - {label} - + {typeof label === 'string' || typeof label === 'number' ? ( + + {label} + + ) : ( + label + )} {children} {hint ? ( @@ -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 ( + { + 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 & ControlProps>( function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) { const { border, surface, text, primary, danger } = useAdminTheme(); diff --git a/resources/lang/de/api.php b/resources/lang/de/api.php index db3966f..87949a3 100644 --- a/resources/lang/de/api.php +++ b/resources/lang/de/api.php @@ -47,5 +47,9 @@ return [ 'title' => 'Tenant-Speicherlimit erreicht', 'message' => 'Dieser Tenant hat sein Speicher-Kontingent erreicht.', ], + 'task_limit_exceeded' => [ + 'title' => 'Fotoaufgaben-Limit erreicht', + 'message' => 'Dieses Event hat sein Fotoaufgaben-Kontingent erreicht. Upgrade das Event-Paket, um weitere Aufgaben zu aktivieren.', + ], ], ]; diff --git a/resources/lang/en/api.php b/resources/lang/en/api.php index 411ef44..2cf2920 100644 --- a/resources/lang/en/api.php +++ b/resources/lang/en/api.php @@ -47,5 +47,9 @@ return [ 'title' => 'Tenant storage limit reached', 'message' => 'This tenant has reached its storage allowance.', ], + 'task_limit_exceeded' => [ + 'title' => 'Task limit reached', + 'message' => 'This event has reached its photo task limit. Upgrade the package to add more tasks.', + ], ], ]; diff --git a/tests/Feature/Tenant/TaskApiTest.php b/tests/Feature/Tenant/TaskApiTest.php index fd732b6..8d99f89 100644 --- a/tests/Feature/Tenant/TaskApiTest.php +++ b/tests/Feature/Tenant/TaskApiTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature\Tenant; use App\Models\Event; +use App\Models\EventPackage; +use App\Models\Package; use App\Models\Task; use App\Models\TaskCollection; use App\Models\Tenant; @@ -232,6 +234,76 @@ class TaskApiTest extends TenantTestCase $this->assertEquals(3, $event->tasks()->count()); } + #[Test] + public function task_assignment_respects_package_task_limit() + { + $package = Package::factory()->endcustomer()->create([ + 'max_tasks' => 1, + ]); + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + ]); + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + ]); + + $existingTask = Task::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); + $event->tasks()->attach($existingTask->id); + + $nextTask = Task::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); + + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->postJson("/api/v1/tenant/tasks/{$nextTask->id}/assign-event/{$event->id}"); + + $response->assertStatus(402) + ->assertJsonPath('error.code', 'task_limit_exceeded'); + } + + #[Test] + public function task_collection_import_skips_tasks_over_limit() + { + $package = Package::factory()->endcustomer()->create([ + 'max_tasks' => 2, + ]); + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + ]); + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + ]); + + $collection = TaskCollection::factory()->create([ + 'tenant_id' => null, + 'event_type_id' => $event->event_type_id, + ]); + $tasks = Task::factory(4)->create([ + 'tenant_id' => null, + 'event_type_id' => $event->event_type_id, + ]); + $collection->tasks()->attach($tasks->pluck('id')->all()); + + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->postJson("/api/v1/tenant/task-collections/{$collection->id}/activate", [ + 'event_slug' => $event->slug, + ]); + + $response->assertStatus(200); + + $this->assertSame(2, $event->tasks()->count()); + } + #[Test] public function can_get_tasks_for_specific_event() {