feat: registration field polish, multi-category tags, file uploads, Partner icon

- Restructure field editor dialog: move Options section to bottom with
  divider and subheader, fix delete button with flex layout
- Change tag_category (single string) to tag_categories (JSON array)
  supporting multiple category selection in tag picker fields
- Portal tag picker now groups tags by category with subheaders
- Add generic file upload endpoint (FileUploadService + UploadController)
- Replace email branding logo URL text field with ImageUploadField
- Update Partner crowd type default icon to tabler-affiliate
- Apply changes consistently to both field and template dialogs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 18:03:49 +02:00
parent d57dcdb616
commit 6a8d21a5b6
31 changed files with 813 additions and 239 deletions

View File

@@ -98,7 +98,7 @@ final class PublicRegistrationDataController extends Controller
'field_type' => $field->field_type->value,
'options' => $field->options,
'normalized_options' => $field->normalized_options,
'tag_category' => $field->tag_category,
'tag_categories' => $field->tag_categories,
'is_required' => $field->is_required,
'help_text' => $field->help_text,
'display_width' => $field->display_width->value,
@@ -108,11 +108,11 @@ final class PublicRegistrationDataController extends Controller
$query = PersonTag::where('organisation_id', $organisationId)
->where('is_active', true);
if ($field->tag_category) {
$query->where('category', $field->tag_category);
if (!empty($field->tag_categories)) {
$query->whereIn('category', $field->tag_categories);
}
$data['available_tags'] = $query->orderBy('sort_order')
$data['available_tags'] = $query->orderBy('category')->orderBy('sort_order')
->get()
->map(fn (PersonTag $tag) => [
'id' => $tag->id,

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Services\FileUploadService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UploadController extends Controller
{
public function __construct(
private readonly FileUploadService $uploadService,
) {}
public function uploadImage(Request $request): JsonResponse
{
$request->validate([
'file' => ['required', 'file', 'max:5120'],
'purpose' => ['required', 'string', 'in:logo,banner,icon,avatar'],
]);
$user = $request->user();
$organisation = $user->organisations()->first();
try {
$url = $this->uploadService->uploadImage(
file: $request->file('file'),
directory: 'uploads/' . $request->input('purpose'),
organisationId: $organisation?->id,
);
} catch (\DomainException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
activity('upload')
->causedBy($user)
->withProperties([
'purpose' => $request->input('purpose'),
'original_name' => $request->file('file')->getClientOriginalName(),
'size_bytes' => $request->file('file')->getSize(),
'mime' => $request->file('file')->getMimeType(),
])
->log('image.uploaded');
return response()->json([
'data' => ['url' => $url],
]);
}
}

View File

@@ -43,11 +43,11 @@ final class StoreRegistrationFieldTemplateRequest extends FormRequest
'options.*' => ['required'],
'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'],
'options.*.description' => ['nullable', 'string', 'max:200'],
'tag_category' => [
'tag_categories' => [
$type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited',
'string',
'max:50',
'array',
],
'tag_categories.*' => ['string', 'max:50'],
'is_required' => ['nullable', 'boolean'],
'is_filterable' => ['nullable', 'boolean'],
'is_portal_visible' => ['nullable', 'boolean'],

View File

@@ -43,11 +43,11 @@ final class StoreRegistrationFormFieldRequest extends FormRequest
'options.*' => ['required'],
'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'],
'options.*.description' => ['nullable', 'string', 'max:200'],
'tag_category' => [
'tag_categories' => [
$type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited',
'string',
'max:50',
'array',
],
'tag_categories.*' => ['string', 'max:50'],
'is_required' => ['nullable', 'boolean'],
'is_portal_visible' => ['nullable', 'boolean'],
'is_admin_only' => ['nullable', 'boolean'],

View File

@@ -25,7 +25,8 @@ final class UpdateRegistrationFieldTemplateRequest extends FormRequest
'options.*' => ['required'],
'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'],
'options.*.description' => ['nullable', 'string', 'max:200'],
'tag_category' => ['nullable', 'string', 'max:50'],
'tag_categories' => ['nullable', 'array'],
'tag_categories.*' => ['string', 'max:50'],
'is_required' => ['nullable', 'boolean'],
'is_filterable' => ['nullable', 'boolean'],
'is_portal_visible' => ['nullable', 'boolean'],

View File

@@ -24,7 +24,8 @@ final class UpdateRegistrationFormFieldRequest extends FormRequest
'options.*' => ['required'],
'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'],
'options.*.description' => ['nullable', 'string', 'max:200'],
'tag_category' => ['nullable', 'string', 'max:50'],
'tag_categories' => ['nullable', 'array'],
'tag_categories.*' => ['string', 'max:50'],
'is_required' => ['nullable', 'boolean'],
'is_portal_visible' => ['nullable', 'boolean'],
'is_admin_only' => ['nullable', 'boolean'],

View File

@@ -19,7 +19,7 @@ final class RegistrationFieldTemplateResource extends JsonResource
'field_type' => $this->field_type->value,
'options' => $this->options,
'normalized_options' => $this->normalized_options,
'tag_category' => $this->tag_category,
'tag_categories' => $this->tag_categories,
'is_required' => $this->is_required,
'is_filterable' => $this->is_filterable,
'is_portal_visible' => $this->is_portal_visible,

View File

@@ -21,7 +21,7 @@ final class RegistrationFormFieldResource extends JsonResource
'field_type' => $this->field_type->value,
'options' => $this->options,
'normalized_options' => $this->normalized_options,
'tag_category' => $this->tag_category,
'tag_categories' => $this->tag_categories,
'is_required' => $this->is_required,
'is_portal_visible' => $this->is_portal_visible,
'is_admin_only' => $this->is_admin_only,
@@ -37,11 +37,11 @@ final class RegistrationFormFieldResource extends JsonResource
$query = PersonTag::where('organisation_id', $this->event->organisation_id)
->where('is_active', true);
if ($this->tag_category) {
$query->where('category', $this->tag_category);
if (!empty($this->tag_categories)) {
$query->whereIn('category', $this->tag_categories);
}
return PersonTagResource::collection($query->orderBy('sort_order')->get());
return PersonTagResource::collection($query->orderBy('category')->orderBy('sort_order')->get());
}
),
];

