feat: portal cross-event my-shifts endpoint and dashboard page
GET /portal/my-shifts aggregates shift assignments across all events the logged-in user is linked to via Person records. Groups by event then date, showing only active assignments (approved/pending_approval) for approved/pending persons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1\Portal;
|
namespace App\Http\Controllers\Api\V1\Portal;
|
||||||
|
|
||||||
use App\Enums\CancellationSource;
|
use App\Enums\CancellationSource;
|
||||||
|
use App\Enums\PersonStatus;
|
||||||
use App\Enums\ShiftAssignmentStatus;
|
use App\Enums\ShiftAssignmentStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
@@ -23,6 +24,98 @@ final class PortalShiftController extends Controller
|
|||||||
private readonly ShiftAssignmentService $shiftAssignmentService,
|
private readonly ShiftAssignmentService $shiftAssignmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All shifts across all events for the logged-in user.
|
||||||
|
* Groups by event → date, only includes active assignments.
|
||||||
|
*/
|
||||||
|
public function allMyShifts(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Find all person records linked to this user (across all events).
|
||||||
|
// OrganisationScope is a no-op here since no org/event route param exists.
|
||||||
|
$personIds = Person::where('user_id', $user->id)
|
||||||
|
->whereIn('status', [
|
||||||
|
PersonStatus::APPROVED->value,
|
||||||
|
PersonStatus::PENDING->value,
|
||||||
|
])
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
if ($personIds->isEmpty()) {
|
||||||
|
return $this->success([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignments = ShiftAssignment::whereIn('person_id', $personIds)
|
||||||
|
->active()
|
||||||
|
->with([
|
||||||
|
'shift.festivalSection',
|
||||||
|
'shift.timeSlot',
|
||||||
|
'shift.location',
|
||||||
|
'person.event',
|
||||||
|
])
|
||||||
|
->get()
|
||||||
|
->sortBy(fn (ShiftAssignment $a) => $a->shift->timeSlot->date->format('Y-m-d') . ' ' .
|
||||||
|
Carbon::parse($a->shift->timeSlot->start_time)->format('H:i'))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$grouped = $assignments
|
||||||
|
->groupBy(fn (ShiftAssignment $a) => $a->person->event_id)
|
||||||
|
->map(function ($eventAssignments) {
|
||||||
|
$event = $eventAssignments->first()->person->event;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'event' => [
|
||||||
|
'id' => $event->id,
|
||||||
|
'name' => $event->name,
|
||||||
|
'start_date' => $event->start_date->format('Y-m-d'),
|
||||||
|
'end_date' => $event->end_date->format('Y-m-d'),
|
||||||
|
],
|
||||||
|
'assignments' => $eventAssignments
|
||||||
|
->groupBy(fn (ShiftAssignment $a) => $a->shift->timeSlot->date->format('Y-m-d'))
|
||||||
|
->map(function ($dateAssignments, string $date) {
|
||||||
|
$carbonDate = Carbon::parse($date);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'date' => $date,
|
||||||
|
'date_label' => ucfirst($carbonDate->translatedFormat('l j F')),
|
||||||
|
'shifts' => $dateAssignments->map(function (ShiftAssignment $a) {
|
||||||
|
$shift = $a->shift;
|
||||||
|
$timeSlot = $shift->timeSlot;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $a->id,
|
||||||
|
'status' => $a->status->value,
|
||||||
|
'shift' => [
|
||||||
|
'id' => $shift->id,
|
||||||
|
'title' => $shift->title ?? $shift->festivalSection->name,
|
||||||
|
'section_name' => $shift->festivalSection->name,
|
||||||
|
'section_icon' => $shift->festivalSection->icon,
|
||||||
|
'time_slot_name' => $timeSlot->name,
|
||||||
|
'date' => $timeSlot->date->format('Y-m-d'),
|
||||||
|
'start_time' => Carbon::parse($shift->actual_start_time ?? $timeSlot->start_time)->format('H:i'),
|
||||||
|
'end_time' => Carbon::parse($shift->actual_end_time ?? $timeSlot->end_time)->format('H:i'),
|
||||||
|
'report_time' => $shift->report_time
|
||||||
|
? Carbon::parse($shift->report_time)->format('H:i')
|
||||||
|
: null,
|
||||||
|
'location' => $shift->location ? [
|
||||||
|
'name' => $shift->location->name,
|
||||||
|
'address' => $shift->location->address,
|
||||||
|
] : null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
})->values()->all(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $this->success($grouped);
|
||||||
|
}
|
||||||
|
|
||||||
public function availableShifts(Request $request, Event $event): JsonResponse
|
public function availableShifts(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$person = $this->resolvePerson($event);
|
$person = $this->resolvePerson($event);
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::get('portal/me', [PortalMeController::class, 'index']);
|
Route::get('portal/me', [PortalMeController::class, 'index']);
|
||||||
Route::put('portal/profile', [PortalMeController::class, 'updateProfile']);
|
Route::put('portal/profile', [PortalMeController::class, 'updateProfile']);
|
||||||
Route::put('portal/password', [PortalMeController::class, 'updatePassword']);
|
Route::put('portal/password', [PortalMeController::class, 'updatePassword']);
|
||||||
|
Route::get('portal/my-shifts', [PortalShiftController::class, 'allMyShifts']);
|
||||||
Route::get('portal/events/{event}/available-shifts', [PortalShiftController::class, 'availableShifts']);
|
Route::get('portal/events/{event}/available-shifts', [PortalShiftController::class, 'availableShifts']);
|
||||||
Route::get('portal/events/{event}/my-shifts', [PortalShiftController::class, 'myShifts']);
|
Route::get('portal/events/{event}/my-shifts', [PortalShiftController::class, 'myShifts']);
|
||||||
Route::post('portal/events/{event}/shifts/{shift}/claim', [PortalShiftController::class, 'claim']);
|
Route::post('portal/events/{event}/shifts/{shift}/claim', [PortalShiftController::class, 'claim']);
|
||||||
|
|||||||
409
api/tests/Feature/Api/V1/Portal/PortalAllMyShiftsTest.php
Normal file
409
api/tests/Feature/Api/V1/Portal/PortalAllMyShiftsTest.php
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\V1\Portal;
|
||||||
|
|
||||||
|
use App\Enums\ShiftAssignmentStatus;
|
||||||
|
use App\Models\CrowdType;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\FestivalSection;
|
||||||
|
use App\Models\Location;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Person;
|
||||||
|
use App\Models\Shift;
|
||||||
|
use App\Models\ShiftAssignment;
|
||||||
|
use App\Models\TimeSlot;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PortalAllMyShiftsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $volunteer;
|
||||||
|
private Organisation $organisation;
|
||||||
|
private Event $event;
|
||||||
|
private FestivalSection $section;
|
||||||
|
private TimeSlot $timeSlot;
|
||||||
|
private CrowdType $crowdType;
|
||||||
|
private Person $person;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seed(RoleSeeder::class);
|
||||||
|
|
||||||
|
$this->organisation = Organisation::factory()->create();
|
||||||
|
$this->volunteer = User::factory()->create();
|
||||||
|
$this->organisation->users()->attach($this->volunteer, ['role' => 'org_member']);
|
||||||
|
|
||||||
|
$this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||||
|
$this->section = FestivalSection::factory()->create([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
]);
|
||||||
|
$this->timeSlot = TimeSlot::factory()->create([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'person_type' => 'VOLUNTEER',
|
||||||
|
'date' => now()->addMonth(),
|
||||||
|
]);
|
||||||
|
$this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
$this->person = Person::factory()->approved()->create([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
'user_id' => $this->volunteer->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createShiftWithAssignment(array $shiftOverrides = [], array $assignmentOverrides = []): ShiftAssignment
|
||||||
|
{
|
||||||
|
$shift = Shift::factory()->open()->create(array_merge([
|
||||||
|
'festival_section_id' => $this->section->id,
|
||||||
|
'time_slot_id' => $this->timeSlot->id,
|
||||||
|
'slots_total' => 4,
|
||||||
|
'slots_open_for_claiming' => 3,
|
||||||
|
], $shiftOverrides));
|
||||||
|
|
||||||
|
// Use approved() only when no explicit status override is given,
|
||||||
|
// because approved() uses afterCreating() which would override create() attributes.
|
||||||
|
$factory = ShiftAssignment::factory();
|
||||||
|
if (! array_key_exists('status', $assignmentOverrides)) {
|
||||||
|
$factory = $factory->approved();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $factory->create(array_merge([
|
||||||
|
'shift_id' => $shift->id,
|
||||||
|
'person_id' => $this->person->id,
|
||||||
|
'time_slot_id' => $this->timeSlot->id,
|
||||||
|
], $assignmentOverrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Happy path
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function test_returns_shifts_for_linked_persons(): void
|
||||||
|
{
|
||||||
|
$this->createShiftWithAssignment(['title' => 'Tapper']);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->volunteer);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [
|
||||||
|
'*' => [
|
||||||
|
'event' => ['id', 'name', 'start_date', 'end_date'],
|
||||||
|
'assignments' => [
|
||||||
|
'*' => [
|
||||||
|
'date',
|
||||||
|
'date_label',
|
||||||
|
'shifts' => [
|
||||||
|
'*' => [
|
||||||
|
'id',
|
||||||
|
'status',
|
||||||
|
'shift' => [
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'section_name',
|
||||||
|
'section_icon',
|
||||||
|
'time_slot_name',
|
||||||
|
'date',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'report_time',
|
||||||
|
'location',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertCount(1, $response->json('data'));
|
||||||
|
$this->assertEquals($this->event->id, $response->json('data.0.event.id'));
|
||||||
|
$this->assertEquals('Tapper', $response->json('data.0.assignments.0.shifts.0.shift.title'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Empty results
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function test_returns_empty_when_user_has_no_linked_persons(): void
|
||||||
|
{
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
|
||||||
|
Sanctum::actingAs($otherUser);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_empty_when_no_active_assignments(): void
|
||||||
|
{
|
||||||
|
// Person exists but no assignments
|
||||||
|
Sanctum::actingAs($this->volunteer);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Status filtering
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function test_only_returns_approved_and_pending_approval_assignments(): void
|
||||||
|
{
|
||||||
|
// Approved — should appear
|
||||||
|
$this->createShiftWithAssignment(
|
||||||
|
['title' => 'Approved Shift'],
|
||||||
|
['status' => ShiftAssignmentStatus::APPROVED],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pending approval — should appear
|
||||||
|
$this->createShiftWithAssignment(
|
||||||
|
['title' => 'Pending Shift'],
|
||||||
|
['status' => ShiftAssignmentStatus::PENDING_APPROVAL],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancelled — should NOT appear
|
||||||
|
$this->createShiftWithAssignment(
|
||||||
|
['title' => 'Cancelled Shift'],
|
||||||
|
['status' => ShiftAssignmentStatus::CANCELLED],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rejected — should NOT appear
|
||||||
|
$this->createShiftWithAssignment(
|
||||||
|
['title' => 'Rejected Shift'],
|
||||||
|
['status' => ShiftAssignmentStatus::REJECTED],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Completed — should NOT appear
|
||||||
|
$this->createShiftWithAssignment(
|
||||||
|
['title' => 'Completed Shift'],
|
||||||
|
['status' => ShiftAssignmentStatus::COMPLETED],
|
||||||
|
);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->volunteer);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$allShifts = collect($response->json('data'))
|
||||||
|
->flatMap(fn ($e) => collect($e['assignments']))
|
||||||
|
->flatMap(fn ($d) => $d['shifts']);
|
||||||
|
|
||||||
|
$this->assertCount(2, $allShifts);
|
||||||
|
|
||||||
|
$statuses = $allShifts->pluck('status')->sort()->values()->all();
|
||||||
|
$this->assertEquals(['approved', 'pending_approval'], $statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_excludes_persons_with_rejected_status(): void
|
||||||
|
{
|
||||||
|
// Create a rejected person with an approved assignment
|
||||||
|
$rejectedPerson = Person::factory()->create([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
'user_id' => $this->volunteer->id,
|
||||||
|
'status' => 'rejected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$shift = Shift::factory()->open()->create([
|
||||||
|
'festival_section_id' => $this->section->id,
|
||||||
|
'time_slot_id' => $this->timeSlot->id,
|
||||||
|
'slots_total' => 4,
|
||||||
|
'slots_open_for_claiming' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ShiftAssignment::factory()->approved()->create([
|
||||||
|
'shift_id' => $shift->id,
|
||||||
|
'person_id' => $rejectedPerson->id,
|
||||||
|
'time_slot_id' => $this->timeSlot->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->volunteer);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
// Only the approved person should contribute shifts
|
||||||
|
$allShifts = collect($response->json('data'))
|
||||||
|
->flatMap(fn ($e) => collect($e['assignments']))
|
||||||
|
->flatMap(fn ($d) => $d['shifts']);
|
||||||
|
|
||||||
|
$this->assertCount(0, $allShifts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Grouping
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function test_grouped_by_event_and_date(): void
|
||||||
|
{
|
||||||
|
// Create a second event with its own person and assignment
|
||||||
|
$event2 = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||||
|
$section2 = FestivalSection::factory()->create(['event_id' => $event2->id]);
|
||||||
|
$timeSlot2 = TimeSlot::factory()->create([
|
||||||
|
'event_id' => $event2->id,
|
||||||
|
'person_type' => 'VOLUNTEER',
|
||||||
|
'date' => now()->addMonths(2),
|
||||||
|
]);
|
||||||
|
$crowdType2 = CrowdType::factory()->systemType('VOLUNTEER')->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
$person2 = Person::factory()->approved()->create([
|
||||||
|
'event_id' => $event2->id,
|
||||||
|
'crowd_type_id' => $crowdType2->id,
|
||||||
|
'user_id' => $this->volunteer->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assignment in event 1
|
||||||
|
$this->createShiftWithAssignment(['title' => 'Event 1 Shift']);
|
||||||
|
|
||||||
|
// Assignment in event 2
|
||||||
|
$shift2 = Shift::factory()->open()->create([
|
||||||
|
'festival_section_id' => $section2->id,
|
||||||
|
'time_slot_id' => $timeSlot2->id,
|
||||||
|
'slots_total' => 4,
|
||||||
|
'slots_open_for_claiming' => 3,
|
||||||
|
'title' => 'Event 2 Shift',
|
||||||
|
]);
|
||||||
|
ShiftAssignment::factory()->approved()->create([
|
||||||
|
'shift_id' => $shift2->id,
|
||||||
|
'person_id' => $person2->id,
|
||||||
|
'time_slot_id' => $timeSlot2->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->volunteer);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
// Should have 2 event groups
|
||||||
|
$this->assertCount(2, $response->json('data'));
|
||||||
|
|
||||||
|
$eventIds = collect($response->json('data'))->pluck('event.id')->sort()->values()->all();
|
||||||
|
$this->assertContains($this->event->id, $eventIds);
|
||||||
|
$this->assertContains($event2->id, $eventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_multiple_dates_within_same_event(): void
|
||||||
|
{
|
||||||
|
$timeSlot2 = TimeSlot::factory()->create([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'person_type' => 'VOLUNTEER',
|
||||||
|
'date' => now()->addMonths(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Day 1 shift
|
||||||
|
$this->createShiftWithAssignment(['title' => 'Day 1 Shift']);
|
||||||
|
|
||||||
|
// Day 2 shift
|
||||||
|
$shift2 = Shift::factory()->open()->create([
|
||||||
|
'festival_section_id' => $this->section->id,
|
||||||
|
'time_slot_id' => $timeSlot2->id,
|
||||||
|
'slots_total' => 4,
|
||||||
|
'slots_open_for_claiming' => 3,
|
||||||
|
'title' => 'Day 2 Shift',
|
||||||
|
]);
|
||||||
|
ShiftAssignment::factory()->approved()->create([
|
||||||
|
'shift_id' => $shift2->id,
|
||||||
|
'person_id' => $this->person->id,
|
||||||
|
'time_slot_id' => $timeSlot2->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->volunteer);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
// 1 event group with 2 date groups
|
||||||
|
$this->assertCount(1, $response->json('data'));
|
||||||
|
$this->assertCount(2, $response->json('data.0.assignments'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Authentication
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function test_unauthenticated_returns_401(): void
|
||||||
|
{
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Response data
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function test_shift_includes_location_data(): void
|
||||||
|
{
|
||||||
|
$location = Location::factory()->create([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'name' => 'Hoofdpodium',
|
||||||
|
'address' => 'Festivalplein 1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->createShiftWithAssignment([
|
||||||
|
'title' => 'Shift met locatie',
|
||||||
|
'location_id' => $location->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->volunteer);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$shiftData = $response->json('data.0.assignments.0.shifts.0.shift');
|
||||||
|
$this->assertEquals('Hoofdpodium', $shiftData['location']['name']);
|
||||||
|
$this->assertEquals('Festivalplein 1', $shiftData['location']['address']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shift_without_location_returns_null(): void
|
||||||
|
{
|
||||||
|
$this->createShiftWithAssignment([
|
||||||
|
'title' => 'Shift zonder locatie',
|
||||||
|
'location_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->volunteer);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertNull($response->json('data.0.assignments.0.shifts.0.shift.location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shift_title_falls_back_to_section_name(): void
|
||||||
|
{
|
||||||
|
$this->createShiftWithAssignment(['title' => null]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->volunteer);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/portal/my-shifts');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$shiftTitle = $response->json('data.0.assignments.0.shifts.0.shift.title');
|
||||||
|
$this->assertEquals($this->section->name, $shiftTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
import type { AvailableShiftsDay, MyShiftsResponse } from '@/types/portal-shift'
|
import type { AllMyShiftsEventGroup, AvailableShiftsDay, MyShiftsResponse } from '@/types/portal-shift'
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
data: T
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAllMyShifts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['portal-all-my-shifts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<AllMyShiftsEventGroup[]>>(
|
||||||
|
'/portal/my-shifts',
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useAvailableShifts(eventId: Ref<string | null>) {
|
export function useAvailableShifts(eventId: Ref<string | null>) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['available-shifts', eventId],
|
queryKey: ['available-shifts', eventId],
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useAllMyShifts } from '@/composables/api/usePortalShifts'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import type { AllMyShiftsAssignment } from '@/types/portal-shift'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
name: 'portal-shifts',
|
name: 'portal-shifts',
|
||||||
meta: {
|
meta: {
|
||||||
@@ -6,6 +10,26 @@ definePage({
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const { data: eventGroups, isLoading, isError, refetch } = useAllMyShifts()
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||||
|
pending_approval: { label: 'In afwachting', color: 'warning' },
|
||||||
|
approved: { label: 'Bevestigd', color: 'success' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('nl-NL', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusConfig(assignment: AllMyShiftsAssignment) {
|
||||||
|
return statusConfig[assignment.status] ?? { label: assignment.status, color: 'default' }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -15,14 +39,199 @@ definePage({
|
|||||||
md="8"
|
md="8"
|
||||||
lg="6"
|
lg="6"
|
||||||
>
|
>
|
||||||
<VCard class="text-center pa-6">
|
<h5 class="text-h5 mb-6">
|
||||||
<VCardTitle class="text-h5">
|
Mijn diensten
|
||||||
Mijn Shifts
|
</h5>
|
||||||
</VCardTitle>
|
|
||||||
<VCardSubtitle>
|
<!-- Not authenticated -->
|
||||||
Overzicht van je ingeplande shifts
|
<VAlert
|
||||||
</VCardSubtitle>
|
v-if="!auth.isAuthenticated"
|
||||||
</VCard>
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
start
|
||||||
|
icon="tabler-login"
|
||||||
|
/>
|
||||||
|
Log in om je diensten te bekijken.
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Loading -->
|
||||||
|
<template v-if="isLoading">
|
||||||
|
<VSkeletonLoader
|
||||||
|
v-for="n in 3"
|
||||||
|
:key="n"
|
||||||
|
type="card"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<VAlert
|
||||||
|
v-else-if="isError"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
Er ging iets mis bij het ophalen van je diensten.
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="refetch()"
|
||||||
|
>
|
||||||
|
Opnieuw proberen
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<VCard
|
||||||
|
v-else-if="!eventGroups?.length"
|
||||||
|
variant="flat"
|
||||||
|
class="text-center pa-8"
|
||||||
|
>
|
||||||
|
<VAvatar
|
||||||
|
size="64"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-calendar-off"
|
||||||
|
size="32"
|
||||||
|
/>
|
||||||
|
</VAvatar>
|
||||||
|
<p class="text-body-1 text-medium-emphasis mb-4">
|
||||||
|
Je hebt nog geen diensten toegewezen gekregen.
|
||||||
|
</p>
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
to="/evenementen"
|
||||||
|
>
|
||||||
|
Bekijk je evenementen
|
||||||
|
</VBtn>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Shift groups -->
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="eventGroup in eventGroups"
|
||||||
|
:key="eventGroup.event.id"
|
||||||
|
class="mb-8"
|
||||||
|
>
|
||||||
|
<!-- Event header -->
|
||||||
|
<div class="d-flex align-center gap-2 mb-4">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-calendar-event"
|
||||||
|
size="20"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<span class="text-subtitle-1 font-weight-bold">
|
||||||
|
{{ eventGroup.event.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date groups -->
|
||||||
|
<div
|
||||||
|
v-for="dateGroup in eventGroup.assignments"
|
||||||
|
:key="dateGroup.date"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<div class="text-subtitle-2 text-medium-emphasis mb-2">
|
||||||
|
{{ dateGroup.date_label ?? formatDate(dateGroup.date) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VCard
|
||||||
|
v-for="assignment in dateGroup.shifts"
|
||||||
|
:key="assignment.id"
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-2 shift-card"
|
||||||
|
:class="`shift-card--${assignment.status}`"
|
||||||
|
>
|
||||||
|
<VCardItem>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon
|
||||||
|
v-if="assignment.shift.section_icon"
|
||||||
|
:icon="assignment.shift.section_icon"
|
||||||
|
size="24"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<VAvatar
|
||||||
|
v-else
|
||||||
|
size="32"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ assignment.shift.section_name[0] }}
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VCardTitle class="text-subtitle-1 font-weight-bold">
|
||||||
|
{{ assignment.shift.title }}
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardSubtitle>{{ assignment.shift.section_name }}</VCardSubtitle>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<VChip
|
||||||
|
:color="getStatusConfig(assignment).color"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ getStatusConfig(assignment).label }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
|
<VCardText class="pt-0">
|
||||||
|
<div class="d-flex flex-wrap gap-x-4 gap-y-1 text-body-2">
|
||||||
|
<span>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-clock"
|
||||||
|
size="14"
|
||||||
|
class="me-1"
|
||||||
|
/>
|
||||||
|
{{ assignment.shift.start_time }} - {{ assignment.shift.end_time }}
|
||||||
|
</span>
|
||||||
|
<span v-if="assignment.shift.report_time">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-alert-circle"
|
||||||
|
size="14"
|
||||||
|
class="me-1"
|
||||||
|
/>
|
||||||
|
Aanwezig: {{ assignment.shift.report_time }}
|
||||||
|
</span>
|
||||||
|
<span v-if="assignment.shift.location">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-map-pin"
|
||||||
|
size="14"
|
||||||
|
class="me-1"
|
||||||
|
/>
|
||||||
|
{{ assignment.shift.location.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shift-card {
|
||||||
|
border-inline-start: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-card--approved {
|
||||||
|
border-inline-start-color: rgb(var(--v-theme-success));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-card--pending_approval {
|
||||||
|
border-inline-start-color: rgb(var(--v-theme-warning));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -48,3 +48,40 @@ export interface MyShiftAssignment {
|
|||||||
report_time: string | null
|
report_time: string | null
|
||||||
can_cancel: boolean
|
can_cancel: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-event "all my shifts" types
|
||||||
|
export interface AllMyShiftsAssignment {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
shift: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
section_name: string
|
||||||
|
section_icon: string | null
|
||||||
|
time_slot_name: string
|
||||||
|
date: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
report_time: string | null
|
||||||
|
location: {
|
||||||
|
name: string
|
||||||
|
address: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AllMyShiftsDateGroup {
|
||||||
|
date: string
|
||||||
|
date_label: string
|
||||||
|
shifts: AllMyShiftsAssignment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AllMyShiftsEventGroup {
|
||||||
|
event: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
}
|
||||||
|
assignments: AllMyShiftsDateGroup[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -538,6 +538,47 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al
|
|||||||
|
|
||||||
- `POST /portal/token-auth` — public. Validates a portal token against artists/production_requests tables. Returns `{ context, data, event }` on success. Returns 501 if token tables don't exist yet, 401 if token is invalid.
|
- `POST /portal/token-auth` — public. Validates a portal token against artists/production_requests tables. Returns `{ context, data, event }` on success. Returns 501 if token tables don't exist yet, 401 if token is invalid.
|
||||||
- `GET /portal/me` — auth:sanctum. Returns the authenticated user's person record for a given event. Query param: `event_id` (required, ULID). Resolves sub-events to parent festival. Returns `PersonResource` with crowdType, shiftAssignments, and volunteerAvailabilities eager-loaded. Returns 404 if no registration found.
|
- `GET /portal/me` — auth:sanctum. Returns the authenticated user's person record for a given event. Query param: `event_id` (required, ULID). Resolves sub-events to parent festival. Returns `PersonResource` with crowdType, shiftAssignments, and volunteerAvailabilities eager-loaded. Returns 404 if no registration found.
|
||||||
|
- `GET /portal/my-shifts` — auth:sanctum. Returns all active shift assignments across all events for the authenticated user. Finds all Person records linked via `user_id` (approved/pending status), then returns their active assignments (approved/pending_approval). Response grouped by event → date.
|
||||||
|
- `GET /portal/events/{event}/available-shifts` — auth:sanctum. Returns shifts available to claim, grouped by date → time slot. Requires approved person status.
|
||||||
|
- `GET /portal/events/{event}/my-shifts` — auth:sanctum. Returns the user's shift assignments for a specific event, categorized as upcoming/past/cancelled.
|
||||||
|
- `POST /portal/events/{event}/shifts/{shift}/claim` — auth:sanctum. Claim a shift. Returns assignment with status (pending_approval or approved based on section auto-accept).
|
||||||
|
- `POST /portal/events/{event}/assignments/{shiftAssignment}/cancel` — auth:sanctum. Cancel own assignment. Must be future and cancellable status.
|
||||||
|
|
||||||
|
### Portal My-Shifts Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"event": { "id": "ulid", "name": "Festival X", "start_date": "2026-07-01", "end_date": "2026-07-03" },
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"date": "2026-07-01",
|
||||||
|
"date_label": "Woensdag 1 juli",
|
||||||
|
"shifts": [
|
||||||
|
{
|
||||||
|
"id": "ulid",
|
||||||
|
"status": "approved",
|
||||||
|
"shift": {
|
||||||
|
"id": "ulid",
|
||||||
|
"title": "Tapper",
|
||||||
|
"section_name": "Bar",
|
||||||
|
"section_icon": "tabler-beer",
|
||||||
|
"time_slot_name": "Avond",
|
||||||
|
"date": "2026-07-01",
|
||||||
|
"start_time": "18:00",
|
||||||
|
"end_time": "23:00",
|
||||||
|
"report_time": "17:30",
|
||||||
|
"location": { "name": "Hoofdpodium", "address": "Festivalplein 1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Registration Field Templates (Organisation Settings)
|
## Registration Field Templates (Organisation Settings)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user