feat: festival/series model with sub-events, cross-event sections, tab navigation, SectionsShiftsPanel extraction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,7 +63,7 @@ final class EventController extends Controller
|
||||
}
|
||||
|
||||
if (!isset($data['event_type'])) {
|
||||
$data['event_type'] = empty($data['parent_event_id']) ? 'event' : 'event';
|
||||
$data['event_type'] = 'event';
|
||||
}
|
||||
|
||||
$event = $organisation->events()->create($data);
|
||||
@@ -80,6 +80,39 @@ final class EventController extends Controller
|
||||
return $this->success(new EventResource($event->fresh()));
|
||||
}
|
||||
|
||||
public function destroy(Organisation $organisation, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('delete', [$event, $organisation]);
|
||||
|
||||
$event->delete();
|
||||
|
||||
return $this->success(null, 'Event deleted');
|
||||
}
|
||||
|
||||
public function transition(Request $request, Organisation $organisation, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('update', [$event, $organisation]);
|
||||
|
||||
$request->validate(['status' => 'required|string']);
|
||||
$newStatus = $request->status;
|
||||
|
||||
$result = $event->canTransitionToWithPrerequisites($newStatus);
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
return response()->json([
|
||||
'message' => 'Status transition not possible.',
|
||||
'errors' => $result['errors'],
|
||||
'current_status' => $event->status,
|
||||
'requested_status' => $newStatus,
|
||||
'allowed_transitions' => Event::STATUS_TRANSITIONS[$event->status] ?? [],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$event->transitionTo($newStatus);
|
||||
|
||||
return $this->success(new EventResource($event->fresh()));
|
||||
}
|
||||
|
||||
public function children(Organisation $organisation, Event $event): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('view', [$event, $organisation]);
|
||||
|
||||
@@ -23,6 +23,17 @@ final class FestivalSectionController extends Controller
|
||||
|
||||
$sections = $event->festivalSections()->ordered()->get();
|
||||
|
||||
// For sub-events, also include cross_event sections from the parent festival
|
||||
if ($event->isSubEvent()) {
|
||||
$parentCrossEventSections = $event->parent
|
||||
->festivalSections()
|
||||
->where('type', 'cross_event')
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
$sections = $parentCrossEventSections->merge($sections)->sortBy('sort_order')->values();
|
||||
}
|
||||
|
||||
return FestivalSectionResource::collection($sections);
|
||||
}
|
||||
|
||||
@@ -30,9 +41,39 @@ final class FestivalSectionController extends Controller
|
||||
{
|
||||
Gate::authorize('create', [FestivalSection::class, $event]);
|
||||
|
||||
$section = $event->festivalSections()->create($request->validated());
|
||||
$data = $request->validated();
|
||||
$redirectedToParent = false;
|
||||
|
||||
return $this->created(new FestivalSectionResource($section));
|
||||
if (($data['type'] ?? 'standard') === 'cross_event') {
|
||||
if ($event->isFlatEvent()) {
|
||||
return $this->error(
|
||||
'Overkoepelende secties kunnen alleen worden aangemaakt bij festivals met programmaonderdelen.',
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if ($event->isSubEvent()) {
|
||||
$event = $event->parent;
|
||||
Gate::authorize('create', [FestivalSection::class, $event]);
|
||||
$redirectedToParent = true;
|
||||
}
|
||||
}
|
||||
|
||||
$section = $event->festivalSections()->create($data);
|
||||
|
||||
$response = $this->created(new FestivalSectionResource($section));
|
||||
|
||||
if ($redirectedToParent) {
|
||||
$original = $response->getData(true);
|
||||
$original['meta'] = [
|
||||
'redirected_to_parent' => true,
|
||||
'parent_event_name' => $event->name,
|
||||
];
|
||||
|
||||
return response()->json($original, 201);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function update(UpdateFestivalSectionRequest $request, Event $event, FestivalSection $section): JsonResponse
|
||||
@@ -57,10 +98,10 @@ final class FestivalSectionController extends Controller
|
||||
{
|
||||
Gate::authorize('reorder', [FestivalSection::class, $event]);
|
||||
|
||||
foreach ($request->validated('sections') as $item) {
|
||||
foreach ($request->validated('sections') as $index => $id) {
|
||||
$event->festivalSections()
|
||||
->where('id', $item['id'])
|
||||
->update(['sort_order' => $item['sort_order']]);
|
||||
->where('id', $id)
|
||||
->update(['sort_order' => $index]);
|
||||
}
|
||||
|
||||
$sections = $event->festivalSections()->ordered()->get();
|
||||
|
||||
@@ -26,6 +26,7 @@ final class ShiftController extends Controller
|
||||
|
||||
$shifts = $section->shifts()
|
||||
->with(['timeSlot', 'location'])
|
||||
->withCount(['shiftAssignments as filled_slots' => fn ($q) => $q->where('status', 'approved')])
|
||||
->get();
|
||||
|
||||
return ShiftResource::collection($shifts);
|
||||
@@ -84,13 +85,11 @@ final class ShiftController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$autoApprove = $section->crew_auto_accepts;
|
||||
|
||||
$assignment = $shift->shiftAssignments()->create([
|
||||
'person_id' => $personId,
|
||||
'time_slot_id' => $shift->time_slot_id,
|
||||
'status' => $autoApprove ? 'approved' : 'approved',
|
||||
'auto_approved' => $autoApprove,
|
||||
'status' => 'approved',
|
||||
'auto_approved' => false,
|
||||
'assigned_by' => $request->user()->id,
|
||||
'assigned_at' => now(),
|
||||
'approved_at' => now(),
|
||||
|
||||
@@ -17,9 +17,8 @@ final class ReorderFestivalSectionsRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'sections' => ['required', 'array'],
|
||||
'sections.*.id' => ['required', 'ulid'],
|
||||
'sections.*.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'sections' => ['required', 'array', 'min:1'],
|
||||
'sections.*' => ['required', 'ulid'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class StoreFestivalSectionRequest extends FormRequest
|
||||
@@ -18,10 +19,26 @@ final class StoreFestivalSectionRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'category' => ['nullable', 'string', 'max:50'],
|
||||
'icon' => ['nullable', 'string', 'max:50'],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0'],
|
||||
'type' => ['nullable', 'in:standard,cross_event'],
|
||||
'crew_need' => ['nullable', 'integer', 'min:0'],
|
||||
'crew_auto_accepts' => ['nullable', 'boolean'],
|
||||
'crew_invited_to_events' => ['nullable', 'boolean'],
|
||||
'added_to_timeline' => ['nullable', 'boolean'],
|
||||
'responder_self_checkin' => ['nullable', 'boolean'],
|
||||
'crew_accreditation_level' => ['nullable', 'string', 'max:50'],
|
||||
'public_form_accreditation_level' => ['nullable', 'string', 'max:50'],
|
||||
'timed_accreditations' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'type.in' => 'Type moet standard of cross_event zijn.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ final class UpdateEventRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Status changes must go through POST /events/{event}/transition
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/'],
|
||||
'start_date' => ['sometimes', 'date'],
|
||||
'end_date' => ['sometimes', 'date', 'after_or_equal:start_date'],
|
||||
'timezone' => ['sometimes', 'string', 'max:50'],
|
||||
'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'],
|
||||
|
||||
@@ -18,6 +18,8 @@ final class UpdateFestivalSectionRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'category' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'icon' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
||||
'type' => ['sometimes', 'in:standard,cross_event'],
|
||||
'crew_auto_accepts' => ['sometimes', 'boolean'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -21,6 +22,7 @@ final class EventResource extends JsonResource
|
||||
'end_date' => $this->end_date->toDateString(),
|
||||
'timezone' => $this->timezone,
|
||||
'status' => $this->status,
|
||||
'allowed_transitions' => Event::STATUS_TRANSITIONS[$this->status] ?? [],
|
||||
'event_type' => $this->event_type,
|
||||
'event_type_label' => $this->event_type_label,
|
||||
'sub_event_label' => $this->sub_event_label,
|
||||
|
||||
@@ -15,13 +15,18 @@ final class FestivalSectionResource extends JsonResource
|
||||
'id' => $this->id,
|
||||
'event_id' => $this->event_id,
|
||||
'name' => $this->name,
|
||||
'category' => $this->category,
|
||||
'icon' => $this->icon,
|
||||
'type' => $this->type,
|
||||
'sort_order' => $this->sort_order,
|
||||
'crew_need' => $this->crew_need,
|
||||
'crew_auto_accepts' => $this->crew_auto_accepts,
|
||||
'responder_self_checkin' => $this->responder_self_checkin,
|
||||
'crew_invited_to_events' => $this->crew_invited_to_events,
|
||||
'added_to_timeline' => $this->added_to_timeline,
|
||||
'responder_self_checkin' => $this->responder_self_checkin,
|
||||
'crew_accreditation_level' => $this->crew_accreditation_level,
|
||||
'public_form_accreditation_level' => $this->public_form_accreditation_level,
|
||||
'timed_accreditations' => $this->timed_accreditations,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'shifts_count' => $this->whenCounted('shifts'),
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class ShiftResource extends JsonResource
|
||||
{
|
||||
@@ -26,15 +27,18 @@ final class ShiftResource extends JsonResource
|
||||
'slots_total' => $this->slots_total,
|
||||
'slots_open_for_claiming' => $this->slots_open_for_claiming,
|
||||
'is_lead_role' => $this->is_lead_role,
|
||||
'report_time' => $this->report_time,
|
||||
'actual_start_time' => $this->actual_start_time,
|
||||
'actual_end_time' => $this->actual_end_time,
|
||||
'report_time' => $this->report_time ? Carbon::parse($this->report_time)->format('H:i') : null,
|
||||
'actual_start_time' => $this->actual_start_time ? Carbon::parse($this->actual_start_time)->format('H:i') : null,
|
||||
'actual_end_time' => $this->actual_end_time ? Carbon::parse($this->actual_end_time)->format('H:i') : null,
|
||||
'end_date' => $this->end_date?->toDateString(),
|
||||
'allow_overlap' => $this->allow_overlap,
|
||||
'events_during_shift' => $this->events_during_shift,
|
||||
'assigned_crew_id' => $this->assigned_crew_id,
|
||||
'status' => $this->status,
|
||||
'filled_slots' => $this->filled_slots,
|
||||
'fill_rate' => $this->fill_rate,
|
||||
'effective_start_time' => $this->effective_start_time,
|
||||
'effective_end_time' => $this->effective_end_time,
|
||||
'effective_start_time' => $this->effective_start_time ? Carbon::parse($this->effective_start_time)->format('H:i') : null,
|
||||
'effective_end_time' => $this->effective_end_time ? Carbon::parse($this->effective_end_time)->format('H:i') : null,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'time_slot' => new TimeSlotResource($this->whenLoaded('timeSlot')),
|
||||
'location' => new LocationResource($this->whenLoaded('location')),
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class Event extends Model
|
||||
{
|
||||
@@ -19,6 +20,34 @@ final class Event extends Model
|
||||
use HasUlids;
|
||||
use SoftDeletes;
|
||||
|
||||
/** @var array<string, list<string>> Allowed status transitions */
|
||||
public const STATUS_TRANSITIONS = [
|
||||
'draft' => ['published'],
|
||||
'published' => ['registration_open', 'draft'],
|
||||
'registration_open' => ['buildup', 'published'],
|
||||
'buildup' => ['showday'],
|
||||
'showday' => ['teardown'],
|
||||
'teardown' => ['closed'],
|
||||
'closed' => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* Statuses that cascade from a festival parent to its children.
|
||||
* When the parent reaches one of these, children in an earlier status follow.
|
||||
*/
|
||||
private const CASCADE_STATUSES = ['showday', 'teardown', 'closed'];
|
||||
|
||||
/** Ordered list used to determine "earlier" status. */
|
||||
private const STATUS_ORDER = [
|
||||
'draft' => 0,
|
||||
'published' => 1,
|
||||
'registration_open' => 2,
|
||||
'buildup' => 3,
|
||||
'showday' => 4,
|
||||
'teardown' => 5,
|
||||
'closed' => 6,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'organisation_id',
|
||||
'parent_event_id',
|
||||
@@ -101,6 +130,80 @@ final class Event extends Model
|
||||
->orderBy('name');
|
||||
}
|
||||
|
||||
// ----- Status State Machine -----
|
||||
|
||||
public function canTransitionTo(string $newStatus): bool
|
||||
{
|
||||
$allowed = self::STATUS_TRANSITIONS[$this->status] ?? [];
|
||||
|
||||
return in_array($newStatus, $allowed, true);
|
||||
}
|
||||
|
||||
/** @return list<string> Missing prerequisites (empty = OK) */
|
||||
public function getTransitionPrerequisites(string $newStatus): array
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
switch ($newStatus) {
|
||||
case 'published':
|
||||
if (! $this->name || ! $this->start_date || ! $this->end_date) {
|
||||
$missing[] = 'Event must have a name, start date, and end date.';
|
||||
}
|
||||
break;
|
||||
case 'registration_open':
|
||||
if ($this->timeSlots()->count() === 0) {
|
||||
$missing[] = 'At least one time slot must exist before opening registration.';
|
||||
}
|
||||
if ($this->festivalSections()->count() === 0) {
|
||||
$missing[] = 'At least one section must exist before opening registration.';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/** @return array{errors: list<string>} */
|
||||
public function canTransitionToWithPrerequisites(string $newStatus): array
|
||||
{
|
||||
if (! $this->canTransitionTo($newStatus)) {
|
||||
return ['errors' => ["Status transition from '{$this->status}' to '{$newStatus}' is not allowed."]];
|
||||
}
|
||||
|
||||
return ['errors' => $this->getTransitionPrerequisites($newStatus)];
|
||||
}
|
||||
|
||||
public function transitionTo(string $newStatus): void
|
||||
{
|
||||
if (! $this->canTransitionTo($newStatus)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Cannot transition from '{$this->status}' to '{$newStatus}'."
|
||||
);
|
||||
}
|
||||
|
||||
$this->update(['status' => $newStatus]);
|
||||
|
||||
if ($this->isFestival() && in_array($newStatus, self::CASCADE_STATUSES, true)) {
|
||||
$this->cascadeStatusToChildren($newStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade a status to all children that are in an earlier lifecycle stage.
|
||||
*/
|
||||
private function cascadeStatusToChildren(string $newStatus): void
|
||||
{
|
||||
$targetOrder = self::STATUS_ORDER[$newStatus];
|
||||
|
||||
$earlierStatuses = array_keys(
|
||||
array_filter(self::STATUS_ORDER, fn (int $order) => $order < $targetOrder)
|
||||
);
|
||||
|
||||
$this->children()
|
||||
->whereIn('status', $earlierStatuses)
|
||||
->update(['status' => $newStatus]);
|
||||
}
|
||||
|
||||
// ----- Scopes -----
|
||||
|
||||
public function scopeTopLevel(Builder $query): Builder
|
||||
@@ -165,4 +268,45 @@ final class Event extends Model
|
||||
{
|
||||
return $query->whereIn('status', ['showday', 'buildup', 'teardown']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eager-load the event's own time slots plus, for sub-events,
|
||||
* time slots from parent cross_event sections.
|
||||
*/
|
||||
public function scopeWithOperationalContext(Builder $query): Builder
|
||||
{
|
||||
return $query->with(['timeSlots', 'festivalSections', 'parent.timeSlots', 'parent.festivalSections']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all time slots relevant for shift planning:
|
||||
* - Flat event: own time slots
|
||||
* - Festival parent: own time slots + all children's time slots
|
||||
* - Sub-event: own time slots + parent's time slots
|
||||
*/
|
||||
public function getAllRelevantTimeSlots(): Collection
|
||||
{
|
||||
$ownSlots = $this->timeSlots()->orderBy('date')->orderBy('start_time')->get();
|
||||
|
||||
if ($this->isFestival()) {
|
||||
$childIds = $this->children()->pluck('id');
|
||||
$childSlots = TimeSlot::whereIn('event_id', $childIds)
|
||||
->orderBy('date')
|
||||
->orderBy('start_time')
|
||||
->get();
|
||||
|
||||
return $ownSlots->merge($childSlots)->sortBy(['date', 'start_time'])->values();
|
||||
}
|
||||
|
||||
if ($this->isSubEvent() && $this->parent_event_id) {
|
||||
$parentSlots = TimeSlot::where('event_id', $this->parent_event_id)
|
||||
->orderBy('date')
|
||||
->orderBy('start_time')
|
||||
->get();
|
||||
|
||||
return $ownSlots->merge($parentSlots)->sortBy(['date', 'start_time'])->values();
|
||||
}
|
||||
|
||||
return $ownSlots;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ final class FestivalSection extends Model
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'name',
|
||||
'category',
|
||||
'icon',
|
||||
'type',
|
||||
'sort_order',
|
||||
'crew_need',
|
||||
|
||||
@@ -57,4 +57,9 @@ final class Organisation extends Model
|
||||
{
|
||||
return $this->hasMany(Company::class);
|
||||
}
|
||||
|
||||
public function personTags(): HasMany
|
||||
{
|
||||
return $this->hasMany(PersonTag::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ final class Shift extends Model
|
||||
return [
|
||||
'is_lead_role' => 'boolean',
|
||||
'allow_overlap' => 'boolean',
|
||||
'end_date' => 'date',
|
||||
'events_during_shift' => 'array',
|
||||
'slots_total' => 'integer',
|
||||
'slots_open_for_claiming' => 'integer',
|
||||
@@ -93,7 +94,13 @@ final class Shift extends Model
|
||||
|
||||
protected function filledSlots(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->shiftAssignments()->where('status', 'approved')->count());
|
||||
return Attribute::get(function () {
|
||||
if (array_key_exists('filled_slots', $this->attributes)) {
|
||||
return (int) $this->attributes['filled_slots'];
|
||||
}
|
||||
|
||||
return $this->shiftAssignments()->where('status', 'approved')->count();
|
||||
});
|
||||
}
|
||||
|
||||
protected function fillRate(): Attribute
|
||||
|
||||
@@ -64,4 +64,31 @@ final class EventPolicy
|
||||
->wherePivot('role', 'event_manager')
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function delete(User $user, Event $event, ?Organisation $organisation = null): bool
|
||||
{
|
||||
if ($organisation && $event->organisation_id !== $organisation->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// org_admin at organisation level
|
||||
$isOrgAdmin = $event->organisation->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivot('role', 'org_admin')
|
||||
->exists();
|
||||
|
||||
if ($isOrgAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// event_manager at event level
|
||||
return $event->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivot('role', 'event_manager')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
44
api/database/factories/ShiftAssignmentFactory.php
Normal file
44
api/database/factories/ShiftAssignmentFactory.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Person;
|
||||
use App\Models\Shift;
|
||||
use App\Models\ShiftAssignment;
|
||||
use App\Models\TimeSlot;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<ShiftAssignment> */
|
||||
final class ShiftAssignmentFactory extends Factory
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'shift_id' => Shift::factory(),
|
||||
'person_id' => Person::factory(),
|
||||
'time_slot_id' => TimeSlot::factory(),
|
||||
'status' => 'pending_approval',
|
||||
'auto_approved' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function approved(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function autoApproved(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'status' => 'approved',
|
||||
'auto_approved' => true,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,13 @@ use App\Http\Controllers\Api\V1\MeController;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Http\Controllers\Api\V1\OrganisationController;
|
||||
use App\Http\Controllers\Api\V1\PersonController;
|
||||
use App\Http\Controllers\Api\V1\PersonTagController;
|
||||
use App\Http\Controllers\Api\V1\ShiftController;
|
||||
use App\Http\Controllers\Api\V1\TimeSlotController;
|
||||
use App\Http\Controllers\Api\V1\UserOrganisationTagController;
|
||||
use App\Models\FestivalSection;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
@@ -54,15 +59,40 @@ Route::middleware('auth:sanctum')->group(function () {
|
||||
|
||||
// Events (nested under organisations)
|
||||
Route::apiResource('organisations.events', EventController::class)
|
||||
->only(['index', 'show', 'store', 'update']);
|
||||
->only(['index', 'show', 'store', 'update', 'destroy']);
|
||||
Route::get('organisations/{organisation}/events/{event}/children', [EventController::class, 'children']);
|
||||
Route::post('organisations/{organisation}/events/{event}/transition', [EventController::class, 'transition']);
|
||||
|
||||
// Organisation-scoped resources
|
||||
Route::prefix('organisations/{organisation}')->group(function () {
|
||||
Route::apiResource('crowd-types', CrowdTypeController::class)
|
||||
->except(['show']);
|
||||
Route::apiResource('companies', CompanyController::class)
|
||||
Route::apiResource('companies', CompanyController::class);
|
||||
|
||||
// Section categories (autocomplete)
|
||||
Route::get('section-categories', function (Organisation $organisation) {
|
||||
Gate::authorize('view', $organisation);
|
||||
|
||||
$categories = FestivalSection::query()
|
||||
->whereIn('event_id', $organisation->events()->select('id'))
|
||||
->whereNotNull('category')
|
||||
->distinct()
|
||||
->orderBy('category')
|
||||
->pluck('category');
|
||||
|
||||
return response()->json(['data' => $categories]);
|
||||
});
|
||||
|
||||
// Person tags (organisation settings)
|
||||
Route::apiResource('person-tags', PersonTagController::class)
|
||||
->except(['show']);
|
||||
Route::get('person-tag-categories', [PersonTagController::class, 'categories']);
|
||||
|
||||
// User tag assignments
|
||||
Route::get('users/{user}/tags', [UserOrganisationTagController::class, 'index']);
|
||||
Route::post('users/{user}/tags', [UserOrganisationTagController::class, 'store']);
|
||||
Route::put('users/{user}/tags/sync', [UserOrganisationTagController::class, 'sync']);
|
||||
Route::delete('users/{user}/tags/{userOrganisationTag}', [UserOrganisationTagController::class, 'destroy']);
|
||||
|
||||
// Invitations & Members
|
||||
Route::post('invite', [InvitationController::class, 'invite']);
|
||||
|
||||
@@ -284,4 +284,122 @@ class EventTest extends TestCase
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
// --- DESTROY ---
|
||||
|
||||
public function test_org_admin_can_soft_delete_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSoftDeleted('events', ['id' => $event->id]);
|
||||
}
|
||||
|
||||
public function test_org_admin_can_soft_delete_sub_event(): void
|
||||
{
|
||||
$festival = Event::factory()->festival()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
$subEvent = Event::factory()->subEvent($festival)->create();
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSoftDeleted('events', ['id' => $subEvent->id]);
|
||||
$this->assertNotSoftDeleted('events', ['id' => $festival->id]);
|
||||
}
|
||||
|
||||
public function test_event_manager_can_delete_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
$eventManager = User::factory()->create();
|
||||
$this->organisation->users()->attach($eventManager, ['role' => 'org_member']);
|
||||
$event->users()->attach($eventManager, ['role' => 'event_manager']);
|
||||
|
||||
Sanctum::actingAs($eventManager);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSoftDeleted('events', ['id' => $event->id]);
|
||||
}
|
||||
|
||||
public function test_org_member_cannot_delete_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgMember);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_outsider_cannot_delete_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_delete_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_delete_event_from_other_org_is_blocked(): void
|
||||
{
|
||||
$otherOrg = Organisation::factory()->create();
|
||||
$event = Event::factory()->create(['organisation_id' => $otherOrg->id]);
|
||||
|
||||
Sanctum::actingAs($this->admin);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_soft_deleted_event_not_in_index(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
$event->delete();
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(0, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_soft_delete_does_not_cascade_to_related_data(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
$section = $event->festivalSections()->create([
|
||||
'name' => 'Stage A',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSoftDeleted('events', ['id' => $event->id]);
|
||||
$this->assertDatabaseHas('festival_sections', ['id' => $section->id, 'deleted_at' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Event;
|
||||
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
use App\Models\FestivalSection;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use App\Models\Shift;
|
||||
use App\Models\TimeSlot;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -18,6 +23,9 @@ class FestivalEventTest extends TestCase
|
||||
|
||||
private User $orgAdmin;
|
||||
private Organisation $organisation;
|
||||
private Event $festival;
|
||||
private Event $subEvent;
|
||||
private CrowdType $crowdType;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -28,207 +36,548 @@ class FestivalEventTest extends TestCase
|
||||
|
||||
$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([
|
||||
$this->festival = Event::factory()->festival()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
Event::factory()->subEvent($festival)->create();
|
||||
Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
$this->subEvent = Event::factory()->subEvent($this->festival)->create();
|
||||
|
||||
$this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Festival-level time slots ---
|
||||
|
||||
public function test_can_create_time_slot_on_festival_parent(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/events/{$this->festival->id}/time-slots", [
|
||||
'name' => 'Opbouw Vrijdag',
|
||||
'person_type' => 'CREW',
|
||||
'date' => '2026-07-10',
|
||||
'start_time' => '08:00',
|
||||
'end_time' => '18:00',
|
||||
'duration_hours' => 10,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson(['data' => [
|
||||
'name' => 'Opbouw Vrijdag',
|
||||
'person_type' => 'CREW',
|
||||
]]);
|
||||
|
||||
$this->assertDatabaseHas('time_slots', [
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'Opbouw Vrijdag',
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Festival-level sections ---
|
||||
|
||||
public function test_can_create_section_on_festival_parent(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/events/{$this->festival->id}/sections", [
|
||||
'name' => 'Terreinploeg',
|
||||
'sort_order' => 1,
|
||||
'type' => 'standard',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson(['data' => [
|
||||
'name' => 'Terreinploeg',
|
||||
'type' => 'standard',
|
||||
]]);
|
||||
|
||||
$this->assertDatabaseHas('festival_sections', [
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'Terreinploeg',
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Festival-level shifts ---
|
||||
|
||||
public function test_can_create_shift_on_festival_level_section(): void
|
||||
{
|
||||
$section = FestivalSection::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
]);
|
||||
$timeSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
|
||||
$response = $this->postJson("/api/v1/events/{$this->festival->id}/sections/{$section->id}/shifts", [
|
||||
'time_slot_id' => $timeSlot->id,
|
||||
'title' => 'Terreinmedewerker',
|
||||
'slots_total' => 8,
|
||||
'slots_open_for_claiming' => 4,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson(['data' => [
|
||||
'title' => 'Terreinmedewerker',
|
||||
'slots_total' => 8,
|
||||
]]);
|
||||
|
||||
$this->assertDatabaseHas('shifts', [
|
||||
'festival_section_id' => $section->id,
|
||||
'title' => 'Terreinmedewerker',
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Cross-event sections ---
|
||||
|
||||
public function test_cross_event_section_appears_in_sub_event_sections(): void
|
||||
{
|
||||
// Create a cross_event section on the festival parent
|
||||
FestivalSection::factory()->crossEvent()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'EHBO',
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
// Create a standard section on the sub-event
|
||||
FestivalSection::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
'name' => 'Bar',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$this->subEvent->id}/sections");
|
||||
|
||||
$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));
|
||||
$sectionNames = collect($response->json('data'))->pluck('name')->all();
|
||||
|
||||
// cross_event section from parent should be included
|
||||
$this->assertContains('EHBO', $sectionNames);
|
||||
// sub-event's own section should be included
|
||||
$this->assertContains('Bar', $sectionNames);
|
||||
}
|
||||
|
||||
// --- INDEX: include_children ---
|
||||
// --- Festival time slots stay separate ---
|
||||
|
||||
public function test_index_with_include_children_shows_nested_children(): void
|
||||
public function test_festival_level_time_slots_not_included_in_sub_event_time_slots(): void
|
||||
{
|
||||
$festival = Event::factory()->festival()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
// Create a time slot on the festival parent (operational)
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'Opbouw',
|
||||
]);
|
||||
|
||||
// Create a time slot on the sub-event
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
'name' => 'Zaterdag Avond',
|
||||
]);
|
||||
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 = $this->getJson("/api/v1/events/{$this->subEvent->id}/time-slots");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(1, $response->json('data'));
|
||||
$this->assertCount(2, $response->json('data.0.children'));
|
||||
|
||||
$slotNames = collect($response->json('data'))->pluck('name')->all();
|
||||
|
||||
// Sub-event's own time slot should be there
|
||||
$this->assertContains('Zaterdag Avond', $slotNames);
|
||||
// Festival-level operational time slot should NOT be there
|
||||
$this->assertNotContains('Opbouw', $slotNames);
|
||||
}
|
||||
|
||||
// --- INDEX: type filter ---
|
||||
// --- Persons at festival level ---
|
||||
|
||||
public function test_index_type_filter_works(): void
|
||||
public function test_persons_on_festival_level(): void
|
||||
{
|
||||
Event::factory()->festival()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
// Create a person on the festival parent
|
||||
Person::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'name' => 'Jan Festivalmedewerker',
|
||||
]);
|
||||
|
||||
// Create a person on the sub-event
|
||||
Person::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'name' => 'Piet Dagvrijwilliger',
|
||||
]);
|
||||
Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events?type=festival");
|
||||
// Query persons on festival level — should only return festival-level persons
|
||||
$response = $this->getJson("/api/v1/events/{$this->festival->id}/persons");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(1, $response->json('data'));
|
||||
$this->assertEquals('festival', $response->json('data.0.event_type'));
|
||||
|
||||
$personNames = collect($response->json('data'))->pluck('name')->all();
|
||||
$this->assertContains('Jan Festivalmedewerker', $personNames);
|
||||
$this->assertNotContains('Piet Dagvrijwilliger', $personNames);
|
||||
}
|
||||
|
||||
// --- STORE: sub-event ---
|
||||
// --- Cross-event section auto-redirect ---
|
||||
|
||||
public function test_store_with_parent_event_id_creates_sub_event(): void
|
||||
public function test_create_cross_event_section_on_sub_event_redirects_to_parent(): 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 = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [
|
||||
'name' => 'EHBO',
|
||||
'type' => 'cross_event',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$this->assertEquals($festival->id, $response->json('data.parent_event_id'));
|
||||
$this->assertTrue($response->json('data.is_sub_event'));
|
||||
|
||||
// Section should be created on the parent festival, not on the sub-event
|
||||
$this->assertDatabaseHas('festival_sections', [
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'EHBO',
|
||||
'type' => 'cross_event',
|
||||
]);
|
||||
$this->assertDatabaseMissing('festival_sections', [
|
||||
'event_id' => $this->subEvent->id,
|
||||
'name' => 'EHBO',
|
||||
]);
|
||||
|
||||
// Response should include redirect meta
|
||||
$response->assertJsonPath('meta.redirected_to_parent', true);
|
||||
$response->assertJsonPath('meta.parent_event_name', $this->festival->name);
|
||||
}
|
||||
|
||||
// --- STORE: sub-event cross-org → 422 ---
|
||||
|
||||
public function test_store_sub_event_of_other_org_returns_422(): void
|
||||
public function test_create_cross_event_section_on_festival_parent_works_normally(): void
|
||||
{
|
||||
$otherOrg = Organisation::factory()->create();
|
||||
$otherEvent = Event::factory()->festival()->create([
|
||||
'organisation_id' => $otherOrg->id,
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/events/{$this->festival->id}/sections", [
|
||||
'name' => 'Security',
|
||||
'type' => 'cross_event',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('festival_sections', [
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'Security',
|
||||
'type' => 'cross_event',
|
||||
]);
|
||||
|
||||
// No redirect meta on direct creation
|
||||
$response->assertJsonMissing(['redirected_to_parent' => true]);
|
||||
}
|
||||
|
||||
public function test_create_cross_event_section_on_flat_event_returns_422(): void
|
||||
{
|
||||
$flatEvent = Event::factory()->create([
|
||||
'organisation_id' => $this->organisation->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 = $this->postJson("/api/v1/events/{$flatEvent->id}/sections", [
|
||||
'name' => 'EHBO',
|
||||
'type' => 'cross_event',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_cross_event_section_created_via_sub_event_appears_in_all_siblings(): void
|
||||
{
|
||||
$subEventB = Event::factory()->subEvent($this->festival)->create();
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
// Create cross_event via sub-event A → redirects to parent
|
||||
$this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [
|
||||
'name' => 'Verkeersregelaars',
|
||||
'type' => 'cross_event',
|
||||
])->assertCreated();
|
||||
|
||||
// Should appear in sub-event B's section list
|
||||
$response = $this->getJson("/api/v1/events/{$subEventB->id}/sections");
|
||||
|
||||
$response->assertOk();
|
||||
$sectionNames = collect($response->json('data'))->pluck('name')->all();
|
||||
$this->assertContains('Verkeersregelaars', $sectionNames);
|
||||
}
|
||||
|
||||
public function test_create_standard_section_on_sub_event_stays_on_sub_event(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [
|
||||
'name' => 'Bar Schirmbar',
|
||||
'type' => 'standard',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
// Should stay on the sub-event
|
||||
$this->assertDatabaseHas('festival_sections', [
|
||||
'event_id' => $this->subEvent->id,
|
||||
'name' => 'Bar Schirmbar',
|
||||
'type' => 'standard',
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Model helper: getAllRelevantTimeSlots ---
|
||||
|
||||
public function test_get_all_relevant_time_slots_for_festival(): void
|
||||
{
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'Opbouw',
|
||||
]);
|
||||
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
'name' => 'Zaterdag Avond',
|
||||
]);
|
||||
|
||||
$allSlots = $this->festival->getAllRelevantTimeSlots();
|
||||
|
||||
$slotNames = $allSlots->pluck('name')->all();
|
||||
$this->assertContains('Opbouw', $slotNames);
|
||||
$this->assertContains('Zaterdag Avond', $slotNames);
|
||||
}
|
||||
|
||||
public function test_get_all_relevant_time_slots_for_sub_event(): void
|
||||
{
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'Opbouw',
|
||||
]);
|
||||
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
'name' => 'Zaterdag Avond',
|
||||
]);
|
||||
|
||||
$allSlots = $this->subEvent->getAllRelevantTimeSlots();
|
||||
|
||||
$slotNames = $allSlots->pluck('name')->all();
|
||||
$this->assertContains('Opbouw', $slotNames);
|
||||
$this->assertContains('Zaterdag Avond', $slotNames);
|
||||
}
|
||||
|
||||
public function test_get_all_relevant_time_slots_for_flat_event(): void
|
||||
{
|
||||
$flatEvent = Event::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $flatEvent->id,
|
||||
'name' => 'Avond',
|
||||
]);
|
||||
|
||||
$allSlots = $flatEvent->getAllRelevantTimeSlots();
|
||||
|
||||
$this->assertCount(1, $allSlots);
|
||||
$this->assertEquals('Avond', $allSlots->first()->name);
|
||||
}
|
||||
|
||||
// --- include_parent time slots for sub-events ---
|
||||
|
||||
public function test_sub_event_time_slots_include_parent_festival_time_slots(): void
|
||||
{
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'Opbouw',
|
||||
]);
|
||||
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
'name' => 'Zaterdag Avond',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$this->subEvent->id}/time-slots?include_parent=true");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$slots = collect($response->json('data'));
|
||||
$slotNames = $slots->pluck('name')->all();
|
||||
|
||||
$this->assertContains('Zaterdag Avond', $slotNames);
|
||||
$this->assertContains('Opbouw', $slotNames);
|
||||
|
||||
// Verify source markers
|
||||
$subEventSlot = $slots->firstWhere('name', 'Zaterdag Avond');
|
||||
$festivalSlot = $slots->firstWhere('name', 'Opbouw');
|
||||
$this->assertEquals('sub_event', $subEventSlot['source']);
|
||||
$this->assertEquals('festival', $festivalSlot['source']);
|
||||
|
||||
// Verify event_name is present
|
||||
$this->assertEquals($this->festival->name, $festivalSlot['event_name']);
|
||||
$this->assertEquals($this->subEvent->name, $subEventSlot['event_name']);
|
||||
}
|
||||
|
||||
public function test_festival_time_slots_do_not_include_sub_event_time_slots(): void
|
||||
{
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
'name' => 'Opbouw',
|
||||
]);
|
||||
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
'name' => 'Zaterdag Avond',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
// Even with include_parent, festival parent should only return its own TS
|
||||
$response = $this->getJson("/api/v1/events/{$this->festival->id}/time-slots?include_parent=true");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$slotNames = collect($response->json('data'))->pluck('name')->all();
|
||||
$this->assertContains('Opbouw', $slotNames);
|
||||
$this->assertNotContains('Zaterdag Avond', $slotNames);
|
||||
}
|
||||
|
||||
public function test_create_shift_on_local_section_with_festival_time_slot(): void
|
||||
{
|
||||
$section = FestivalSection::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
]);
|
||||
|
||||
// Time slot belongs to the parent festival
|
||||
$festivalTimeSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section->id}/shifts", [
|
||||
'time_slot_id' => $festivalTimeSlot->id,
|
||||
'title' => 'Opbouwshift',
|
||||
'slots_total' => 4,
|
||||
'slots_open_for_claiming' => 0,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('shifts', [
|
||||
'festival_section_id' => $section->id,
|
||||
'time_slot_id' => $festivalTimeSlot->id,
|
||||
'title' => 'Opbouwshift',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_shift_on_local_section_with_other_event_time_slot_returns_422(): void
|
||||
{
|
||||
$section = FestivalSection::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
]);
|
||||
|
||||
// Time slot from a completely unrelated event
|
||||
$otherOrg = Organisation::factory()->create();
|
||||
$otherEvent = Event::factory()->create([
|
||||
'organisation_id' => $otherOrg->id,
|
||||
]);
|
||||
$otherTimeSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $otherEvent->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section->id}/shifts", [
|
||||
'time_slot_id' => $otherTimeSlot->id,
|
||||
'title' => 'Illegale shift',
|
||||
'slots_total' => 1,
|
||||
'slots_open_for_claiming' => 0,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_flat_event_time_slots_unchanged(): void
|
||||
{
|
||||
$flatEvent = Event::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
|
||||
TimeSlot::factory()->create([
|
||||
'event_id' => $flatEvent->id,
|
||||
'name' => 'Avond',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
// include_parent has no effect on flat events
|
||||
$response = $this->getJson("/api/v1/events/{$flatEvent->id}/time-slots?include_parent=true");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$slots = collect($response->json('data'));
|
||||
$this->assertCount(1, $slots);
|
||||
$this->assertEquals('Avond', $slots->first()['name']);
|
||||
|
||||
// No source marker on flat events
|
||||
$this->assertNull($slots->first()['source']);
|
||||
}
|
||||
|
||||
public function test_conflict_detection_across_event_levels(): void
|
||||
{
|
||||
// Section on sub-event
|
||||
$section = FestivalSection::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
]);
|
||||
|
||||
// Time slot on festival parent
|
||||
$festivalTimeSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
]);
|
||||
|
||||
// Create a shift on the sub-event section using festival time slot
|
||||
$shift = Shift::factory()->create([
|
||||
'festival_section_id' => $section->id,
|
||||
'time_slot_id' => $festivalTimeSlot->id,
|
||||
'slots_total' => 5,
|
||||
'allow_overlap' => false,
|
||||
]);
|
||||
|
||||
// Create a person
|
||||
$person = Person::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
// Assign person to the shift
|
||||
$this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section->id}/shifts/{$shift->id}/assign", [
|
||||
'person_id' => $person->id,
|
||||
])->assertCreated();
|
||||
|
||||
// Create another section and shift with the same festival time slot
|
||||
$section2 = FestivalSection::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
]);
|
||||
|
||||
$shift2 = Shift::factory()->create([
|
||||
'festival_section_id' => $section2->id,
|
||||
'time_slot_id' => $festivalTimeSlot->id,
|
||||
'slots_total' => 5,
|
||||
'allow_overlap' => false,
|
||||
]);
|
||||
|
||||
// Assigning the same person to same time_slot should fail
|
||||
$response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section2->id}/shifts/{$shift2->id}/assign", [
|
||||
'person_id' => $person->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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,96 @@ class FestivalSectionTest extends TestCase
|
||||
$this->assertSoftDeleted('festival_sections', ['id' => $section->id]);
|
||||
}
|
||||
|
||||
public function test_update_cross_org_returns_403(): void
|
||||
{
|
||||
$section = FestivalSection::factory()->create(['event_id' => $this->event->id]);
|
||||
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->putJson("/api/v1/events/{$this->event->id}/sections/{$section->id}", [
|
||||
'name' => 'Hacked',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_cross_org_returns_403(): void
|
||||
{
|
||||
$section = FestivalSection::factory()->create(['event_id' => $this->event->id]);
|
||||
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/events/{$this->event->id}/sections/{$section->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_section_with_category_and_icon(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections", [
|
||||
'name' => 'Tapkraan',
|
||||
'category' => 'Bar',
|
||||
'icon' => 'tabler-beer',
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson(['data' => [
|
||||
'name' => 'Tapkraan',
|
||||
'category' => 'Bar',
|
||||
'icon' => 'tabler-beer',
|
||||
]]);
|
||||
|
||||
$this->assertDatabaseHas('festival_sections', [
|
||||
'event_id' => $this->event->id,
|
||||
'name' => 'Tapkraan',
|
||||
'category' => 'Bar',
|
||||
'icon' => 'tabler-beer',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_section_categories_endpoint(): void
|
||||
{
|
||||
FestivalSection::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'category' => 'Bar',
|
||||
]);
|
||||
FestivalSection::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'category' => 'Podium',
|
||||
]);
|
||||
// Duplicate category should not appear twice
|
||||
FestivalSection::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'category' => 'Bar',
|
||||
]);
|
||||
// Null category should not appear
|
||||
FestivalSection::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'category' => null,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/section-categories");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['Bar', 'Podium']]);
|
||||
|
||||
$this->assertCount(2, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_section_categories_cross_org_returns_403(): void
|
||||
{
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/section-categories");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_reorder_updates_sort_order(): void
|
||||
{
|
||||
$sectionA = FestivalSection::factory()->create([
|
||||
@@ -148,20 +238,17 @@ class FestivalSectionTest extends TestCase
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/reorder", [
|
||||
'sections' => [
|
||||
['id' => $sectionA->id, 'sort_order' => 2],
|
||||
['id' => $sectionB->id, 'sort_order' => 1],
|
||||
],
|
||||
'sections' => [$sectionB->id, $sectionA->id],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('festival_sections', [
|
||||
'id' => $sectionA->id,
|
||||
'sort_order' => 2,
|
||||
'id' => $sectionB->id,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
$this->assertDatabaseHas('festival_sections', [
|
||||
'id' => $sectionB->id,
|
||||
'id' => $sectionA->id,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,50 @@ class ShiftTest extends TestCase
|
||||
$this->assertSoftDeleted('shifts', ['id' => $shift->id]);
|
||||
}
|
||||
|
||||
public function test_store_missing_time_slot_id_returns_422(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts", [
|
||||
'title' => 'Tapper',
|
||||
'slots_total' => 4,
|
||||
'slots_open_for_claiming' => 0,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors('time_slot_id');
|
||||
}
|
||||
|
||||
public function test_update_cross_org_returns_403(): void
|
||||
{
|
||||
$shift = Shift::factory()->create([
|
||||
'festival_section_id' => $this->section->id,
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->putJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [
|
||||
'title' => 'Hacked',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_cross_org_returns_403(): void
|
||||
{
|
||||
$shift = Shift::factory()->create([
|
||||
'festival_section_id' => $this->section->id,
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_assign_creates_shift_assignment(): void
|
||||
{
|
||||
$shift = Shift::factory()->create([
|
||||
|
||||
5
apps/app/components.d.ts
vendored
5
apps/app/components.d.ts
vendored
@@ -28,6 +28,7 @@ declare module 'vue' {
|
||||
CardStatisticsHorizontal: typeof import('./src/@core/components/cards/CardStatisticsHorizontal.vue')['default']
|
||||
CardStatisticsVertical: typeof import('./src/@core/components/cards/CardStatisticsVertical.vue')['default']
|
||||
CardStatisticsVerticalSimple: typeof import('./src/@core/components/CardStatisticsVerticalSimple.vue')['default']
|
||||
CompanyDialog: typeof import('./src/components/organisation/CompanyDialog.vue')['default']
|
||||
ConfirmDialog: typeof import('./src/components/dialogs/ConfirmDialog.vue')['default']
|
||||
CreateAppDialog: typeof import('./src/components/dialogs/CreateAppDialog.vue')['default']
|
||||
CreateEventDialog: typeof import('./src/components/events/CreateEventDialog.vue')['default']
|
||||
@@ -36,6 +37,7 @@ declare module 'vue' {
|
||||
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']
|
||||
CrowdTypesManager: typeof import('./src/components/organisations/CrowdTypesManager.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']
|
||||
CustomCheckboxesWithImage: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithImage.vue')['default']
|
||||
@@ -43,12 +45,14 @@ declare module 'vue' {
|
||||
CustomRadios: typeof import('./src/@core/components/app-form-elements/CustomRadios.vue')['default']
|
||||
CustomRadiosWithIcon: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithIcon.vue')['default']
|
||||
CustomRadiosWithImage: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithImage.vue')['default']
|
||||
DeleteSubEventDialog: typeof import('./src/components/events/DeleteSubEventDialog.vue')['default']
|
||||
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
|
||||
DropZone: typeof import('./src/@core/components/DropZone.vue')['default']
|
||||
EditEventDialog: typeof import('./src/components/events/EditEventDialog.vue')['default']
|
||||
EditMemberRoleDialog: typeof import('./src/components/members/EditMemberRoleDialog.vue')['default']
|
||||
EditOrganisationDialog: typeof import('./src/components/organisations/EditOrganisationDialog.vue')['default']
|
||||
EditPersonDialog: typeof import('./src/components/persons/EditPersonDialog.vue')['default']
|
||||
EditSectionDialog: typeof import('./src/components/sections/EditSectionDialog.vue')['default']
|
||||
EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default']
|
||||
ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default']
|
||||
EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default']
|
||||
@@ -63,6 +67,7 @@ declare module 'vue' {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default']
|
||||
SectionsShiftsPanel: typeof import('./src/components/sections/SectionsShiftsPanel.vue')['default']
|
||||
ShareProjectDialog: typeof import('./src/components/dialogs/ShareProjectDialog.vue')['default']
|
||||
Shortcuts: typeof import('./src/@core/components/Shortcuts.vue')['default']
|
||||
TablePagination: typeof import('./src/@core/components/TablePagination.vue')['default']
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"@casl/ability": "6.7.3",
|
||||
"@casl/vue": "2.2.2",
|
||||
"@floating-ui/dom": "1.6.8",
|
||||
"@formkit/drag-and-drop": "0.1.6",
|
||||
"@sindresorhus/is": "7.1.0",
|
||||
"@tanstack/vue-query": "^5.95.2",
|
||||
"@tiptap/extension-highlight": "^2.27.1",
|
||||
@@ -55,6 +54,7 @@
|
||||
"vue-router": "4.5.1",
|
||||
"vue3-apexcharts": "1.5.3",
|
||||
"vue3-perfect-scrollbar": "2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "3.10.8",
|
||||
"webfontloader": "1.6.28",
|
||||
"zod": "3"
|
||||
|
||||
26
apps/app/pnpm-lock.yaml
generated
26
apps/app/pnpm-lock.yaml
generated
@@ -21,9 +21,6 @@ importers:
|
||||
'@floating-ui/dom':
|
||||
specifier: 1.6.8
|
||||
version: 1.6.8
|
||||
'@formkit/drag-and-drop':
|
||||
specifier: 0.1.6
|
||||
version: 0.1.6
|
||||
'@sindresorhus/is':
|
||||
specifier: 7.1.0
|
||||
version: 7.1.0
|
||||
@@ -135,6 +132,9 @@ importers:
|
||||
vue3-perfect-scrollbar:
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0(vue@3.5.22(typescript@5.9.3))
|
||||
vuedraggable:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(vue@3.5.22(typescript@5.9.3))
|
||||
vuetify:
|
||||
specifier: 3.10.8
|
||||
version: 3.10.8(typescript@5.9.3)(vite-plugin-vuetify@2.1.2)(vue@3.5.22(typescript@5.9.3))
|
||||
@@ -745,9 +745,6 @@ packages:
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@formkit/drag-and-drop@0.1.6':
|
||||
resolution: {integrity: sha512-wZyxvk7WTbQ12q8ZGvLoYner1ktBOUf+lCblJT3P0LyqpjGCKTfQMKJtwToKQzJgTbhvow4LBu+yP92Mux321w==}
|
||||
|
||||
'@fullcalendar/core@6.1.19':
|
||||
resolution: {integrity: sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==}
|
||||
|
||||
@@ -4091,6 +4088,9 @@ packages:
|
||||
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
sortablejs@1.14.0:
|
||||
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4741,6 +4741,11 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vuedraggable@4.1.0:
|
||||
resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.1
|
||||
|
||||
vuetify@3.10.8:
|
||||
resolution: {integrity: sha512-TV1bx8mUjOPbhmEsamm38/CBcVe5DHYepOZGE6aQJ2uxvg96B4k+QHgIJcD5uKVfKmxKkJRtHdEXyq6JP9wBtg==}
|
||||
peerDependencies:
|
||||
@@ -5338,8 +5343,6 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@formkit/drag-and-drop@0.1.6': {}
|
||||
|
||||
'@fullcalendar/core@6.1.19':
|
||||
dependencies:
|
||||
preact: 10.12.1
|
||||
@@ -9149,6 +9152,8 @@ snapshots:
|
||||
astral-regex: 2.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
|
||||
sortablejs@1.14.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map@0.6.1:
|
||||
@@ -9933,6 +9938,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
vuedraggable@4.1.0(vue@3.5.22(typescript@5.9.3)):
|
||||
dependencies:
|
||||
sortablejs: 1.14.0
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
vuetify@3.10.8(typescript@5.9.3)(vite-plugin-vuetify@2.1.2)(vue@3.5.22(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
@@ -41,6 +41,7 @@ const eventTypeOptions: { title: string; value: EventTypeEnum }[] = [
|
||||
|
||||
const subEventLabelOptions = [
|
||||
'Dag',
|
||||
'Evenement',
|
||||
'Programmaonderdeel',
|
||||
'Editie',
|
||||
'Locatie',
|
||||
@@ -134,11 +135,11 @@ function onSubmit() {
|
||||
@after-leave="resetForm"
|
||||
>
|
||||
<VCard title="Nieuw evenement">
|
||||
<VCardText>
|
||||
<VForm
|
||||
ref="refVForm"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<VForm
|
||||
ref="refVForm"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<AppTextField
|
||||
@@ -147,6 +148,7 @@ function onSubmit() {
|
||||
:rules="[requiredValidator]"
|
||||
:error-messages="errors.name"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
@@ -158,6 +160,7 @@ function onSubmit() {
|
||||
:error-messages="errors.slug"
|
||||
hint="Wordt gebruikt in de URL"
|
||||
persistent-hint
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
@@ -203,6 +206,7 @@ function onSubmit() {
|
||||
:error-messages="errors.sub_event_label"
|
||||
hint="Kies uit de lijst of typ een eigen naam"
|
||||
persistent-hint
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
@@ -214,6 +218,7 @@ function onSubmit() {
|
||||
:error-messages="errors.event_type_label"
|
||||
maxlength="50"
|
||||
counter
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
@@ -250,24 +255,24 @@ function onSubmit() {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
@click="onSubmit"
|
||||
>
|
||||
Aanmaken
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
>
|
||||
Aanmaken
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VForm>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const props = defineProps<{
|
||||
const modelValue = defineModel<boolean>({ required: true })
|
||||
|
||||
const orgIdRef = computed(() => props.orgId)
|
||||
const parentEventIdRef = computed(() => props.parentEvent.id)
|
||||
|
||||
const subEventLabel = computed(() =>
|
||||
props.parentEvent.sub_event_label ?? 'Programmaonderdeel',
|
||||
@@ -29,7 +30,7 @@ const errors = ref<Record<string, string>>({})
|
||||
const refVForm = ref<VForm>()
|
||||
const showSuccess = ref(false)
|
||||
|
||||
const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef)
|
||||
const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef, parentEventIdRef)
|
||||
|
||||
const statusOptions: { title: string; value: EventStatus }[] = [
|
||||
{ title: 'Draft', value: 'draft' },
|
||||
@@ -72,7 +73,6 @@ function onSubmit() {
|
||||
onSuccess: () => {
|
||||
modelValue.value = false
|
||||
showSuccess.value = true
|
||||
resetForm()
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const data = err.response?.data
|
||||
@@ -97,15 +97,15 @@ function onSubmit() {
|
||||
@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"
|
||||
>
|
||||
<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
|
||||
@@ -115,6 +115,7 @@ function onSubmit() {
|
||||
:error-messages="errors.name"
|
||||
:placeholder="`Dag 1, ${subEventLabel} 1...`"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
@@ -159,24 +160,24 @@ function onSubmit() {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
@click="onSubmit"
|
||||
>
|
||||
Toevoegen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
>
|
||||
Toevoegen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VForm>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
|
||||
65
apps/app/src/components/events/DeleteSubEventDialog.vue
Normal file
65
apps/app/src/components/events/DeleteSubEventDialog.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { useDeleteEvent } from '@/composables/api/useEvents'
|
||||
import type { EventItem } from '@/types/event'
|
||||
|
||||
const props = defineProps<{
|
||||
event: EventItem | null
|
||||
orgId: string
|
||||
parentEventId: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>({ required: true })
|
||||
const emit = defineEmits<{
|
||||
deleted: [name: string]
|
||||
}>()
|
||||
|
||||
const orgIdRef = computed(() => props.orgId)
|
||||
const parentEventIdRef = computed(() => props.parentEventId)
|
||||
|
||||
const { mutate: deleteEvent, isPending } = useDeleteEvent(orgIdRef, parentEventIdRef)
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.event) return
|
||||
|
||||
const name = props.event.name
|
||||
|
||||
deleteEvent(props.event.id, {
|
||||
onSuccess: () => {
|
||||
modelValue.value = false
|
||||
emit('deleted', name)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-model="modelValue"
|
||||
max-width="450"
|
||||
>
|
||||
<VCard title="Programmaonderdeel verwijderen">
|
||||
<VCardText>
|
||||
<p class="text-body-1 mb-0">
|
||||
Weet je zeker dat je <strong>'{{ event?.name }}'</strong> wilt verwijderen?
|
||||
Dit programmaonderdeel en alle bijbehorende secties, shifts en toewijzingen worden gearchiveerd.
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="error"
|
||||
:loading="isPending"
|
||||
@click="onConfirm"
|
||||
>
|
||||
Verwijderen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -21,6 +21,7 @@ const form = ref({
|
||||
end_date: '',
|
||||
timezone: '',
|
||||
status: '' as EventStatus,
|
||||
sub_event_label: '' as string | null,
|
||||
})
|
||||
|
||||
const errors = ref<Record<string, string>>({})
|
||||
@@ -29,6 +30,19 @@ const showSuccess = ref(false)
|
||||
|
||||
const { mutate: updateEvent, isPending } = useUpdateEvent(orgIdRef, eventIdRef)
|
||||
|
||||
const isFestivalOrSeries = computed(() =>
|
||||
props.event.event_type === 'festival' || props.event.event_type === 'series',
|
||||
)
|
||||
|
||||
const subEventLabelOptions = [
|
||||
'Dag',
|
||||
'Evenement',
|
||||
'Programmaonderdeel',
|
||||
'Editie',
|
||||
'Locatie',
|
||||
'Ronde',
|
||||
]
|
||||
|
||||
const timezoneOptions = [
|
||||
{ title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
|
||||
{ title: 'Europe/London', value: 'Europe/London' },
|
||||
@@ -62,6 +76,7 @@ watch(() => props.event, (ev) => {
|
||||
end_date: ev.end_date,
|
||||
timezone: ev.timezone,
|
||||
status: ev.status,
|
||||
sub_event_label: ev.sub_event_label ?? '',
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -71,7 +86,14 @@ function onSubmit() {
|
||||
|
||||
errors.value = {}
|
||||
|
||||
updateEvent(form.value, {
|
||||
const payload = {
|
||||
...form.value,
|
||||
sub_event_label: isFestivalOrSeries.value && form.value.sub_event_label
|
||||
? form.value.sub_event_label
|
||||
: null,
|
||||
}
|
||||
|
||||
updateEvent(payload, {
|
||||
onSuccess: () => {
|
||||
modelValue.value = false
|
||||
showSuccess.value = true
|
||||
@@ -98,11 +120,11 @@ function onSubmit() {
|
||||
max-width="550"
|
||||
>
|
||||
<VCard title="Evenement bewerken">
|
||||
<VCardText>
|
||||
<VForm
|
||||
ref="refVForm"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<VForm
|
||||
ref="refVForm"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<AppTextField
|
||||
@@ -111,6 +133,7 @@ function onSubmit() {
|
||||
:rules="[requiredValidator]"
|
||||
:error-messages="errors.name"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
@@ -119,6 +142,21 @@ function onSubmit() {
|
||||
label="Slug"
|
||||
:rules="[requiredValidator]"
|
||||
:error-messages="errors.slug"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</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
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -168,24 +206,24 @@ function onSubmit() {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
@click="onSubmit"
|
||||
>
|
||||
Opslaan
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
>
|
||||
Opslaan
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VForm>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventDetail } from '@/composables/api/useEvents'
|
||||
import { useEventDetail, useEventChildren } from '@/composables/api/useEvents'
|
||||
import { dutchPlural } from '@/lib/dutch-plural'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||
import EditEventDialog from '@/components/events/EditEventDialog.vue'
|
||||
import type { EventStatus } from '@/types/event'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
hideTabs?: boolean
|
||||
}>(), {
|
||||
hideTabs: false,
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const orgStore = useOrganisationStore()
|
||||
@@ -20,6 +15,9 @@ const eventId = computed(() => String((route.params as { id: string }).id))
|
||||
|
||||
const { data: event, isLoading, isError, refetch } = useEventDetail(orgId, eventId)
|
||||
|
||||
// Children count for programmaonderdelen badge — only for festivals
|
||||
const { data: children } = useEventChildren(orgId, eventId)
|
||||
|
||||
// Set active event in store
|
||||
watch(eventId, (id) => {
|
||||
if (id) orgStore.setActiveEvent(id)
|
||||
@@ -52,7 +50,7 @@ function formatDate(iso: string) {
|
||||
return dateFormatter.format(new Date(iso))
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
const baseTabs = [
|
||||
{ label: 'Overzicht', icon: 'tabler-layout-dashboard', route: 'events-id' },
|
||||
{ label: 'Personen', icon: 'tabler-users', route: 'events-id-persons' },
|
||||
{ label: 'Secties & Shifts', icon: 'tabler-layout-grid', route: 'events-id-sections' },
|
||||
@@ -61,9 +59,38 @@ const tabs = [
|
||||
{ label: 'Instellingen', icon: 'tabler-settings', route: 'events-id-settings' },
|
||||
]
|
||||
|
||||
const programmaonderdelenLabel = computed(() => {
|
||||
const label = event.value?.sub_event_label
|
||||
? dutchPlural(event.value.sub_event_label)
|
||||
: 'Programmaonderdelen'
|
||||
const count = children.value?.length ?? event.value?.children_count ?? 0
|
||||
return `${label} (${count})`
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
if (!event.value?.is_festival) return baseTabs
|
||||
|
||||
// Festival tab order: Overzicht | Programmaonderdelen | Secties & Shifts | Personen | Artiesten | Briefings | Instellingen
|
||||
const festivalTab = {
|
||||
label: programmaonderdelenLabel.value,
|
||||
icon: 'tabler-calendar-event',
|
||||
route: 'events-id-programmaonderdelen',
|
||||
}
|
||||
|
||||
return [
|
||||
baseTabs[0], // Overzicht
|
||||
festivalTab,
|
||||
baseTabs[2], // Secties & Shifts
|
||||
baseTabs[1], // Personen
|
||||
baseTabs[3], // Artiesten
|
||||
baseTabs[4], // Briefings
|
||||
baseTabs[5], // Instellingen
|
||||
]
|
||||
})
|
||||
|
||||
const activeTab = computed(() => {
|
||||
const name = route.name as string
|
||||
return tabs.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
|
||||
return tabs.value.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
|
||||
})
|
||||
|
||||
const backRoute = computed(() => {
|
||||
@@ -100,22 +127,6 @@ const backRoute = computed(() => {
|
||||
</VAlert>
|
||||
|
||||
<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 -->
|
||||
<div class="d-flex justify-space-between align-center mb-6">
|
||||
<div class="d-flex align-center gap-x-3">
|
||||
@@ -125,6 +136,15 @@ const backRoute = computed(() => {
|
||||
:to="backRoute"
|
||||
/>
|
||||
<h4 class="text-h4">
|
||||
<template v-if="event.is_sub_event && event.parent && event.parent_event_id">
|
||||
<RouterLink
|
||||
:to="{ name: 'events-id', params: { id: event.parent_event_id } }"
|
||||
class="text-medium-emphasis text-decoration-none"
|
||||
>
|
||||
{{ event.parent.name }}
|
||||
</RouterLink>
|
||||
<span class="text-medium-emphasis mx-1">»</span>
|
||||
</template>
|
||||
{{ event.name }}
|
||||
</h4>
|
||||
<VChip
|
||||
@@ -153,9 +173,8 @@ const backRoute = computed(() => {
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal tabs (hidden for festival containers) -->
|
||||
<!-- Horizontal tabs -->
|
||||
<VTabs
|
||||
v-if="!hideTabs"
|
||||
:model-value="activeTab"
|
||||
class="mb-6"
|
||||
>
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import { useCreateSection } from '@/composables/api/useSections'
|
||||
import { useCreateSection, useSectionCategories } from '@/composables/api/useSections'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { requiredValidator } from '@core/utils/validators'
|
||||
import type { SectionType } from '@/types/section'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
eventId: string
|
||||
isSubEvent?: boolean
|
||||
nextSortOrder?: number
|
||||
}>(), {
|
||||
isSubEvent: false,
|
||||
nextSortOrder: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [payload: { name: string; redirectedToParent: boolean; parentEventName?: string }]
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>({ required: true })
|
||||
|
||||
const eventIdRef = computed(() => props.eventId)
|
||||
const authStore = useAuthStore()
|
||||
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
||||
const { data: categorySuggestions } = useSectionCategories(orgId)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
category: null as string | null,
|
||||
icon: null as string | null,
|
||||
type: 'standard' as SectionType,
|
||||
crew_auto_accepts: false,
|
||||
responder_self_checkin: true,
|
||||
@@ -29,9 +44,15 @@ const typeOptions = [
|
||||
{ title: 'Overkoepelend', value: 'cross_event' },
|
||||
]
|
||||
|
||||
const showCrossEventHint = computed(() =>
|
||||
props.isSubEvent && form.value.type === 'cross_event',
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
category: null,
|
||||
icon: null,
|
||||
type: 'standard',
|
||||
crew_auto_accepts: false,
|
||||
responder_self_checkin: true,
|
||||
@@ -49,13 +70,21 @@ function onSubmit() {
|
||||
createSection(
|
||||
{
|
||||
name: form.value.name,
|
||||
category: form.value.category || null,
|
||||
icon: form.value.icon || null,
|
||||
type: form.value.type,
|
||||
sort_order: props.nextSortOrder,
|
||||
crew_auto_accepts: form.value.crew_auto_accepts,
|
||||
responder_self_checkin: form.value.responder_self_checkin,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (result) => {
|
||||
modelValue.value = false
|
||||
emit('created', {
|
||||
name: result.section.name,
|
||||
redirectedToParent: result.redirectedToParent,
|
||||
parentEventName: result.parentEventName,
|
||||
})
|
||||
resetForm()
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@@ -65,6 +94,9 @@ function onSubmit() {
|
||||
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
|
||||
)
|
||||
}
|
||||
else if (data?.message) {
|
||||
errors.value = { type: data.message }
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -79,11 +111,11 @@ function onSubmit() {
|
||||
@after-leave="resetForm"
|
||||
>
|
||||
<VCard title="Sectie aanmaken">
|
||||
<VCardText>
|
||||
<VForm
|
||||
ref="refVForm"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<VForm
|
||||
ref="refVForm"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<AppTextField
|
||||
@@ -92,8 +124,38 @@ function onSubmit() {
|
||||
:rules="[requiredValidator]"
|
||||
:error-messages="errors.name"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCombobox
|
||||
v-model="form.category"
|
||||
label="Categorie"
|
||||
:items="categorySuggestions ?? []"
|
||||
placeholder="Bijv. Bar, Podium, Operationeel..."
|
||||
clearable
|
||||
:error-messages="errors.category"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<div class="d-flex align-center gap-x-2">
|
||||
<AppTextField
|
||||
v-model="form.icon"
|
||||
label="Icoon"
|
||||
placeholder="tabler-beer"
|
||||
clearable
|
||||
:error-messages="errors.icon"
|
||||
class="flex-grow-1"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<VIcon
|
||||
v-if="form.icon"
|
||||
:icon="form.icon"
|
||||
size="24"
|
||||
/>
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<AppSelect
|
||||
v-model="form.type"
|
||||
@@ -101,6 +163,15 @@ function onSubmit() {
|
||||
:items="typeOptions"
|
||||
:error-messages="errors.type"
|
||||
/>
|
||||
<VAlert
|
||||
v-if="showCrossEventHint"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-2"
|
||||
>
|
||||
Deze sectie wordt automatisch aangemaakt op festival-niveau en is zichtbaar in alle programmaonderdelen.
|
||||
</VAlert>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
@@ -119,24 +190,24 @@ function onSubmit() {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
@click="onSubmit"
|
||||
>
|
||||
Aanmaken
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
>
|
||||
Aanmaken
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VForm>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
1130
apps/app/src/components/sections/SectionsShiftsPanel.vue
Normal file
1130
apps/app/src/components/sections/SectionsShiftsPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -81,7 +81,7 @@ export function useCreateEvent(orgId: Ref<string>) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateSubEvent(orgId: Ref<string>) {
|
||||
export function useCreateSubEvent(orgId: Ref<string>, parentEventId: Ref<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
@@ -94,6 +94,21 @@ export function useCreateSubEvent(orgId: Ref<string>) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events', orgId.value] })
|
||||
queryClient.invalidateQueries({ queryKey: ['event-children', parentEventId.value] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteEvent(orgId: Ref<string>, parentEventId: Ref<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (eventId: string) => {
|
||||
await apiClient.delete(`/organisations/${orgId.value}/events/${eventId}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events', orgId.value] })
|
||||
queryClient.invalidateQueries({ queryKey: ['event-children', parentEventId.value] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,20 @@ interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
}
|
||||
|
||||
export function useSectionCategories(orgId: Ref<string>) {
|
||||
return useQuery({
|
||||
queryKey: ['section-categories', orgId],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<{ data: string[] }>(
|
||||
`/organisations/${orgId.value}/section-categories`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: () => !!orgId.value,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSectionList(eventId: Ref<string>) {
|
||||
return useQuery({
|
||||
queryKey: ['sections', eventId],
|
||||
@@ -27,17 +41,27 @@ export function useSectionList(eventId: Ref<string>) {
|
||||
})
|
||||
}
|
||||
|
||||
export interface CreateSectionResult {
|
||||
section: FestivalSection
|
||||
redirectedToParent: boolean
|
||||
parentEventName?: string
|
||||
}
|
||||
|
||||
export function useCreateSection(eventId: Ref<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: CreateSectionPayload) => {
|
||||
const { data } = await apiClient.post<ApiResponse<FestivalSection>>(
|
||||
mutationFn: async (payload: CreateSectionPayload): Promise<CreateSectionResult> => {
|
||||
const { data } = await apiClient.post<ApiResponse<FestivalSection> & { meta?: { redirected_to_parent?: boolean; parent_event_name?: string } }>(
|
||||
`/events/${eventId.value}/sections`,
|
||||
payload,
|
||||
)
|
||||
|
||||
return data.data
|
||||
return {
|
||||
section: data.data,
|
||||
redirectedToParent: data.meta?.redirected_to_parent ?? false,
|
||||
parentEventName: data.meta?.parent_event_name,
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
|
||||
@@ -58,7 +82,8 @@ export function useUpdateSection(eventId: Ref<string>) {
|
||||
return data.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
|
||||
// Invalidate all section lists — a cross_event section update affects multiple events
|
||||
queryClient.invalidateQueries({ queryKey: ['sections'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -78,15 +103,32 @@ export function useDeleteSection(eventId: Ref<string>) {
|
||||
|
||||
export function useReorderSections(eventId: Ref<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
let previousSections: FestivalSection[] | undefined
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (orderedIds: string[]) => {
|
||||
await apiClient.post(`/events/${eventId.value}/sections/reorder`, {
|
||||
ordered_ids: orderedIds,
|
||||
sections: orderedIds,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
|
||||
onMutate: async (orderedIds) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['sections', eventId.value] })
|
||||
previousSections = queryClient.getQueryData<FestivalSection[]>(['sections', eventId.value])
|
||||
|
||||
// Optimistically update query cache so watch doesn't snap back
|
||||
if (previousSections) {
|
||||
const byId = new Map(previousSections.map(s => [s.id, s]))
|
||||
const reordered = orderedIds
|
||||
.map(id => byId.get(id))
|
||||
.filter((s): s is FestivalSection => !!s)
|
||||
queryClient.setQueryData(['sections', eventId.value], reordered)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
if (previousSections) {
|
||||
queryClient.setQueryData(['sections', eventId.value], previousSections)
|
||||
}
|
||||
},
|
||||
// No onSuccess invalidation — query cache and v-model are already in sync
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||
import CreateSubEventDialog from '@/components/events/CreateSubEventDialog.vue'
|
||||
import { useEventDetail, useEventChildren } from '@/composables/api/useEvents'
|
||||
import { useEventChildren } from '@/composables/api/useEvents'
|
||||
import { dutchPlural } from '@/lib/dutch-plural'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import type { EventStatus, EventItem } from '@/types/event'
|
||||
|
||||
@@ -18,16 +18,9 @@ const authStore = useAuthStore()
|
||||
const orgId = computed(() => authStore.currentOrganisation?.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
|
||||
// Children query — only enabled for festivals (composable handles this)
|
||||
const { data: children, isLoading: childrenLoading } = useEventChildren(orgId, eventId)
|
||||
|
||||
const isCreateSubEventOpen = ref(false)
|
||||
|
||||
const statusColor: Record<EventStatus, string> = {
|
||||
draft: 'default',
|
||||
published: 'info',
|
||||
@@ -48,20 +41,17 @@ function formatDate(iso: string) {
|
||||
return dateFormatter.format(new Date(iso))
|
||||
}
|
||||
|
||||
const subEventLabel = computed(() =>
|
||||
event.value?.sub_event_label ?? 'Programmaonderdeel',
|
||||
)
|
||||
// Compact preview: max 4 children
|
||||
const previewChildren = computed(() => (children.value ?? []).slice(0, 4))
|
||||
|
||||
const subEventLabelPlural = computed(() =>
|
||||
event.value?.sub_event_label
|
||||
? `${event.value.sub_event_label}en`
|
||||
: 'Programmaonderdelen',
|
||||
)
|
||||
function navigateToChild(child: EventItem) {
|
||||
router.push({ name: 'events-id', params: { id: child.id } })
|
||||
}
|
||||
|
||||
// --- Flat event tiles (existing behaviour) ---
|
||||
// --- Flat event / sub-event tiles ---
|
||||
const tiles = [
|
||||
{ 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: 'events-id-sections', enabled: true },
|
||||
{ title: 'Artiesten', value: 0, icon: 'tabler-music', color: 'warning', route: null, enabled: false },
|
||||
{ title: 'Briefings', value: 0, icon: 'tabler-mail', color: 'info', route: null, enabled: false },
|
||||
]
|
||||
@@ -70,199 +60,195 @@ function onTileClick(tile: typeof tiles[number]) {
|
||||
if (tile.enabled && tile.route)
|
||||
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>
|
||||
|
||||
<template>
|
||||
<!-- 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>
|
||||
<EventTabsNav>
|
||||
<template #default="{ event }">
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<!-- Festival Overzicht (dashboard) -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<template v-if="event?.is_festival">
|
||||
<!-- Programmaonderdelen compact preview -->
|
||||
<VCard class="mb-6">
|
||||
<VCardTitle class="d-flex align-center justify-space-between">
|
||||
<span>
|
||||
{{ event.sub_event_label ? dutchPlural(event.sub_event_label) : 'Programmaonderdelen' }}
|
||||
({{ children?.length ?? event.children_count ?? 0 }})
|
||||
</span>
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
append-icon="tabler-arrow-right"
|
||||
:to="{ name: 'events-id-programmaonderdelen', params: { id: event.id } }"
|
||||
>
|
||||
Bekijk alle
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
|
||||
<!-- Children loading -->
|
||||
<VSkeletonLoader
|
||||
v-if="childrenLoading"
|
||||
type="card@3"
|
||||
/>
|
||||
<VCardText>
|
||||
<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"
|
||||
<VRow v-else-if="previewChildren.length">
|
||||
<VCol
|
||||
v-for="child in previewChildren"
|
||||
:key="child.id"
|
||||
cols="6"
|
||||
md="3"
|
||||
>
|
||||
<VCard
|
||||
variant="outlined"
|
||||
class="cursor-pointer"
|
||||
hover
|
||||
@click="navigateToChild(child)"
|
||||
>
|
||||
{{ child.status }}
|
||||
</VChip>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ formatDate(child.start_date) }}
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VCardText class="pa-3">
|
||||
<h6 class="text-subtitle-1 font-weight-medium text-truncate mb-1">
|
||||
{{ child.name }}
|
||||
</h6>
|
||||
<p class="text-body-2 text-medium-emphasis mb-1">
|
||||
{{ formatDate(child.start_date) }}
|
||||
</p>
|
||||
<VChip
|
||||
:color="statusColor[child.status]"
|
||||
size="x-small"
|
||||
>
|
||||
{{ child.status }}
|
||||
</VChip>
|
||||
</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>
|
||||
<p
|
||||
v-else
|
||||
class="text-body-2 text-disabled mb-0"
|
||||
>
|
||||
Nog geen {{ event.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }}.
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Bottom info cards -->
|
||||
<VRow class="mt-6">
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<!-- Vrijwilligers + Capaciteit cards -->
|
||||
<VRow>
|
||||
<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: event.id } }"
|
||||
>
|
||||
Beheren
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<div class="d-flex align-center gap-x-3">
|
||||
<VAvatar
|
||||
color="success"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
size="44"
|
||||
rounded
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-users"
|
||||
icon="tabler-chart-bar"
|
||||
size="28"
|
||||
/>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<p class="text-body-1 mb-0">
|
||||
Vrijwilligers
|
||||
Capaciteitsoverzicht
|
||||
</p>
|
||||
<p class="text-body-2 text-disabled mb-0">
|
||||
Volledige capaciteitsplanning zichtbaar zodra {{ event.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }} zijn aangemaakt
|
||||
</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>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<!-- Flat event / Sub-event Overzicht (tiles) -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<VRow
|
||||
v-else
|
||||
class="mb-6"
|
||||
>
|
||||
<VCol
|
||||
v-for="tile in tiles"
|
||||
:key="tile.title"
|
||||
cols="12"
|
||||
md="6"
|
||||
sm="6"
|
||||
md="3"
|
||||
>
|
||||
<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>
|
||||
<VCard
|
||||
:class="{ 'cursor-pointer': tile.enabled }"
|
||||
:style="!tile.enabled ? 'opacity: 0.5' : ''"
|
||||
@click="onTileClick(tile)"
|
||||
>
|
||||
<VCardText class="d-flex align-center gap-x-4">
|
||||
<VAvatar
|
||||
:color="tile.color"
|
||||
variant="tonal"
|
||||
size="44"
|
||||
rounded
|
||||
>
|
||||
<VIcon
|
||||
:icon="tile.icon"
|
||||
size="28"
|
||||
/>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<p class="text-body-1 mb-0">
|
||||
{{ tile.title }}
|
||||
</p>
|
||||
<h4 class="text-h4">
|
||||
{{ tile.value }}
|
||||
</h4>
|
||||
</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">
|
||||
<VCol
|
||||
v-for="tile in tiles"
|
||||
:key="tile.title"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="3"
|
||||
>
|
||||
<VCard
|
||||
:class="{ 'cursor-pointer': tile.enabled }"
|
||||
:style="!tile.enabled ? 'opacity: 0.5' : ''"
|
||||
@click="onTileClick(tile)"
|
||||
>
|
||||
<VCardText class="d-flex align-center gap-x-4">
|
||||
<VAvatar
|
||||
:color="tile.color"
|
||||
variant="tonal"
|
||||
size="44"
|
||||
rounded
|
||||
>
|
||||
<VIcon
|
||||
:icon="tile.icon"
|
||||
size="28"
|
||||
/>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<p class="text-body-1 mb-0">
|
||||
{{ tile.title }}
|
||||
</p>
|
||||
<h4 class="text-h4">
|
||||
{{ tile.value }}
|
||||
</h4>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
</EventTabsNav>
|
||||
</template>
|
||||
|
||||
180
apps/app/src/pages/events/[id]/programmaonderdelen/index.vue
Normal file
180
apps/app/src/pages/events/[id]/programmaonderdelen/index.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||
import CreateSubEventDialog from '@/components/events/CreateSubEventDialog.vue'
|
||||
import DeleteSubEventDialog from '@/components/events/DeleteSubEventDialog.vue'
|
||||
import { useEventChildren } from '@/composables/api/useEvents'
|
||||
import { dutchPlural } from '@/lib/dutch-plural'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import type { EventStatus, EventItem } from '@/types/event'
|
||||
|
||||
definePage({
|
||||
meta: {
|
||||
navActiveLink: 'events',
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
||||
const eventId = computed(() => String((route.params as { id: string }).id))
|
||||
|
||||
const { data: children, isLoading: childrenLoading } = useEventChildren(orgId, eventId)
|
||||
|
||||
const isCreateSubEventOpen = ref(false)
|
||||
const isDeleteSubEventOpen = ref(false)
|
||||
const subEventToDelete = ref<EventItem | null>(null)
|
||||
const showDeleteSuccess = ref(false)
|
||||
const deletedSubEventName = ref('')
|
||||
|
||||
function openDeleteDialog(child: EventItem) {
|
||||
subEventToDelete.value = child
|
||||
isDeleteSubEventOpen.value = true
|
||||
}
|
||||
|
||||
function onSubEventDeleted(name: string) {
|
||||
deletedSubEventName.value = name
|
||||
showDeleteSuccess.value = true
|
||||
subEventToDelete.value = null
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
function navigateToChild(child: EventItem) {
|
||||
router.push({ name: 'events-id', params: { id: child.id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EventTabsNav>
|
||||
<template #default="{ event }">
|
||||
<!-- 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 }}
|
||||
{{ event?.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }}
|
||||
</div>
|
||||
<VBtn
|
||||
prepend-icon="tabler-plus"
|
||||
size="small"
|
||||
@click="isCreateSubEventOpen = true"
|
||||
>
|
||||
{{ event?.sub_event_label ?? 'Programmaonderdeel' }} 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>
|
||||
<div class="d-flex align-center gap-x-2">
|
||||
<VChip
|
||||
:color="statusColor[child.status]"
|
||||
size="small"
|
||||
>
|
||||
{{ child.status }}
|
||||
</VChip>
|
||||
<VMenu>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<VBtn
|
||||
v-bind="menuProps"
|
||||
icon="tabler-dots-vertical"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
prepend-icon="tabler-trash"
|
||||
title="Verwijderen"
|
||||
class="text-error"
|
||||
@click="openDeleteDialog(child)"
|
||||
/>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ formatDate(child.start_date) }} – {{ formatDate(child.end_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 {{ event?.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }}.
|
||||
Voeg je eerste {{ (event?.sub_event_label ?? 'programmaonderdeel').toLowerCase() }} toe.
|
||||
</p>
|
||||
</VCard>
|
||||
|
||||
<CreateSubEventDialog
|
||||
v-if="event"
|
||||
v-model="isCreateSubEventOpen"
|
||||
:parent-event="event"
|
||||
:org-id="orgId"
|
||||
/>
|
||||
|
||||
<DeleteSubEventDialog
|
||||
v-model="isDeleteSubEventOpen"
|
||||
:event="subEventToDelete"
|
||||
:org-id="orgId"
|
||||
:parent-event-id="eventId"
|
||||
@deleted="onSubEventDeleted"
|
||||
/>
|
||||
|
||||
<VSnackbar
|
||||
v-model="showDeleteSuccess"
|
||||
color="success"
|
||||
:timeout="3000"
|
||||
>
|
||||
'{{ deletedSubEventName }}' is verwijderd
|
||||
</VSnackbar>
|
||||
</template>
|
||||
</EventTabsNav>
|
||||
</template>
|
||||
@@ -1,13 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useSectionList, useDeleteSection, useReorderSections } from '@/composables/api/useSections'
|
||||
import { useShiftList, useDeleteShift } from '@/composables/api/useShifts'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||
import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue'
|
||||
import CreateTimeSlotDialog from '@/components/sections/CreateTimeSlotDialog.vue'
|
||||
import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue'
|
||||
import AssignShiftDialog from '@/components/sections/AssignShiftDialog.vue'
|
||||
import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
|
||||
import SectionsShiftsPanel from '@/components/sections/SectionsShiftsPanel.vue'
|
||||
import { useEventChildren } from '@/composables/api/useEvents'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
definePage({
|
||||
meta: {
|
||||
@@ -21,567 +16,18 @@ const authStore = useAuthStore()
|
||||
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
||||
const eventId = computed(() => String((route.params as { id: string }).id))
|
||||
|
||||
// --- Section list ---
|
||||
const { data: sections, isLoading: sectionsLoading } = useSectionList(eventId)
|
||||
const { mutate: deleteSection } = useDeleteSection(eventId)
|
||||
const { mutate: reorderSections } = useReorderSections(eventId)
|
||||
|
||||
const activeSectionId = ref<string | null>(null)
|
||||
|
||||
const activeSection = computed(() =>
|
||||
sections.value?.find(s => s.id === activeSectionId.value) ?? null,
|
||||
)
|
||||
|
||||
// Auto-select first section
|
||||
watch(sections, (list) => {
|
||||
if (list?.length && !activeSectionId.value) {
|
||||
activeSectionId.value = list[0].id
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// --- Shifts for active section ---
|
||||
const activeSectionIdRef = computed(() => activeSectionId.value ?? '')
|
||||
|
||||
const { data: shifts, isLoading: shiftsLoading } = useShiftList(eventId, activeSectionIdRef)
|
||||
const { mutate: deleteShiftMutation, isPending: isDeleting } = useDeleteShift(eventId, activeSectionIdRef)
|
||||
|
||||
// Group shifts by time_slot_id
|
||||
const shiftsByTimeSlot = computed(() => {
|
||||
if (!shifts.value) return []
|
||||
|
||||
const groups = new Map<string, { timeSlotName: string; date: string; startTime: string; endTime: string; totalSlots: number; filledSlots: number; shifts: Shift[] }>()
|
||||
|
||||
for (const shift of shifts.value) {
|
||||
const tsId = shift.time_slot_id
|
||||
if (!groups.has(tsId)) {
|
||||
groups.set(tsId, {
|
||||
timeSlotName: shift.time_slot?.name ?? 'Onbekend',
|
||||
date: shift.time_slot?.date ?? '',
|
||||
startTime: shift.effective_start_time,
|
||||
endTime: shift.effective_end_time,
|
||||
totalSlots: 0,
|
||||
filledSlots: 0,
|
||||
shifts: [],
|
||||
})
|
||||
}
|
||||
const group = groups.get(tsId)!
|
||||
group.shifts.push(shift)
|
||||
group.totalSlots += shift.slots_total
|
||||
group.filledSlots += shift.filled_slots
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
})
|
||||
|
||||
// --- Dialogs ---
|
||||
const isCreateSectionOpen = ref(false)
|
||||
const isEditSectionOpen = ref(false)
|
||||
const isCreateTimeSlotOpen = ref(false)
|
||||
const isCreateShiftOpen = ref(false)
|
||||
const isAssignShiftOpen = ref(false)
|
||||
|
||||
const editingShift = ref<Shift | null>(null)
|
||||
const assigningShift = ref<Shift | null>(null)
|
||||
|
||||
// Delete section
|
||||
const isDeleteSectionOpen = ref(false)
|
||||
const deletingSectionId = ref<string | null>(null)
|
||||
|
||||
function onDeleteSectionConfirm(section: FestivalSection) {
|
||||
deletingSectionId.value = section.id
|
||||
isDeleteSectionOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteSectionExecute() {
|
||||
if (!deletingSectionId.value) return
|
||||
deleteSection(deletingSectionId.value, {
|
||||
onSuccess: () => {
|
||||
isDeleteSectionOpen.value = false
|
||||
if (activeSectionId.value === deletingSectionId.value) {
|
||||
activeSectionId.value = sections.value?.[0]?.id ?? null
|
||||
}
|
||||
deletingSectionId.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Delete shift
|
||||
const isDeleteShiftOpen = ref(false)
|
||||
const deletingShiftId = ref<string | null>(null)
|
||||
|
||||
function onDeleteShiftConfirm(shift: Shift) {
|
||||
deletingShiftId.value = shift.id
|
||||
isDeleteShiftOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteShiftExecute() {
|
||||
if (!deletingShiftId.value) return
|
||||
deleteShiftMutation(deletingShiftId.value, {
|
||||
onSuccess: () => {
|
||||
isDeleteShiftOpen.value = false
|
||||
deletingShiftId.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function onEditShift(shift: Shift) {
|
||||
editingShift.value = shift
|
||||
isCreateShiftOpen.value = true
|
||||
}
|
||||
|
||||
function onAssignShift(shift: Shift) {
|
||||
assigningShift.value = shift
|
||||
isAssignShiftOpen.value = true
|
||||
}
|
||||
|
||||
function onAddShift() {
|
||||
editingShift.value = null
|
||||
isCreateShiftOpen.value = true
|
||||
}
|
||||
|
||||
function onEditSection() {
|
||||
// Re-use create dialog for editing section (section name in header)
|
||||
isEditSectionOpen.value = true
|
||||
}
|
||||
|
||||
// Status styling
|
||||
const statusColor: Record<ShiftStatus, string> = {
|
||||
draft: 'default',
|
||||
open: 'info',
|
||||
full: 'success',
|
||||
in_progress: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
}
|
||||
|
||||
const statusLabel: Record<ShiftStatus, string> = {
|
||||
draft: 'Concept',
|
||||
open: 'Open',
|
||||
full: 'Vol',
|
||||
in_progress: 'Bezig',
|
||||
completed: 'Voltooid',
|
||||
cancelled: 'Geannuleerd',
|
||||
}
|
||||
|
||||
function fillRateColor(rate: number): string {
|
||||
if (rate >= 80) return 'success'
|
||||
if (rate >= 40) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
// Drag & drop reorder
|
||||
const dragIndex = ref<number | null>(null)
|
||||
|
||||
function onDragStart(index: number) {
|
||||
dragIndex.value = index
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onDrop(targetIndex: number) {
|
||||
if (dragIndex.value === null || dragIndex.value === targetIndex || !sections.value) return
|
||||
|
||||
const items = [...sections.value]
|
||||
const [moved] = items.splice(dragIndex.value, 1)
|
||||
items.splice(targetIndex, 0, moved)
|
||||
|
||||
reorderSections(items.map(s => s.id))
|
||||
dragIndex.value = null
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragIndex.value = null
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
})
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return ''
|
||||
return dateFormatter.format(new Date(iso))
|
||||
}
|
||||
|
||||
// Success snackbar
|
||||
const showSuccess = ref(false)
|
||||
const successMessage = ref('')
|
||||
// Load children for festivals — needed for time slot context chips (Opbouw/Afbraak/Transitie)
|
||||
const { data: children } = useEventChildren(orgId, eventId)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EventTabsNav>
|
||||
<VRow>
|
||||
<!-- LEFT COLUMN — Sections list -->
|
||||
<VCol
|
||||
cols="12"
|
||||
md="3"
|
||||
style="min-inline-size: 280px; max-inline-size: 320px;"
|
||||
>
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center justify-space-between">
|
||||
<span>Secties</span>
|
||||
<VBtn
|
||||
icon="tabler-plus"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="isCreateSectionOpen = true"
|
||||
/>
|
||||
</VCardTitle>
|
||||
|
||||
<!-- Loading -->
|
||||
<VSkeletonLoader
|
||||
v-if="sectionsLoading"
|
||||
type="list-item@4"
|
||||
/>
|
||||
|
||||
<!-- Empty -->
|
||||
<VCardText
|
||||
v-else-if="!sections?.length"
|
||||
class="text-center text-disabled"
|
||||
>
|
||||
Geen secties — maak er een aan
|
||||
</VCardText>
|
||||
|
||||
<!-- Section list -->
|
||||
<VList
|
||||
v-else
|
||||
density="compact"
|
||||
nav
|
||||
>
|
||||
<VListItem
|
||||
v-for="(section, index) in sections"
|
||||
:key="section.id"
|
||||
:active="section.id === activeSectionId"
|
||||
color="primary"
|
||||
draggable="true"
|
||||
@click="activeSectionId = section.id"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop(index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-grip-vertical"
|
||||
size="16"
|
||||
class="cursor-grab me-1"
|
||||
style="opacity: 0.4;"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle>{{ section.name }}</VListItemTitle>
|
||||
<template #append>
|
||||
<VChip
|
||||
v-if="section.type === 'cross_event'"
|
||||
size="x-small"
|
||||
color="info"
|
||||
class="me-1"
|
||||
>
|
||||
Overkoepelend
|
||||
</VChip>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- RIGHT COLUMN — Shifts for active section -->
|
||||
<VCol>
|
||||
<!-- No section selected -->
|
||||
<VCard
|
||||
v-if="!activeSection"
|
||||
class="text-center pa-8"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-layout-grid"
|
||||
size="48"
|
||||
class="mb-4 text-disabled"
|
||||
/>
|
||||
<p class="text-body-1 text-disabled">
|
||||
Selecteer een sectie om shifts te beheren
|
||||
</p>
|
||||
</VCard>
|
||||
|
||||
<!-- Section selected -->
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle class="d-flex align-center justify-space-between flex-wrap gap-2">
|
||||
<div class="d-flex align-center gap-x-2">
|
||||
<span>{{ activeSection.name }}</span>
|
||||
<VChip
|
||||
v-if="activeSection.type === 'cross_event'"
|
||||
size="small"
|
||||
color="info"
|
||||
>
|
||||
Overkoepelend
|
||||
</VChip>
|
||||
<span
|
||||
v-if="activeSection.crew_need"
|
||||
class="text-body-2 text-disabled"
|
||||
>
|
||||
Crew nodig: {{ activeSection.crew_need }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex gap-x-2">
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="tabler-clock"
|
||||
@click="isCreateTimeSlotOpen = true"
|
||||
>
|
||||
Time Slot
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="tabler-plus"
|
||||
@click="onAddShift"
|
||||
>
|
||||
Shift
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
icon="tabler-edit"
|
||||
@click="onEditSection"
|
||||
/>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
icon="tabler-trash"
|
||||
color="error"
|
||||
@click="onDeleteSectionConfirm(activeSection)"
|
||||
/>
|
||||
</div>
|
||||
</VCardTitle>
|
||||
</VCard>
|
||||
|
||||
<!-- Loading shifts -->
|
||||
<VSkeletonLoader
|
||||
v-if="shiftsLoading"
|
||||
type="card@3"
|
||||
/>
|
||||
|
||||
<!-- No shifts -->
|
||||
<VCard
|
||||
v-else-if="!shifts?.length"
|
||||
class="text-center pa-8"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-calendar-time"
|
||||
size="48"
|
||||
class="mb-4 text-disabled"
|
||||
/>
|
||||
<p class="text-body-1 text-disabled mb-4">
|
||||
Nog geen shifts voor deze sectie
|
||||
</p>
|
||||
<VBtn
|
||||
prepend-icon="tabler-plus"
|
||||
@click="onAddShift"
|
||||
>
|
||||
Shift toevoegen
|
||||
</VBtn>
|
||||
</VCard>
|
||||
|
||||
<!-- Shifts grouped by time slot -->
|
||||
<template v-else>
|
||||
<VCard
|
||||
v-for="(group, gi) in shiftsByTimeSlot"
|
||||
:key="gi"
|
||||
class="mb-4"
|
||||
>
|
||||
<!-- Group header -->
|
||||
<VCardTitle class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<span>{{ group.timeSlotName }}</span>
|
||||
<span class="text-body-2 text-disabled ms-2">
|
||||
{{ formatDate(group.date) }} {{ group.startTime }}–{{ group.endTime }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-body-2">
|
||||
{{ group.filledSlots }}/{{ group.totalSlots }} ingevuld
|
||||
</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<!-- Shifts in group -->
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="shift in group.shifts"
|
||||
:key="shift.id"
|
||||
>
|
||||
<div class="d-flex align-center gap-x-3 py-1 flex-wrap">
|
||||
<!-- Title + lead badge -->
|
||||
<div class="d-flex align-center gap-x-2" style="min-inline-size: 160px;">
|
||||
<span class="text-body-1 font-weight-medium">
|
||||
{{ shift.title ?? 'Shift' }}
|
||||
</span>
|
||||
<VChip
|
||||
v-if="shift.is_lead_role"
|
||||
size="x-small"
|
||||
color="warning"
|
||||
>
|
||||
Hoofdrol
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<!-- Fill rate -->
|
||||
<div class="d-flex align-center gap-x-2" style="min-inline-size: 160px;">
|
||||
<VProgressLinear
|
||||
:model-value="shift.fill_rate"
|
||||
:color="fillRateColor(shift.fill_rate)"
|
||||
height="8"
|
||||
rounded
|
||||
style="inline-size: 80px;"
|
||||
/>
|
||||
<span class="text-body-2 text-no-wrap">
|
||||
{{ shift.filled_slots }}/{{ shift.slots_total }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<VChip
|
||||
:color="statusColor[shift.status]"
|
||||
size="small"
|
||||
>
|
||||
{{ statusLabel[shift.status] }}
|
||||
</VChip>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="d-flex gap-x-1">
|
||||
<VBtn
|
||||
icon="tabler-user-plus"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="Toewijzen"
|
||||
@click="onAssignShift(shift)"
|
||||
/>
|
||||
<VBtn
|
||||
icon="tabler-edit"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="Bewerken"
|
||||
@click="onEditShift(shift)"
|
||||
/>
|
||||
<VBtn
|
||||
icon="tabler-trash"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
title="Verwijderen"
|
||||
@click="onDeleteShiftConfirm(shift)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCard>
|
||||
</template>
|
||||
</template>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<CreateSectionDialog
|
||||
v-model="isCreateSectionOpen"
|
||||
:event-id="eventId"
|
||||
/>
|
||||
|
||||
<CreateSectionDialog
|
||||
v-model="isEditSectionOpen"
|
||||
:event-id="eventId"
|
||||
/>
|
||||
|
||||
<CreateTimeSlotDialog
|
||||
v-model="isCreateTimeSlotOpen"
|
||||
:event-id="eventId"
|
||||
/>
|
||||
|
||||
<CreateShiftDialog
|
||||
v-if="activeSection"
|
||||
v-model="isCreateShiftOpen"
|
||||
:event-id="eventId"
|
||||
:section-id="activeSection.id"
|
||||
:shift="editingShift"
|
||||
/>
|
||||
|
||||
<AssignShiftDialog
|
||||
v-if="activeSection"
|
||||
v-model="isAssignShiftOpen"
|
||||
:event-id="eventId"
|
||||
:section-id="activeSection.id"
|
||||
:shift="assigningShift"
|
||||
/>
|
||||
|
||||
<!-- Delete section confirmation -->
|
||||
<VDialog
|
||||
v-model="isDeleteSectionOpen"
|
||||
max-width="400"
|
||||
>
|
||||
<VCard title="Sectie verwijderen">
|
||||
<VCardText>
|
||||
Weet je zeker dat je deze sectie en alle bijbehorende shifts wilt verwijderen?
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="isDeleteSectionOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="error"
|
||||
@click="onDeleteSectionExecute"
|
||||
>
|
||||
Verwijderen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- Delete shift confirmation -->
|
||||
<VDialog
|
||||
v-model="isDeleteShiftOpen"
|
||||
max-width="400"
|
||||
>
|
||||
<VCard title="Shift verwijderen">
|
||||
<VCardText>
|
||||
Weet je zeker dat je deze shift wilt verwijderen?
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="isDeleteShiftOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="error"
|
||||
:loading="isDeleting"
|
||||
@click="onDeleteShiftExecute"
|
||||
>
|
||||
Verwijderen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- Success snackbar -->
|
||||
<VSnackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
:timeout="3000"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</VSnackbar>
|
||||
<template #default="{ event }">
|
||||
<SectionsShiftsPanel
|
||||
:event-id="eventId"
|
||||
:is-sub-event="event?.is_sub_event ?? false"
|
||||
:children="children ?? []"
|
||||
/>
|
||||
</template>
|
||||
</EventTabsNav>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventList } from '@/composables/api/useEvents'
|
||||
import { dutchPlural } from '@/lib/dutch-plural'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import CreateEventDialog from '@/components/events/CreateEventDialog.vue'
|
||||
import type { EventStatus, EventItem } from '@/types/event'
|
||||
@@ -174,7 +175,7 @@ function navigateToEvent(event: EventItem) {
|
||||
icon="tabler-layout-grid"
|
||||
size="16"
|
||||
/>
|
||||
{{ event.children_count }} {{ event.sub_event_label?.toLowerCase() ?? 'programmaonderdelen' }}
|
||||
{{ event.children_count }} {{ event.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }}
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
20
apps/app/src/stores/useSectionsUiStore.ts
Normal file
20
apps/app/src/stores/useSectionsUiStore.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSectionsUiStore = defineStore('sectionsUi', () => {
|
||||
const timeSlotsExpanded = ref(false)
|
||||
|
||||
function toggleTimeSlots() {
|
||||
timeSlotsExpanded.value = !timeSlotsExpanded.value
|
||||
}
|
||||
|
||||
function expandTimeSlots() {
|
||||
timeSlotsExpanded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
timeSlotsExpanded,
|
||||
toggleTimeSlots,
|
||||
expandTimeSlots,
|
||||
}
|
||||
})
|
||||
@@ -6,6 +6,8 @@ export interface FestivalSection {
|
||||
id: string
|
||||
event_id: string
|
||||
name: string
|
||||
category: string | null
|
||||
icon: string | null
|
||||
type: SectionType
|
||||
sort_order: number
|
||||
crew_need: number | null
|
||||
@@ -14,15 +16,22 @@ export interface FestivalSection {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type PersonType = 'CREW' | 'VOLUNTEER' | 'PRESS' | 'PHOTO' | 'PARTNER'
|
||||
|
||||
export type TimeSlotSource = 'sub_event' | 'festival'
|
||||
|
||||
export interface TimeSlot {
|
||||
id: string
|
||||
event_id: string
|
||||
name: string
|
||||
person_type: 'CREW' | 'VOLUNTEER' | 'PRESS' | 'PHOTO' | 'PARTNER'
|
||||
person_type: PersonType
|
||||
date: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
duration_hours: number | null
|
||||
source?: TimeSlotSource | null
|
||||
event_name?: string | null
|
||||
shifts_count?: number
|
||||
}
|
||||
|
||||
export interface Shift {
|
||||
@@ -59,6 +68,8 @@ export interface ShiftAssignment {
|
||||
|
||||
export interface CreateSectionPayload {
|
||||
name: string
|
||||
category?: string | null
|
||||
icon?: string | null
|
||||
type?: SectionType
|
||||
sort_order?: number
|
||||
crew_auto_accepts?: boolean
|
||||
|
||||
3
apps/app/typed-router.d.ts
vendored
3
apps/app/typed-router.d.ts
vendored
@@ -26,11 +26,14 @@ declare module 'vue-router/auto-routes' {
|
||||
'events-id-artists': RouteRecordInfo<'events-id-artists', '/events/:id/artists', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'events-id-briefings': RouteRecordInfo<'events-id-briefings', '/events/:id/briefings', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'events-id-persons': RouteRecordInfo<'events-id-persons', '/events/:id/persons', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'events-id-programmaonderdelen': RouteRecordInfo<'events-id-programmaonderdelen', '/events/:id/programmaonderdelen', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'events-id-sections': RouteRecordInfo<'events-id-sections', '/events/:id/sections', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue<true> }, { token: ParamValue<false> }>,
|
||||
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
|
||||
'organisation': RouteRecordInfo<'organisation', '/organisation', Record<never, never>, Record<never, never>>,
|
||||
'organisation-companies': RouteRecordInfo<'organisation-companies', '/organisation/companies', Record<never, never>, Record<never, never>>,
|
||||
'organisation-members': RouteRecordInfo<'organisation-members', '/organisation/members', Record<never, never>, Record<never, never>>,
|
||||
'select-organisation': RouteRecordInfo<'select-organisation', '/select-organisation', Record<never, never>, Record<never, never>>,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user