View File

@@ -29,7 +29,7 @@ final class RegistrationFieldTemplate extends Model
'slug',
'field_type',
'options',
'tag_category',
'tag_categories',
'is_required',
'is_filterable',
'is_portal_visible',
@@ -46,6 +46,7 @@ final class RegistrationFieldTemplate extends Model
return [
'field_type' => RegistrationFieldType::class,
'options' => 'array',
'tag_categories' => 'array',
'is_required' => 'boolean',
'is_filterable' => 'boolean',
'is_portal_visible' => 'boolean',

View File

@@ -33,7 +33,7 @@ final class RegistrationFormField extends Model
'slug',
'field_type',
'options',
'tag_category',
'tag_categories',
'is_required',
'is_portal_visible',
'is_admin_only',
@@ -48,6 +48,7 @@ final class RegistrationFormField extends Model
return [
'field_type' => RegistrationFieldType::class,
'options' => 'array',
'tag_categories' => 'array',
'is_required' => 'boolean',
'is_portal_visible' => 'boolean',
'is_admin_only' => 'boolean',

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class FileUploadService
{
private const ALLOWED_IMAGE_MIMES = [
'image/jpeg',
'image/png',
'image/webp',
'image/svg+xml',
'image/gif',
];
private const MAX_IMAGE_SIZE_KB = 5120; // 5MB
public function uploadImage(
UploadedFile $file,
string $directory,
?string $organisationId = null,
): string {
$this->validateImage($file);
$extension = $file->getClientOriginalExtension();
$filename = Str::ulid() . '.' . $extension;
$path = trim($directory, '/');
if ($organisationId) {
$path .= '/' . $organisationId;
}
Storage::disk('public')->putFileAs($path, $file, $filename);
return Storage::disk('public')->url($path . '/' . $filename);
}
public function deleteByUrl(string $url): void
{
$baseUrl = Storage::disk('public')->url('');
$path = str_replace($baseUrl, '', $url);
if ($path && Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
private function validateImage(UploadedFile $file): void
{
if (!in_array($file->getMimeType(), self::ALLOWED_IMAGE_MIMES, true)) {
throw new \DomainException(
'Alleen JPG, PNG, WebP, SVG of GIF bestanden zijn toegestaan.'
);
}
if ($file->getSize() / 1024 > self::MAX_IMAGE_SIZE_KB) {
throw new \DomainException(
'Bestand is te groot. Maximum: 5MB.'
);
}
}
}

View File

@@ -110,7 +110,7 @@ final class RegistrationFieldTemplateService
'slug' => $slug,
'field_type' => $template->field_type,
'options' => $template->options,
'tag_category' => $template->tag_category,
'tag_categories' => $template->tag_categories,
'is_required' => $template->is_required,
'is_portal_visible' => $template->is_portal_visible,
'is_admin_only' => $template->is_admin_only,
@@ -155,7 +155,7 @@ final class RegistrationFieldTemplateService
['label' => 'EHBO / BHV diploma', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 10],
['label' => 'Rijbewijs', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 11],
['label' => 'Eerder vrijwilliger geweest', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 12],
['label' => 'Certificaten & vaardigheden', 'field_type' => 'tag_picker', 'tag_category' => null, 'is_filterable' => true, 'display_width' => 'full', 'sort_order' => 13],
['label' => 'Certificaten & vaardigheden', 'field_type' => 'tag_picker', 'tag_categories' => null, 'is_filterable' => true, 'display_width' => 'full', 'sort_order' => 13],
['label' => 'Aanvullende informatie', 'field_type' => 'heading', 'display_width' => 'full', 'sort_order' => 14],
['label' => 'Toestemming gegevensverwerking', 'field_type' => 'boolean', 'is_required' => true, 'help_text' => 'Ik geef toestemming voor de verwerking van mijn persoonsgegevens ten behoeve van de organisatie van dit evenement, conform de Algemene Verordening Gegevensbescherming (AVG).', 'display_width' => 'full', 'sort_order' => 15],
['label' => 'Opmerkingen', 'field_type' => 'textarea', 'display_width' => 'full', 'sort_order' => 16],

View File

@@ -171,7 +171,7 @@ final class RegistrationFormFieldService
'slug' => $slug,
'field_type' => $sourceField->field_type,
'options' => $sourceField->options,
'tag_category' => $sourceField->tag_category,
'tag_categories' => $sourceField->tag_categories,
'is_required' => $sourceField->is_required,
'is_portal_visible' => $sourceField->is_portal_visible,
'is_admin_only' => $sourceField->is_admin_only,