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:
@@ -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"
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user