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:
2026-04-10 20:32:31 +02:00
parent c220446920
commit 968e17c6d6
10 changed files with 1872 additions and 13 deletions

View File

@@ -5,9 +5,13 @@ import { useShiftList, useDeleteShift } from '@/composables/api/useShifts'
import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue'
import EditSectionDialog from '@/components/sections/EditSectionDialog.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'
const shiftDetailStore = useShiftDetailStore()
const props = defineProps<{
eventId: string
isSubEvent?: boolean
@@ -179,9 +183,10 @@ const statusLabel: Record<ShiftStatus, string> = {
cancelled: 'Geannuleerd',
}
function fillRateColor(rate: number): string {
if (rate >= 80) return 'success'
if (rate >= 40) return 'warning'
function fillRateColor(shift: Shift): string {
if (shift.is_overbooked) return 'warning'
if (shift.fill_rate >= 80) return 'success'
if (shift.fill_rate >= 40) return 'warning'
return 'error'
}
@@ -197,6 +202,12 @@ function formatDate(iso: string) {
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
const showSuccess = ref(false)
const successMessage = ref('')
@@ -481,8 +492,8 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
<!-- Fill rate -->
<div class="d-flex align-center gap-x-2" style="min-inline-size: 160px;">
<VProgressLinear
:model-value="shift.fill_rate"
:color="fillRateColor(shift.fill_rate)"
:model-value="shift.is_overbooked ? 100 : shift.fill_rate"
:color="fillRateColor(shift)"
height="8"
rounded
style="inline-size: 80px;"
@@ -490,6 +501,12 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
<span class="text-body-2 text-no-wrap">
{{ shift.filled_slots }}/{{ shift.slots_total }}
</span>
<VIcon
v-if="shift.is_overbooked"
icon="tabler-alert-triangle"
size="16"
color="warning"
/>
</div>
<!-- Status -->
@@ -499,11 +516,32 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
>
{{ statusLabel[shift.status] }}
</VChip>
<VChip
v-if="shift.is_overbooked"
color="warning"
size="small"
prepend-icon="tabler-alert-triangle"
>
Overbezet
</VChip>
<VSpacer />
<!-- Actions -->
<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
icon="tabler-user-plus"
variant="text"
@@ -563,7 +601,7 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
:is-sub-event="isSubEvent"
/>
<AssignShiftDialog
<AssignPersonDialog
v-if="activeSection"
v-model="isAssignShiftOpen"
:event-id="activeSectionEventId"
@@ -626,6 +664,13 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
</VCard>
</VDialog>
<!-- Shift detail side panel -->
<ShiftDetailPanel
v-model="shiftDetailStore.isOpen"
:event-id="activeSectionEventId"
:shift="selectedShift"
/>
<!-- Success snackbar -->
<VSnackbar
v-model="showSuccess"