feat: smart re-assignment with cancellation source tracking
Add cancelled_by, cancellation_source (organiser|volunteer|system), and cancelled_at columns to shift_assignments. Cancel flow now records who cancelled and why. Assign flow reactivates existing cancelled/rejected records instead of creating duplicates, preventing UNIQUE constraint violations. Assignable-persons endpoint returns previous_assignment data for contextual UI indicators. Frontend shows cancellation source labels, previous assignment history in assign dialog, and "Opnieuw toewijzen" buttons with volunteer-cancelled confirmation dialogs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
api/app/Enums/CancellationSource.php
Normal file
12
api/app/Enums/CancellationSource.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CancellationSource: string
|
||||
{
|
||||
case ORGANISER = 'organiser';
|
||||
case VOLUNTEER = 'volunteer';
|
||||
case SYSTEM = 'system';
|
||||
}
|
||||
@@ -120,7 +120,7 @@ final class ShiftAssignmentController extends Controller
|
||||
->get()
|
||||
->keyBy('person_id');
|
||||
|
||||
// Get all assignments for THIS shift in one query
|
||||
// Get active (non-cancelled/rejected) assignments for THIS shift
|
||||
$alreadyAssigned = ShiftAssignment::where('shift_id', $shiftId)
|
||||
->whereNotIn('status', [
|
||||
ShiftAssignmentStatus::REJECTED,
|
||||
@@ -129,15 +129,25 @@ final class ShiftAssignmentController extends Controller
|
||||
->pluck('person_id')
|
||||
->flip();
|
||||
|
||||
// Get previous cancelled/rejected assignments on THIS shift
|
||||
$previousAssignments = ShiftAssignment::where('shift_id', $shiftId)
|
||||
->whereIn('status', [
|
||||
ShiftAssignmentStatus::CANCELLED,
|
||||
ShiftAssignmentStatus::REJECTED,
|
||||
])
|
||||
->get()
|
||||
->keyBy('person_id');
|
||||
|
||||
$persons = Person::where('event_id', $festivalEventId)
|
||||
->where('status', PersonStatus::APPROVED)
|
||||
->with('crowdType')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (Person $person) use ($conflicts, $alreadyAssigned, $shiftId) {
|
||||
->map(function (Person $person) use ($conflicts, $alreadyAssigned, $previousAssignments, $shiftId) {
|
||||
$isAlreadyAssigned = $alreadyAssigned->has($person->id);
|
||||
$conflict = $conflicts->get($person->id);
|
||||
$hasConflict = $conflict && $conflict->shift_id !== $shiftId;
|
||||
$previous = $previousAssignments->get($person->id);
|
||||
|
||||
return [
|
||||
'id' => $person->id,
|
||||
@@ -158,6 +168,12 @@ final class ShiftAssignmentController extends Controller
|
||||
'time' => $conflict->shift->timeSlot->start_time
|
||||
. '–' . $conflict->shift->timeSlot->end_time,
|
||||
] : null,
|
||||
'previous_assignment' => $previous ? [
|
||||
'status' => $previous->status->value,
|
||||
'cancellation_source' => $previous->cancellation_source?->value,
|
||||
'cancelled_at' => $previous->cancelled_at?->toIso8601String(),
|
||||
'rejection_reason' => $previous->rejection_reason,
|
||||
] : null,
|
||||
];
|
||||
})
|
||||
->sortBy([
|
||||
|
||||
@@ -23,6 +23,9 @@ final class ShiftAssignmentResource extends JsonResource
|
||||
'approved_by' => $this->approved_by,
|
||||
'approved_at' => $this->approved_at?->toIso8601String(),
|
||||
'rejection_reason' => $this->rejection_reason,
|
||||
'cancelled_by' => $this->cancelled_by,
|
||||
'cancellation_source' => $this->cancellation_source?->value,
|
||||
'cancelled_at' => $this->cancelled_at?->toIso8601String(),
|
||||
'hours_expected' => $this->hours_expected,
|
||||
'hours_completed' => $this->hours_completed,
|
||||
'checked_in_at' => $this->checked_in_at?->toIso8601String(),
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CancellationSource;
|
||||
use App\Enums\ShiftAssignmentStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
@@ -29,6 +30,9 @@ final class ShiftAssignment extends Model
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'rejection_reason',
|
||||
'cancelled_by',
|
||||
'cancellation_source',
|
||||
'cancelled_at',
|
||||
'hours_expected',
|
||||
'hours_completed',
|
||||
'checked_in_at',
|
||||
@@ -39,9 +43,11 @@ final class ShiftAssignment extends Model
|
||||
{
|
||||
return [
|
||||
'status' => ShiftAssignmentStatus::class,
|
||||
'cancellation_source' => CancellationSource::class,
|
||||
'auto_approved' => 'boolean',
|
||||
'assigned_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
'checked_in_at' => 'datetime',
|
||||
'checked_out_at' => 'datetime',
|
||||
'hours_expected' => 'decimal:2',
|
||||
@@ -74,6 +80,11 @@ final class ShiftAssignment extends Model
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function cancelledByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'cancelled_by');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CancellationSource;
|
||||
use App\Enums\ShiftAssignmentStatus;
|
||||
use App\Models\Person;
|
||||
use App\Models\Shift;
|
||||
@@ -72,6 +73,44 @@ final class ShiftAssignmentService
|
||||
return DB::transaction(function () use ($shift, $person, $assignedBy): ShiftAssignment {
|
||||
$this->validateNoConflict($shift, $person);
|
||||
|
||||
// Check for existing cancelled/rejected assignment on THIS shift
|
||||
$existing = ShiftAssignment::where('shift_id', $shift->id)
|
||||
->where('person_id', $person->id)
|
||||
->whereIn('status', [
|
||||
ShiftAssignmentStatus::CANCELLED,
|
||||
ShiftAssignmentStatus::REJECTED,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$previousStatus = $existing->status->value;
|
||||
|
||||
$existing->update([
|
||||
'status' => ShiftAssignmentStatus::APPROVED,
|
||||
'assigned_by' => $assignedBy->id,
|
||||
'assigned_at' => now(),
|
||||
'approved_by' => $assignedBy->id,
|
||||
'approved_at' => now(),
|
||||
'rejection_reason' => null,
|
||||
'cancelled_by' => null,
|
||||
'cancellation_source' => null,
|
||||
'cancelled_at' => null,
|
||||
]);
|
||||
|
||||
activity('shift_assignment')
|
||||
->causedBy($assignedBy)
|
||||
->performedOn($existing)
|
||||
->withProperties([
|
||||
'previous_status' => $previousStatus,
|
||||
'person_name' => $person->name,
|
||||
])
|
||||
->log('shift_assignment.reactivated');
|
||||
|
||||
$this->updateShiftStatusIfFull($shift);
|
||||
|
||||
return $existing->fresh();
|
||||
}
|
||||
|
||||
// Log overbooking for audit trail (organizers may intentionally overbook)
|
||||
$filledSlots = $shift->shiftAssignments()
|
||||
->where('status', ShiftAssignmentStatus::APPROVED)
|
||||
@@ -189,9 +228,12 @@ final class ShiftAssignmentService
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function cancel(ShiftAssignment $assignment, User $cancelledBy): ShiftAssignment
|
||||
{
|
||||
return DB::transaction(function () use ($assignment, $cancelledBy): ShiftAssignment {
|
||||
public function cancel(
|
||||
ShiftAssignment $assignment,
|
||||
User $cancelledBy,
|
||||
CancellationSource $source = CancellationSource::ORGANISER,
|
||||
): ShiftAssignment {
|
||||
return DB::transaction(function () use ($assignment, $cancelledBy, $source): ShiftAssignment {
|
||||
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::CANCELLED);
|
||||
|
||||
$wasApproved = $assignment->status === ShiftAssignmentStatus::APPROVED;
|
||||
@@ -199,6 +241,9 @@ final class ShiftAssignmentService
|
||||
|
||||
$assignment->update([
|
||||
'status' => ShiftAssignmentStatus::CANCELLED,
|
||||
'cancelled_by' => $cancelledBy->id,
|
||||
'cancellation_source' => $source,
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
|
||||
if ($wasApproved) {
|
||||
@@ -211,6 +256,7 @@ final class ShiftAssignmentService
|
||||
->withProperties([
|
||||
'old_status' => $oldStatus->value,
|
||||
'new_status' => ShiftAssignmentStatus::CANCELLED->value,
|
||||
'source' => $source->value,
|
||||
])
|
||||
->log('shift_assignment.cancelled');
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('shift_assignments', function (Blueprint $table) {
|
||||
$table->ulid('cancelled_by')->nullable()->after('rejection_reason');
|
||||
$table->string('cancellation_source')->nullable()->after('cancelled_by');
|
||||
$table->timestamp('cancelled_at')->nullable()->after('cancellation_source');
|
||||
|
||||
$table->foreign('cancelled_by')->references('id')->on('users')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('shift_assignments', function (Blueprint $table) {
|
||||
$table->dropForeign(['cancelled_by']);
|
||||
$table->dropColumn(['cancelled_by', 'cancellation_source', 'cancelled_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\V1;
|
||||
|
||||
use App\Enums\CancellationSource;
|
||||
use App\Enums\ShiftAssignmentStatus;
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
@@ -293,4 +294,206 @@ class AssignablePersonsTest extends TestCase
|
||||
$message = $response->json('errors.person_id.0');
|
||||
$this->assertStringContainsString('Je bent al ingepland bij', $message);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Cancellation source tracking
|
||||
// =========================================================================
|
||||
|
||||
public function test_cancel_stores_cancellation_source_and_cancelled_by(): void
|
||||
{
|
||||
$shift = $this->createOpenShift();
|
||||
$person = $this->createPerson();
|
||||
|
||||
$assignment = ShiftAssignment::factory()->approved()->create([
|
||||
'shift_id' => $shift->id,
|
||||
'person_id' => $person->id,
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel",
|
||||
);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.cancellation_source', 'organiser')
|
||||
->assertJsonPath('data.cancelled_by', $this->orgAdmin->id);
|
||||
|
||||
$this->assertNotNull($response->json('data.cancelled_at'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Re-assignment (reactivation)
|
||||
// =========================================================================
|
||||
|
||||
public function test_assign_after_cancellation_reactivates_existing_record(): void
|
||||
{
|
||||
$shift = $this->createOpenShift();
|
||||
$person = $this->createPerson();
|
||||
|
||||
$assignment = ShiftAssignment::factory()->create([
|
||||
'shift_id' => $shift->id,
|
||||
'person_id' => $person->id,
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
'status' => ShiftAssignmentStatus::CANCELLED,
|
||||
'cancelled_by' => $this->orgAdmin->id,
|
||||
'cancellation_source' => CancellationSource::ORGANISER,
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign",
|
||||
['person_id' => $person->id],
|
||||
);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.status', 'approved')
|
||||
->assertJsonPath('data.cancelled_by', null)
|
||||
->assertJsonPath('data.cancellation_source', null)
|
||||
->assertJsonPath('data.cancelled_at', null);
|
||||
|
||||
// Same record reactivated, not a new one
|
||||
$this->assertJsonPath($response, 'data.id', $assignment->id);
|
||||
$this->assertDatabaseCount('shift_assignments', 1);
|
||||
}
|
||||
|
||||
public function test_assign_after_rejection_reactivates_existing_record(): void
|
||||
{
|
||||
$shift = $this->createOpenShift();
|
||||
$person = $this->createPerson();
|
||||
|
||||
$assignment = ShiftAssignment::factory()->create([
|
||||
'shift_id' => $shift->id,
|
||||
'person_id' => $person->id,
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
'status' => ShiftAssignmentStatus::REJECTED,
|
||||
'rejection_reason' => 'Niet geschikt',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign",
|
||||
['person_id' => $person->id],
|
||||
);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.status', 'approved')
|
||||
->assertJsonPath('data.rejection_reason', null);
|
||||
|
||||
$this->assertEquals($assignment->id, $response->json('data.id'));
|
||||
$this->assertDatabaseCount('shift_assignments', 1);
|
||||
}
|
||||
|
||||
public function test_conflict_check_excludes_cancelled_assignments(): void
|
||||
{
|
||||
$shift1 = $this->createOpenShift();
|
||||
$shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]);
|
||||
$person = $this->createPerson();
|
||||
|
||||
// Cancelled assignment on shift1 should NOT block assignment on shift2
|
||||
ShiftAssignment::factory()->create([
|
||||
'shift_id' => $shift1->id,
|
||||
'person_id' => $person->id,
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
'status' => ShiftAssignmentStatus::CANCELLED,
|
||||
]);
|
||||
|
||||
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->assertCreated();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Assignable persons — previous assignment data
|
||||
// =========================================================================
|
||||
|
||||
public function test_assignable_persons_cancelled_person_has_previous_assignment(): 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::CANCELLED,
|
||||
'cancellation_source' => CancellationSource::ORGANISER,
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
|
||||
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', false)
|
||||
->assertJsonPath('data.0.is_available', true)
|
||||
->assertJsonPath('data.0.previous_assignment.status', 'cancelled')
|
||||
->assertJsonPath('data.0.previous_assignment.cancellation_source', 'organiser');
|
||||
}
|
||||
|
||||
public function test_assignable_persons_volunteer_cancelled_has_volunteer_source(): 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::CANCELLED,
|
||||
'cancellation_source' => CancellationSource::VOLUNTEER,
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson(
|
||||
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
|
||||
);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.0.previous_assignment.cancellation_source', 'volunteer');
|
||||
}
|
||||
|
||||
public function test_assignable_persons_rejected_person_has_previous_assignment(): 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::REJECTED,
|
||||
'rejection_reason' => 'Geen ervaring',
|
||||
]);
|
||||
|
||||
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', false)
|
||||
->assertJsonPath('data.0.previous_assignment.status', 'rejected')
|
||||
->assertJsonPath('data.0.previous_assignment.rejection_reason', 'Geen ervaring');
|
||||
}
|
||||
|
||||
private function assertJsonPath($response, string $path, mixed $expected): void
|
||||
{
|
||||
$this->assertEquals($expected, $response->json($path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,10 @@ const assignError = ref<string | null>(null)
|
||||
const showSuccess = ref(false)
|
||||
const successName = ref('')
|
||||
|
||||
// Overbooking confirmation
|
||||
// Confirmation dialogs
|
||||
const pendingPerson = ref<AssignablePerson | null>(null)
|
||||
const showOverbookConfirm = ref(false)
|
||||
const showVolunteerReassignConfirm = ref(false)
|
||||
|
||||
const isShiftFull = computed(() => {
|
||||
if (!props.shift) return false
|
||||
@@ -115,6 +116,14 @@ function handleAssign(person: AssignablePerson) {
|
||||
if (!props.shift) return
|
||||
assignError.value = null
|
||||
|
||||
// Volunteer self-cancelled — extra warning
|
||||
if (person.previous_assignment?.cancellation_source === 'volunteer') {
|
||||
pendingPerson.value = person
|
||||
showVolunteerReassignConfirm.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Shift is full — overbooking warning
|
||||
if (isShiftFull.value) {
|
||||
pendingPerson.value = person
|
||||
showOverbookConfirm.value = true
|
||||
@@ -132,6 +141,20 @@ function confirmOverbook() {
|
||||
pendingPerson.value = null
|
||||
}
|
||||
|
||||
function confirmVolunteerReassign() {
|
||||
if (pendingPerson.value) {
|
||||
// Still check overbooking after volunteer confirmation
|
||||
if (isShiftFull.value) {
|
||||
showVolunteerReassignConfirm.value = false
|
||||
showOverbookConfirm.value = true
|
||||
return
|
||||
}
|
||||
executeAssign(pendingPerson.value)
|
||||
}
|
||||
showVolunteerReassignConfirm.value = false
|
||||
pendingPerson.value = null
|
||||
}
|
||||
|
||||
async function executeAssign(person: AssignablePerson) {
|
||||
if (!props.shift) return
|
||||
|
||||
@@ -350,7 +373,52 @@ async function executeAssign(person: AssignablePerson) {
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle>{{ person.name }}</VListItemTitle>
|
||||
<VListItemSubtitle>{{ person.email }}</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
<span>{{ person.email }}</span>
|
||||
<!-- Previous assignment indicator -->
|
||||
<template v-if="person.previous_assignment">
|
||||
<br>
|
||||
<template v-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'organiser'">
|
||||
<VIcon
|
||||
size="14"
|
||||
color="info"
|
||||
class="me-1"
|
||||
>
|
||||
tabler-history
|
||||
</VIcon>
|
||||
<span class="text-info text-caption">
|
||||
Eerder toegewezen, geannuleerd door organisator
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'volunteer'">
|
||||
<VIcon
|
||||
size="14"
|
||||
color="warning"
|
||||
class="me-1"
|
||||
>
|
||||
tabler-alert-triangle
|
||||
</VIcon>
|
||||
<span class="text-warning text-caption">
|
||||
Heeft zichzelf afgemeld voor deze shift
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="person.previous_assignment.status === 'rejected'">
|
||||
<VIcon
|
||||
size="14"
|
||||
color="error"
|
||||
class="me-1"
|
||||
>
|
||||
tabler-x
|
||||
</VIcon>
|
||||
<span class="text-error text-caption">
|
||||
Eerder afgewezen
|
||||
<span v-if="person.previous_assignment.rejection_reason">
|
||||
({{ person.previous_assignment.rejection_reason }})
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VChip
|
||||
v-if="person.crowd_type"
|
||||
@@ -427,6 +495,39 @@ async function executeAssign(person: AssignablePerson) {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- Volunteer re-assign confirmation -->
|
||||
<VDialog
|
||||
v-model="showVolunteerReassignConfirm"
|
||||
max-width="420"
|
||||
>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h6 pt-5 px-5">
|
||||
Vrijwilliger opnieuw toewijzen?
|
||||
</VCardTitle>
|
||||
<VCardText class="px-5">
|
||||
<strong>{{ pendingPerson?.name }}</strong> heeft zichzelf afgemeld
|
||||
voor deze shift. Weet je zeker dat je deze persoon opnieuw wilt toewijzen?
|
||||
</VCardText>
|
||||
<VCardActions class="px-5 pb-5">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="showVolunteerReassignConfirm = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
:loading="isAssigning"
|
||||
@click="confirmVolunteerReassign"
|
||||
>
|
||||
Toch toewijzen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<VSnackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useRejectAssignment,
|
||||
useCancelAssignment,
|
||||
useBulkApproveAssignments,
|
||||
useAssignPersonToShift,
|
||||
} from '@/composables/api/useShiftAssignments'
|
||||
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
|
||||
import { useShiftDetailStore } from '@/stores/useShiftDetailStore'
|
||||
@@ -41,6 +42,50 @@ const { mutate: approveAssignment, isPending: isApproving } = useApproveAssignme
|
||||
const { mutate: rejectAssignment, isPending: isRejecting } = useRejectAssignment(eventIdRef)
|
||||
const { mutate: cancelAssignment, isPending: isCancelling } = useCancelAssignment(eventIdRef)
|
||||
const { mutate: bulkApprove, isPending: isBulkApproving } = useBulkApproveAssignments(eventIdRef)
|
||||
const { mutateAsync: assignPersonMutation } = useAssignPersonToShift(eventIdRef)
|
||||
|
||||
// Re-assign
|
||||
const reassigning = ref<string | null>(null)
|
||||
const showVolunteerReassignConfirm = ref(false)
|
||||
const reassigningAssignment = ref<ShiftAssignment | null>(null)
|
||||
|
||||
function onReassign(assignment: ShiftAssignment) {
|
||||
if (assignment.cancellation_source === 'volunteer') {
|
||||
reassigningAssignment.value = assignment
|
||||
showVolunteerReassignConfirm.value = true
|
||||
return
|
||||
}
|
||||
executeReassign(assignment)
|
||||
}
|
||||
|
||||
function confirmReassign() {
|
||||
if (reassigningAssignment.value) {
|
||||
executeReassign(reassigningAssignment.value)
|
||||
}
|
||||
showVolunteerReassignConfirm.value = false
|
||||
reassigningAssignment.value = null
|
||||
}
|
||||
|
||||
async function executeReassign(assignment: ShiftAssignment) {
|
||||
if (!store.selectedSectionId) return
|
||||
reassigning.value = assignment.id
|
||||
try {
|
||||
await assignPersonMutation({
|
||||
sectionId: store.selectedSectionId,
|
||||
shiftId: assignment.shift_id,
|
||||
personId: assignment.person_id,
|
||||
})
|
||||
successMessage.value = `${assignment.person?.name ?? 'Persoon'} opnieuw toegewezen`
|
||||
showSuccess.value = true
|
||||
}
|
||||
catch {
|
||||
successMessage.value = 'Fout bij opnieuw toewijzen'
|
||||
showSuccess.value = true
|
||||
}
|
||||
finally {
|
||||
reassigning.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Status counts
|
||||
const pendingAssignments = computed(() =>
|
||||
@@ -642,12 +687,45 @@ function fillRateColor(): string {
|
||||
Auto
|
||||
</VChip>
|
||||
</div>
|
||||
<!-- Cancellation source -->
|
||||
<span
|
||||
v-if="assignment.status === ShiftAssignmentStatus.CANCELLED && assignment.cancellation_source === 'volunteer'"
|
||||
class="text-caption text-warning"
|
||||
>
|
||||
(afgemeld door vrijwilliger)
|
||||
</span>
|
||||
<span
|
||||
v-else-if="assignment.status === ShiftAssignmentStatus.CANCELLED && assignment.cancellation_source === 'organiser'"
|
||||
class="text-caption text-medium-emphasis"
|
||||
>
|
||||
(geannuleerd door organisator)
|
||||
</span>
|
||||
|
||||
<p class="text-caption text-disabled mb-0">
|
||||
{{ formatDateTime(assignment.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Re-assign button for cancelled/rejected -->
|
||||
<VBtn
|
||||
v-if="assignment.status === ShiftAssignmentStatus.CANCELLED || assignment.status === ShiftAssignmentStatus.REJECTED"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
:loading="reassigning === assignment.id"
|
||||
class="me-1"
|
||||
@click="onReassign(assignment)"
|
||||
>
|
||||
<VIcon
|
||||
start
|
||||
size="14"
|
||||
>
|
||||
tabler-refresh
|
||||
</VIcon>
|
||||
Opnieuw toewijzen
|
||||
</VBtn>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<VMenu v-if="assignment.is_approvable || assignment.is_cancellable">
|
||||
<template #activator="{ props: menuProps }">
|
||||
@@ -789,6 +867,40 @@ function fillRateColor(): string {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- Volunteer re-assign confirmation -->
|
||||
<VDialog
|
||||
v-model="showVolunteerReassignConfirm"
|
||||
max-width="420"
|
||||
>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h6 pt-5 px-5">
|
||||
Vrijwilliger opnieuw toewijzen?
|
||||
</VCardTitle>
|
||||
<VCardText class="px-5">
|
||||
<strong>{{ reassigningAssignment?.person?.name ?? 'Deze persoon' }}</strong>
|
||||
heeft zichzelf afgemeld voor deze shift. Weet je zeker dat je deze
|
||||
persoon opnieuw wilt toewijzen?
|
||||
</VCardText>
|
||||
<VCardActions class="px-5 pb-5">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="showVolunteerReassignConfirm = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
:loading="reassigning === reassigningAssignment?.id"
|
||||
@click="confirmReassign"
|
||||
>
|
||||
Toch toewijzen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- Assign person dialog -->
|
||||
<AssignPersonDialog
|
||||
v-if="shift"
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ShiftAssignmentStatus = {
|
||||
|
||||
export type ShiftAssignmentStatus = (typeof ShiftAssignmentStatus)[keyof typeof ShiftAssignmentStatus]
|
||||
|
||||
export type CancellationSource = 'organiser' | 'volunteer' | 'system'
|
||||
|
||||
export interface ShiftAssignment {
|
||||
id: string
|
||||
shift_id: string
|
||||
@@ -23,6 +25,9 @@ export interface ShiftAssignment {
|
||||
approved_by: string | null
|
||||
approved_at: string | null
|
||||
rejection_reason: string | null
|
||||
cancelled_by: string | null
|
||||
cancellation_source: CancellationSource | null
|
||||
cancelled_at: string | null
|
||||
hours_expected: number | null
|
||||
hours_completed: number | null
|
||||
checked_in_at: string | null
|
||||
@@ -46,6 +51,13 @@ export interface BulkApproveDto {
|
||||
assignment_ids: string[]
|
||||
}
|
||||
|
||||
export interface PreviousAssignment {
|
||||
status: 'cancelled' | 'rejected'
|
||||
cancellation_source: CancellationSource | null
|
||||
cancelled_at: string | null
|
||||
rejection_reason: string | null
|
||||
}
|
||||
|
||||
export interface AssignablePerson {
|
||||
id: string
|
||||
name: string
|
||||
@@ -64,4 +76,5 @@ export interface AssignablePerson {
|
||||
time_slot_name: string
|
||||
time: string
|
||||
} | null
|
||||
previous_assignment: PreviousAssignment | null
|
||||
}
|
||||
|
||||
@@ -438,6 +438,9 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
| `approved_by` | ULID FK nullable | → users |
|
||||
| `approved_at` | timestamp nullable | |
|
||||
| `rejection_reason` | text nullable | |
|
||||
| `cancelled_by` | ULID FK nullable | → users (who performed the cancellation) |
|
||||
| `cancellation_source` | enum nullable | `organiser\|volunteer\|system` |
|
||||
| `cancelled_at` | timestamp nullable | |
|
||||
| `hours_expected` | decimal(4,2) nullable | Planned hours for this assignment |
|
||||
| `hours_completed` | decimal(4,2) nullable | Actual hours worked — set after shift completion |
|
||||
| `checked_in_at` | timestamp nullable | Shift-level check-in (when reported at section) |
|
||||
|
||||
Reference in New Issue
Block a user