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

@@ -110,6 +110,7 @@ class TaskCollectionController extends Controller
), ),
'created_task_ids' => $result['created_task_ids'], 'created_task_ids' => $result['created_task_ids'],
'attached_task_ids' => $result['attached_task_ids'], 'attached_task_ids' => $result['attached_task_ids'],
'skipped_task_ids' => $result['skipped_task_ids'],
]); ]);
} }

View File

@@ -10,6 +10,7 @@ use App\Models\Event;
use App\Models\Task; use App\Models\Task;
use App\Models\TaskCollection; use App\Models\TaskCollection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\TenantMemberPermissions; use App\Support\TenantMemberPermissions;
use App\Support\TenantRequestResolver; use App\Support\TenantRequestResolver;
@@ -20,6 +21,8 @@ use Symfony\Component\HttpFoundation\Response;
class TaskController extends Controller class TaskController extends Controller
{ {
public function __construct(private readonly PackageLimitEvaluator $packageLimitEvaluator) {}
/** /**
* Display a listing of the tenant's tasks. * Display a listing of the tenant's tasks.
*/ */
@@ -163,7 +166,8 @@ class TaskController extends Controller
{ {
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage'); 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) { if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
abort(404); abort(404);
@@ -173,6 +177,11 @@ class TaskController extends Controller
return response()->json(['message' => 'Task ist bereits diesem Event zugewiesen.'], 409); 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); $task->assignedEvents()->attach($event->id);
return response()->json([ return response()->json([
@@ -187,7 +196,8 @@ class TaskController extends Controller
{ {
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage'); TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id; $tenant = $this->currentTenant($request);
$tenantId = $tenant->id;
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
abort(404); abort(404);
@@ -203,12 +213,27 @@ class TaskController extends Controller
); );
} }
$taskIds = array_values(array_unique(array_map('intval', $taskIds)));
$tasks = Task::whereIn('id', $taskIds) $tasks = Task::whereIn('id', $taskIds)
->where(function ($query) use ($tenantId) { ->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); $query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
}) })
->get(); ->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; $attached = 0;
foreach ($tasks as $task) { foreach ($tasks as $task) {
if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) { if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) {
@@ -330,6 +355,52 @@ class TaskController extends Controller
return TenantRequestResolver::resolve($request); 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 protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array
{ {
if (array_key_exists('title', $data)) { if (array_key_exists('title', $data)) {

View File

@@ -97,13 +97,26 @@ class EventResource extends JsonResource
'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed, 'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed,
] : null, ] : null,
'limits' => $eventPackage && $limitEvaluator 'limits' => $eventPackage && $limitEvaluator
? $limitEvaluator->summarizeEventPackage($eventPackage) ? $limitEvaluator->summarizeEventPackage($eventPackage, $this->resolveTasksUsed())
: null, : null,
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [], 'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
'member_permissions' => $memberPermissions, '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<string, mixed> $settings * @param array<string, mixed> $settings
* @return array<string, mixed> * @return array<string, mixed>

View File

@@ -223,7 +223,7 @@ class PackageLimitEvaluator
return $eventPackage; return $eventPackage;
} }
public function summarizeEventPackage(EventPackage $eventPackage): array public function summarizeEventPackage(EventPackage $eventPackage, ?int $tasksUsed = null): array
{ {
$limits = $eventPackage->effectiveLimits(); $limits = $eventPackage->effectiveLimits();
@@ -244,12 +244,22 @@ class PackageLimitEvaluator
config('package-limits.gallery_warning_days', []) config('package-limits.gallery_warning_days', [])
); );
$taskSummary = $tasksUsed === null
? null
: $this->buildUsageSummary(
$tasksUsed,
$limits['max_tasks'],
[]
);
return [ return [
'photos' => $photoSummary, 'photos' => $photoSummary,
'guests' => $guestSummary, 'guests' => $guestSummary,
'gallery' => $gallerySummary, 'gallery' => $gallerySummary,
'tasks' => $taskSummary,
'can_upload_photos' => $photoSummary['state'] !== 'limit_reached' && $gallerySummary['state'] !== 'expired', 'can_upload_photos' => $photoSummary['state'] !== 'limit_reached' && $gallerySummary['state'] !== 'expired',
'can_add_guests' => $guestSummary['state'] !== 'limit_reached', 'can_add_guests' => $guestSummary['state'] !== 'limit_reached',
'can_add_tasks' => $taskSummary ? $taskSummary['state'] !== 'limit_reached' : null,
]; ];
} }

View File

@@ -11,12 +11,10 @@ use RuntimeException;
class TaskCollectionImportService class TaskCollectionImportService
{ {
public function __construct(private readonly DatabaseManager $db) public function __construct(private readonly DatabaseManager $db) {}
{
}
/** /**
* @return array{collection: TaskCollection, created_task_ids: array<int>, attached_task_ids: array<int>} * @return array{collection: TaskCollection, created_task_ids: array<int>, attached_task_ids: array<int>, skipped_task_ids: array<int>}
*/ */
public function import(TaskCollection $collection, Event $event): array public function import(TaskCollection $collection, Event $event): array
{ {
@@ -33,8 +31,28 @@ class TaskCollectionImportService
$createdTaskIds = []; $createdTaskIds = [];
$attachedTaskIds = []; $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) { foreach ($collection->tasks as $task) {
if ($remaining !== null && $remaining <= 0) {
$skippedTaskIds[] = $task->id;
continue;
}
$tenantTask = $this->resolveTenantTask($task, $targetCollection, $tenantId); $tenantTask = $this->resolveTenantTask($task, $targetCollection, $tenantId);
if ($tenantTask->wasRecentlyCreated) { if ($tenantTask->wasRecentlyCreated) {
@@ -44,6 +62,9 @@ class TaskCollectionImportService
if (! $tenantTask->assignedEvents()->where('event_id', $event->id)->exists()) { if (! $tenantTask->assignedEvents()->where('event_id', $event->id)->exists()) {
$tenantTask->assignedEvents()->attach($event->id); $tenantTask->assignedEvents()->attach($event->id);
$attachedTaskIds[] = $tenantTask->id; $attachedTaskIds[] = $tenantTask->id;
if ($remaining !== null) {
$remaining = max(0, $remaining - 1);
}
} }
} }
@@ -55,6 +76,7 @@ class TaskCollectionImportService
'collection' => $targetCollection->fresh(), 'collection' => $targetCollection->fresh(),
'created_task_ids' => $createdTaskIds, 'created_task_ids' => $createdTaskIds,
'attached_task_ids' => $attachedTaskIds, 'attached_task_ids' => $attachedTaskIds,
'skipped_task_ids' => $skippedTaskIds,
]; ];
}); });
} }
@@ -139,10 +161,10 @@ class TaskCollectionImportService
protected function buildCollectionSlug(?string $slug, int $tenantId): string protected function buildCollectionSlug(?string $slug, int $tenantId): string
{ {
$base = Str::slug(($slug ?: 'collection') . '-' . $tenantId); $base = Str::slug(($slug ?: 'collection').'-'.$tenantId);
do { do {
$candidate = $base . '-' . Str::random(4); $candidate = $base.'-'.Str::random(4);
} while (TaskCollection::where('slug', $candidate)->exists()); } while (TaskCollection::where('slug', $candidate)->exists());
return $candidate; return $candidate;
@@ -153,7 +175,7 @@ class TaskCollectionImportService
$slugBase = Str::slug($base) ?: 'task'; $slugBase = Str::slug($base) ?: 'task';
do { do {
$candidate = $slugBase . '-' . Str::random(6); $candidate = $slugBase.'-'.Str::random(6);
} while (Task::where('slug', $candidate)->exists()); } while (Task::where('slug', $candidate)->exists());
return $candidate; return $candidate;

View File

@@ -558,6 +558,10 @@
"actions": "Aktionen", "actions": "Aktionen",
"assigned": "Fotoaufgabe hinzugefügt", "assigned": "Fotoaufgabe hinzugefügt",
"updateFailed": "Fotoaufgabe konnte nicht gespeichert werden.", "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", "created": "Fotoaufgabe gespeichert",
"removed": "Fotoaufgabe entfernt", "removed": "Fotoaufgabe entfernt",
"imported": "Fotoaufgabenpaket importiert", "imported": "Fotoaufgabenpaket importiert",

View File

@@ -554,6 +554,10 @@
"actions": "Actions", "actions": "Actions",
"assigned": "Photo task added", "assigned": "Photo task added",
"updateFailed": "Photo task could not be saved.", "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", "created": "Photo task saved",
"removed": "Photo task removed", "removed": "Photo task removed",
"imported": "Photo task pack imported", "imported": "Photo task pack imported",

View File

@@ -32,8 +32,10 @@ export type EventLimitSummary = {
photos: LimitUsageSummary; photos: LimitUsageSummary;
guests: LimitUsageSummary; guests: LimitUsageSummary;
gallery: GallerySummary; gallery: GallerySummary;
tasks?: LimitUsageSummary;
can_upload_photos: boolean; can_upload_photos: boolean;
can_add_guests: boolean; can_add_guests: boolean;
can_add_tasks?: boolean | null;
} | null | undefined; } | null | undefined;
type TranslateFn = (key: string, options?: Record<string, unknown>) => string; type TranslateFn = (key: string, options?: Record<string, unknown>) => string;

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import { withAlpha } from './colors';
import { useAdminTheme } from '../theme'; import { useAdminTheme } from '../theme';
type FieldProps = { type FieldProps = {
label: string; label: React.ReactNode;
hint?: string; hint?: string;
error?: string | null; error?: string | null;
children: React.ReactNode; children: React.ReactNode;
@@ -19,9 +19,13 @@ export function MobileField({ label, hint, error, children }: FieldProps) {
return ( return (
<YStack space="$1.5"> <YStack space="$1.5">
{typeof label === 'string' || typeof label === 'number' ? (
<Text fontSize="$sm" fontWeight="800" color={text}> <Text fontSize="$sm" fontWeight="800" color={text}>
{label} {label}
</Text> </Text>
) : (
label
)}
{children} {children}
{hint ? ( {hint ? (
<Text fontSize="$xs" color={muted}> <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>( export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) { function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme(); const { border, surface, text, primary, danger } = useAdminTheme();

View File

@@ -47,5 +47,9 @@ return [
'title' => 'Tenant-Speicherlimit erreicht', 'title' => 'Tenant-Speicherlimit erreicht',
'message' => 'Dieser Tenant hat sein Speicher-Kontingent 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.',
],
], ],
]; ];

View File

@@ -47,5 +47,9 @@ return [
'title' => 'Tenant storage limit reached', 'title' => 'Tenant storage limit reached',
'message' => 'This tenant has reached its storage allowance.', '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.',
],
], ],
]; ];

View File

@@ -3,6 +3,8 @@
namespace Tests\Feature\Tenant; namespace Tests\Feature\Tenant;
use App\Models\Event; use App\Models\Event;
use App\Models\EventPackage;
use App\Models\Package;
use App\Models\Task; use App\Models\Task;
use App\Models\TaskCollection; use App\Models\TaskCollection;
use App\Models\Tenant; use App\Models\Tenant;
@@ -232,6 +234,76 @@ class TaskApiTest extends TenantTestCase
$this->assertEquals(3, $event->tasks()->count()); $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] #[Test]
public function can_get_tasks_for_specific_event() public function can_get_tasks_for_specific_event()
{ {