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:
@@ -14,10 +14,12 @@ use App\Models\Event;
|
||||
use App\Models\Person;
|
||||
use App\Models\Shift;
|
||||
use App\Models\ShiftAssignment;
|
||||
use App\Models\VolunteerAvailability;
|
||||
use App\Services\ShiftAssignmentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
final class ShiftAssignmentController extends Controller
|
||||
@@ -106,6 +108,8 @@ final class ShiftAssignmentController extends Controller
|
||||
{
|
||||
Gate::authorize('viewAny', [ShiftAssignment::class, $event]);
|
||||
|
||||
$shift->load(['festivalSection', 'timeSlot']);
|
||||
|
||||
$festivalEventId = $event->parent_event_id ?? $event->id;
|
||||
$timeSlotId = $shift->time_slot_id;
|
||||
$shiftId = $shift->id;
|
||||
@@ -142,8 +146,37 @@ final class ShiftAssignmentController extends Controller
|
||||
->where('status', PersonStatus::APPROVED)
|
||||
->with('crowdType')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (Person $person) use ($conflicts, $alreadyAssigned, $previousAssignments, $shiftId) {
|
||||
->get();
|
||||
|
||||
// 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);
|
||||
$conflict = $conflicts->get($person->id);
|
||||
$hasConflict = $conflict && $conflict->shift_id !== $shiftId;
|
||||
@@ -174,6 +207,16 @@ final class ShiftAssignmentController extends Controller
|
||||
'cancelled_at' => $previous->cancelled_at?->toIso8601String(),
|
||||
'rejection_reason' => $previous->rejection_reason,
|
||||
] : 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([
|
||||
@@ -183,6 +226,15 @@ final class ShiftAssignmentController extends Controller
|
||||
])
|
||||
->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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ use App\Models\Event;
|
||||
use App\Models\FestivalSection;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use App\Models\PersonTag;
|
||||
use App\Models\Shift;
|
||||
use App\Models\ShiftAssignment;
|
||||
use App\Models\TimeSlot;
|
||||
use App\Models\User;
|
||||
use App\Models\UserOrganisationTag;
|
||||
use App\Models\VolunteerAvailability;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
@@ -492,6 +495,210 @@ class AssignablePersonsTest extends TestCase
|
||||
->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
|
||||
{
|
||||
$this->assertEquals($expected, $response->json($path));
|
||||
|
||||
@@ -18,12 +18,13 @@ 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 { data: assignableData, isLoading } = useAssignablePersons(eventIdRef, shiftIdRef)
|
||||
const { mutateAsync: assignPerson, isPending: isAssigning } = useAssignPersonToShift(eventIdRef)
|
||||
|
||||
// Search and filters
|
||||
const searchQuery = ref('')
|
||||
const showOnlyAvailable = ref(true)
|
||||
const showRecommendedOnly = ref(false)
|
||||
const selectedCrowdType = ref<string | null>(null)
|
||||
const assignError = ref<string | null>(null)
|
||||
const showSuccess = ref(false)
|
||||
@@ -39,16 +40,43 @@ const isShiftFull = computed(() => {
|
||||
return props.shift.filled_slots >= props.shift.slots_total
|
||||
})
|
||||
|
||||
// Clear error on filter changes
|
||||
watch([searchQuery, showOnlyAvailable, selectedCrowdType], () => {
|
||||
// Clear error on any filter change
|
||||
watch([searchQuery, showOnlyAvailable, showRecommendedOnly, selectedCrowdType], () => {
|
||||
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
|
||||
watch(modelValue, (open) => {
|
||||
if (open) {
|
||||
searchQuery.value = ''
|
||||
showOnlyAvailable.value = true
|
||||
selectedCrowdType.value = null
|
||||
assignError.value = null
|
||||
}
|
||||
@@ -56,9 +84,9 @@ watch(modelValue, (open) => {
|
||||
|
||||
// Crowd type filter options (derived from data)
|
||||
const crowdTypeOptions = computed(() => {
|
||||
if (!assignablePersons.value) return []
|
||||
if (!assignableData.value) return []
|
||||
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)) {
|
||||
seen.set(p.crowd_type.system_type, p.crowd_type.name)
|
||||
}
|
||||
@@ -69,9 +97,10 @@ const crowdTypeOptions = computed(() => {
|
||||
|
||||
// Filtered persons
|
||||
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) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
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 (!person.is_available || person.already_assigned) return false
|
||||
}
|
||||
|
||||
if (selectedCrowdType.value) {
|
||||
if (person.crowd_type?.system_type !== selectedCrowdType.value) return false
|
||||
if (showRecommendedOnly.value) {
|
||||
const hasPreference = person.section_preferences.some(
|
||||
sp => sp.section_name === sectionName,
|
||||
)
|
||||
if (!hasPreference && !person.has_availability) return false
|
||||
}
|
||||
|
||||
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
|
||||
const emptyReason = computed(() => {
|
||||
if (!assignablePersons.value?.length) {
|
||||
if (!assignableData.value?.persons?.length) {
|
||||
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.'
|
||||
}
|
||||
|
||||
@@ -112,6 +171,12 @@ function getInitials(name: string) {
|
||||
.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) {
|
||||
if (!props.shift) return
|
||||
assignError.value = null
|
||||
@@ -228,7 +293,7 @@ async function executeAssign(person: AssignablePerson) {
|
||||
density="compact"
|
||||
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.
|
||||
</VAlert>
|
||||
|
||||
@@ -257,7 +322,14 @@ async function executeAssign(person: AssignablePerson) {
|
||||
/>
|
||||
|
||||
<!-- 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
|
||||
v-model="showOnlyAvailable"
|
||||
label="Alleen beschikbaar"
|
||||
@@ -291,145 +363,187 @@ async function executeAssign(person: AssignablePerson) {
|
||||
|
||||
<!-- Person list -->
|
||||
<VList
|
||||
v-else-if="filteredPersons.length"
|
||||
v-else-if="sortedPersons.length"
|
||||
density="compact"
|
||||
class="person-list overflow-y-auto"
|
||||
style="max-block-size: 400px;"
|
||||
>
|
||||
<template
|
||||
v-for="person in filteredPersons"
|
||||
<VListItem
|
||||
v-for="person in sortedPersons"
|
||||
: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 -->
|
||||
<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>
|
||||
<template #prepend>
|
||||
<VAvatar
|
||||
size="36"
|
||||
:color="person.is_available ? 'primary' : 'grey'"
|
||||
variant="tonal"
|
||||
>
|
||||
<span class="text-caption">{{ getInitials(person.name) }}</span>
|
||||
</VAvatar>
|
||||
</template>
|
||||
|
||||
<!-- Conflict (unavailable) -->
|
||||
<VListItem
|
||||
v-else-if="!person.is_available && person.conflict"
|
||||
disabled
|
||||
class="opacity-50"
|
||||
>
|
||||
<template #prepend>
|
||||
<VAvatar
|
||||
size="36"
|
||||
color="grey"
|
||||
<VListItemTitle class="d-flex align-center ga-2">
|
||||
{{ person.name }}
|
||||
<VChip
|
||||
v-if="person.crowd_type"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ person.crowd_type.name }}
|
||||
</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"
|
||||
:color="tag.color || 'default'"
|
||||
:prepend-icon="tag.icon || undefined"
|
||||
>
|
||||
<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>
|
||||
{{ tag.name }}
|
||||
<span
|
||||
v-if="tag.proficiency"
|
||||
class="ml-1 font-italic"
|
||||
>
|
||||
({{ { beginner: 'beg.', experienced: 'erv.', expert: 'exp.' }[tag.proficiency] }})
|
||||
</span>
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<!-- Section preference match -->
|
||||
<div
|
||||
v-if="getPreferenceMatch(person)"
|
||||
class="mt-1"
|
||||
>
|
||||
<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"
|
||||
size="18"
|
||||
>
|
||||
tabler-alert-triangle
|
||||
</VIcon>
|
||||
</template>
|
||||
</VListItem>
|
||||
<span class="text-warning text-caption">
|
||||
Ingepland bij "{{ person.conflict.section_name }}" — {{ person.conflict.time_slot_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Available -->
|
||||
<VListItem
|
||||
v-else
|
||||
:disabled="isAssigning"
|
||||
class="cursor-pointer"
|
||||
@click="handleAssign(person)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VAvatar
|
||||
size="36"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
<!-- Already assigned -->
|
||||
<div
|
||||
v-if="person.already_assigned"
|
||||
class="mt-1"
|
||||
>
|
||||
<VIcon
|
||||
size="14"
|
||||
color="success"
|
||||
>
|
||||
<span class="text-caption">{{ getInitials(person.name) }}</span>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle>{{ person.name }}</VListItemTitle>
|
||||
<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"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
tabler-check
|
||||
</VIcon>
|
||||
<span class="text-success text-caption">Al toegewezen aan deze shift</span>
|
||||
</div>
|
||||
|
||||
<!-- Previous assignment indicator -->
|
||||
<template v-if="person.previous_assignment && person.is_available">
|
||||
<div
|
||||
v-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'organiser'"
|
||||
class="mt-1"
|
||||
>
|
||||
{{ person.crowd_type.name }}
|
||||
</VChip>
|
||||
<VIcon
|
||||
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>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
|
||||
<!-- Empty state -->
|
||||
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
import type { AssignablePerson, AssignablePersonsMeta, ShiftAssignment } from '@/types/shiftAssignment'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean
|
||||
@@ -151,11 +151,14 @@ export function useAssignablePersons(eventId: MaybeRef<string>, shiftId: MaybeRe
|
||||
return useQuery({
|
||||
queryKey: ['assignable-persons', eventId, shiftId],
|
||||
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`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
return {
|
||||
persons: data.data,
|
||||
meta: data.meta,
|
||||
}
|
||||
},
|
||||
enabled: () => !!unref(eventId) && !!unref(shiftId),
|
||||
})
|
||||
|
||||
@@ -58,6 +58,18 @@ export interface PreviousAssignment {
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
@@ -77,4 +89,15 @@ export interface AssignablePerson {
|
||||
time: string
|
||||
} | 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user