feat: enrich assignable-persons with tags, preferences, availability and cascading filters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 22:05:02 +02:00
parent 9e4e0c3d4b
commit 04ceecc51d
5 changed files with 540 additions and 141 deletions

View File

@@ -14,10 +14,12 @@ use App\Models\Event;
use App\Models\Person; use App\Models\Person;
use App\Models\Shift; use App\Models\Shift;
use App\Models\ShiftAssignment; use App\Models\ShiftAssignment;
use App\Models\VolunteerAvailability;
use App\Services\ShiftAssignmentService; use App\Services\ShiftAssignmentService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
final class ShiftAssignmentController extends Controller final class ShiftAssignmentController extends Controller
@@ -106,6 +108,8 @@ final class ShiftAssignmentController extends Controller
{ {
Gate::authorize('viewAny', [ShiftAssignment::class, $event]); Gate::authorize('viewAny', [ShiftAssignment::class, $event]);
$shift->load(['festivalSection', 'timeSlot']);
$festivalEventId = $event->parent_event_id ?? $event->id; $festivalEventId = $event->parent_event_id ?? $event->id;
$timeSlotId = $shift->time_slot_id; $timeSlotId = $shift->time_slot_id;
$shiftId = $shift->id; $shiftId = $shift->id;
@@ -142,8 +146,37 @@ final class ShiftAssignmentController extends Controller
->where('status', PersonStatus::APPROVED) ->where('status', PersonStatus::APPROVED)
->with('crowdType') ->with('crowdType')
->orderBy('name') ->orderBy('name')
->get() ->get();
->map(function (Person $person) use ($conflicts, $alreadyAssigned, $previousAssignments, $shiftId) {
// Batch: tags for all persons with user_id
$userIds = $persons->pluck('user_id')->filter()->unique();
$allTags = collect();
if ($userIds->isNotEmpty()) {
$allTags = DB::table('user_organisation_tags')
->join('person_tags', 'user_organisation_tags.person_tag_id', '=', 'person_tags.id')
->whereIn('user_organisation_tags.user_id', $userIds)
->where('user_organisation_tags.organisation_id', $event->organisation_id)
->where('person_tags.is_active', true)
->select(
'user_organisation_tags.user_id',
'person_tags.name',
'person_tags.icon',
'person_tags.color',
'user_organisation_tags.proficiency',
)
->get()
->groupBy('user_id');
}
// Batch: availability for this shift's time slot
$personIds = $persons->pluck('id');
$availablePersonIds = VolunteerAvailability::where('time_slot_id', $shift->time_slot_id)
->whereIn('person_id', $personIds)
->pluck('person_id')
->flip();
$mappedPersons = $persons
->map(function (Person $person) use ($conflicts, $alreadyAssigned, $previousAssignments, $shiftId, $allTags, $availablePersonIds) {
$isAlreadyAssigned = $alreadyAssigned->has($person->id); $isAlreadyAssigned = $alreadyAssigned->has($person->id);
$conflict = $conflicts->get($person->id); $conflict = $conflicts->get($person->id);
$hasConflict = $conflict && $conflict->shift_id !== $shiftId; $hasConflict = $conflict && $conflict->shift_id !== $shiftId;
@@ -174,6 +207,16 @@ final class ShiftAssignmentController extends Controller
'cancelled_at' => $previous->cancelled_at?->toIso8601String(), 'cancelled_at' => $previous->cancelled_at?->toIso8601String(),
'rejection_reason' => $previous->rejection_reason, 'rejection_reason' => $previous->rejection_reason,
] : null, ] : null,
'tags' => $person->user_id
? ($allTags->get($person->user_id) ?? collect())->map(fn ($t) => [
'name' => $t->name,
'icon' => $t->icon,
'color' => $t->color,
'proficiency' => $t->proficiency,
])->values()->toArray()
: [],
'section_preferences' => $person->custom_fields['section_preferences'] ?? [],
'has_availability' => $availablePersonIds->has($person->id),
]; ];
}) })
->sortBy([ ->sortBy([
@@ -183,6 +226,15 @@ final class ShiftAssignmentController extends Controller
]) ])
->values(); ->values();
return response()->json(['data' => $persons]); return response()->json([
'data' => $mappedPersons,
'meta' => [
'section_name' => $shift->festivalSection->name,
'time_slot_name' => $shift->timeSlot->name,
'slots_total' => $shift->slots_total,
'filled_slots' => $shift->filled_slots,
'is_overbooked' => $shift->filled_slots >= $shift->slots_total,
],
]);
} }
} }

