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:
2026-04-14 15:07:08 +02:00
parent d4004c798c
commit 53100d4f6d
7 changed files with 812 additions and 9 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Portal;
use App\Enums\CancellationSource;
use App\Enums\PersonStatus;
use App\Enums\ShiftAssignmentStatus;
use App\Http\Controllers\Controller;
use App\Models\Event;
@@ -23,6 +24,98 @@ final class PortalShiftController extends Controller
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
{
$person = $this->resolvePerson($event);