Enforce task limits and update event form
This commit is contained in:
@@ -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'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
{typeof label === 'string' || typeof label === 'number' ? (
|
||||||
{label}
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
</Text>
|
{label}
|
||||||
|
</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();
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user