feat: festival/event model frontend + topbar activeren
- Events lijst: card grid met festival/serie chips - Festival detail: programmaonderdelen grid - CreateSubEventDialog voor sub-events binnen festival - EventTabsNav: breadcrumb terug naar festival - Sessie A: festival-bewuste EventResource + children endpoint - Topbar: zoekbalk, theme switcher, shortcuts, notificaties - Schema v1.7 + BACKLOG.md toegevoegd - 121 tests groen
This commit is contained in:
@@ -11,34 +11,62 @@ use App\Http\Resources\Api\V1\EventResource;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
final class EventController extends Controller
|
final class EventController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Organisation $organisation): AnonymousResourceCollection
|
public function index(Request $request, Organisation $organisation): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
Gate::authorize('viewAny', [Event::class, $organisation]);
|
Gate::authorize('viewAny', [Event::class, $organisation]);
|
||||||
|
|
||||||
$events = $organisation->events()
|
$query = $organisation->events()
|
||||||
->latest('start_date')
|
->topLevel()
|
||||||
->paginate();
|
->latest('start_date');
|
||||||
|
|
||||||
return EventResource::collection($events);
|
if ($request->query('type')) {
|
||||||
|
$query->where('event_type', $request->query('type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->boolean('include_children')) {
|
||||||
|
$query->with('children');
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventResource::collection($query->paginate());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Organisation $organisation, Event $event): JsonResponse
|
public function show(Organisation $organisation, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('view', [$event, $organisation]);
|
Gate::authorize('view', [$event, $organisation]);
|
||||||
|
|
||||||
return $this->success(new EventResource($event->load('organisation')));
|
$event->load(['organisation', 'children', 'parent'])
|
||||||
|
->loadCount('children');
|
||||||
|
|
||||||
|
return $this->success(new EventResource($event));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreEventRequest $request, Organisation $organisation): JsonResponse
|
public function store(StoreEventRequest $request, Organisation $organisation): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('create', [Event::class, $organisation]);
|
Gate::authorize('create', [Event::class, $organisation]);
|
||||||
|
|
||||||
$event = $organisation->events()->create($request->validated());
|
$data = $request->validated();
|
||||||
|
|
||||||
|
if (!empty($data['parent_event_id'])) {
|
||||||
|
$parentEvent = Event::where('id', $data['parent_event_id'])
|
||||||
|
->where('organisation_id', $organisation->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$parentEvent) {
|
||||||
|
return $this->error('Parent event does not belong to this organisation.', 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($data['event_type'])) {
|
||||||
|
$data['event_type'] = empty($data['parent_event_id']) ? 'event' : 'event';
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = $organisation->events()->create($data);
|
||||||
|
|
||||||
return $this->created(new EventResource($event));
|
return $this->created(new EventResource($event));
|
||||||
}
|
}
|
||||||
@@ -51,4 +79,16 @@ final class EventController extends Controller
|
|||||||
|
|
||||||
return $this->success(new EventResource($event->fresh()));
|
return $this->success(new EventResource($event->fresh()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function children(Organisation $organisation, Event $event): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
Gate::authorize('view', [$event, $organisation]);
|
||||||
|
|
||||||
|
$children = $event->children()
|
||||||
|
->orderBy('start_date')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate();
|
||||||
|
|
||||||
|
return EventResource::collection($children);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ final class StoreEventRequest extends FormRequest
|
|||||||
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
|
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
|
||||||
'timezone' => ['sometimes', 'string', 'max:50'],
|
'timezone' => ['sometimes', 'string', 'max:50'],
|
||||||
'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'],
|
'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'],
|
||||||
|
'parent_event_id' => ['nullable', 'ulid', 'exists:events,id'],
|
||||||
|
'event_type' => ['nullable', 'in:event,festival,series'],
|
||||||
|
'event_type_label' => ['nullable', 'string', 'max:50'],
|
||||||
|
'sub_event_label' => ['nullable', 'string', 'max:50'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ final class UpdateEventRequest extends FormRequest
|
|||||||
'end_date' => ['sometimes', 'date', 'after_or_equal:start_date'],
|
'end_date' => ['sometimes', 'date', 'after_or_equal:start_date'],
|
||||||
'timezone' => ['sometimes', 'string', 'max:50'],
|
'timezone' => ['sometimes', 'string', 'max:50'],
|
||||||
'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'],
|
'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'],
|
||||||
|
'parent_event_id' => ['nullable', 'ulid', 'exists:events,id'],
|
||||||
|
'event_type' => ['sometimes', 'in:event,festival,series'],
|
||||||
|
'event_type_label' => ['nullable', 'string', 'max:50'],
|
||||||
|
'sub_event_label' => ['nullable', 'string', 'max:50'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,27 @@ final class EventResource extends JsonResource
|
|||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'organisation_id' => $this->organisation_id,
|
'organisation_id' => $this->organisation_id,
|
||||||
|
'parent_event_id' => $this->parent_event_id,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'slug' => $this->slug,
|
'slug' => $this->slug,
|
||||||
'start_date' => $this->start_date->toDateString(),
|
'start_date' => $this->start_date->toDateString(),
|
||||||
'end_date' => $this->end_date->toDateString(),
|
'end_date' => $this->end_date->toDateString(),
|
||||||
'timezone' => $this->timezone,
|
'timezone' => $this->timezone,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
|
'event_type' => $this->event_type,
|
||||||
|
'event_type_label' => $this->event_type_label,
|
||||||
|
'sub_event_label' => $this->sub_event_label,
|
||||||
|
'is_recurring' => $this->is_recurring,
|
||||||
|
'is_festival' => $this->resource->isFestival(),
|
||||||
|
'is_sub_event' => $this->resource->isSubEvent(),
|
||||||
|
'is_flat_event' => $this->resource->isFlatEvent(),
|
||||||
|
'has_children' => $this->resource->hasChildren(),
|
||||||
'created_at' => $this->created_at->toIso8601String(),
|
'created_at' => $this->created_at->toIso8601String(),
|
||||||
'updated_at' => $this->updated_at->toIso8601String(),
|
'updated_at' => $this->updated_at->toIso8601String(),
|
||||||
|
'children_count' => $this->whenCounted('children'),
|
||||||
'organisation' => new OrganisationResource($this->whenLoaded('organisation')),
|
'organisation' => new OrganisationResource($this->whenLoaded('organisation')),
|
||||||
|
'parent' => new EventResource($this->whenLoaded('parent')),
|
||||||
|
'children' => EventResource::collection($this->whenLoaded('children')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final class EventFactory extends Factory
|
|||||||
'end_date' => fake()->dateTimeBetween($startDate, (clone $startDate)->modify('+3 days')),
|
'end_date' => fake()->dateTimeBetween($startDate, (clone $startDate)->modify('+3 days')),
|
||||||
'timezone' => 'Europe/Amsterdam',
|
'timezone' => 'Europe/Amsterdam',
|
||||||
'status' => 'draft',
|
'status' => 'draft',
|
||||||
|
'event_type' => 'event',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,4 +35,31 @@ final class EventFactory extends Factory
|
|||||||
{
|
{
|
||||||
return $this->state(fn () => ['status' => 'published']);
|
return $this->state(fn () => ['status' => 'published']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function festival(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'event_type' => 'festival',
|
||||||
|
'event_type_label' => 'Festival',
|
||||||
|
'sub_event_label' => 'Dag',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function series(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'event_type' => 'series',
|
||||||
|
'event_type_label' => 'Serie',
|
||||||
|
'sub_event_label' => 'Editie',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subEvent(Event $parent): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'parent_event_id' => $parent->id,
|
||||||
|
'organisation_id' => $parent->organisation_id,
|
||||||
|
'event_type' => 'event',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
// Events (nested under organisations)
|
// Events (nested under organisations)
|
||||||
Route::apiResource('organisations.events', EventController::class)
|
Route::apiResource('organisations.events', EventController::class)
|
||||||
->only(['index', 'show', 'store', 'update']);
|
->only(['index', 'show', 'store', 'update']);
|
||||||
|
Route::get('organisations/{organisation}/events/{event}/children', [EventController::class, 'children']);
|
||||||
|
|
||||||
// Organisation-scoped resources
|
// Organisation-scoped resources
|
||||||
Route::prefix('organisations/{organisation}')->group(function () {
|
Route::prefix('organisations/{organisation}')->group(function () {
|
||||||
|
|||||||
234
api/tests/Feature/Event/FestivalEventTest.php
Normal file
234
api/tests/Feature/Event/FestivalEventTest.php
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Event;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class FestivalEventTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $orgAdmin;
|
||||||
|
private Organisation $organisation;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seed(RoleSeeder::class);
|
||||||
|
|
||||||
|
$this->organisation = Organisation::factory()->create();
|
||||||
|
|
||||||
|
$this->orgAdmin = User::factory()->create();
|
||||||
|
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- INDEX: top-level only ---
|
||||||
|
|
||||||
|
public function test_index_shows_only_top_level_events(): void
|
||||||
|
{
|
||||||
|
$festival = Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
Event::factory()->subEvent($festival)->create();
|
||||||
|
Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertCount(2, $response->json('data'));
|
||||||
|
|
||||||
|
$ids = collect($response->json('data'))->pluck('parent_event_id');
|
||||||
|
$this->assertTrue($ids->every(fn ($v) => $v === null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- INDEX: include_children ---
|
||||||
|
|
||||||
|
public function test_index_with_include_children_shows_nested_children(): void
|
||||||
|
{
|
||||||
|
$festival = Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
Event::factory()->subEvent($festival)->count(2)->create();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events?include_children=true");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertCount(1, $response->json('data'));
|
||||||
|
$this->assertCount(2, $response->json('data.0.children'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- INDEX: type filter ---
|
||||||
|
|
||||||
|
public function test_index_type_filter_works(): void
|
||||||
|
{
|
||||||
|
Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events?type=festival");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertCount(1, $response->json('data'));
|
||||||
|
$this->assertEquals('festival', $response->json('data.0.event_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- STORE: sub-event ---
|
||||||
|
|
||||||
|
public function test_store_with_parent_event_id_creates_sub_event(): void
|
||||||
|
{
|
||||||
|
$festival = Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
|
||||||
|
'name' => 'Dag 1',
|
||||||
|
'slug' => 'dag-1',
|
||||||
|
'start_date' => '2026-07-01',
|
||||||
|
'end_date' => '2026-07-01',
|
||||||
|
'parent_event_id' => $festival->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated();
|
||||||
|
$this->assertEquals($festival->id, $response->json('data.parent_event_id'));
|
||||||
|
$this->assertTrue($response->json('data.is_sub_event'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- STORE: sub-event cross-org → 422 ---
|
||||||
|
|
||||||
|
public function test_store_sub_event_of_other_org_returns_422(): void
|
||||||
|
{
|
||||||
|
$otherOrg = Organisation::factory()->create();
|
||||||
|
$otherEvent = Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $otherOrg->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
|
||||||
|
'name' => 'Cross-org sub',
|
||||||
|
'slug' => 'cross-org-sub',
|
||||||
|
'start_date' => '2026-07-01',
|
||||||
|
'end_date' => '2026-07-01',
|
||||||
|
'parent_event_id' => $otherEvent->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CHILDREN endpoint ---
|
||||||
|
|
||||||
|
public function test_children_endpoint_returns_sub_events(): void
|
||||||
|
{
|
||||||
|
$festival = Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
Event::factory()->subEvent($festival)->count(3)->create();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/children");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertCount(3, $response->json('data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_children_of_flat_event_returns_empty_list(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}/children");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertCount(0, $response->json('data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SHOW: festival with children ---
|
||||||
|
|
||||||
|
public function test_show_festival_contains_children(): void
|
||||||
|
{
|
||||||
|
$festival = Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
Event::factory()->subEvent($festival)->count(2)->create();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertCount(2, $response->json('data.children'));
|
||||||
|
$this->assertEquals(2, $response->json('data.children_count'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SHOW: sub-event with parent ---
|
||||||
|
|
||||||
|
public function test_show_sub_event_contains_parent(): void
|
||||||
|
{
|
||||||
|
$festival = Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
$subEvent = Event::factory()->subEvent($festival)->create();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertEquals($festival->id, $response->json('data.parent.id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper methods ---
|
||||||
|
|
||||||
|
public function test_is_festival_helper(): void
|
||||||
|
{
|
||||||
|
$festival = Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($festival->isFestival());
|
||||||
|
$this->assertFalse($festival->isSubEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_is_sub_event_helper(): void
|
||||||
|
{
|
||||||
|
$festival = Event::factory()->festival()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
$subEvent = Event::factory()->subEvent($festival)->create();
|
||||||
|
|
||||||
|
$this->assertTrue($subEvent->isSubEvent());
|
||||||
|
$this->assertFalse($subEvent->isFestival());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_is_flat_event_helper(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($event->isFlatEvent());
|
||||||
|
$this->assertFalse($event->isFestival());
|
||||||
|
$this->assertFalse($event->isSubEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/app/components.d.ts
vendored
1
apps/app/components.d.ts
vendored
@@ -34,6 +34,7 @@ declare module 'vue' {
|
|||||||
CreatePersonDialog: typeof import('./src/components/persons/CreatePersonDialog.vue')['default']
|
CreatePersonDialog: typeof import('./src/components/persons/CreatePersonDialog.vue')['default']
|
||||||
CreateSectionDialog: typeof import('./src/components/sections/CreateSectionDialog.vue')['default']
|
CreateSectionDialog: typeof import('./src/components/sections/CreateSectionDialog.vue')['default']
|
||||||
CreateShiftDialog: typeof import('./src/components/sections/CreateShiftDialog.vue')['default']
|
CreateShiftDialog: typeof import('./src/components/sections/CreateShiftDialog.vue')['default']
|
||||||
|
CreateSubEventDialog: typeof import('./src/components/events/CreateSubEventDialog.vue')['default']
|
||||||
CreateTimeSlotDialog: typeof import('./src/components/sections/CreateTimeSlotDialog.vue')['default']
|
CreateTimeSlotDialog: typeof import('./src/components/sections/CreateTimeSlotDialog.vue')['default']
|
||||||
CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.vue')['default']
|
CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.vue')['default']
|
||||||
CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default']
|
CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default']
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { VForm } from 'vuetify/components/VForm'
|
import { VForm } from 'vuetify/components/VForm'
|
||||||
import { useCreateEvent } from '@/composables/api/useEvents'
|
import { useCreateEvent } from '@/composables/api/useEvents'
|
||||||
import { requiredValidator } from '@core/utils/validators'
|
import { requiredValidator } from '@core/utils/validators'
|
||||||
|
import type { EventTypeEnum } from '@/types/event'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
orgId: string
|
orgId: string
|
||||||
@@ -17,6 +18,9 @@ const form = ref({
|
|||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
timezone: 'Europe/Amsterdam',
|
timezone: 'Europe/Amsterdam',
|
||||||
|
event_type: 'event' as EventTypeEnum,
|
||||||
|
event_type_label: '',
|
||||||
|
sub_event_label: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = ref<Record<string, string>>({})
|
const errors = ref<Record<string, string>>({})
|
||||||
@@ -25,6 +29,24 @@ const showSuccess = ref(false)
|
|||||||
|
|
||||||
const { mutate: createEvent, isPending } = useCreateEvent(orgIdRef)
|
const { mutate: createEvent, isPending } = useCreateEvent(orgIdRef)
|
||||||
|
|
||||||
|
const isFestivalOrSeries = computed(() =>
|
||||||
|
form.value.event_type === 'festival' || form.value.event_type === 'series',
|
||||||
|
)
|
||||||
|
|
||||||
|
const eventTypeOptions: { title: string; value: EventTypeEnum }[] = [
|
||||||
|
{ title: 'Evenement', value: 'event' },
|
||||||
|
{ title: 'Festival', value: 'festival' },
|
||||||
|
{ title: 'Serie', value: 'series' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const subEventLabelOptions = [
|
||||||
|
'Dag',
|
||||||
|
'Programmaonderdeel',
|
||||||
|
'Editie',
|
||||||
|
'Locatie',
|
||||||
|
'Ronde',
|
||||||
|
]
|
||||||
|
|
||||||
const timezoneOptions = [
|
const timezoneOptions = [
|
||||||
{ title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
|
{ title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
|
||||||
{ title: 'Europe/London', value: 'Europe/London' },
|
{ title: 'Europe/London', value: 'Europe/London' },
|
||||||
@@ -50,7 +72,16 @@ const endDateRule = (v: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.value = { name: '', slug: '', start_date: '', end_date: '', timezone: 'Europe/Amsterdam' }
|
form.value = {
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
timezone: 'Europe/Amsterdam',
|
||||||
|
event_type: 'event',
|
||||||
|
event_type_label: '',
|
||||||
|
sub_event_label: '',
|
||||||
|
}
|
||||||
errors.value = {}
|
errors.value = {}
|
||||||
refVForm.value?.resetValidation()
|
refVForm.value?.resetValidation()
|
||||||
}
|
}
|
||||||
@@ -61,7 +92,20 @@ function onSubmit() {
|
|||||||
|
|
||||||
errors.value = {}
|
errors.value = {}
|
||||||
|
|
||||||
createEvent(form.value, {
|
const payload = {
|
||||||
|
name: form.value.name,
|
||||||
|
slug: form.value.slug,
|
||||||
|
start_date: form.value.start_date,
|
||||||
|
end_date: form.value.end_date,
|
||||||
|
timezone: form.value.timezone,
|
||||||
|
event_type: form.value.event_type,
|
||||||
|
event_type_label: form.value.event_type_label || null,
|
||||||
|
sub_event_label: isFestivalOrSeries.value && form.value.sub_event_label
|
||||||
|
? form.value.sub_event_label
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
createEvent(payload, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
modelValue.value = false
|
modelValue.value = false
|
||||||
showSuccess.value = true
|
showSuccess.value = true
|
||||||
@@ -105,6 +149,7 @@ function onSubmit() {
|
|||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<AppTextField
|
<AppTextField
|
||||||
v-model="form.slug"
|
v-model="form.slug"
|
||||||
@@ -115,6 +160,51 @@ function onSubmit() {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12">
|
||||||
|
<VBtnToggle
|
||||||
|
v-model="form.event_type"
|
||||||
|
mandatory
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
divided
|
||||||
|
density="comfortable"
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
v-for="opt in eventTypeOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.title }}
|
||||||
|
</VBtn>
|
||||||
|
</VBtnToggle>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
v-if="isFestivalOrSeries"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<AppCombobox
|
||||||
|
v-model="form.sub_event_label"
|
||||||
|
label="Hoe noem jij de onderdelen?"
|
||||||
|
:items="subEventLabelOptions"
|
||||||
|
:error-messages="errors.sub_event_label"
|
||||||
|
hint="Kies uit de lijst of typ een eigen naam"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.event_type_label"
|
||||||
|
label="Naam van het type (optioneel)"
|
||||||
|
placeholder="Festival, Evenement, Schaatsbaan..."
|
||||||
|
:error-messages="errors.event_type_label"
|
||||||
|
maxlength="50"
|
||||||
|
counter
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
<VCol
|
<VCol
|
||||||
cols="12"
|
cols="12"
|
||||||
md="6"
|
md="6"
|
||||||
|
|||||||
190
apps/app/src/components/events/CreateSubEventDialog.vue
Normal file
190
apps/app/src/components/events/CreateSubEventDialog.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { VForm } from 'vuetify/components/VForm'
|
||||||
|
import { useCreateSubEvent } from '@/composables/api/useEvents'
|
||||||
|
import { requiredValidator } from '@core/utils/validators'
|
||||||
|
import type { EventItem, EventStatus } from '@/types/event'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
parentEvent: EventItem
|
||||||
|
orgId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<boolean>({ required: true })
|
||||||
|
|
||||||
|
const orgIdRef = computed(() => props.orgId)
|
||||||
|
|
||||||
|
const subEventLabel = computed(() =>
|
||||||
|
props.parentEvent.sub_event_label ?? 'Programmaonderdeel',
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
date: '',
|
||||||
|
start_time: '',
|
||||||
|
end_time: '',
|
||||||
|
status: 'draft' as EventStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = ref<Record<string, string>>({})
|
||||||
|
const refVForm = ref<VForm>()
|
||||||
|
const showSuccess = ref(false)
|
||||||
|
|
||||||
|
const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef)
|
||||||
|
|
||||||
|
const statusOptions: { title: string; value: EventStatus }[] = [
|
||||||
|
{ title: 'Draft', value: 'draft' },
|
||||||
|
{ title: 'Published', value: 'published' },
|
||||||
|
{ title: 'Registration Open', value: 'registration_open' },
|
||||||
|
{ title: 'Build-up', value: 'buildup' },
|
||||||
|
{ title: 'Show Day', value: 'showday' },
|
||||||
|
{ title: 'Tear-down', value: 'teardown' },
|
||||||
|
{ title: 'Closed', value: 'closed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.value = { name: '', date: '', start_time: '', end_time: '', status: 'draft' }
|
||||||
|
errors.value = {}
|
||||||
|
refVForm.value?.resetValidation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
refVForm.value?.validate().then(({ valid }) => {
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
errors.value = {}
|
||||||
|
|
||||||
|
const slug = form.value.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
createSubEvent({
|
||||||
|
name: form.value.name,
|
||||||
|
slug,
|
||||||
|
start_date: form.value.date,
|
||||||
|
end_date: form.value.date,
|
||||||
|
timezone: props.parentEvent.timezone,
|
||||||
|
event_type: 'event',
|
||||||
|
parent_event_id: props.parentEvent.id,
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
modelValue.value = false
|
||||||
|
showSuccess.value = true
|
||||||
|
resetForm()
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
const data = err.response?.data
|
||||||
|
if (data?.errors) {
|
||||||
|
errors.value = Object.fromEntries(
|
||||||
|
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (data?.message) {
|
||||||
|
errors.value = { name: data.message }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog
|
||||||
|
v-model="modelValue"
|
||||||
|
max-width="500"
|
||||||
|
@after-leave="resetForm"
|
||||||
|
>
|
||||||
|
<VCard :title="`${subEventLabel} toevoegen`">
|
||||||
|
<VCardText>
|
||||||
|
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Onderdeel van: <strong>{{ parentEvent.name }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VForm
|
||||||
|
ref="refVForm"
|
||||||
|
@submit.prevent="onSubmit"
|
||||||
|
>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.name"
|
||||||
|
label="Naam"
|
||||||
|
:rules="[requiredValidator]"
|
||||||
|
:error-messages="errors.name"
|
||||||
|
:placeholder="`Dag 1, ${subEventLabel} 1...`"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.date"
|
||||||
|
label="Datum"
|
||||||
|
type="date"
|
||||||
|
:rules="[requiredValidator]"
|
||||||
|
:error-messages="errors.date ?? errors.start_date"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.start_time"
|
||||||
|
label="Starttijd"
|
||||||
|
type="time"
|
||||||
|
:rules="[requiredValidator]"
|
||||||
|
:error-messages="errors.start_time"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.end_time"
|
||||||
|
label="Eindtijd"
|
||||||
|
type="time"
|
||||||
|
:rules="[requiredValidator]"
|
||||||
|
:error-messages="errors.end_time"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppSelect
|
||||||
|
v-model="form.status"
|
||||||
|
label="Status"
|
||||||
|
:items="statusOptions"
|
||||||
|
:error-messages="errors.status"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="modelValue = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
:loading="isPending"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
Toevoegen
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<VSnackbar
|
||||||
|
v-model="showSuccess"
|
||||||
|
color="success"
|
||||||
|
:timeout="3000"
|
||||||
|
>
|
||||||
|
{{ subEventLabel }} aangemaakt
|
||||||
|
</VSnackbar>
|
||||||
|
</template>
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
import { VForm } from 'vuetify/components/VForm'
|
import { VForm } from 'vuetify/components/VForm'
|
||||||
import { useUpdateEvent } from '@/composables/api/useEvents'
|
import { useUpdateEvent } from '@/composables/api/useEvents'
|
||||||
import { requiredValidator } from '@core/utils/validators'
|
import { requiredValidator } from '@core/utils/validators'
|
||||||
import type { EventStatus, EventType } from '@/types/event'
|
import type { EventStatus, EventItem } from '@/types/event'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
event: EventType
|
event: EventItem
|
||||||
orgId: string
|
orgId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
|||||||
import EditEventDialog from '@/components/events/EditEventDialog.vue'
|
import EditEventDialog from '@/components/events/EditEventDialog.vue'
|
||||||
import type { EventStatus } from '@/types/event'
|
import type { EventStatus } from '@/types/event'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
hideTabs?: boolean
|
||||||
|
}>(), {
|
||||||
|
hideTabs: false,
|
||||||
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const orgStore = useOrganisationStore()
|
const orgStore = useOrganisationStore()
|
||||||
@@ -31,6 +37,11 @@ const statusColor: Record<EventStatus, string> = {
|
|||||||
closed: 'error',
|
closed: 'error',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventTypeColor: Record<string, string> = {
|
||||||
|
festival: 'purple',
|
||||||
|
series: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@@ -54,6 +65,13 @@ const activeTab = computed(() => {
|
|||||||
const name = route.name as string
|
const name = route.name as string
|
||||||
return tabs.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
|
return tabs.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const backRoute = computed(() => {
|
||||||
|
if (event.value?.is_sub_event && event.value.parent_event_id) {
|
||||||
|
return { name: 'events-id' as const, params: { id: event.value.parent_event_id } }
|
||||||
|
}
|
||||||
|
return { name: 'events' as const }
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -82,17 +100,41 @@ const activeTab = computed(() => {
|
|||||||
</VAlert>
|
</VAlert>
|
||||||
|
|
||||||
<template v-else-if="event">
|
<template v-else-if="event">
|
||||||
|
<!-- Sub-event breadcrumb -->
|
||||||
|
<div
|
||||||
|
v-if="event.is_sub_event && event.parent && event.parent_event_id"
|
||||||
|
class="text-caption text-medium-emphasis mb-1"
|
||||||
|
>
|
||||||
|
<VIcon size="12">
|
||||||
|
tabler-arrow-left
|
||||||
|
</VIcon>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'events-id', params: { id: event.parent_event_id } }"
|
||||||
|
class="text-medium-emphasis"
|
||||||
|
>
|
||||||
|
{{ event.parent.name }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="d-flex justify-space-between align-center mb-6">
|
<div class="d-flex justify-space-between align-center mb-6">
|
||||||
<div class="d-flex align-center gap-x-3">
|
<div class="d-flex align-center gap-x-3">
|
||||||
<VBtn
|
<VBtn
|
||||||
icon="tabler-arrow-left"
|
icon="tabler-arrow-left"
|
||||||
variant="text"
|
variant="text"
|
||||||
:to="{ name: 'events' }"
|
:to="backRoute"
|
||||||
/>
|
/>
|
||||||
<h4 class="text-h4">
|
<h4 class="text-h4">
|
||||||
{{ event.name }}
|
{{ event.name }}
|
||||||
</h4>
|
</h4>
|
||||||
|
<VChip
|
||||||
|
v-if="event.event_type === 'festival' || event.event_type === 'series'"
|
||||||
|
:color="eventTypeColor[event.event_type]"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ event.event_type_label ?? (event.event_type === 'festival' ? 'Festival' : 'Serie') }}
|
||||||
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
:color="statusColor[event.status]"
|
:color="statusColor[event.status]"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -111,8 +153,9 @@ const activeTab = computed(() => {
|
|||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Horizontal tabs -->
|
<!-- Horizontal tabs (hidden for festival containers) -->
|
||||||
<VTabs
|
<VTabs
|
||||||
|
v-if="!hideTabs"
|
||||||
:model-value="activeTab"
|
:model-value="activeTab"
|
||||||
class="mb-6"
|
class="mb-6"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
|
|||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
import type {
|
import type {
|
||||||
CreateEventPayload,
|
CreateEventPayload,
|
||||||
EventType,
|
EventItem,
|
||||||
UpdateEventPayload,
|
UpdateEventPayload,
|
||||||
} from '@/types/event'
|
} from '@/types/event'
|
||||||
|
|
||||||
@@ -28,8 +28,9 @@ export function useEventList(orgId: Ref<string>) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['events', orgId],
|
queryKey: ['events', orgId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiClient.get<PaginatedResponse<EventType>>(
|
const { data } = await apiClient.get<PaginatedResponse<EventItem>>(
|
||||||
`/organisations/${orgId.value}/events`,
|
`/organisations/${orgId.value}/events`,
|
||||||
|
{ params: { include_children: true } },
|
||||||
)
|
)
|
||||||
return data.data
|
return data.data
|
||||||
},
|
},
|
||||||
@@ -41,7 +42,7 @@ export function useEventDetail(orgId: Ref<string>, id: Ref<string>) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['events', orgId, id],
|
queryKey: ['events', orgId, id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiClient.get<ApiResponse<EventType>>(
|
const { data } = await apiClient.get<ApiResponse<EventItem>>(
|
||||||
`/organisations/${orgId.value}/events/${id.value}`,
|
`/organisations/${orgId.value}/events/${id.value}`,
|
||||||
)
|
)
|
||||||
return data.data
|
return data.data
|
||||||
@@ -50,12 +51,42 @@ export function useEventDetail(orgId: Ref<string>, id: Ref<string>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useEventChildren(orgId: Ref<string>, eventId: Ref<string>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['event-children', eventId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<PaginatedResponse<EventItem>>(
|
||||||
|
`/organisations/${orgId.value}/events/${eventId.value}/children`,
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
enabled: () => !!orgId.value && !!eventId.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateEvent(orgId: Ref<string>) {
|
export function useCreateEvent(orgId: Ref<string>) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: CreateEventPayload) => {
|
mutationFn: async (payload: CreateEventPayload) => {
|
||||||
const { data } = await apiClient.post<ApiResponse<EventType>>(
|
const { data } = await apiClient.post<ApiResponse<EventItem>>(
|
||||||
|
`/organisations/${orgId.value}/events`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events', orgId.value] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateSubEvent(orgId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: CreateEventPayload) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<EventItem>>(
|
||||||
`/organisations/${orgId.value}/events`,
|
`/organisations/${orgId.value}/events`,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
@@ -72,7 +103,7 @@ export function useUpdateEvent(orgId: Ref<string>, id: Ref<string>) {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: UpdateEventPayload) => {
|
mutationFn: async (payload: UpdateEventPayload) => {
|
||||||
const { data } = await apiClient.put<ApiResponse<EventType>>(
|
const { data } = await apiClient.put<ApiResponse<EventItem>>(
|
||||||
`/organisations/${orgId.value}/events/${id.value}`,
|
`/organisations/${orgId.value}/events/${id.value}`,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { themeConfig } from '@themeConfig'
|
|||||||
import Footer from '@/layouts/components/Footer.vue'
|
import Footer from '@/layouts/components/Footer.vue'
|
||||||
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
|
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
|
||||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||||
import NavBarI18n from '@core/components/I18n.vue'
|
|
||||||
import { HorizontalNavLayout } from '@layouts'
|
import { HorizontalNavLayout } from '@layouts'
|
||||||
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
|
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
|
||||||
</script>
|
</script>
|
||||||
@@ -28,11 +27,6 @@ import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<NavBarI18n
|
|
||||||
v-if="themeConfig.app.i18n.enable && themeConfig.app.i18n.langConfig?.length"
|
|
||||||
:languages="themeConfig.app.i18n.langConfig"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NavbarThemeSwitcher class="me-2" />
|
<NavbarThemeSwitcher class="me-2" />
|
||||||
<UserProfile />
|
<UserProfile />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import navItems from '@/navigation/vertical'
|
import navItems from '@/navigation/vertical'
|
||||||
import { themeConfig } from '@themeConfig'
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import Footer from '@/layouts/components/Footer.vue'
|
import Footer from '@/layouts/components/Footer.vue'
|
||||||
|
import NavBarNotifications from '@/layouts/components/NavBarNotifications.vue'
|
||||||
|
import NavSearchBar from '@/layouts/components/NavSearchBar.vue'
|
||||||
|
import NavbarShortcuts from '@/layouts/components/NavbarShortcuts.vue'
|
||||||
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
|
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
|
||||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||||
import OrganisationSwitcher from '@/components/layout/OrganisationSwitcher.vue'
|
import OrganisationSwitcher from '@/components/layout/OrganisationSwitcher.vue'
|
||||||
import NavBarI18n from '@core/components/I18n.vue'
|
|
||||||
|
|
||||||
// @layouts plugin
|
// @layouts plugin
|
||||||
import { VerticalNavLayout } from '@layouts'
|
import { VerticalNavLayout } from '@layouts'
|
||||||
@@ -21,12 +22,15 @@ import { VerticalNavLayout } from '@layouts'
|
|||||||
<div class="vertical-nav-items-shadow" />
|
<div class="vertical-nav-items-shadow" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 👉 navbar -->
|
<!-- 👉 navbar (match Vuexy full-version: search + actions; search flex-grows) -->
|
||||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||||
<div class="d-flex h-100 align-center">
|
<div
|
||||||
|
class="d-flex h-100 align-center w-100"
|
||||||
|
style="min-inline-size: 0;"
|
||||||
|
>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
id="vertical-nav-toggle-btn"
|
id="vertical-nav-toggle-btn"
|
||||||
class="ms-n3 d-lg-none"
|
class="ms-n3 d-lg-none flex-shrink-0"
|
||||||
@click="toggleVerticalOverlayNavActive(true)"
|
@click="toggleVerticalOverlayNavActive(true)"
|
||||||
>
|
>
|
||||||
<VIcon
|
<VIcon
|
||||||
@@ -35,15 +39,12 @@ import { VerticalNavLayout } from '@layouts'
|
|||||||
/>
|
/>
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
|
|
||||||
<NavbarThemeSwitcher />
|
<NavSearchBar class="flex-grow-1 ms-lg-n3 min-w-0" />
|
||||||
|
|
||||||
<VSpacer />
|
<NavbarThemeSwitcher class="flex-shrink-0 me-2" />
|
||||||
|
<NavbarShortcuts class="flex-shrink-0" />
|
||||||
<NavBarI18n
|
<NavBarNotifications class="flex-shrink-0 me-1" />
|
||||||
v-if="themeConfig.app.i18n.enable && themeConfig.app.i18n.langConfig?.length"
|
<UserProfile class="flex-shrink-0" />
|
||||||
:languages="themeConfig.app.i18n.langConfig"
|
|
||||||
/>
|
|
||||||
<UserProfile />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import avatar1 from '@images/avatars/avatar-1.png'
|
import { computed } from 'vue'
|
||||||
import { useLogout } from '@/composables/api/useAuth'
|
import { useLogout } from '@/composables/api/useAuth'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
@@ -7,6 +7,28 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const { mutate: logout, isPending: isLoggingOut } = useLogout()
|
const { mutate: logout, isPending: isLoggingOut } = useLogout()
|
||||||
|
|
||||||
|
const user = computed(() => authStore.user)
|
||||||
|
|
||||||
|
const initials = computed(() => {
|
||||||
|
const name = user.value?.name?.trim() ?? ''
|
||||||
|
if (!name)
|
||||||
|
return '?'
|
||||||
|
const parts = name.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length >= 2)
|
||||||
|
return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase()
|
||||||
|
return name.slice(0, 2).toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
const avatarSrc = computed((): string | null => {
|
||||||
|
const raw = user.value?.avatar
|
||||||
|
if (!raw)
|
||||||
|
return null
|
||||||
|
if (raw.startsWith('http://') || raw.startsWith('https://'))
|
||||||
|
return raw
|
||||||
|
const base = (import.meta.env.VITE_API_URL as string | undefined)?.replace(/\/api\/v1\/?$/, '') ?? ''
|
||||||
|
return raw.startsWith('/') ? `${base}${raw}` : `${base}/${raw}`
|
||||||
|
})
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
logout(undefined, {
|
logout(undefined, {
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
@@ -17,105 +39,57 @@ function handleLogout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VBadge
|
<VAvatar
|
||||||
dot
|
class="cursor-pointer"
|
||||||
location="bottom right"
|
color="primary"
|
||||||
offset-x="3"
|
variant="tonal"
|
||||||
offset-y="3"
|
size="38"
|
||||||
bordered
|
|
||||||
color="success"
|
|
||||||
>
|
>
|
||||||
<VAvatar
|
<VImg
|
||||||
class="cursor-pointer"
|
v-if="avatarSrc"
|
||||||
color="primary"
|
:src="avatarSrc"
|
||||||
variant="tonal"
|
:alt="user?.name ?? ''"
|
||||||
|
cover
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-caption font-weight-bold"
|
||||||
>
|
>
|
||||||
<VImg :src="avatar1" />
|
{{ initials }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- SECTION Menu -->
|
<VMenu
|
||||||
<VMenu
|
activator="parent"
|
||||||
activator="parent"
|
width="260"
|
||||||
width="230"
|
location="bottom end"
|
||||||
location="bottom end"
|
offset="14px"
|
||||||
offset="14px"
|
>
|
||||||
>
|
<VList>
|
||||||
<VList>
|
<VListItem class="text-high-emphasis">
|
||||||
<!-- 👉 User Avatar & Name -->
|
<VListItemTitle class="font-weight-semibold">
|
||||||
<VListItem>
|
{{ user?.name ?? '—' }}
|
||||||
<template #prepend>
|
</VListItemTitle>
|
||||||
<VListItemAction start>
|
<VListItemSubtitle class="text-wrap">
|
||||||
<VBadge
|
{{ user?.email ?? '' }}
|
||||||
dot
|
</VListItemSubtitle>
|
||||||
location="bottom right"
|
</VListItem>
|
||||||
offset-x="3"
|
|
||||||
offset-y="3"
|
|
||||||
color="success"
|
|
||||||
>
|
|
||||||
<VAvatar
|
|
||||||
color="primary"
|
|
||||||
variant="tonal"
|
|
||||||
>
|
|
||||||
<VImg :src="avatar1" />
|
|
||||||
</VAvatar>
|
|
||||||
</VBadge>
|
|
||||||
</VListItemAction>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VListItemTitle class="font-weight-semibold">
|
<VDivider class="my-2" />
|
||||||
{{ authStore.user?.name ?? 'User' }}
|
|
||||||
</VListItemTitle>
|
|
||||||
<VListItemSubtitle>{{ authStore.currentOrganisation?.role ?? '' }}</VListItemSubtitle>
|
|
||||||
</VListItem>
|
|
||||||
|
|
||||||
<VDivider class="my-2" />
|
<VListItem
|
||||||
|
:disabled="isLoggingOut"
|
||||||
<!-- 👉 Profile -->
|
@click="handleLogout"
|
||||||
<VListItem link>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon
|
<VIcon
|
||||||
class="me-2"
|
class="me-2"
|
||||||
icon="tabler-user"
|
icon="tabler-logout"
|
||||||
size="22"
|
size="22"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<VListItemTitle>Uitloggen</VListItemTitle>
|
||||||
<VListItemTitle>Profile</VListItemTitle>
|
</VListItem>
|
||||||
</VListItem>
|
</VList>
|
||||||
|
</VMenu>
|
||||||
<!-- 👉 Settings -->
|
</VAvatar>
|
||||||
<VListItem link>
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon
|
|
||||||
class="me-2"
|
|
||||||
icon="tabler-settings"
|
|
||||||
size="22"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VListItemTitle>Settings</VListItemTitle>
|
|
||||||
</VListItem>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<VDivider class="my-2" />
|
|
||||||
|
|
||||||
<!-- 👉 Logout -->
|
|
||||||
<VListItem
|
|
||||||
:disabled="isLoggingOut"
|
|
||||||
@click="handleLogout"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon
|
|
||||||
class="me-2"
|
|
||||||
icon="tabler-logout"
|
|
||||||
size="22"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VListItemTitle>Logout</VListItemTitle>
|
|
||||||
</VListItem>
|
|
||||||
</VList>
|
|
||||||
</VMenu>
|
|
||||||
<!-- !SECTION -->
|
|
||||||
</VAvatar>
|
|
||||||
</VBadge>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default [
|
|||||||
icon: { icon: 'tabler-smart-home' },
|
icon: { icon: 'tabler-smart-home' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Events',
|
title: 'Evenementen',
|
||||||
to: { name: 'events' },
|
to: { name: 'events' },
|
||||||
icon: { icon: 'tabler-calendar-event' },
|
icon: { icon: 'tabler-calendar-event' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||||
|
import CreateSubEventDialog from '@/components/events/CreateSubEventDialog.vue'
|
||||||
|
import { useEventDetail, useEventChildren } from '@/composables/api/useEvents'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import type { EventStatus, EventItem } from '@/types/event'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
@@ -9,9 +13,52 @@ definePage({
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
||||||
const eventId = computed(() => String((route.params as { id: string }).id))
|
const eventId = computed(() => String((route.params as { id: string }).id))
|
||||||
|
|
||||||
|
const { data: event } = useEventDetail(orgId, eventId)
|
||||||
|
|
||||||
|
const isFestival = computed(() => event.value?.is_festival ?? false)
|
||||||
|
const isFlatEvent = computed(() => event.value?.is_flat_event ?? false)
|
||||||
|
|
||||||
|
// Children query — only enabled for festivals
|
||||||
|
const { data: children, isLoading: childrenLoading } = useEventChildren(orgId, eventId)
|
||||||
|
|
||||||
|
const isCreateSubEventOpen = ref(false)
|
||||||
|
|
||||||
|
const statusColor: Record<EventStatus, string> = {
|
||||||
|
draft: 'default',
|
||||||
|
published: 'info',
|
||||||
|
registration_open: 'cyan',
|
||||||
|
buildup: 'warning',
|
||||||
|
showday: 'success',
|
||||||
|
teardown: 'warning',
|
||||||
|
closed: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return dateFormatter.format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
const subEventLabel = computed(() =>
|
||||||
|
event.value?.sub_event_label ?? 'Programmaonderdeel',
|
||||||
|
)
|
||||||
|
|
||||||
|
const subEventLabelPlural = computed(() =>
|
||||||
|
event.value?.sub_event_label
|
||||||
|
? `${event.value.sub_event_label}en`
|
||||||
|
: 'Programmaonderdelen',
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Flat event tiles (existing behaviour) ---
|
||||||
const tiles = [
|
const tiles = [
|
||||||
{ title: 'Personen', value: 0, icon: 'tabler-users', color: 'success', route: 'events-id-persons', enabled: true },
|
{ title: 'Personen', value: 0, icon: 'tabler-users', color: 'success', route: 'events-id-persons', enabled: true },
|
||||||
{ title: 'Secties & Shifts', value: 0, icon: 'tabler-layout-grid', color: 'primary', route: null, enabled: false },
|
{ title: 'Secties & Shifts', value: 0, icon: 'tabler-layout-grid', color: 'primary', route: null, enabled: false },
|
||||||
@@ -23,10 +70,163 @@ function onTileClick(tile: typeof tiles[number]) {
|
|||||||
if (tile.enabled && tile.route)
|
if (tile.enabled && tile.route)
|
||||||
router.push({ name: tile.route as 'events-id-persons', params: { id: eventId.value } })
|
router.push({ name: tile.route as 'events-id-persons', params: { id: eventId.value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateToChild(child: EventItem) {
|
||||||
|
router.push({ name: 'events-id', params: { id: child.id } })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<EventTabsNav>
|
<!-- Festival / Series view -->
|
||||||
|
<template v-if="isFestival && event">
|
||||||
|
<EventTabsNav :hide-tabs="true">
|
||||||
|
<!-- Sub-events subtitle bar -->
|
||||||
|
<div class="d-flex justify-space-between align-center mb-6">
|
||||||
|
<div class="text-body-1 text-medium-emphasis">
|
||||||
|
{{ children?.length ?? event.children_count ?? 0 }} {{ subEventLabelPlural.toLowerCase() }}
|
||||||
|
</div>
|
||||||
|
<VBtn
|
||||||
|
prepend-icon="tabler-plus"
|
||||||
|
size="small"
|
||||||
|
@click="isCreateSubEventOpen = true"
|
||||||
|
>
|
||||||
|
{{ subEventLabel }} toevoegen
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Children loading -->
|
||||||
|
<VSkeletonLoader
|
||||||
|
v-if="childrenLoading"
|
||||||
|
type="card@3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Children grid -->
|
||||||
|
<VRow v-else-if="children?.length">
|
||||||
|
<VCol
|
||||||
|
v-for="child in children"
|
||||||
|
:key="child.id"
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VCard
|
||||||
|
class="cursor-pointer"
|
||||||
|
hover
|
||||||
|
@click="navigateToChild(child)"
|
||||||
|
>
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex justify-space-between align-start mb-1">
|
||||||
|
<h6 class="text-h6">
|
||||||
|
{{ child.name }}
|
||||||
|
</h6>
|
||||||
|
<VChip
|
||||||
|
:color="statusColor[child.status]"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ child.status }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||||
|
{{ formatDate(child.start_date) }}
|
||||||
|
</p>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Children empty -->
|
||||||
|
<VCard
|
||||||
|
v-else
|
||||||
|
class="text-center pa-6"
|
||||||
|
>
|
||||||
|
<p class="text-body-1 text-disabled mb-0">
|
||||||
|
Nog geen {{ subEventLabelPlural.toLowerCase() }}. Voeg je eerste {{ subEventLabel.toLowerCase() }} toe.
|
||||||
|
</p>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Bottom info cards -->
|
||||||
|
<VRow class="mt-6">
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VCard>
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<div class="d-flex align-center gap-x-3">
|
||||||
|
<VAvatar
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
size="44"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-users"
|
||||||
|
size="28"
|
||||||
|
/>
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<p class="text-body-1 mb-0">
|
||||||
|
Vrijwilligers
|
||||||
|
</p>
|
||||||
|
<h4 class="text-h4">
|
||||||
|
0
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VBtn
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
:to="{ name: 'events-id-persons', params: { id: eventId } }"
|
||||||
|
>
|
||||||
|
Beheren
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VCard>
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex align-center gap-x-3">
|
||||||
|
<VAvatar
|
||||||
|
color="info"
|
||||||
|
variant="tonal"
|
||||||
|
size="44"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-chart-bar"
|
||||||
|
size="28"
|
||||||
|
/>
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<p class="text-body-1 mb-0">
|
||||||
|
Capaciteitsoverzicht
|
||||||
|
</p>
|
||||||
|
<p class="text-body-2 text-disabled mb-0">
|
||||||
|
Volledige capaciteitsplanning zichtbaar zodra {{ subEventLabelPlural.toLowerCase() }} zijn aangemaakt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<CreateSubEventDialog
|
||||||
|
v-if="event"
|
||||||
|
v-model="isCreateSubEventOpen"
|
||||||
|
:parent-event="event"
|
||||||
|
:org-id="orgId"
|
||||||
|
/>
|
||||||
|
</EventTabsNav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Flat event / Sub-event view (existing behaviour) -->
|
||||||
|
<EventTabsNav v-else>
|
||||||
<VRow class="mb-6">
|
<VRow class="mb-6">
|
||||||
<VCol
|
<VCol
|
||||||
v-for="tile in tiles"
|
v-for="tile in tiles"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useEventList } from '@/composables/api/useEvents'
|
import { useEventList } from '@/composables/api/useEvents'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
import CreateEventDialog from '@/components/events/CreateEventDialog.vue'
|
import CreateEventDialog from '@/components/events/CreateEventDialog.vue'
|
||||||
import type { EventStatus, EventType } from '@/types/event'
|
import type { EventStatus, EventItem } from '@/types/event'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -13,13 +13,10 @@ const { data: events, isLoading, isError, refetch } = useEventList(orgId)
|
|||||||
|
|
||||||
const isCreateDialogOpen = ref(false)
|
const isCreateDialogOpen = ref(false)
|
||||||
|
|
||||||
const headers = [
|
// Filter: only top-level events (no sub-events)
|
||||||
{ title: 'Naam', key: 'name' },
|
const topLevelEvents = computed(() =>
|
||||||
{ title: 'Status', key: 'status' },
|
events.value?.filter(e => !e.is_sub_event) ?? [],
|
||||||
{ title: 'Startdatum', key: 'start_date' },
|
)
|
||||||
{ title: 'Einddatum', key: 'end_date' },
|
|
||||||
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' as const },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusColor: Record<EventStatus, string> = {
|
const statusColor: Record<EventStatus, string> = {
|
||||||
draft: 'default',
|
draft: 'default',
|
||||||
@@ -31,6 +28,11 @@ const statusColor: Record<EventStatus, string> = {
|
|||||||
closed: 'error',
|
closed: 'error',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventTypeColor: Record<string, string> = {
|
||||||
|
festival: 'purple',
|
||||||
|
series: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@@ -41,8 +43,8 @@ function formatDate(iso: string) {
|
|||||||
return dateFormatter.format(new Date(iso))
|
return dateFormatter.format(new Date(iso))
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToDetail(_event: Event, row: { item: EventType }) {
|
function navigateToEvent(event: EventItem) {
|
||||||
router.push({ name: 'events-id', params: { id: row.item.id } })
|
router.push({ name: 'events-id', params: { id: event.id } })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -74,14 +76,14 @@ function navigateToDetail(_event: Event, row: { item: EventType }) {
|
|||||||
prepend-icon="tabler-plus"
|
prepend-icon="tabler-plus"
|
||||||
@click="isCreateDialogOpen = true"
|
@click="isCreateDialogOpen = true"
|
||||||
>
|
>
|
||||||
Nieuw evenement
|
Nieuw aanmaken
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<VSkeletonLoader
|
<VSkeletonLoader
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
type="table"
|
type="card@4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
@@ -103,7 +105,7 @@ function navigateToDetail(_event: Event, row: { item: EventType }) {
|
|||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<VCard
|
<VCard
|
||||||
v-else-if="!events?.length"
|
v-else-if="!topLevelEvents.length"
|
||||||
class="text-center pa-8"
|
class="text-center pa-8"
|
||||||
>
|
>
|
||||||
<VIcon
|
<VIcon
|
||||||
@@ -112,47 +114,72 @@ function navigateToDetail(_event: Event, row: { item: EventType }) {
|
|||||||
class="mb-4 text-disabled"
|
class="mb-4 text-disabled"
|
||||||
/>
|
/>
|
||||||
<p class="text-body-1 text-disabled">
|
<p class="text-body-1 text-disabled">
|
||||||
Nog geen evenementen
|
Nog geen evenementen. Maak je eerste evenement aan.
|
||||||
</p>
|
</p>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
<!-- Data table -->
|
<!-- Event cards grid -->
|
||||||
<VCard v-else>
|
<VRow v-else>
|
||||||
<VDataTable
|
<VCol
|
||||||
:headers="headers"
|
v-for="event in topLevelEvents"
|
||||||
:items="events"
|
:key="event.id"
|
||||||
item-value="id"
|
cols="12"
|
||||||
hover
|
md="6"
|
||||||
@click:row="navigateToDetail"
|
|
||||||
>
|
>
|
||||||
<template #item.status="{ item }">
|
<VCard
|
||||||
<VChip
|
class="cursor-pointer"
|
||||||
:color="statusColor[item.status]"
|
hover
|
||||||
size="small"
|
@click="navigateToEvent(event)"
|
||||||
>
|
>
|
||||||
{{ item.status }}
|
<VCardText>
|
||||||
</VChip>
|
<div class="d-flex justify-space-between align-start mb-2">
|
||||||
</template>
|
<h5 class="text-h5">
|
||||||
|
{{ event.name }}
|
||||||
|
</h5>
|
||||||
|
<div class="d-flex gap-x-2">
|
||||||
|
<VChip
|
||||||
|
v-if="event.event_type === 'festival'"
|
||||||
|
color="purple"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Festival
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-else-if="event.event_type === 'series'"
|
||||||
|
color="info"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Serie
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
:color="statusColor[event.status]"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ event.status }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #item.start_date="{ item }">
|
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||||
{{ formatDate(item.start_date) }}
|
{{ formatDate(event.start_date) }} – {{ formatDate(event.end_date) }}
|
||||||
</template>
|
</p>
|
||||||
|
|
||||||
<template #item.end_date="{ item }">
|
<p
|
||||||
{{ formatDate(item.end_date) }}
|
v-if="event.children_count && event.children_count > 0"
|
||||||
</template>
|
class="text-body-2 text-medium-emphasis d-flex align-center gap-x-1 mt-1 mb-0"
|
||||||
|
>
|
||||||
<template #item.actions="{ item }">
|
<VIcon
|
||||||
<VBtn
|
icon="tabler-layout-grid"
|
||||||
icon="tabler-eye"
|
size="16"
|
||||||
variant="text"
|
/>
|
||||||
size="small"
|
{{ event.children_count }} {{ event.sub_event_label?.toLowerCase() ?? 'programmaonderdelen' }}
|
||||||
:to="{ name: 'events-id', params: { id: item.id } }"
|
</p>
|
||||||
@click.stop
|
</VCardText>
|
||||||
/>
|
</VCard>
|
||||||
</template>
|
</VCol>
|
||||||
</VDataTable>
|
</VRow>
|
||||||
</VCard>
|
|
||||||
|
|
||||||
<CreateEventDialog
|
<CreateEventDialog
|
||||||
v-model="isCreateDialogOpen"
|
v-model="isCreateDialogOpen"
|
||||||
|
|||||||
@@ -7,16 +7,30 @@ export type EventStatus =
|
|||||||
| 'teardown'
|
| 'teardown'
|
||||||
| 'closed'
|
| 'closed'
|
||||||
|
|
||||||
export interface EventType {
|
export type EventTypeEnum = 'event' | 'festival' | 'series'
|
||||||
|
|
||||||
|
export interface EventItem {
|
||||||
id: string
|
id: string
|
||||||
organisation_id: string
|
organisation_id: string
|
||||||
|
parent_event_id: string | null
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
status: EventStatus
|
status: EventStatus
|
||||||
|
event_type: EventTypeEnum
|
||||||
|
event_type_label: string | null
|
||||||
|
sub_event_label: string | null
|
||||||
|
is_recurring: boolean
|
||||||
|
is_festival: boolean
|
||||||
|
is_sub_event: boolean
|
||||||
|
is_flat_event: boolean
|
||||||
|
has_children: boolean
|
||||||
start_date: string
|
start_date: string
|
||||||
end_date: string
|
end_date: string
|
||||||
timezone: string
|
timezone: string
|
||||||
created_at: string
|
created_at: string
|
||||||
|
children?: EventItem[]
|
||||||
|
parent?: EventItem | null
|
||||||
|
children_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateEventPayload {
|
export interface CreateEventPayload {
|
||||||
@@ -25,6 +39,10 @@ export interface CreateEventPayload {
|
|||||||
start_date: string
|
start_date: string
|
||||||
end_date: string
|
end_date: string
|
||||||
timezone: string
|
timezone: string
|
||||||
|
event_type?: EventTypeEnum
|
||||||
|
event_type_label?: string | null
|
||||||
|
sub_event_label?: string | null
|
||||||
|
parent_event_id?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateEventPayload extends Partial<CreateEventPayload> {
|
export interface UpdateEventPayload extends Partial<CreateEventPayload> {
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { breakpointsVuetifyV3 } from '@vueuse/core'
|
import { breakpointsVuetifyV3 } from '@vueuse/core'
|
||||||
|
import { h } from 'vue'
|
||||||
import { VIcon } from 'vuetify/components/VIcon'
|
import { VIcon } from 'vuetify/components/VIcon'
|
||||||
import { defineThemeConfig } from '@core'
|
import { defineThemeConfig } from '@core'
|
||||||
import { Skins } from '@core/enums'
|
import { Skins } from '@core/enums'
|
||||||
|
|
||||||
// ❗ Logo SVG must be imported with ?raw suffix
|
|
||||||
import logo from '@images/logo.svg?raw'
|
|
||||||
|
|
||||||
import { AppContentLayoutNav, ContentWidth, FooterType, NavbarType } from '@layouts/enums'
|
import { AppContentLayoutNav, ContentWidth, FooterType, NavbarType } from '@layouts/enums'
|
||||||
|
|
||||||
export const { themeConfig, layoutConfig } = defineThemeConfig({
|
export const { themeConfig, layoutConfig } = defineThemeConfig({
|
||||||
app: {
|
app: {
|
||||||
title: 'Organizer' as Lowercase<string>,
|
title: 'Crewli',
|
||||||
logo: h('div', { innerHTML: logo, style: 'line-height:0; color: rgb(var(--v-global-theme-primary))' }),
|
logo: h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: 'crewli-wordmark text-h5 font-weight-bold',
|
||||||
|
style: 'line-height: 1.2; letter-spacing: -0.02em; color: rgb(var(--v-theme-primary));',
|
||||||
|
},
|
||||||
|
'Crewli',
|
||||||
|
),
|
||||||
contentWidth: ContentWidth.Boxed,
|
contentWidth: ContentWidth.Boxed,
|
||||||
contentLayoutNav: AppContentLayoutNav.Vertical,
|
contentLayoutNav: AppContentLayoutNav.Vertical,
|
||||||
overlayNavFromBreakpoint: breakpointsVuetifyV3.lg - 1, // 1 for matching with vuetify breakpoint. Docs: https://next.vuetifyjs.com/en/features/display-and-platform/
|
overlayNavFromBreakpoint: breakpointsVuetifyV3.lg - 1, // 1 for matching with vuetify breakpoint. Docs: https://next.vuetifyjs.com/en/features/display-and-platform/
|
||||||
|
|||||||
Reference in New Issue
Block a user