Files
crewli/apps/app/src/components/sections/SectionsShiftsPanel.vue
bert.hausmans b4f5bbe7c2 fix(app): resolve Bucket A/C/D lint items (trivial / style / Vuetify class)
WS-3 session 1b-ii Task 4 (audit Buckets A, C, D — 26 items resolved
this commit; 24 indent items in useTimeSlotDropdown.ts remain — see
deviations).

Bucket A — Trivial fixes (12 items resolved):
- A.1: second-pass eslint --fix on App.vue resolved 4 multi-attribute
  warnings. AppKpiCard / PortalLayout / PublicLayout
  lines-around-comment items were attempted via blank-line addition,
  but that introduced an equal number of vue/block-tag-newline
  errors (the rules conflict at the SFC <script>-tag boundary). The
  blank-line additions were reverted; net-zero, the 3 items remain
  for a 1b-iii .eslintrc.cjs override decision.
- A.3: 6 unused-imports / unused-vars manual deletes:
  * OrganisationSwitcher.vue: removed orphan toggleMenu() function
  * CreateShiftDialog.vue: removed unused 'scenario' from destructure
  * pages/events/[id]/time-slots/index.vue: removed unused 'event'
    slot scope binding (template <#default="{ event }"> → <#default>)
  * pages/organisation/companies.vue: removed unused authStore
    declaration + import
  * pages/platform/activity-log/index.vue: removed unused
    search/searchDebounced pair
  * PersonDetailPanel.vue:77: removed redundant single-statement
    if-braces (curly autofix that the original pass didn't reach)

Bucket C — Style preference (8 items resolved):
- DismissFailureDialog.vue:43: collapsed two consecutive `if cond return false`
  branches into `return !(cond)`
- FormFailureDetail.vue:44: replaced `void clipboard.writeText(...)` with
  `clipboard.writeText(...).catch(() => {})` — fire-and-forget with
  silent rejection (the no-void rule wants the void operator gone;
  .catch() handles it semantically).
- AssignShiftDialog.vue:40-46: hasOverlapWarning collapsed from
  always-false branching to `computed(() => false)` (the early-return
  was dead code; backend enforces the constraint).
- SectionsShiftsPanel.vue:333 + registration-fields.vue:335: rewrote
  `:delay-on-touch-only="true"` to attribute-shorthand `delay-on-touch-only`.
- AssignPersonDialog.vue:120-128: collapsed two `if outer { if inner ... }`
  pairs into single `if (outer && inner)` form (sonarjs/no-collapsible-if).
- useImpersonationStore.ts:99-104: collapsed the same nested-if pattern
  into `if (!data.data.active && state.value)`.

Bucket D — Vuetify utility class rename (5 items, 3 files):
- ml-1 → ms-1 (PersonDetailPanel:271, SectionsShiftsPanel:357,
  AssignPersonDialog:496)
- pl-4 → ps-4 (AssignPersonDialog:457)
- ml-auto → ms-auto (AssignPersonDialog:471)
LTR/RTL-aware Vuetify utilities, matching the Vuexy reference idiom.

Tests + typecheck verified green.

Lint baseline: 62 → 36.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:20:34 +02:00

767 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useDeleteSection, useReorderSections, useSectionList } from '@/composables/api/useSections'
import { useDeleteShift, useShiftList } from '@/composables/api/useShifts'
import { useEventDetail } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue'
import EditSectionDialog from '@/components/sections/EditSectionDialog.vue'
import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue'
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
import ShiftDetailPanel from '@/components/shifts/ShiftDetailPanel.vue'
import { useShiftDetailStore } from '@/stores/useShiftDetailStore'
import InfoTooltip from '@/components/common/InfoTooltip.vue'
import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
import type { EventItem } from '@/types/event'
const props = defineProps<{
eventId: string
isSubEvent?: boolean
parentEvent?: EventItem | null
}>()
const shiftDetailStore = useShiftDetailStore()
const authStore = useAuthStore()
const orgIdRef = computed(() => authStore.currentOrganisation?.id ?? '')
const eventIdRef = computed(() => props.eventId)
// Event detail for context banner
const { data: eventDetail } = useEventDetail(orgIdRef, eventIdRef)
const isSubEvent = computed(() => props.isSubEvent ?? false)
// --- Section list ---
const { data: sectionsQuery, isLoading: sectionsLoading } = useSectionList(orgIdRef, eventIdRef)
const { mutate: reorderSections } = useReorderSections(orgIdRef, eventIdRef)
// Local ref for draggable — synced from query but not overwritten during drag
const sections = ref<FestivalSection[]>([])
watch(sectionsQuery, newData => {
if (newData)
sections.value = [...newData]
}, { immediate: true })
function onDragEnd() {
reorderSections(sections.value.map(s => s.id))
}
const activeSectionId = ref<string | null>(null)
const activeSection = computed(() =>
sections.value?.find(s => s.id === activeSectionId.value) ?? null,
)
watch(sections, list => {
if (list?.length && !activeSectionId.value)
activeSectionId.value = list[0].id
}, { immediate: true })
// For cross_event sections, use the section's own event_id (parent festival)
const activeSectionEventId = computed(() => {
if (activeSection.value?.type === 'cross_event')
return activeSection.value.event_id
return props.eventId
})
const activeSectionEventIdRef = computed(() => activeSectionEventId.value)
const { mutate: deleteSection } = useDeleteSection(orgIdRef, activeSectionEventIdRef)
// --- Shifts for active section ---
const activeSectionIdRef = computed(() => activeSectionId.value ?? '')
const { data: shifts, isLoading: shiftsLoading } = useShiftList(orgIdRef, activeSectionEventIdRef, activeSectionIdRef)
const { mutate: deleteShiftMutation, isPending: isDeleting } = useDeleteShift(orgIdRef, activeSectionEventIdRef, activeSectionIdRef)
// Group shifts by time_slot_id
const shiftsByTimeSlot = computed(() => {
if (!shifts.value)
return []
const groups = new Map<string, { timeSlotName: string; date: string; startTime: string; endTime: string; totalSlots: number; filledSlots: number; shifts: Shift[] }>()
for (const shift of shifts.value) {
const tsId = shift.time_slot_id
if (!groups.has(tsId)) {
groups.set(tsId, {
timeSlotName: shift.time_slot?.name ?? 'Onbekend',
date: shift.time_slot?.date ?? '',
startTime: shift.effective_start_time,
endTime: shift.effective_end_time,
totalSlots: 0,
filledSlots: 0,
shifts: [],
})
}
const group = groups.get(tsId)!
group.shifts.push(shift)
group.totalSlots += shift.slots_total
group.filledSlots += shift.filled_slots
}
return Array.from(groups.values())
})
// --- Dialogs ---
const isCreateSectionOpen = ref(false)
const isEditSectionOpen = ref(false)
const isCreateShiftOpen = ref(false)
const isAssignShiftOpen = ref(false)
const editingShift = ref<Shift | null>(null)
const assigningShift = ref<Shift | null>(null)
// Delete section
const isDeleteSectionOpen = ref(false)
const deletingSectionId = ref<string | null>(null)
function onDeleteSectionConfirm(section: FestivalSection) {
deletingSectionId.value = section.id
isDeleteSectionOpen.value = true
}
function onDeleteSectionExecute() {
if (!deletingSectionId.value)
return
deleteSection(deletingSectionId.value, {
onSuccess: () => {
isDeleteSectionOpen.value = false
if (activeSectionId.value === deletingSectionId.value)
activeSectionId.value = sections.value?.[0]?.id ?? null
deletingSectionId.value = null
},
})
}
// Delete shift
const isDeleteShiftOpen = ref(false)
const deletingShiftId = ref<string | null>(null)
function onDeleteShiftConfirm(shift: Shift) {
deletingShiftId.value = shift.id
isDeleteShiftOpen.value = true
}
function onDeleteShiftExecute() {
if (!deletingShiftId.value)
return
deleteShiftMutation(deletingShiftId.value, {
onSuccess: () => {
isDeleteShiftOpen.value = false
deletingShiftId.value = null
},
})
}
function onEditShift(shift: Shift) {
editingShift.value = shift
isCreateShiftOpen.value = true
}
function onAssignShift(shift: Shift) {
assigningShift.value = shift
isAssignShiftOpen.value = true
}
function onAddShift() {
editingShift.value = null
isCreateShiftOpen.value = true
}
function onEditSection() {
isEditSectionOpen.value = true
}
// Status styling
const statusColor: Record<ShiftStatus, string> = {
draft: 'default',
open: 'info',
full: 'success',
in_progress: 'warning',
completed: 'success',
cancelled: 'error',
}
const statusLabel: Record<ShiftStatus, string> = {
draft: 'Concept',
open: 'Open',
full: 'Vol',
in_progress: 'Bezig',
completed: 'Voltooid',
cancelled: 'Geannuleerd',
}
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'
}
// Date formatting
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
})
function formatDate(iso: string) {
if (!iso)
return ''
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('')
const successColor = ref('success')
function onSectionUpdated() {
successMessage.value = 'Sectie bijgewerkt.'
successColor.value = 'success'
showSuccess.value = true
}
function onSectionCreated(payload: { name: string; redirectedToParent: boolean; parentEventName?: string }) {
if (payload.redirectedToParent) {
successMessage.value = `Sectie '${payload.name}' aangemaakt op festival-niveau (${payload.parentEventName}). Zichtbaar in alle programmaonderdelen.`
successColor.value = 'info'
}
else {
successMessage.value = 'Sectie aangemaakt.'
successColor.value = 'success'
}
showSuccess.value = true
}
</script>
<template>
<!-- -->
<!-- SECTIONS + SHIFTS (two-column layout) -->
<!-- -->
<VRow>
<!-- LEFT COLUMN Sections list -->
<VCol
cols="12"
md="3"
style="min-inline-size: 320px; max-inline-size: 380px;"
>
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-x-2">
<span>Secties</span>
<!-- Contextual help tooltip -->
<InfoTooltip v-if="isSubEvent && parentEvent">
<p>
Sommige secties zijn <strong>festival-breed</strong>: ze zijn bij elk
programmaonderdeel actief en worden centraal beheerd. Je herkent ze aan
de festivalnaam achter de sectienaam.
</p>
<div class="mt-2 pa-2 bg-surface rounded text-caption">
<strong>Voorbeeld:</strong> EHBO en Security zijn festival-breed ze
staan bij elk programmaonderdeel.
</div>
</InfoTooltip>
<VTooltip
v-else
location="bottom"
max-width="300"
>
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
class="text-disabled"
/>
</template>
Secties zijn de operationele afdelingen van je evenement (bijv. Bar, Security, Hospitality). Elke sectie heeft eigen shifts en teamleden.
</VTooltip>
</div>
<VBtn
icon="tabler-plus"
variant="text"
size="small"
@click="isCreateSectionOpen = true"
/>
</VCardTitle>
<!-- Loading -->
<VSkeletonLoader
v-if="sectionsLoading"
type="list-item@4"
/>
<!-- Empty -->
<VCardText
v-else-if="!sections?.length"
class="text-center text-disabled"
>
Geen secties maak er een aan
</VCardText>
<!-- Section list -->
<Draggable
v-else
v-model="sections"
item-key="id"
ghost-class="section-ghost"
chosen-class="section-chosen"
drag-class="section-drag"
:animation="200"
:delay="100"
delay-on-touch-only
direction="vertical"
class="v-list v-list--nav v-list--density-compact"
@end="onDragEnd"
>
<template #item="{ element }">
<VListItem
:active="element.id === activeSectionId"
color="primary"
class="section-item"
@click="activeSectionId = element.id"
>
<template #prepend>
<VIcon
v-if="element.icon"
:icon="element.icon"
size="16"
class="me-1"
/>
</template>
<VListItemTitle>
<span>{{ element.name }}</span>
<span
v-if="element.type === 'cross_event' && parentEvent"
class="text-caption text-medium-emphasis ms-1"
>
· {{ parentEvent.name }}
</span>
</VListItemTitle>
</VListItem>
</template>
</Draggable>
</VCard>
</VCol>
<!-- RIGHT COLUMN Shifts for active section -->
<VCol>
<!-- No section selected -->
<VCard
v-if="!activeSection"
class="text-center pa-8"
>
<VIcon
icon="tabler-layout-grid"
size="48"
class="mb-4 text-disabled"
/>
<p class="text-body-1 text-disabled">
Selecteer een sectie om shifts te beheren
</p>
</VCard>
<!-- Section selected -->
<template v-else>
<!-- Context banner for cross_event sections viewed from sub-event -->
<VAlert
v-if="activeSection.type === 'cross_event' && isSubEvent && parentEvent"
type="info"
variant="tonal"
density="compact"
class="mb-4"
>
<div class="d-flex justify-space-between align-center flex-wrap gap-2">
<span>
Je bekijkt {{ activeSection.name }} vanuit
<strong>{{ eventDetail?.name }}</strong>
</span>
<RouterLink
:to="{ name: 'events-id-sections', params: { id: activeSection.event_id } }"
class="text-caption"
>
Bekijk alle diensten
</RouterLink>
</div>
</VAlert>
<!-- Header -->
<VCard class="mb-4">
<VCardTitle class="d-flex align-center justify-space-between flex-wrap gap-2">
<div class="d-flex align-center gap-x-2">
<VIcon
v-if="activeSection.icon"
:icon="activeSection.icon"
size="20"
/>
<span>{{ activeSection.name }}</span>
<VChip
v-if="activeSection.type === 'cross_event'"
size="small"
color="info"
variant="tonal"
>
festival-breed
</VChip>
<span
v-if="activeSection.crew_need"
class="text-body-2 text-disabled"
>
Crew nodig: {{ activeSection.crew_need }}
</span>
</div>
<div class="d-flex align-center gap-x-2">
<!-- Contextual help tooltip on shifts -->
<VTooltip
location="bottom"
max-width="300"
>
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
class="text-disabled"
/>
</template>
Een shift is een concrete taak binnen een sectie, gekoppeld aan een time slot. Stel het aantal plekken in en bepaal hoeveel daarvan door vrijwilligers geclaimd kunnen worden.
</VTooltip>
<VBtn
size="small"
variant="tonal"
prepend-icon="tabler-plus"
@click="onAddShift"
>
Shift
</VBtn>
<VBtn
size="small"
variant="tonal"
icon="tabler-edit"
@click="onEditSection"
/>
<VBtn
size="small"
variant="tonal"
icon="tabler-trash"
color="error"
@click="onDeleteSectionConfirm(activeSection)"
/>
</div>
</VCardTitle>
</VCard>
<!-- Loading shifts -->
<VSkeletonLoader
v-if="shiftsLoading"
type="card@3"
/>
<!-- No shifts -->
<VCard
v-else-if="!shifts?.length"
class="text-center pa-8"
>
<VIcon
icon="tabler-calendar-time"
size="48"
class="mb-4 text-disabled"
/>
<p class="text-body-1 text-disabled mb-4">
Nog geen shifts voor deze sectie
</p>
<VBtn
prepend-icon="tabler-plus"
@click="onAddShift"
>
Shift toevoegen
</VBtn>
</VCard>
<!-- Shifts grouped by time slot -->
<template v-else>
<VCard
v-for="(group, gi) in shiftsByTimeSlot"
:key="gi"
class="mb-4"
>
<!-- Group header -->
<VCardTitle class="d-flex align-center justify-space-between">
<div>
<span>{{ group.timeSlotName }}</span>
<span class="text-body-2 text-disabled ms-2">
{{ formatDate(group.date) }} {{ group.startTime }}{{ group.endTime }}
</span>
</div>
<span class="text-body-2">
{{ group.filledSlots }}/{{ group.totalSlots }} ingevuld
</span>
</VCardTitle>
<VDivider />
<!-- Shifts in group -->
<VList density="compact">
<VListItem
v-for="shift in group.shifts"
:key="shift.id"
>
<div class="d-flex align-center gap-x-3 py-1 flex-wrap">
<!-- Title + lead badge -->
<div
class="d-flex align-center gap-x-2"
style="min-inline-size: 160px;"
>
<span class="text-body-1 font-weight-medium">
{{ shift.title ?? 'Shift' }}
</span>
<VChip
v-if="shift.is_lead_role"
size="x-small"
color="warning"
>
Hoofdrol
</VChip>
</div>
<!-- Fill rate -->
<div
class="d-flex align-center gap-x-2"
style="min-inline-size: 160px;"
>
<VProgressLinear
:model-value="shift.is_overbooked ? 100 : shift.fill_rate"
:color="fillRateColor(shift)"
height="8"
rounded
style="inline-size: 80px;"
/>
<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 -->
<VChip
:color="statusColor[shift.status]"
size="small"
>
{{ 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"
size="small"
title="Toewijzen"
@click="onAssignShift(shift)"
/>
<VBtn
icon="tabler-edit"
variant="text"
size="small"
title="Bewerken"
@click="onEditShift(shift)"
/>
<VBtn
icon="tabler-trash"
variant="text"
size="small"
color="error"
title="Verwijderen"
@click="onDeleteShiftConfirm(shift)"
/>
</div>
</div>
</VListItem>
</VList>
</VCard>
</template>
</template>
</VCol>
</VRow>
<!-- -->
<!-- DIALOGS -->
<!-- -->
<CreateSectionDialog
v-model="isCreateSectionOpen"
:event-id="eventId"
:is-sub-event="isSubEvent"
:next-sort-order="sections.length"
@created="onSectionCreated"
/>
<EditSectionDialog
v-model="isEditSectionOpen"
:event-id="activeSectionEventId"
:section="activeSection"
@updated="onSectionUpdated"
/>
<CreateShiftDialog
v-if="activeSection"
v-model="isCreateShiftOpen"
:event-id="activeSectionEventId"
:section-id="activeSection.id"
:section="activeSection"
:shift="editingShift"
:is-sub-event="isSubEvent"
/>
<AssignPersonDialog
v-if="activeSection"
v-model="isAssignShiftOpen"
:event-id="activeSectionEventId"
:section-id="activeSection.id"
:shift="assigningShift"
/>
<!-- Delete section confirmation -->
<VDialog
v-model="isDeleteSectionOpen"
max-width="400"
>
<VCard title="Sectie verwijderen">
<VCardText>
Weet je zeker dat je deze sectie en alle bijbehorende shifts wilt verwijderen?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteSectionOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
@click="onDeleteSectionExecute"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Delete shift confirmation -->
<VDialog
v-model="isDeleteShiftOpen"
max-width="400"
>
<VCard title="Shift verwijderen">
<VCardText>
Weet je zeker dat je deze shift wilt verwijderen?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteShiftOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isDeleting"
@click="onDeleteShiftExecute"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Shift detail side panel -->
<ShiftDetailPanel
v-model="shiftDetailStore.isOpen"
:event-id="activeSectionEventId"
:shift="selectedShift"
/>
<!-- Success snackbar -->
<VSnackbar
v-model="showSuccess"
:color="successColor"
:timeout="4000"
>
{{ successMessage }}
</VSnackbar>
</template>
<style scoped>
.section-item {
cursor: grab;
}
:deep(.section-ghost) {
opacity: 0.4;
background: rgb(var(--v-theme-primary), 0.08);
border-radius: 8px;
}
:deep(.section-chosen) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: scale(1.02);
border-radius: 8px;
background: rgb(var(--v-theme-surface));
}
:deep(.section-drag) {
cursor: grabbing;
}
</style>