View File

@@ -11,10 +11,13 @@ use App\Models\Event;
use App\Models\FestivalSection; use App\Models\FestivalSection;
use App\Models\Organisation; use App\Models\Organisation;
use App\Models\Person; use App\Models\Person;
use App\Models\PersonTag;
use App\Models\Shift; use App\Models\Shift;
use App\Models\ShiftAssignment; use App\Models\ShiftAssignment;
use App\Models\TimeSlot; use App\Models\TimeSlot;
use App\Models\User; use App\Models\User;
use App\Models\UserOrganisationTag;
use App\Models\VolunteerAvailability;
use Database\Seeders\RoleSeeder; use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
@@ -492,6 +495,210 @@ class AssignablePersonsTest extends TestCase
->assertJsonPath('data.0.previous_assignment.rejection_reason', 'Geen ervaring'); ->assertJsonPath('data.0.previous_assignment.rejection_reason', 'Geen ervaring');
} }
// =========================================================================
// Enriched data: tags, section_preferences, has_availability, meta
// =========================================================================
public function test_assignable_persons_includes_tags_for_person_with_user_id(): void
{
$shift = $this->createOpenShift();
$user = User::factory()->create();
$person = $this->createPerson(['user_id' => $user->id]);
$tag = PersonTag::factory()->create([
'organisation_id' => $this->organisation->id,
'name' => 'Tapper',
'icon' => 'tabler-beer',
'color' => '#FF9800',
'is_active' => true,
]);
UserOrganisationTag::factory()->create([
'user_id' => $user->id,
'organisation_id' => $this->organisation->id,
'person_tag_id' => $tag->id,
'proficiency' => 'experienced',
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson(
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
);
$response->assertOk()
->assertJsonCount(1, 'data.0.tags')
->assertJsonPath('data.0.tags.0.name', 'Tapper')
->assertJsonPath('data.0.tags.0.icon', 'tabler-beer')
->assertJsonPath('data.0.tags.0.color', '#FF9800')
->assertJsonPath('data.0.tags.0.proficiency', 'experienced');
}
public function test_assignable_persons_tags_empty_for_person_without_user_id(): void
{
$shift = $this->createOpenShift();
$this->createPerson(['user_id' => null]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson(
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
);
$response->assertOk()
->assertJsonPath('data.0.tags', []);
}
public function test_assignable_persons_excludes_inactive_tags(): void
{
$shift = $this->createOpenShift();
$user = User::factory()->create();
$this->createPerson(['user_id' => $user->id]);
$tag = PersonTag::factory()->inactive()->create([
'organisation_id' => $this->organisation->id,
]);
UserOrganisationTag::factory()->create([
'user_id' => $user->id,
'organisation_id' => $this->organisation->id,
'person_tag_id' => $tag->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson(
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
);
$response->assertOk()
->assertJsonPath('data.0.tags', []);
}
public function test_assignable_persons_includes_section_preferences_from_custom_fields(): void
{
$shift = $this->createOpenShift();
$this->createPerson([
'custom_fields' => [
'section_preferences' => [
['section_name' => $this->section->name, 'priority' => 1],
['section_name' => 'Other Section', 'priority' => 2],
],
],
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson(
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
);
$response->assertOk()
->assertJsonCount(2, 'data.0.section_preferences')
->assertJsonPath('data.0.section_preferences.0.section_name', $this->section->name)
->assertJsonPath('data.0.section_preferences.0.priority', 1);
}
public function test_assignable_persons_section_preferences_empty_when_not_set(): void
{
$shift = $this->createOpenShift();
$this->createPerson(['custom_fields' => null]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson(
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
);
$response->assertOk()
->assertJsonPath('data.0.section_preferences', []);
}
public function test_assignable_persons_has_availability_true_when_record_exists(): void
{
$shift = $this->createOpenShift();
$person = $this->createPerson();
VolunteerAvailability::factory()->create([
'person_id' => $person->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson(
"/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons",
);
$response->assertOk()
->assertJsonPath('data.0.has_availability', true);
}
public function test_assignable_persons_has_availability_false_when_no_record(): 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.has_availability', false);
}
public function test_assignable_persons_meta_includes_shift_context(): void
{
$shift = $this->createOpenShift();
$this->createPerson();
// Create an approved assignment so filled_slots > 0
$otherPerson = $this->createPerson();
ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => $otherPerson->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('meta.section_name', $this->section->name)
->assertJsonPath('meta.time_slot_name', $this->timeSlot->name)
->assertJsonPath('meta.slots_total', 4)
->assertJsonPath('meta.filled_slots', 1)
->assertJsonPath('meta.is_overbooked', false);
}
public function test_assignable_persons_meta_is_overbooked_when_full(): void
{
$shift = $this->createOpenShift(['slots_total' => 1]);
$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('meta.is_overbooked', true);
}
private function assertJsonPath($response, string $path, mixed $expected): void private function assertJsonPath($response, string $path, mixed $expected): void
{ {
$this->assertEquals($expected, $response->json($path)); $this->assertEquals($expected, $response->json($path));

View File

@@ -18,12 +18,13 @@ const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId) const eventIdRef = computed(() => props.eventId)
const shiftIdRef = computed(() => props.shift?.id ?? '') const shiftIdRef = computed(() => props.shift?.id ?? '')
const { data: assignablePersons, isLoading } = useAssignablePersons(eventIdRef, shiftIdRef) const { data: assignableData, isLoading } = useAssignablePersons(eventIdRef, shiftIdRef)
const { mutateAsync: assignPerson, isPending: isAssigning } = useAssignPersonToShift(eventIdRef) const { mutateAsync: assignPerson, isPending: isAssigning } = useAssignPersonToShift(eventIdRef)
// Search and filters // Search and filters
const searchQuery = ref('') const searchQuery = ref('')
const showOnlyAvailable = ref(true) const showOnlyAvailable = ref(true)
const showRecommendedOnly = ref(false)
const selectedCrowdType = ref<string | null>(null) const selectedCrowdType = ref<string | null>(null)
const assignError = ref<string | null>(null) const assignError = ref<string | null>(null)
const showSuccess = ref(false) const showSuccess = ref(false)
@@ -39,16 +40,43 @@ const isShiftFull = computed(() => {
return props.shift.filled_slots >= props.shift.slots_total return props.shift.filled_slots >= props.shift.slots_total
}) })
// Clear error on filter changes // Clear error on any filter change
watch([searchQuery, showOnlyAvailable, selectedCrowdType], () => { watch([searchQuery, showOnlyAvailable, showRecommendedOnly, selectedCrowdType], () => {
assignError.value = null assignError.value = null
}) })
// Cascading auto-filter when data loads
watch(() => assignableData.value, (data) => {
if (!data) return
const { persons, meta } = data
const recommended = persons.filter(p =>
p.is_available && !p.already_assigned
&& p.section_preferences.some(sp => sp.section_name === meta.section_name),
)
const available = persons.filter(p =>
p.is_available && !p.already_assigned,
)
if (recommended.length > 0) {
showRecommendedOnly.value = true
showOnlyAvailable.value = true
}
else if (available.length > 0) {
showRecommendedOnly.value = false
showOnlyAvailable.value = true
}
else {
showRecommendedOnly.value = false
showOnlyAvailable.value = false
}
}, { immediate: true })
// Reset state when dialog opens // Reset state when dialog opens
watch(modelValue, (open) => { watch(modelValue, (open) => {
if (open) { if (open) {
searchQuery.value = '' searchQuery.value = ''
showOnlyAvailable.value = true
selectedCrowdType.value = null selectedCrowdType.value = null
assignError.value = null assignError.value = null
} }
@@ -56,9 +84,9 @@ watch(modelValue, (open) => {
// Crowd type filter options (derived from data) // Crowd type filter options (derived from data)
const crowdTypeOptions = computed(() => { const crowdTypeOptions = computed(() => {
if (!assignablePersons.value) return [] if (!assignableData.value) return []
const seen = new Map<string, string>() const seen = new Map<string, string>()
for (const p of assignablePersons.value) { for (const p of assignableData.value.persons) {
if (p.crowd_type && !seen.has(p.crowd_type.system_type)) { if (p.crowd_type && !seen.has(p.crowd_type.system_type)) {
seen.set(p.crowd_type.system_type, p.crowd_type.name) seen.set(p.crowd_type.system_type, p.crowd_type.name)
} }
@@ -69,9 +97,10 @@ const crowdTypeOptions = computed(() => {
// Filtered persons // Filtered persons
const filteredPersons = computed(() => { const filteredPersons = computed(() => {
if (!assignablePersons.value) return [] if (!assignableData.value) return []
const sectionName = assignableData.value.meta.section_name
return assignablePersons.value.filter((person) => { return assignableData.value.persons.filter((person) => {
if (searchQuery.value) { if (searchQuery.value) {
const q = searchQuery.value.toLowerCase() const q = searchQuery.value.toLowerCase()
if (!person.name.toLowerCase().includes(q) && !person.email.toLowerCase().includes(q)) { if (!person.name.toLowerCase().includes(q) && !person.email.toLowerCase().includes(q)) {
@@ -79,24 +108,54 @@ const filteredPersons = computed(() => {
} }
} }
if (selectedCrowdType.value) {
if (person.crowd_type?.system_type !== selectedCrowdType.value) return false
}
if (showOnlyAvailable.value) { if (showOnlyAvailable.value) {
if (!person.is_available || person.already_assigned) return false if (!person.is_available || person.already_assigned) return false
} }
if (selectedCrowdType.value) { if (showRecommendedOnly.value) {
if (person.crowd_type?.system_type !== selectedCrowdType.value) return false const hasPreference = person.section_preferences.some(
sp => sp.section_name === sectionName,
)
if (!hasPreference && !person.has_availability) return false
} }
return true return true
}) })
}) })
// Smart sorting
const sortedPersons = computed(() => {
const sectionName = assignableData.value?.meta?.section_name || ''
return [...filteredPersons.value].sort((a, b) => {
if (a.already_assigned !== b.already_assigned) return a.already_assigned ? 1 : -1
if (a.is_available !== b.is_available) return a.is_available ? -1 : 1
const aMatch = a.section_preferences.find(p => p.section_name === sectionName)
const bMatch = b.section_preferences.find(p => p.section_name === sectionName)
if (!!aMatch !== !!bMatch) return aMatch ? -1 : 1
if (aMatch && bMatch) return aMatch.priority - bMatch.priority
if (a.has_availability !== b.has_availability) return a.has_availability ? -1 : 1
if (a.tags.length !== b.tags.length) return b.tags.length - a.tags.length
return a.name.localeCompare(b.name)
})
})
// Empty state reason // Empty state reason
const emptyReason = computed(() => { const emptyReason = computed(() => {
if (!assignablePersons.value?.length) { if (!assignableData.value?.persons?.length) {
return 'Er zijn geen goedgekeurde personen voor dit evenement.' return 'Er zijn geen goedgekeurde personen voor dit evenement.'
} }
if (showOnlyAvailable.value && !filteredPersons.value.length && assignablePersons.value.length) { if (showRecommendedOnly.value && !filteredPersons.value.length) {
return 'Geen aanbevolen personen gevonden. Zet \'Aanbevolen\' uit om alle personen te zien.'
}
if (showOnlyAvailable.value && !filteredPersons.value.length) {
return 'Alle personen zijn al ingepland voor dit tijdslot. Zet \'Alleen beschikbaar\' uit om alle personen te zien.' return 'Alle personen zijn al ingepland voor dit tijdslot. Zet \'Alleen beschikbaar\' uit om alle personen te zien.'
} }
@@ -112,6 +171,12 @@ function getInitials(name: string) {
.slice(0, 2) .slice(0, 2)
} }
function getPreferenceMatch(person: AssignablePerson) {
const sectionName = assignableData.value?.meta?.section_name
if (!sectionName) return null
return person.section_preferences.find(sp => sp.section_name === sectionName)
}
function handleAssign(person: AssignablePerson) { function handleAssign(person: AssignablePerson) {
if (!props.shift) return if (!props.shift) return
assignError.value = null assignError.value = null
@@ -228,7 +293,7 @@ async function executeAssign(person: AssignablePerson) {
density="compact" density="compact"
class="mb-3" class="mb-3"
> >
<strong>Shift is vol</strong> {{ shift.filled_slots }}/{{ shift.slots_total }} <strong>Shift is vol</strong> {{ shift?.filled_slots }}/{{ shift?.slots_total }}
plekken bezet. Je kunt nog steeds iemand toewijzen, maar de shift wordt overbezet. plekken bezet. Je kunt nog steeds iemand toewijzen, maar de shift wordt overbezet.
</VAlert> </VAlert>
@@ -257,7 +322,14 @@ async function executeAssign(person: AssignablePerson) {
/> />
<!-- Filters --> <!-- Filters -->
<div class="d-flex align-center ga-3 mb-3"> <div class="d-flex align-center flex-wrap ga-3 mb-3">
<VSwitch
v-model="showRecommendedOnly"
label="Aanbevolen"
density="compact"
hide-details
color="warning"
/>
<VSwitch <VSwitch
v-model="showOnlyAvailable" v-model="showOnlyAvailable"
label="Alleen beschikbaar" label="Alleen beschikbaar"
@@ -291,145 +363,187 @@ async function executeAssign(person: AssignablePerson) {
<!-- Person list --> <!-- Person list -->
<VList <VList
v-else-if="filteredPersons.length" v-else-if="sortedPersons.length"
density="compact" density="compact"
class="person-list overflow-y-auto" class="person-list overflow-y-auto"
style="max-block-size: 400px;" style="max-block-size: 400px;"
> >
<template <VListItem
v-for="person in filteredPersons" v-for="person in sortedPersons"
:key="person.id" :key="person.id"
:disabled="!person.is_available || person.already_assigned"
:class="{
'opacity-50': !person.is_available || person.already_assigned,
'cursor-pointer': person.is_available && !person.already_assigned,
}"
@click="person.is_available && !person.already_assigned && handleAssign(person)"
> >
<!-- Already assigned --> <template #prepend>
<VListItem <VAvatar
v-if="person.already_assigned" size="36"
disabled :color="person.is_available ? 'primary' : 'grey'"
class="opacity-40" variant="tonal"
> >
<template #prepend> <span class="text-caption">{{ getInitials(person.name) }}</span>
<VAvatar </VAvatar>
size="36" </template>
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) --> <VListItemTitle class="d-flex align-center ga-2">
<VListItem {{ person.name }}
v-else-if="!person.is_available && person.conflict" <VChip
disabled v-if="person.crowd_type"
class="opacity-50" size="x-small"
> variant="tonal"
<template #prepend> class="ml-auto"
<VAvatar >
size="36" {{ person.crowd_type.name }}
color="grey" </VChip>
</VListItemTitle>
<VListItemSubtitle>
<div>{{ person.email }}</div>
<!-- Tags -->
<div
v-if="person.tags.length"
class="d-flex flex-wrap ga-1 mt-1"
>
<VChip
v-for="tag in person.tags"
:key="tag.name"
size="x-small"
variant="tonal" variant="tonal"
:color="tag.color || 'default'"
:prepend-icon="tag.icon || undefined"
> >
<span class="text-caption">{{ getInitials(person.name) }}</span> {{ tag.name }}
</VAvatar> <span
</template> v-if="tag.proficiency"
<VListItemTitle>{{ person.name }}</VListItemTitle> class="ml-1 font-italic"
<VListItemSubtitle> >
<span>{{ person.email }}</span> ({{ { beginner: 'beg.', experienced: 'erv.', expert: 'exp.' }[tag.proficiency] }})
<br> </span>
<span class="text-warning text-caption"> </VChip>
Ingepland bij "{{ person.conflict.section_name }}" {{ person.conflict.time_slot_name }} </div>
</span>
</VListItemSubtitle> <!-- Section preference match -->
<template #append> <div
v-if="getPreferenceMatch(person)"
class="mt-1"
>
<VIcon <VIcon
size="14"
color="warning"
>
tabler-star-filled
</VIcon>
<span class="text-warning text-caption">
Voorkeur: {{ assignableData?.meta?.section_name }} (#{{ getPreferenceMatch(person)!.priority }})
</span>
</div>
<!-- Availability -->
<div
v-if="person.is_available"
class="mt-1"
>
<VIcon
size="14"
:color="person.has_availability ? 'success' : 'grey'"
>
{{ person.has_availability ? 'tabler-check' : 'tabler-clock-question' }}
</VIcon>
<span
:class="person.has_availability ? 'text-success' : 'text-medium-emphasis'"
class="text-caption"
>
{{ person.has_availability ? 'Beschikbaar voor dit tijdslot' : 'Geen beschikbaarheid opgegeven' }}
</span>
</div>
<!-- Conflict (unavailable) -->
<div
v-if="person.conflict"
class="mt-1"
>
<VIcon
size="14"
color="warning" color="warning"
size="18"
> >
tabler-alert-triangle tabler-alert-triangle
</VIcon> </VIcon>
</template> <span class="text-warning text-caption">
</VListItem> Ingepland bij "{{ person.conflict.section_name }}" {{ person.conflict.time_slot_name }}
</span>
</div>
<!-- Available --> <!-- Already assigned -->
<VListItem <div
v-else v-if="person.already_assigned"
:disabled="isAssigning" class="mt-1"
class="cursor-pointer" >
@click="handleAssign(person)" <VIcon
> size="14"
<template #prepend> color="success"
<VAvatar
size="36"
color="primary"
variant="tonal"
> >
<span class="text-caption">{{ getInitials(person.name) }}</span> tabler-check
</VAvatar> </VIcon>
</template> <span class="text-success text-caption">Al toegewezen aan deze shift</span>
<VListItemTitle>{{ person.name }}</VListItemTitle> </div>
<VListItemSubtitle>
<span>{{ person.email }}</span> <!-- Previous assignment indicator -->
<!-- Previous assignment indicator --> <template v-if="person.previous_assignment && person.is_available">
<template v-if="person.previous_assignment"> <div
<br> v-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'organiser'"
<template v-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'organiser'"> class="mt-1"
<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"
size="x-small"
variant="tonal"
> >
{{ person.crowd_type.name }} <VIcon
</VChip> size="14"
color="info"
class="me-1"
>
tabler-history
</VIcon>
<span class="text-info text-caption">
Eerder toegewezen, geannuleerd door organisator
</span>
</div>
<div
v-else-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'volunteer'"
class="mt-1"
>
<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>
</div>
<div
v-else-if="person.previous_assignment.status === 'rejected'"
class="mt-1"
>
<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>
</div>
</template> </template>
</VListItem> </VListItemSubtitle>
</template> </VListItem>
</VList> </VList>
<!-- Empty state --> <!-- Empty state -->

View File

@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { MaybeRef, Ref } from 'vue' import type { MaybeRef, Ref } from 'vue'
import { unref } from 'vue' import { unref } from 'vue'
import { apiClient } from '@/lib/axios' import { apiClient } from '@/lib/axios'
import type { AssignablePerson, ShiftAssignment } from '@/types/shiftAssignment' import type { AssignablePerson, AssignablePersonsMeta, ShiftAssignment } from '@/types/shiftAssignment'
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean success: boolean
@@ -151,11 +151,14 @@ export function useAssignablePersons(eventId: MaybeRef<string>, shiftId: MaybeRe
return useQuery({ return useQuery({
queryKey: ['assignable-persons', eventId, shiftId], queryKey: ['assignable-persons', eventId, shiftId],
queryFn: async () => { queryFn: async () => {
const { data } = await apiClient.get<{ data: AssignablePerson[] }>( const { data } = await apiClient.get<{ data: AssignablePerson[]; meta: AssignablePersonsMeta }>(
`/events/${unref(eventId)}/shifts/${unref(shiftId)}/assignable-persons`, `/events/${unref(eventId)}/shifts/${unref(shiftId)}/assignable-persons`,
) )
return data.data return {
persons: data.data,
meta: data.meta,
}
}, },
enabled: () => !!unref(eventId) && !!unref(shiftId), enabled: () => !!unref(eventId) && !!unref(shiftId),
}) })

View File

@@ -58,6 +58,18 @@ export interface PreviousAssignment {
rejection_reason: string | null rejection_reason: string | null
} }
export interface PersonTag {
name: string
icon: string | null
color: string | null
proficiency: 'beginner' | 'experienced' | 'expert' | null
}
export interface SectionPreference {
section_name: string
priority: number
}
export interface AssignablePerson { export interface AssignablePerson {
id: string id: string
name: string name: string
@@ -77,4 +89,15 @@ export interface AssignablePerson {
time: string time: string
} | null } | null
previous_assignment: PreviousAssignment | null previous_assignment: PreviousAssignment | null
tags: PersonTag[]
section_preferences: SectionPreference[]
has_availability: boolean
}
export interface AssignablePersonsMeta {
section_name: string
time_slot_name: string
slots_total: number
filled_slots: number
is_overbooked: boolean
} }