feat: smart assign person dialog with conflict details and assignable-persons endpoint
Add GET /events/{event}/shifts/{shift}/assignable-persons endpoint that
returns approved persons with availability status, conflict details, and
already-assigned flags. Improve ShiftAssignmentService conflict errors to
include section name, time slot, and time range. Replace both assign
dialogs with a new AssignPersonDialog featuring search, crowd type
filtering, availability toggle, and inline conflict warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\PersonStatus;
|
||||||
use App\Enums\ShiftAssignmentStatus;
|
use App\Enums\ShiftAssignmentStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\V1\BulkApproveShiftAssignmentRequest;
|
use App\Http\Requests\Api\V1\BulkApproveShiftAssignmentRequest;
|
||||||
use App\Http\Requests\Api\V1\RejectShiftAssignmentRequest;
|
use App\Http\Requests\Api\V1\RejectShiftAssignmentRequest;
|
||||||
use App\Http\Resources\Api\V1\ShiftAssignmentResource;
|
use App\Http\Resources\Api\V1\ShiftAssignmentResource;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
|
use App\Models\Person;
|
||||||
|
use App\Models\Shift;
|
||||||
use App\Models\ShiftAssignment;
|
use App\Models\ShiftAssignment;
|
||||||
use App\Services\ShiftAssignmentService;
|
use App\Services\ShiftAssignmentService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -98,4 +101,72 @@ final class ShiftAssignmentController extends Controller
|
|||||||
|
|
||||||
return $this->success($results);
|
return $this->success($results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function assignablePersons(Event $event, Shift $shift): JsonResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', [ShiftAssignment::class, $event]);
|
||||||
|
|
||||||
|
$festivalEventId = $event->parent_event_id ?? $event->id;
|
||||||
|
$timeSlotId = $shift->time_slot_id;
|
||||||
|
$shiftId = $shift->id;
|
||||||
|
|
||||||
|
// Get all conflict assignments for this time slot in one query
|
||||||
|
$conflicts = ShiftAssignment::where('time_slot_id', $timeSlotId)
|
||||||
|
->whereIn('status', [
|
||||||
|
ShiftAssignmentStatus::PENDING_APPROVAL,
|
||||||
|
ShiftAssignmentStatus::APPROVED,
|
||||||
|
])
|
||||||
|
->with(['shift.festivalSection', 'shift.timeSlot'])
|
||||||
|
->get()
|
||||||
|
->keyBy('person_id');
|
||||||
|
|
||||||
|
// Get all assignments for THIS shift in one query
|
||||||
|
$alreadyAssigned = ShiftAssignment::where('shift_id', $shiftId)
|
||||||
|
->whereNotIn('status', [
|
||||||
|
ShiftAssignmentStatus::REJECTED,
|
||||||
|
ShiftAssignmentStatus::CANCELLED,
|
||||||
|
])
|
||||||
|
->pluck('person_id')
|
||||||
|
->flip();
|
||||||
|
|
||||||
|
$persons = Person::where('event_id', $festivalEventId)
|
||||||
|
->where('status', PersonStatus::APPROVED)
|
||||||
|
->with('crowdType')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(function (Person $person) use ($conflicts, $alreadyAssigned, $shiftId) {
|
||||||
|
$isAlreadyAssigned = $alreadyAssigned->has($person->id);
|
||||||
|
$conflict = $conflicts->get($person->id);
|
||||||
|
$hasConflict = $conflict && $conflict->shift_id !== $shiftId;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $person->id,
|
||||||
|
'name' => $person->name,
|
||||||
|
'email' => $person->email,
|
||||||
|
'status' => $person->status,
|
||||||
|
'crowd_type' => $person->crowdType ? [
|
||||||
|
'id' => $person->crowdType->id,
|
||||||
|
'name' => $person->crowdType->name,
|
||||||
|
'system_type' => $person->crowdType->system_type,
|
||||||
|
] : null,
|
||||||
|
'is_available' => ! $hasConflict && ! $isAlreadyAssigned,
|
||||||
|
'already_assigned' => $isAlreadyAssigned,
|
||||||
|
'conflict' => $hasConflict ? [
|
||||||
|
'section_name' => $conflict->shift->festivalSection->name,
|
||||||
|
'shift_title' => $conflict->shift->title ?? $conflict->shift->festivalSection->name,
|
||||||
|
'time_slot_name' => $conflict->shift->timeSlot->name,
|
||||||
|
'time' => $conflict->shift->timeSlot->start_time
|
||||||
|
. '–' . $conflict->shift->timeSlot->end_time,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->sortBy([
|
||||||
|
['already_assigned', 'asc'],
|
||||||
|
['is_available', 'desc'],
|
||||||
|
['name', 'asc'],
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json(['data' => $persons]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ final class ShiftAssignmentService
|
|||||||
$this->validateShiftIsOpen($shift);
|
$this->validateShiftIsOpen($shift);
|
||||||
$this->validatePersonApproved($person);
|
$this->validatePersonApproved($person);
|
||||||
$this->validateClaimCapacity($shift);
|
$this->validateClaimCapacity($shift);
|
||||||
$this->validateNoConflict($shift, $person);
|
$this->validateNoConflict($shift, $person, isClaim: true);
|
||||||
|
|
||||||
$autoApprove = $shift->festivalSection->crew_auto_accepts;
|
$autoApprove = $shift->festivalSection->crew_auto_accepts;
|
||||||
$status = $autoApprove
|
$status = $autoApprove
|
||||||
@@ -323,20 +323,30 @@ final class ShiftAssignmentService
|
|||||||
/**
|
/**
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
private function validateNoConflict(Shift $shift, Person $person): void
|
private function validateNoConflict(Shift $shift, Person $person, bool $isClaim = false): void
|
||||||
{
|
{
|
||||||
if ($shift->allow_overlap) {
|
if ($shift->allow_overlap) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$conflict = ShiftAssignment::where('person_id', $person->id)
|
$existingAssignment = ShiftAssignment::where('person_id', $person->id)
|
||||||
->where('time_slot_id', $shift->time_slot_id)
|
->where('time_slot_id', $shift->time_slot_id)
|
||||||
->active()
|
->active()
|
||||||
->exists();
|
->with(['shift.festivalSection', 'shift.timeSlot'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingAssignment) {
|
||||||
|
$section = $existingAssignment->shift->festivalSection->name;
|
||||||
|
$timeSlot = $existingAssignment->shift->timeSlot->name;
|
||||||
|
$time = $existingAssignment->shift->timeSlot->start_time
|
||||||
|
. '–' . $existingAssignment->shift->timeSlot->end_time;
|
||||||
|
|
||||||
|
$message = $isClaim
|
||||||
|
? "Je bent al ingepland bij \"{$section}\" voor {$timeSlot} ({$time})."
|
||||||
|
: "Deze persoon is al ingepland bij \"{$section}\" voor {$timeSlot} ({$time}).";
|
||||||
|
|
||||||
if ($conflict) {
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'person_id' => ['Deze persoon is al ingepland voor dit tijdslot.'],
|
'person_id' => [$message],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
|
|
||||||
// Shift assignments (event-level)
|
// Shift assignments (event-level)
|
||||||
Route::get('shift-assignments', [ShiftAssignmentController::class, 'index']);
|
Route::get('shift-assignments', [ShiftAssignmentController::class, 'index']);
|
||||||
|
Route::get('shifts/{shift}/assignable-persons', [ShiftAssignmentController::class, 'assignablePersons']);
|
||||||
Route::post('shift-assignments/{shiftAssignment}/approve', [ShiftAssignmentController::class, 'approve']);
|
Route::post('shift-assignments/{shiftAssignment}/approve', [ShiftAssignmentController::class, 'approve']);
|
||||||
Route::post('shift-assignments/{shiftAssignment}/reject', [ShiftAssignmentController::class, 'reject']);
|
Route::post('shift-assignments/{shiftAssignment}/reject', [ShiftAssignmentController::class, 'reject']);
|
||||||
Route::post('shift-assignments/{shiftAssignment}/cancel', [ShiftAssignmentController::class, 'cancel']);
|
Route::post('shift-assignments/{shiftAssignment}/cancel', [ShiftAssignmentController::class, 'cancel']);
|
||||||
|
|||||||
296
api/tests/Feature/Api/V1/AssignablePersonsTest.php
Normal file
296
api/tests/Feature/Api/V1/AssignablePersonsTest.php
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ShiftAssignmentStatus;
|
||||||
|
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\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 AssignablePersonsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $orgAdmin;
|
||||||
|
private User $outsider;
|
||||||
|
private Organisation $organisation;
|
||||||
|
private Organisation $otherOrganisation;
|
||||||
|
private Event $event;
|
||||||
|
private FestivalSection $section;
|
||||||
|
private FestivalSection $otherSection;
|
||||||
|
private TimeSlot $timeSlot;
|
||||||
|
private CrowdType $crowdType;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seed(RoleSeeder::class);
|
||||||
|
|
||||||
|
$this->organisation = Organisation::factory()->create();
|
||||||
|
$this->otherOrganisation = Organisation::factory()->create();
|
||||||
|
|
||||||
|
$this->orgAdmin = User::factory()->create();
|
||||||
|
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
|
||||||
|
|
||||||
|
$this->outsider = User::factory()->create();
|
||||||
|
$this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']);
|
||||||
|
|
||||||
|
$this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||||
|
$this->section = FestivalSection::factory()->create(['event_id' => $this->event->id]);
|
||||||
|
$this->otherSection = FestivalSection::factory()->create(['event_id' => $this->event->id]);
|
||||||
|
$this->timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]);
|
||||||
|
$this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createOpenShift(array $overrides = []): Shift
|
||||||
|
{
|
||||||
|
return 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,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPerson(array $overrides = []): Person
|
||||||
|
{
|
||||||
|
return Person::factory()->approved()->create(array_merge([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Assignable persons endpoint
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function test_assignable_persons_returns_available_persons(): void
|
||||||
|
{
|
||||||
|
$shift = $this->createOpenShift();
|
||||||
|
$person = $this->createPerson();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $person->id)
|
||||||
|
->assertJsonPath('data.0.is_available', true)
|
||||||
|
->assertJsonPath('data.0.already_assigned', false)
|
||||||
|
->assertJsonPath('data.0.conflict', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assignable_persons_shows_conflict_details(): void
|
||||||
|
{
|
||||||
|
$shift1 = $this->createOpenShift();
|
||||||
|
$shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]);
|
||||||
|
$person = $this->createPerson();
|
||||||
|
|
||||||
|
// Assign person to shift1 (same time slot)
|
||||||
|
ShiftAssignment::factory()->create([
|
||||||
|
'shift_id' => $shift1->id,
|
||||||
|
'person_id' => $person->id,
|
||||||
|
'time_slot_id' => $this->timeSlot->id,
|
||||||
|
'status' => ShiftAssignmentStatus::APPROVED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/shifts/{$shift2->id}/assignable-persons",
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data.0.is_available', false)
|
||||||
|
->assertJsonPath('data.0.already_assigned', false)
|
||||||
|
->assertJsonPath('data.0.conflict.section_name', $this->section->name)
|
||||||
|
->assertJsonPath('data.0.conflict.time_slot_name', $this->timeSlot->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assignable_persons_shows_already_assigned(): void
|
||||||
|
{
|
||||||
|
$shift = $this->createOpenShift();
|
||||||
|
$person = $this->createPerson();
|
||||||
|
|
||||||
|
ShiftAssignment::factory()->create([
|
||||||
|
'shift_id' => $shift->id,
|
||||||
|
'person_id' => $person->id,
|
||||||
|
'time_slot_id' => $this->timeSlot->id,
|
||||||
|
'status' => ShiftAssignmentStatus::APPROVED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data.0.already_assigned', true)
|
||||||
|
->assertJsonPath('data.0.is_available', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assignable_persons_excludes_non_approved_persons(): void
|
||||||
|
{
|
||||||
|
$shift = $this->createOpenShift();
|
||||||
|
Person::factory()->create([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonCount(0, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assignable_persons_unauthenticated_returns_401(): void
|
||||||
|
{
|
||||||
|
$shift = $this->createOpenShift();
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assignable_persons_wrong_org_returns_403(): void
|
||||||
|
{
|
||||||
|
$shift = $this->createOpenShift();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->outsider);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assignable_persons_sorts_available_first(): void
|
||||||
|
{
|
||||||
|
$shift1 = $this->createOpenShift();
|
||||||
|
$shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]);
|
||||||
|
|
||||||
|
$available = $this->createPerson(['name' => 'Anna Bakker']);
|
||||||
|
$conflicted = $this->createPerson(['name' => 'Bob Jansen']);
|
||||||
|
|
||||||
|
ShiftAssignment::factory()->create([
|
||||||
|
'shift_id' => $shift1->id,
|
||||||
|
'person_id' => $conflicted->id,
|
||||||
|
'time_slot_id' => $this->timeSlot->id,
|
||||||
|
'status' => ShiftAssignmentStatus::APPROVED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/shifts/{$shift2->id}/assignable-persons",
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonCount(2, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $available->id)
|
||||||
|
->assertJsonPath('data.0.is_available', true)
|
||||||
|
->assertJsonPath('data.1.id', $conflicted->id)
|
||||||
|
->assertJsonPath('data.1.is_available', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assignable_persons_includes_crowd_type(): void
|
||||||
|
{
|
||||||
|
$shift = $this->createOpenShift();
|
||||||
|
$this->createPerson();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data.0.crowd_type.system_type', 'VOLUNTEER')
|
||||||
|
->assertJsonPath('data.0.crowd_type.name', $this->crowdType->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Improved conflict error messages
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function test_assign_conflict_error_includes_section_and_timeslot(): void
|
||||||
|
{
|
||||||
|
$shift1 = $this->createOpenShift();
|
||||||
|
$shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]);
|
||||||
|
$person = $this->createPerson();
|
||||||
|
|
||||||
|
ShiftAssignment::factory()->create([
|
||||||
|
'shift_id' => $shift1->id,
|
||||||
|
'person_id' => $person->id,
|
||||||
|
'time_slot_id' => $this->timeSlot->id,
|
||||||
|
'status' => ShiftAssignmentStatus::APPROVED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->postJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/sections/{$this->otherSection->id}/shifts/{$shift2->id}/assign",
|
||||||
|
['person_id' => $person->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['person_id']);
|
||||||
|
|
||||||
|
$message = $response->json('errors.person_id.0');
|
||||||
|
$this->assertStringContainsString($this->section->name, $message);
|
||||||
|
$this->assertStringContainsString($this->timeSlot->name, $message);
|
||||||
|
$this->assertStringContainsString('Deze persoon is al ingepland bij', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_claim_conflict_error_uses_volunteer_language(): void
|
||||||
|
{
|
||||||
|
$shift1 = $this->createOpenShift();
|
||||||
|
$shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]);
|
||||||
|
$person = $this->createPerson(['user_id' => $this->orgAdmin->id]);
|
||||||
|
|
||||||
|
ShiftAssignment::factory()->create([
|
||||||
|
'shift_id' => $shift1->id,
|
||||||
|
'person_id' => $person->id,
|
||||||
|
'time_slot_id' => $this->timeSlot->id,
|
||||||
|
'status' => ShiftAssignmentStatus::APPROVED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->postJson(
|
||||||
|
"/api/v1/events/{$this->event->id}/sections/{$this->otherSection->id}/shifts/{$shift2->id}/claim",
|
||||||
|
['person_id' => $person->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
|
||||||
|
$message = $response->json('errors.person_id.0');
|
||||||
|
$this->assertStringContainsString('Je bent al ingepland bij', $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,13 @@ import { useShiftList, useDeleteShift } from '@/composables/api/useShifts'
|
|||||||
import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue'
|
import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue'
|
||||||
import EditSectionDialog from '@/components/sections/EditSectionDialog.vue'
|
import EditSectionDialog from '@/components/sections/EditSectionDialog.vue'
|
||||||
import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue'
|
import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue'
|
||||||
import AssignShiftDialog from '@/components/sections/AssignShiftDialog.vue'
|
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
|
||||||
|
import ShiftDetailPanel from '@/components/shifts/ShiftDetailPanel.vue'
|
||||||
|
import { useShiftDetailStore } from '@/stores/useShiftDetailStore'
|
||||||
import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
|
import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
|
||||||
|
|
||||||
|
const shiftDetailStore = useShiftDetailStore()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
eventId: string
|
eventId: string
|
||||||
isSubEvent?: boolean
|
isSubEvent?: boolean
|
||||||
@@ -179,9 +183,10 @@ const statusLabel: Record<ShiftStatus, string> = {
|
|||||||
cancelled: 'Geannuleerd',
|
cancelled: 'Geannuleerd',
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillRateColor(rate: number): string {
|
function fillRateColor(shift: Shift): string {
|
||||||
if (rate >= 80) return 'success'
|
if (shift.is_overbooked) return 'warning'
|
||||||
if (rate >= 40) return 'warning'
|
if (shift.fill_rate >= 80) return 'success'
|
||||||
|
if (shift.fill_rate >= 40) return 'warning'
|
||||||
return 'error'
|
return 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +202,12 @@ function formatDate(iso: string) {
|
|||||||
return dateFormatter.format(new Date(iso))
|
return dateFormatter.format(new Date(iso))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Selected shift for detail panel (resolved from store ID)
|
||||||
|
const selectedShift = computed(() => {
|
||||||
|
if (!shiftDetailStore.selectedShiftId || !shifts.value) return null
|
||||||
|
return shifts.value.find(s => s.id === shiftDetailStore.selectedShiftId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
// Success snackbar
|
// Success snackbar
|
||||||
const showSuccess = ref(false)
|
const showSuccess = ref(false)
|
||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
@@ -481,8 +492,8 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
|
|||||||
<!-- Fill rate -->
|
<!-- Fill rate -->
|
||||||
<div class="d-flex align-center gap-x-2" style="min-inline-size: 160px;">
|
<div class="d-flex align-center gap-x-2" style="min-inline-size: 160px;">
|
||||||
<VProgressLinear
|
<VProgressLinear
|
||||||
:model-value="shift.fill_rate"
|
:model-value="shift.is_overbooked ? 100 : shift.fill_rate"
|
||||||
:color="fillRateColor(shift.fill_rate)"
|
:color="fillRateColor(shift)"
|
||||||
height="8"
|
height="8"
|
||||||
rounded
|
rounded
|
||||||
style="inline-size: 80px;"
|
style="inline-size: 80px;"
|
||||||
@@ -490,6 +501,12 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
|
|||||||
<span class="text-body-2 text-no-wrap">
|
<span class="text-body-2 text-no-wrap">
|
||||||
{{ shift.filled_slots }}/{{ shift.slots_total }}
|
{{ shift.filled_slots }}/{{ shift.slots_total }}
|
||||||
</span>
|
</span>
|
||||||
|
<VIcon
|
||||||
|
v-if="shift.is_overbooked"
|
||||||
|
icon="tabler-alert-triangle"
|
||||||
|
size="16"
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
@@ -499,11 +516,32 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
|
|||||||
>
|
>
|
||||||
{{ statusLabel[shift.status] }}
|
{{ statusLabel[shift.status] }}
|
||||||
</VChip>
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-if="shift.is_overbooked"
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="tabler-alert-triangle"
|
||||||
|
>
|
||||||
|
Overbezet
|
||||||
|
</VChip>
|
||||||
|
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="d-flex gap-x-1">
|
<div class="d-flex gap-x-1">
|
||||||
|
<VBtn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="shiftDetailStore.openPanel(shift.id, activeSection!.id)"
|
||||||
|
>
|
||||||
|
<VIcon size="18">
|
||||||
|
tabler-eye
|
||||||
|
</VIcon>
|
||||||
|
<VTooltip activator="parent">
|
||||||
|
Details bekijken
|
||||||
|
</VTooltip>
|
||||||
|
</VBtn>
|
||||||
<VBtn
|
<VBtn
|
||||||
icon="tabler-user-plus"
|
icon="tabler-user-plus"
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -563,7 +601,7 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
|
|||||||
:is-sub-event="isSubEvent"
|
:is-sub-event="isSubEvent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AssignShiftDialog
|
<AssignPersonDialog
|
||||||
v-if="activeSection"
|
v-if="activeSection"
|
||||||
v-model="isAssignShiftOpen"
|
v-model="isAssignShiftOpen"
|
||||||
:event-id="activeSectionEventId"
|
:event-id="activeSectionEventId"
|
||||||
@@ -626,6 +664,13 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- Shift detail side panel -->
|
||||||
|
<ShiftDetailPanel
|
||||||
|
v-model="shiftDetailStore.isOpen"
|
||||||
|
:event-id="activeSectionEventId"
|
||||||
|
:shift="selectedShift"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Success snackbar -->
|
<!-- Success snackbar -->
|
||||||
<VSnackbar
|
<VSnackbar
|
||||||
v-model="showSuccess"
|
v-model="showSuccess"
|
||||||
|
|||||||
351
apps/app/src/components/shifts/AssignPersonDialog.vue
Normal file
351
apps/app/src/components/shifts/AssignPersonDialog.vue
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAssignablePersons, useAssignPersonToShift } from '@/composables/api/useShiftAssignments'
|
||||||
|
import type { AssignablePerson } from '@/types/shiftAssignment'
|
||||||
|
import type { Shift } from '@/types/section'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
eventId: string
|
||||||
|
sectionId: string
|
||||||
|
shift: Shift | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
assigned: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<boolean>({ required: true })
|
||||||
|
|
||||||
|
const eventIdRef = computed(() => props.eventId)
|
||||||
|
const shiftIdRef = computed(() => props.shift?.id ?? '')
|
||||||
|
|
||||||
|
const { data: assignablePersons, isLoading } = useAssignablePersons(eventIdRef, shiftIdRef)
|
||||||
|
const { mutateAsync: assignPerson, isPending: isAssigning } = useAssignPersonToShift(eventIdRef)
|
||||||
|
|
||||||
|
// Search and filters
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const showOnlyAvailable = ref(true)
|
||||||
|
const selectedCrowdType = ref<string | null>(null)
|
||||||
|
const assignError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Clear error on filter changes
|
||||||
|
watch([searchQuery, showOnlyAvailable, selectedCrowdType], () => {
|
||||||
|
assignError.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset state when dialog opens
|
||||||
|
watch(modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
searchQuery.value = ''
|
||||||
|
showOnlyAvailable.value = true
|
||||||
|
selectedCrowdType.value = null
|
||||||
|
assignError.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Crowd type filter options (derived from data)
|
||||||
|
const crowdTypeOptions = computed(() => {
|
||||||
|
if (!assignablePersons.value) return []
|
||||||
|
const seen = new Map<string, string>()
|
||||||
|
for (const p of assignablePersons.value) {
|
||||||
|
if (p.crowd_type && !seen.has(p.crowd_type.system_type)) {
|
||||||
|
seen.set(p.crowd_type.system_type, p.crowd_type.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(seen, ([value, title]) => ({ title, value }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filtered persons
|
||||||
|
const filteredPersons = computed(() => {
|
||||||
|
if (!assignablePersons.value) return []
|
||||||
|
|
||||||
|
return assignablePersons.value.filter((person) => {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
if (!person.name.toLowerCase().includes(q) && !person.email.toLowerCase().includes(q)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOnlyAvailable.value) {
|
||||||
|
if (!person.is_available || person.already_assigned) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCrowdType.value) {
|
||||||
|
if (person.crowd_type?.system_type !== selectedCrowdType.value) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Empty state reason
|
||||||
|
const emptyReason = computed(() => {
|
||||||
|
if (!assignablePersons.value?.length) {
|
||||||
|
return 'Er zijn geen goedgekeurde personen voor dit evenement.'
|
||||||
|
}
|
||||||
|
if (showOnlyAvailable.value && !filteredPersons.value.length && assignablePersons.value.length) {
|
||||||
|
return 'Alle personen zijn al ingepland voor dit tijdslot. Zet \'Alleen beschikbaar\' uit om alle personen te zien.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Geen personen gevonden voor deze zoekopdracht.'
|
||||||
|
})
|
||||||
|
|
||||||
|
function getInitials(name: string) {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(p => p[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssign(person: AssignablePerson) {
|
||||||
|
if (!props.shift) return
|
||||||
|
assignError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assignPerson({
|
||||||
|
sectionId: props.sectionId,
|
||||||
|
shiftId: props.shift.id,
|
||||||
|
personId: person.id,
|
||||||
|
})
|
||||||
|
emit('assigned')
|
||||||
|
modelValue.value = false
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
const message = error.response?.data?.errors?.person_id?.[0]
|
||||||
|
?? error.response?.data?.message
|
||||||
|
?? 'Er is een fout opgetreden bij het toewijzen.'
|
||||||
|
assignError.value = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog
|
||||||
|
v-model="modelValue"
|
||||||
|
max-width="600"
|
||||||
|
:fullscreen="$vuetify.display.smAndDown"
|
||||||
|
>
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center justify-space-between">
|
||||||
|
<span>Persoon toewijzen</span>
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-x"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="modelValue = false"
|
||||||
|
/>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VCardText class="pb-0">
|
||||||
|
<!-- Shift info -->
|
||||||
|
<div
|
||||||
|
v-if="shift"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center gap-x-2 mb-1">
|
||||||
|
<span class="text-h6">{{ shift.title ?? 'Shift' }}</span>
|
||||||
|
<VChip
|
||||||
|
v-if="shift.is_lead_role"
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Hoofdrol
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-disabled">
|
||||||
|
{{ shift.time_slot?.name }} — {{ shift.effective_start_time }}–{{ shift.effective_end_time }}
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-disabled">
|
||||||
|
Capaciteit: {{ shift.filled_slots }}/{{ shift.slots_total }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider class="mb-4" />
|
||||||
|
|
||||||
|
<!-- Error alert -->
|
||||||
|
<VAlert
|
||||||
|
v-if="assignError"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
closable
|
||||||
|
class="mb-3"
|
||||||
|
@click:close="assignError = null"
|
||||||
|
>
|
||||||
|
{{ assignError }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<VTextField
|
||||||
|
v-model="searchQuery"
|
||||||
|
prepend-inner-icon="tabler-search"
|
||||||
|
placeholder="Zoek op naam of e-mail..."
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="d-flex align-center ga-3 mb-3">
|
||||||
|
<VSwitch
|
||||||
|
v-model="showOnlyAvailable"
|
||||||
|
label="Alleen beschikbaar"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<VSelect
|
||||||
|
v-model="selectedCrowdType"
|
||||||
|
:items="crowdTypeOptions"
|
||||||
|
label="Type"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
style="max-inline-size: 200px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="isLoading">
|
||||||
|
<VSkeletonLoader
|
||||||
|
type="list-item-two-line"
|
||||||
|
class="mb-1"
|
||||||
|
/>
|
||||||
|
<VSkeletonLoader
|
||||||
|
type="list-item-two-line"
|
||||||
|
class="mb-1"
|
||||||
|
/>
|
||||||
|
<VSkeletonLoader type="list-item-two-line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Person list -->
|
||||||
|
<VList
|
||||||
|
v-else-if="filteredPersons.length"
|
||||||
|
density="compact"
|
||||||
|
class="person-list overflow-y-auto"
|
||||||
|
style="max-block-size: 400px;"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="person in filteredPersons"
|
||||||
|
:key="person.id"
|
||||||
|
>
|
||||||
|
<!-- Already assigned -->
|
||||||
|
<VListItem
|
||||||
|
v-if="person.already_assigned"
|
||||||
|
disabled
|
||||||
|
class="opacity-40"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar
|
||||||
|
size="36"
|
||||||
|
color="grey"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<span class="text-caption">{{ getInitials(person.name) }}</span>
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-decoration-line-through">
|
||||||
|
{{ person.name }}
|
||||||
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle>
|
||||||
|
<span class="text-success text-caption">Al toegewezen aan deze shift</span>
|
||||||
|
</VListItemSubtitle>
|
||||||
|
</VListItem>
|
||||||
|
|
||||||
|
<!-- Conflict (unavailable) -->
|
||||||
|
<VListItem
|
||||||
|
v-else-if="!person.is_available && person.conflict"
|
||||||
|
disabled
|
||||||
|
class="opacity-50"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar
|
||||||
|
size="36"
|
||||||
|
color="grey"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<span class="text-caption">{{ getInitials(person.name) }}</span>
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ person.name }}</VListItemTitle>
|
||||||
|
<VListItemSubtitle>
|
||||||
|
<span>{{ person.email }}</span>
|
||||||
|
<br>
|
||||||
|
<span class="text-warning text-caption">
|
||||||
|
Ingepland bij "{{ person.conflict.section_name }}" — {{ person.conflict.time_slot_name }}
|
||||||
|
</span>
|
||||||
|
</VListItemSubtitle>
|
||||||
|
<template #append>
|
||||||
|
<VIcon
|
||||||
|
color="warning"
|
||||||
|
size="18"
|
||||||
|
>
|
||||||
|
tabler-alert-triangle
|
||||||
|
</VIcon>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
|
||||||
|
<!-- Available -->
|
||||||
|
<VListItem
|
||||||
|
v-else
|
||||||
|
:disabled="isAssigning"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="handleAssign(person)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar
|
||||||
|
size="36"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<span class="text-caption">{{ getInitials(person.name) }}</span>
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ person.name }}</VListItemTitle>
|
||||||
|
<VListItemSubtitle>{{ person.email }}</VListItemSubtitle>
|
||||||
|
<template #append>
|
||||||
|
<VChip
|
||||||
|
v-if="person.crowd_type"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ person.crowd_type.name }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</template>
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<VCard
|
||||||
|
v-else
|
||||||
|
variant="outlined"
|
||||||
|
class="text-center pa-6"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-users-minus"
|
||||||
|
size="36"
|
||||||
|
class="mb-2 text-disabled"
|
||||||
|
/>
|
||||||
|
<p class="text-body-2 text-disabled mb-0">
|
||||||
|
{{ emptyReason }}
|
||||||
|
</p>
|
||||||
|
</VCard>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="modelValue = false"
|
||||||
|
>
|
||||||
|
Sluiten
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
817
apps/app/src/components/shifts/ShiftDetailPanel.vue
Normal file
817
apps/app/src/components/shifts/ShiftDetailPanel.vue
Normal file
@@ -0,0 +1,817 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
useShiftAssignmentList,
|
||||||
|
useApproveAssignment,
|
||||||
|
useRejectAssignment,
|
||||||
|
useCancelAssignment,
|
||||||
|
useBulkApproveAssignments,
|
||||||
|
} from '@/composables/api/useShiftAssignments'
|
||||||
|
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
|
||||||
|
import { useShiftDetailStore } from '@/stores/useShiftDetailStore'
|
||||||
|
import { ShiftAssignmentStatus } from '@/types/shiftAssignment'
|
||||||
|
import type { ShiftAssignment } from '@/types/shiftAssignment'
|
||||||
|
import type { Shift } from '@/types/section'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
eventId: string
|
||||||
|
shift: Shift | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<boolean>({ required: true })
|
||||||
|
|
||||||
|
const store = useShiftDetailStore()
|
||||||
|
const eventIdRef = computed(() => props.eventId)
|
||||||
|
|
||||||
|
// Fetch assignments filtered by this shift
|
||||||
|
const filters = computed(() => ({
|
||||||
|
shift_id: props.shift?.id ?? '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: assignmentsResponse,
|
||||||
|
isLoading: assignmentsLoading,
|
||||||
|
isError: assignmentsError,
|
||||||
|
refetch: refetchAssignments,
|
||||||
|
} = useShiftAssignmentList(eventIdRef, filters)
|
||||||
|
|
||||||
|
const assignments = computed(() => assignmentsResponse.value?.data ?? [])
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const { mutate: approveAssignment, isPending: isApproving } = useApproveAssignment(eventIdRef)
|
||||||
|
const { mutate: rejectAssignment, isPending: isRejecting } = useRejectAssignment(eventIdRef)
|
||||||
|
const { mutate: cancelAssignment, isPending: isCancelling } = useCancelAssignment(eventIdRef)
|
||||||
|
const { mutate: bulkApprove, isPending: isBulkApproving } = useBulkApproveAssignments(eventIdRef)
|
||||||
|
|
||||||
|
// Status counts
|
||||||
|
const pendingAssignments = computed(() =>
|
||||||
|
assignments.value.filter(a => a.status === ShiftAssignmentStatus.PENDING_APPROVAL),
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusCounts = computed(() => {
|
||||||
|
const counts = { pending: 0, approved: 0, rejected: 0, cancelled: 0, completed: 0 }
|
||||||
|
for (const a of assignments.value) {
|
||||||
|
if (a.status === ShiftAssignmentStatus.PENDING_APPROVAL) counts.pending++
|
||||||
|
else if (a.status === ShiftAssignmentStatus.APPROVED) counts.approved++
|
||||||
|
else if (a.status === ShiftAssignmentStatus.REJECTED) counts.rejected++
|
||||||
|
else if (a.status === ShiftAssignmentStatus.CANCELLED) counts.cancelled++
|
||||||
|
else if (a.status === ShiftAssignmentStatus.COMPLETED) counts.completed++
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
// Status UI maps
|
||||||
|
const statusColor: Record<ShiftAssignmentStatus, string> = {
|
||||||
|
[ShiftAssignmentStatus.PENDING_APPROVAL]: 'warning',
|
||||||
|
[ShiftAssignmentStatus.APPROVED]: 'success',
|
||||||
|
[ShiftAssignmentStatus.REJECTED]: 'error',
|
||||||
|
[ShiftAssignmentStatus.CANCELLED]: 'default',
|
||||||
|
[ShiftAssignmentStatus.COMPLETED]: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<ShiftAssignmentStatus, string> = {
|
||||||
|
[ShiftAssignmentStatus.PENDING_APPROVAL]: 'Wachtend',
|
||||||
|
[ShiftAssignmentStatus.APPROVED]: 'Goedgekeurd',
|
||||||
|
[ShiftAssignmentStatus.REJECTED]: 'Afgewezen',
|
||||||
|
[ShiftAssignmentStatus.CANCELLED]: 'Geannuleerd',
|
||||||
|
[ShiftAssignmentStatus.COMPLETED]: 'Voltooid',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
const statusFilter = ref<ShiftAssignmentStatus | ''>('')
|
||||||
|
|
||||||
|
const filteredAssignments = computed(() => {
|
||||||
|
if (!statusFilter.value) return assignments.value
|
||||||
|
return assignments.value.filter(a => a.status === statusFilter.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusFilterOptions = [
|
||||||
|
{ title: 'Alle', value: '' },
|
||||||
|
{ title: 'Wachtend', value: ShiftAssignmentStatus.PENDING_APPROVAL },
|
||||||
|
{ title: 'Goedgekeurd', value: ShiftAssignmentStatus.APPROVED },
|
||||||
|
{ title: 'Afgewezen', value: ShiftAssignmentStatus.REJECTED },
|
||||||
|
{ title: 'Geannuleerd', value: ShiftAssignmentStatus.CANCELLED },
|
||||||
|
{ title: 'Voltooid', value: ShiftAssignmentStatus.COMPLETED },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Date formatting
|
||||||
|
const dateTimeFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDateTime(iso: string) {
|
||||||
|
return dateTimeFormatter.format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name: string) {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(p => p[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk selection
|
||||||
|
const isAllPendingSelected = computed(() => {
|
||||||
|
if (!pendingAssignments.value.length) return false
|
||||||
|
return pendingAssignments.value.every(a =>
|
||||||
|
store.selectedAssignmentIds.includes(a.id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onToggleSelectAll() {
|
||||||
|
if (isAllPendingSelected.value) {
|
||||||
|
store.clearSelection()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
store.selectAllPending(assignments.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snackbar
|
||||||
|
const showSuccess = ref(false)
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
function onApprove(assignment: ShiftAssignment) {
|
||||||
|
approveAssignment(assignment.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
successMessage.value = `${assignment.person?.name ?? 'Toewijzing'} goedgekeurd`
|
||||||
|
showSuccess.value = true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel(assignment: ShiftAssignment) {
|
||||||
|
cancellingAssignment.value = assignment
|
||||||
|
isCancelDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelExecute() {
|
||||||
|
if (!cancellingAssignment.value) return
|
||||||
|
const name = cancellingAssignment.value.person?.name ?? 'Toewijzing'
|
||||||
|
|
||||||
|
cancelAssignment(cancellingAssignment.value.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
isCancelDialogOpen.value = false
|
||||||
|
cancellingAssignment.value = null
|
||||||
|
successMessage.value = `${name} geannuleerd`
|
||||||
|
showSuccess.value = true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject dialog
|
||||||
|
const isRejectDialogOpen = ref(false)
|
||||||
|
const rejectingAssignment = ref<ShiftAssignment | null>(null)
|
||||||
|
const rejectReason = ref('')
|
||||||
|
|
||||||
|
function onReject(assignment: ShiftAssignment) {
|
||||||
|
rejectingAssignment.value = assignment
|
||||||
|
rejectReason.value = ''
|
||||||
|
isRejectDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRejectExecute() {
|
||||||
|
if (!rejectingAssignment.value) return
|
||||||
|
const name = rejectingAssignment.value.person?.name ?? 'Toewijzing'
|
||||||
|
|
||||||
|
rejectAssignment(
|
||||||
|
{
|
||||||
|
assignmentId: rejectingAssignment.value.id,
|
||||||
|
reason: rejectReason.value || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
isRejectDialogOpen.value = false
|
||||||
|
rejectingAssignment.value = null
|
||||||
|
rejectReason.value = ''
|
||||||
|
successMessage.value = `${name} afgewezen`
|
||||||
|
showSuccess.value = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel dialog
|
||||||
|
const isCancelDialogOpen = ref(false)
|
||||||
|
const cancellingAssignment = ref<ShiftAssignment | null>(null)
|
||||||
|
|
||||||
|
// Bulk approve dialog
|
||||||
|
const isBulkApproveDialogOpen = ref(false)
|
||||||
|
|
||||||
|
function onBulkApprove() {
|
||||||
|
isBulkApproveDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBulkApproveExecute() {
|
||||||
|
if (!store.selectedAssignmentIds.length) return
|
||||||
|
|
||||||
|
bulkApprove(store.selectedAssignmentIds, {
|
||||||
|
onSuccess: () => {
|
||||||
|
isBulkApproveDialogOpen.value = false
|
||||||
|
successMessage.value = `${store.selectedAssignmentIds.length} toewijzingen goedgekeurd`
|
||||||
|
showSuccess.value = true
|
||||||
|
store.clearSelection()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign person dialog
|
||||||
|
const isAssignDialogOpen = ref(false)
|
||||||
|
|
||||||
|
function onPersonAssigned() {
|
||||||
|
successMessage.value = 'Persoon toegewezen'
|
||||||
|
showSuccess.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill rate color
|
||||||
|
function fillRateColor(): string {
|
||||||
|
if (props.shift?.is_overbooked) return 'warning'
|
||||||
|
const rate = props.shift?.fill_rate ?? 0
|
||||||
|
if (rate >= 80) return 'success'
|
||||||
|
if (rate >= 40) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VNavigationDrawer
|
||||||
|
v-model="modelValue"
|
||||||
|
class="shift-detail-drawer"
|
||||||
|
location="end"
|
||||||
|
temporary
|
||||||
|
:width="560"
|
||||||
|
>
|
||||||
|
<template v-if="shift">
|
||||||
|
<div
|
||||||
|
class="d-flex flex-column h-100 overflow-hidden"
|
||||||
|
style="min-height: 0;"
|
||||||
|
>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="pa-6 pb-4">
|
||||||
|
<div class="d-flex justify-space-between align-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-h5 mb-1">
|
||||||
|
{{ shift.title ?? 'Shift' }}
|
||||||
|
</h5>
|
||||||
|
<div class="d-flex gap-x-2 flex-wrap">
|
||||||
|
<VChip
|
||||||
|
v-if="shift.is_lead_role"
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Hoofdrol
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
:color="shift.status === 'open' ? 'info' : shift.status === 'full' ? 'success' : 'default'"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ shift.status }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-x"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
title="Sluiten"
|
||||||
|
@click="modelValue = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shift info -->
|
||||||
|
<VList
|
||||||
|
density="compact"
|
||||||
|
class="pa-0"
|
||||||
|
>
|
||||||
|
<VListItem density="compact">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-clock"
|
||||||
|
size="18"
|
||||||
|
class="me-3"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2">
|
||||||
|
Tijdslot
|
||||||
|
</VListItemTitle>
|
||||||
|
<template #append>
|
||||||
|
<span class="text-body-2">
|
||||||
|
{{ shift.time_slot?.name ?? '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
|
||||||
|
<VListItem density="compact">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-calendar-time"
|
||||||
|
size="18"
|
||||||
|
class="me-3"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2">
|
||||||
|
Tijd
|
||||||
|
</VListItemTitle>
|
||||||
|
<template #append>
|
||||||
|
<span class="text-body-2">
|
||||||
|
{{ shift.effective_start_time }}–{{ shift.effective_end_time }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
|
||||||
|
<VListItem density="compact">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-users"
|
||||||
|
size="18"
|
||||||
|
class="me-3"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2">
|
||||||
|
Bezetting
|
||||||
|
</VListItemTitle>
|
||||||
|
<template #append>
|
||||||
|
<div class="d-flex align-center gap-x-2">
|
||||||
|
<VProgressLinear
|
||||||
|
:model-value="shift.is_overbooked ? 100 : shift.fill_rate"
|
||||||
|
:color="fillRateColor()"
|
||||||
|
height="6"
|
||||||
|
rounded
|
||||||
|
style="inline-size: 60px;"
|
||||||
|
/>
|
||||||
|
<span class="text-body-2">
|
||||||
|
{{ shift.filled_slots }}/{{ shift.slots_total }}
|
||||||
|
</span>
|
||||||
|
<VChip
|
||||||
|
v-if="shift.is_overbooked"
|
||||||
|
color="warning"
|
||||||
|
size="x-small"
|
||||||
|
prepend-icon="tabler-alert-triangle"
|
||||||
|
>
|
||||||
|
Overbezet
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
|
||||||
|
<VListItem
|
||||||
|
v-if="shift.report_time"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-alarm"
|
||||||
|
size="18"
|
||||||
|
class="me-3"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2">
|
||||||
|
Aanwezig
|
||||||
|
</VListItemTitle>
|
||||||
|
<template #append>
|
||||||
|
<span class="text-body-2">{{ shift.report_time }}</span>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<!-- Status breakdown -->
|
||||||
|
<div class="pa-6 py-4">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<VCard
|
||||||
|
variant="tonal"
|
||||||
|
color="warning"
|
||||||
|
class="pa-2 text-center flex-fill"
|
||||||
|
>
|
||||||
|
<p class="text-h6 mb-0">
|
||||||
|
{{ statusCounts.pending }}
|
||||||
|
</p>
|
||||||
|
<p class="text-caption mb-0">
|
||||||
|
Wachtend
|
||||||
|
</p>
|
||||||
|
</VCard>
|
||||||
|
<VCard
|
||||||
|
variant="tonal"
|
||||||
|
color="success"
|
||||||
|
class="pa-2 text-center flex-fill"
|
||||||
|
>
|
||||||
|
<p class="text-h6 mb-0">
|
||||||
|
{{ statusCounts.approved }}
|
||||||
|
</p>
|
||||||
|
<p class="text-caption mb-0">
|
||||||
|
Goedg.
|
||||||
|
</p>
|
||||||
|
</VCard>
|
||||||
|
<VCard
|
||||||
|
variant="tonal"
|
||||||
|
color="info"
|
||||||
|
class="pa-2 text-center flex-fill"
|
||||||
|
>
|
||||||
|
<p class="text-h6 mb-0">
|
||||||
|
{{ statusCounts.completed }}
|
||||||
|
</p>
|
||||||
|
<p class="text-caption mb-0">
|
||||||
|
Voltooid
|
||||||
|
</p>
|
||||||
|
</VCard>
|
||||||
|
<VCard
|
||||||
|
variant="tonal"
|
||||||
|
color="error"
|
||||||
|
class="pa-2 text-center flex-fill"
|
||||||
|
>
|
||||||
|
<p class="text-h6 mb-0">
|
||||||
|
{{ statusCounts.rejected }}
|
||||||
|
</p>
|
||||||
|
<p class="text-caption mb-0">
|
||||||
|
Afgew.
|
||||||
|
</p>
|
||||||
|
</VCard>
|
||||||
|
<VCard
|
||||||
|
variant="tonal"
|
||||||
|
class="pa-2 text-center flex-fill"
|
||||||
|
>
|
||||||
|
<p class="text-h6 mb-0">
|
||||||
|
{{ statusCounts.cancelled }}
|
||||||
|
</p>
|
||||||
|
<p class="text-caption mb-0">
|
||||||
|
Geann.
|
||||||
|
</p>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<!-- Quick actions -->
|
||||||
|
<div class="pa-6 py-3 d-flex gap-x-2 flex-wrap">
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="tabler-user-plus"
|
||||||
|
@click="isAssignDialogOpen = true"
|
||||||
|
>
|
||||||
|
Toewijzen
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
v-if="store.selectedAssignmentIds.length > 0"
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="tabler-circle-check"
|
||||||
|
:loading="isBulkApproving"
|
||||||
|
@click="onBulkApprove"
|
||||||
|
>
|
||||||
|
Goedkeuren ({{ store.selectedAssignmentIds.length }})
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment list (scrollable) -->
|
||||||
|
<div
|
||||||
|
class="pa-6 pt-4 flex-grow-1 overflow-y-auto"
|
||||||
|
style="min-height: 0;"
|
||||||
|
>
|
||||||
|
<div class="d-flex justify-space-between align-center mb-3">
|
||||||
|
<h6 class="text-h6">
|
||||||
|
Toewijzingen ({{ assignments.length }})
|
||||||
|
</h6>
|
||||||
|
<VCheckbox
|
||||||
|
v-if="pendingAssignments.length > 0"
|
||||||
|
:model-value="isAllPendingSelected"
|
||||||
|
label="Alle wachtend"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
@update:model-value="onToggleSelectAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status filter -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<AppSelect
|
||||||
|
v-model="statusFilter"
|
||||||
|
:items="statusFilterOptions"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-inline-size: 200px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="assignmentsLoading">
|
||||||
|
<VSkeletonLoader
|
||||||
|
type="list-item-three-line"
|
||||||
|
class="mb-2"
|
||||||
|
/>
|
||||||
|
<VSkeletonLoader
|
||||||
|
type="list-item-three-line"
|
||||||
|
class="mb-2"
|
||||||
|
/>
|
||||||
|
<VSkeletonLoader type="list-item-three-line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<VAlert
|
||||||
|
v-else-if="assignmentsError"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
Kon toewijzingen niet laden.
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="refetchAssignments()"
|
||||||
|
>
|
||||||
|
Opnieuw proberen
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<VCard
|
||||||
|
v-else-if="!assignments.length"
|
||||||
|
variant="outlined"
|
||||||
|
class="text-center pa-6"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-users"
|
||||||
|
size="36"
|
||||||
|
class="mb-2 text-disabled"
|
||||||
|
/>
|
||||||
|
<p class="text-body-2 text-disabled mb-0">
|
||||||
|
Nog geen toewijzingen voor deze shift.
|
||||||
|
</p>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- No filter results -->
|
||||||
|
<VCard
|
||||||
|
v-else-if="!filteredAssignments.length"
|
||||||
|
variant="outlined"
|
||||||
|
class="text-center pa-6"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-filter-off"
|
||||||
|
size="36"
|
||||||
|
class="mb-2 text-disabled"
|
||||||
|
/>
|
||||||
|
<p class="text-body-2 text-disabled mb-0">
|
||||||
|
Geen toewijzingen gevonden voor dit filter.
|
||||||
|
</p>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Assignment cards -->
|
||||||
|
<div v-else>
|
||||||
|
<VCard
|
||||||
|
v-for="assignment in filteredAssignments"
|
||||||
|
:key="assignment.id"
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<VCardText class="pa-3">
|
||||||
|
<div class="d-flex justify-space-between align-start">
|
||||||
|
<div class="d-flex gap-x-3 align-center flex-grow-1" style="min-width: 0;">
|
||||||
|
<!-- Checkbox for pending items -->
|
||||||
|
<VCheckbox
|
||||||
|
v-if="assignment.is_approvable"
|
||||||
|
:model-value="store.selectedAssignmentIds.includes(assignment.id)"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
@update:model-value="store.toggleAssignmentSelection(assignment.id)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VAvatar
|
||||||
|
size="32"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<span class="text-caption">
|
||||||
|
{{ assignment.person ? getInitials(assignment.person.name) : '?' }}
|
||||||
|
</span>
|
||||||
|
</VAvatar>
|
||||||
|
|
||||||
|
<div style="min-width: 0;" class="flex-grow-1">
|
||||||
|
<div class="d-flex align-center gap-x-2 flex-wrap">
|
||||||
|
<span class="text-body-2 font-weight-medium text-truncate">
|
||||||
|
{{ assignment.person?.name ?? 'Onbekend' }}
|
||||||
|
</span>
|
||||||
|
<VChip
|
||||||
|
v-if="assignment.status === ShiftAssignmentStatus.REJECTED"
|
||||||
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
|
size="x-small"
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{{ statusLabel[assignment.status] }}
|
||||||
|
<VTooltip
|
||||||
|
v-if="assignment.rejection_reason"
|
||||||
|
activator="parent"
|
||||||
|
location="top"
|
||||||
|
>
|
||||||
|
{{ assignment.rejection_reason }}
|
||||||
|
</VTooltip>
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-else
|
||||||
|
:color="statusColor[assignment.status]"
|
||||||
|
variant="tonal"
|
||||||
|
size="x-small"
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{{ statusLabel[assignment.status] }}
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-if="assignment.auto_approved"
|
||||||
|
size="x-small"
|
||||||
|
color="info"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<p class="text-caption text-disabled mb-0">
|
||||||
|
{{ formatDateTime(assignment.created_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions menu -->
|
||||||
|
<VMenu v-if="assignment.is_approvable || assignment.is_cancellable">
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-dots-vertical"
|
||||||
|
variant="text"
|
||||||
|
size="x-small"
|
||||||
|
v-bind="menuProps"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VList density="compact">
|
||||||
|
<VListItem
|
||||||
|
v-if="assignment.is_approvable"
|
||||||
|
prepend-icon="tabler-circle-check"
|
||||||
|
title="Goedkeuren"
|
||||||
|
@click="onApprove(assignment)"
|
||||||
|
/>
|
||||||
|
<VListItem
|
||||||
|
v-if="assignment.is_approvable"
|
||||||
|
prepend-icon="tabler-circle-x"
|
||||||
|
title="Afwijzen"
|
||||||
|
base-color="error"
|
||||||
|
@click="onReject(assignment)"
|
||||||
|
/>
|
||||||
|
<VListItem
|
||||||
|
v-if="assignment.is_cancellable"
|
||||||
|
prepend-icon="tabler-ban"
|
||||||
|
title="Annuleren"
|
||||||
|
base-color="error"
|
||||||
|
@click="onCancel(assignment)"
|
||||||
|
/>
|
||||||
|
</VList>
|
||||||
|
</VMenu>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Reject dialog -->
|
||||||
|
<VDialog
|
||||||
|
v-model="isRejectDialogOpen"
|
||||||
|
max-width="440"
|
||||||
|
>
|
||||||
|
<VCard title="Toewijzing afwijzen">
|
||||||
|
<VCardText>
|
||||||
|
Weet je zeker dat je de toewijzing van
|
||||||
|
<strong>{{ rejectingAssignment?.person?.name ?? 'deze persoon' }}</strong>
|
||||||
|
wilt afwijzen?
|
||||||
|
|
||||||
|
<VTextarea
|
||||||
|
v-model="rejectReason"
|
||||||
|
label="Reden (optioneel)"
|
||||||
|
variant="outlined"
|
||||||
|
rows="3"
|
||||||
|
class="mt-4"
|
||||||
|
placeholder="Bijv. onvoldoende ervaring voor deze rol..."
|
||||||
|
/>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="isRejectDialogOpen = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="error"
|
||||||
|
:loading="isRejecting"
|
||||||
|
@click="onRejectExecute"
|
||||||
|
>
|
||||||
|
Afwijzen
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- Cancel dialog -->
|
||||||
|
<VDialog
|
||||||
|
v-model="isCancelDialogOpen"
|
||||||
|
max-width="440"
|
||||||
|
>
|
||||||
|
<VCard title="Toewijzing annuleren">
|
||||||
|
<VCardText>
|
||||||
|
Weet je zeker dat je de toewijzing van
|
||||||
|
<strong>{{ cancellingAssignment?.person?.name ?? 'deze persoon' }}</strong>
|
||||||
|
wilt annuleren?
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="isCancelDialogOpen = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="error"
|
||||||
|
:loading="isCancelling"
|
||||||
|
@click="onCancelExecute"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- Bulk approve dialog -->
|
||||||
|
<VDialog
|
||||||
|
v-model="isBulkApproveDialogOpen"
|
||||||
|
max-width="440"
|
||||||
|
>
|
||||||
|
<VCard title="Toewijzingen goedkeuren">
|
||||||
|
<VCardText>
|
||||||
|
Weet je zeker dat je
|
||||||
|
<strong>{{ store.selectedAssignmentIds.length }}</strong>
|
||||||
|
{{ store.selectedAssignmentIds.length === 1 ? 'toewijzing' : 'toewijzingen' }}
|
||||||
|
wilt goedkeuren?
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="isBulkApproveDialogOpen = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="success"
|
||||||
|
:loading="isBulkApproving"
|
||||||
|
@click="onBulkApproveExecute"
|
||||||
|
>
|
||||||
|
Goedkeuren
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- Assign person dialog -->
|
||||||
|
<AssignPersonDialog
|
||||||
|
v-if="shift"
|
||||||
|
v-model="isAssignDialogOpen"
|
||||||
|
:event-id="eventId"
|
||||||
|
:section-id="store.selectedSectionId ?? ''"
|
||||||
|
:shift="shift"
|
||||||
|
@assigned="onPersonAssigned"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Success snackbar -->
|
||||||
|
<VSnackbar
|
||||||
|
v-model="showSuccess"
|
||||||
|
color="success"
|
||||||
|
:timeout="3000"
|
||||||
|
>
|
||||||
|
{{ successMessage }}
|
||||||
|
</VSnackbar>
|
||||||
|
</VNavigationDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shift-detail-drawer :deep(.v-navigation-drawer__content) {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
161
apps/app/src/composables/api/useShiftAssignments.ts
Normal file
161
apps/app/src/composables/api/useShiftAssignments.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import type { MaybeRef, Ref } from 'vue'
|
||||||
|
import { unref } from 'vue'
|
||||||
|
import { apiClient } from '@/lib/axios'
|
||||||
|
import type { AssignablePerson, ShiftAssignment } from '@/types/shiftAssignment'
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
data: T[]
|
||||||
|
links: Record<string, string | null>
|
||||||
|
meta: {
|
||||||
|
current_page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
last_page: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftAssignmentFilters {
|
||||||
|
shift_id?: string
|
||||||
|
person_id?: string
|
||||||
|
section_id?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShiftAssignmentList(
|
||||||
|
eventId: Ref<string>,
|
||||||
|
filters?: Ref<ShiftAssignmentFilters>,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['shift-assignments', eventId, filters],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (filters?.value?.shift_id) params.shift_id = filters.value.shift_id
|
||||||
|
if (filters?.value?.person_id) params.person_id = filters.value.person_id
|
||||||
|
if (filters?.value?.section_id) params.section_id = filters.value.section_id
|
||||||
|
if (filters?.value?.status) params.status = filters.value.status
|
||||||
|
|
||||||
|
const { data } = await apiClient.get<PaginatedResponse<ShiftAssignment>>(
|
||||||
|
`/events/${eventId.value}/shift-assignments`,
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
enabled: () => !!eventId.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApproveAssignment(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (assignmentId: string) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<ShiftAssignment>>(
|
||||||
|
`/events/${eventId.value}/shift-assignments/${assignmentId}/approve`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shifts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectAssignment(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ assignmentId, reason }: { assignmentId: string; reason?: string }) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<ShiftAssignment>>(
|
||||||
|
`/events/${eventId.value}/shift-assignments/${assignmentId}/reject`,
|
||||||
|
{ reason },
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shifts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCancelAssignment(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (assignmentId: string) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<ShiftAssignment>>(
|
||||||
|
`/events/${eventId.value}/shift-assignments/${assignmentId}/cancel`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shifts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBulkApproveAssignments(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (assignmentIds: string[]) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<unknown>>(
|
||||||
|
`/events/${eventId.value}/shift-assignments/bulk-approve`,
|
||||||
|
{ assignment_ids: assignmentIds },
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shifts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignPersonToShift(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ sectionId, shiftId, personId }: { sectionId: string; shiftId: string; personId: string }) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<ShiftAssignment>>(
|
||||||
|
`/events/${eventId.value}/sections/${sectionId}/shifts/${shiftId}/assign`,
|
||||||
|
{ person_id: personId },
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shifts'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['persons', eventId.value] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignablePersons(eventId: MaybeRef<string>, shiftId: MaybeRef<string>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['assignable-persons', eventId, shiftId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<{ data: AssignablePerson[] }>(
|
||||||
|
`/events/${unref(eventId)}/shifts/${unref(shiftId)}/assignable-persons`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
enabled: () => !!unref(eventId) && !!unref(shiftId),
|
||||||
|
})
|
||||||
|
}
|
||||||
67
apps/app/src/types/shiftAssignment.ts
Normal file
67
apps/app/src/types/shiftAssignment.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Person } from './person'
|
||||||
|
import type { Shift } from './section'
|
||||||
|
|
||||||
|
export const ShiftAssignmentStatus = {
|
||||||
|
PENDING_APPROVAL: 'pending_approval',
|
||||||
|
APPROVED: 'approved',
|
||||||
|
REJECTED: 'rejected',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ShiftAssignmentStatus = (typeof ShiftAssignmentStatus)[keyof typeof ShiftAssignmentStatus]
|
||||||
|
|
||||||
|
export interface ShiftAssignment {
|
||||||
|
id: string
|
||||||
|
shift_id: string
|
||||||
|
person_id: string
|
||||||
|
time_slot_id: string
|
||||||
|
status: ShiftAssignmentStatus
|
||||||
|
auto_approved: boolean
|
||||||
|
assigned_by: string | null
|
||||||
|
assigned_at: string | null
|
||||||
|
approved_by: string | null
|
||||||
|
approved_at: string | null
|
||||||
|
rejection_reason: string | null
|
||||||
|
hours_expected: number | null
|
||||||
|
hours_completed: number | null
|
||||||
|
checked_in_at: string | null
|
||||||
|
checked_out_at: string | null
|
||||||
|
is_cancellable: boolean
|
||||||
|
is_approvable: boolean
|
||||||
|
person?: Person
|
||||||
|
shift?: Shift
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignPersonToShiftDto {
|
||||||
|
person_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RejectAssignmentDto {
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkApproveDto {
|
||||||
|
assignment_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignablePerson {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
status: string
|
||||||
|
crowd_type: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
system_type: string
|
||||||
|
} | null
|
||||||
|
is_available: boolean
|
||||||
|
already_assigned: boolean
|
||||||
|
conflict: {
|
||||||
|
section_name: string
|
||||||
|
shift_title: string
|
||||||
|
time_slot_name: string
|
||||||
|
time: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
@@ -180,6 +180,46 @@ Auth: org_admin or event_manager on the event's organisation.
|
|||||||
- `POST /events/{event}/shift-assignments/{shiftAssignment}/reject` — reject pending assignment
|
- `POST /events/{event}/shift-assignments/{shiftAssignment}/reject` — reject pending assignment
|
||||||
- `POST /events/{event}/shift-assignments/{shiftAssignment}/cancel` — cancel assignment
|
- `POST /events/{event}/shift-assignments/{shiftAssignment}/cancel` — cancel assignment
|
||||||
- `POST /events/{event}/shift-assignments/bulk-approve` — bulk approve multiple assignments
|
- `POST /events/{event}/shift-assignments/bulk-approve` — bulk approve multiple assignments
|
||||||
|
- `GET /events/{event}/shifts/{shift}/assignable-persons` — list approved persons with availability status
|
||||||
|
|
||||||
|
### Assignable Persons
|
||||||
|
|
||||||
|
`GET /events/{event}/shifts/{shift}/assignable-persons`
|
||||||
|
|
||||||
|
Returns all approved persons for the event with availability status for this shift's time slot.
|
||||||
|
Persons are sorted: available first, then unavailable (conflict), then already assigned.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "ulid",
|
||||||
|
"name": "Jan de Vries",
|
||||||
|
"email": "jan@gmail.com",
|
||||||
|
"status": "approved",
|
||||||
|
"crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" },
|
||||||
|
"is_available": true,
|
||||||
|
"already_assigned": false,
|
||||||
|
"conflict": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ulid",
|
||||||
|
"name": "Ahmed Hassan",
|
||||||
|
"email": "ahmed.h@gmail.com",
|
||||||
|
"status": "approved",
|
||||||
|
"crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" },
|
||||||
|
"is_available": false,
|
||||||
|
"already_assigned": false,
|
||||||
|
"conflict": {
|
||||||
|
"section_name": "EHBO",
|
||||||
|
"shift_title": "EHBO Post",
|
||||||
|
"time_slot_name": "Zaterdag Dag",
|
||||||
|
"time": "10:00–18:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Query Parameters (index)
|
### Query Parameters (index)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user