feat(timetable): keyboard a11y composable + page entry — Session 4 step 11 + ship
useTimetableKeyboard (RFC v0.2 D20): - Arrow ←/→ nudges by SNAP_MIN; Shift+Arrow = ±60min - Arrow ↑/↓ shifts lane; Shift+Arrow ↑/↓ = ±1 stage - [/] cycles stages preserving time + lane - Space starts a "keyboard drag" (announced via aria-live), arrows accumulate the offset, Enter commits, Esc cancels - Enter on a focused block opens the popover; Delete confirms+removes - Pure orchestration — the actual mutation goes through useTimetableMutations so keyboard moves inherit optimistic update + 409 rollback pages/events/[id]/timetable/index.vue: - definePage with organizer context + navActiveLink=events - ?day query param ↔ store.activeDayId in both directions - Composes EventTabsNav, TimeAxis, GridBg, StageHeaderCell, StageRow, Wachtrij, PerformancePopover, AddPerformanceDialog, StageEditor, LineupMatrix, EmptyDayState - Conflict pill in toolbar (header total) per prototype audit §4.8 - Status filter chips applied to canvas blocks via store.isStatusVisible - usePointerDrag + useDragOrClick wires drag to a single move() call; on success flashes pulseSet on cascaded[] for 1.5s (D18 + D21 keyframe) - aria-live region echoes keyboard-drag announcements Tweaks for boundary/lint cleanliness: - Dialog props switched from Ref<T> to T + toRef inside (Vue templates auto-unwrap refs; Ref-typed props clashed with template usage) - Wachtrij counts shadow + sonarjs cleanup - no-void watcher Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, toRef, watch } from 'vue'
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
|
||||
import { createPerformancePayloadSchema } from '@/schemas/timetable'
|
||||
@@ -9,9 +8,9 @@ import { requiredValidator } from '@core/utils/validators'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
orgId: Ref<string>
|
||||
eventId: Ref<string>
|
||||
dayId: Ref<string | null>
|
||||
orgId: string
|
||||
eventId: string
|
||||
dayId: string | null
|
||||
stages: Stage[]
|
||||
/** Engagements available to schedule (filtered to this event). */
|
||||
engagements: ArtistEngagement[]
|
||||
@@ -24,6 +23,10 @@ const emit = defineEmits<{
|
||||
'created': []
|
||||
}>()
|
||||
|
||||
const orgIdRef = toRef(props, 'orgId')
|
||||
const eventIdRef = toRef(props, 'eventId')
|
||||
const dayIdRef = toRef(props, 'dayId')
|
||||
|
||||
const refForm = ref<VForm>()
|
||||
const errors = ref<Record<string, string>>({})
|
||||
|
||||
@@ -50,7 +53,7 @@ watch(() => props.modelValue, open => {
|
||||
}
|
||||
})
|
||||
|
||||
const mutations = useTimetableMutations({ orgId: props.orgId, eventId: props.eventId, dayId: props.dayId })
|
||||
const mutations = useTimetableMutations({ orgId: orgIdRef, eventId: eventIdRef, dayId: dayIdRef })
|
||||
const isPending = computed(() => mutations.create.isPending.value)
|
||||
|
||||
const engagementOptions = computed(() =>
|
||||
@@ -64,7 +67,7 @@ const engagementOptions = computed(() =>
|
||||
async function submit(): Promise<void> {
|
||||
errors.value = {}
|
||||
|
||||
const eventIdValue = props.dayId.value ?? props.eventId.value
|
||||
const eventIdValue = props.dayId ?? props.eventId
|
||||
|
||||
const payload = {
|
||||
engagement_id: form.value.engagement_id,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, toRef, watch } from 'vue'
|
||||
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
|
||||
import type { Stage } from '@/types/timetable'
|
||||
|
||||
@@ -12,9 +11,9 @@ interface SubEvent {
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
orgId: Ref<string>
|
||||
eventId: Ref<string>
|
||||
dayId: Ref<string | null>
|
||||
orgId: string
|
||||
eventId: string
|
||||
dayId: string | null
|
||||
stages: Stage[]
|
||||
subEvents: SubEvent[]
|
||||
}>()
|
||||
@@ -24,6 +23,10 @@ const emit = defineEmits<{
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const orgIdRef = toRef(props, 'orgId')
|
||||
const eventIdRef = toRef(props, 'eventId')
|
||||
const dayIdRef = toRef(props, 'dayId')
|
||||
|
||||
const matrix = ref<Record<string, Set<string>>>({})
|
||||
const errors = ref<Record<string, string>>({})
|
||||
|
||||
@@ -48,7 +51,7 @@ function isOn(stageId: string, eventId: string): boolean {
|
||||
return matrix.value[stageId]?.has(eventId) ?? false
|
||||
}
|
||||
|
||||
const mutations = useTimetableMutations({ orgId: props.orgId, eventId: props.eventId, dayId: props.dayId })
|
||||
const mutations = useTimetableMutations({ orgId: orgIdRef, eventId: eventIdRef, dayId: dayIdRef })
|
||||
const isPending = ref(false)
|
||||
|
||||
const dirtyStages = computed(() =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useEngagement } from '@/composables/api/useTimetable'
|
||||
import { ArtistEngagementStatus, type Performance } from '@/types/timetable'
|
||||
|
||||
@@ -8,7 +8,7 @@ const props = defineProps<{
|
||||
/** Anchor element rect (from PerformanceBlock click). */
|
||||
anchorRect: DOMRect | null
|
||||
performance: Performance | null
|
||||
orgId: import('vue').Ref<string>
|
||||
orgId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -17,9 +17,11 @@ const emit = defineEmits<{
|
||||
'delete': [perf: Performance]
|
||||
}>()
|
||||
|
||||
const orgIdRef = toRef(props, 'orgId')
|
||||
|
||||
const engagementId = computed(() => props.performance?.engagement_id ?? null)
|
||||
const engagementIdRef = computed<string | null>(() => engagementId.value)
|
||||
const { data: engagement, isLoading } = useEngagement(props.orgId, engagementIdRef)
|
||||
const { data: engagement, isLoading } = useEngagement(orgIdRef, engagementIdRef)
|
||||
|
||||
const advancing = computed(() => {
|
||||
const e = engagement.value ?? props.performance?.engagement
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, toRef, watch } from 'vue'
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
|
||||
import { createStagePayloadSchema } from '@/schemas/timetable'
|
||||
@@ -9,9 +8,9 @@ import { requiredValidator } from '@core/utils/validators'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
orgId: Ref<string>
|
||||
eventId: Ref<string>
|
||||
dayId: Ref<string | null>
|
||||
orgId: string
|
||||
eventId: string
|
||||
dayId: string | null
|
||||
/** Pass an existing stage to edit; null = create. */
|
||||
stage: Stage | null
|
||||
}>()
|
||||
@@ -22,6 +21,10 @@ const emit = defineEmits<{
|
||||
'deleted': [stage: Stage]
|
||||
}>()
|
||||
|
||||
const orgIdRef = toRef(props, 'orgId')
|
||||
const eventIdRef = toRef(props, 'eventId')
|
||||
const dayIdRef = toRef(props, 'dayId')
|
||||
|
||||
const STAGE_PALETTE = [
|
||||
'#e85d75',
|
||||
'#d9a93c',
|
||||
@@ -57,7 +60,7 @@ watch(() => props.modelValue, open => {
|
||||
}
|
||||
})
|
||||
|
||||
const mutations = useTimetableMutations({ orgId: props.orgId, eventId: props.eventId, dayId: props.dayId })
|
||||
const mutations = useTimetableMutations({ orgId: orgIdRef, eventId: eventIdRef, dayId: dayIdRef })
|
||||
const isPending = computed(() => mutations.createStage.isPending.value || mutations.updateStage.isPending.value)
|
||||
const isDeleting = computed(() => mutations.deleteStage.isPending.value)
|
||||
|
||||
|
||||
@@ -37,25 +37,23 @@ const filtered = computed(() => {
|
||||
|
||||
return props.performances.filter(p => {
|
||||
const status = p.engagement?.booking_status?.value
|
||||
if (!store.isStatusVisible(status))
|
||||
return false
|
||||
if (search && !(p.engagement?.artist?.name ?? '').toLowerCase().includes(search))
|
||||
return false
|
||||
const visible = store.isStatusVisible(status)
|
||||
const matchesSearch = !search || (p.engagement?.artist?.name ?? '').toLowerCase().includes(search)
|
||||
|
||||
return true
|
||||
return visible && matchesSearch
|
||||
})
|
||||
})
|
||||
|
||||
const counts = computed(() => {
|
||||
const counts = new Map<ArtistEngagementStatusType, number>()
|
||||
const out = new Map<ArtistEngagementStatusType, number>()
|
||||
for (const p of props.performances) {
|
||||
const s = p.engagement?.booking_status?.value
|
||||
if (!s)
|
||||
continue
|
||||
counts.set(s, (counts.get(s) ?? 0) + 1)
|
||||
out.set(s, (out.get(s) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return counts
|
||||
return out
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
184
apps/app/src/composables/timetable/useTimetableKeyboard.ts
Normal file
184
apps/app/src/composables/timetable/useTimetableKeyboard.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { generateIdempotencyKey } from '@/lib/idempotencyKey'
|
||||
import { SNAP_MIN } from '@/lib/timetable/snap'
|
||||
import type { Performance, Stage } from '@/types/timetable'
|
||||
|
||||
/**
|
||||
* RFC v0.2 D20 — keyboard interaction model for the timetable canvas.
|
||||
*
|
||||
* Listens to keydown events on the canvas root once mounted. Routes
|
||||
* directional / modifier keys into the same mutation composable that
|
||||
* the pointer drag uses, so keyboard nudges go through the same
|
||||
* server-transactional path (D18) and inherit optimistic + rollback.
|
||||
*/
|
||||
|
||||
export interface KeyboardMoveCallbacks {
|
||||
|
||||
/** Translate a performance by ±minutes (and optionally ±lanes / ±stages). */
|
||||
nudge: (perf: Performance, deltaMin: number, deltaLane: number, deltaStageIdx: number, idempotencyKey: string) => Promise<void>
|
||||
|
||||
/** Open the popover for the focused block. */
|
||||
openPopover: (perf: Performance, anchor: HTMLElement) => void
|
||||
|
||||
/** Confirm + delete. */
|
||||
remove: (perf: Performance) => Promise<void>
|
||||
}
|
||||
|
||||
export interface UseTimetableKeyboardArgs {
|
||||
rootEl: Ref<HTMLElement | null>
|
||||
|
||||
/** Pinia store reactive ref to the selected performance id. */
|
||||
selectedId: Ref<string | null>
|
||||
|
||||
/** Resolver: id → performance object (uses TanStack cache). */
|
||||
resolvePerformance: (id: string) => Performance | null
|
||||
|
||||
/** Sorted stage list (so [/] navigates left/right). */
|
||||
stages: Ref<Stage[]>
|
||||
callbacks: KeyboardMoveCallbacks
|
||||
}
|
||||
|
||||
export function useTimetableKeyboard(args: UseTimetableKeyboardArgs): { announce: Ref<string> } {
|
||||
const announce = ref('')
|
||||
|
||||
/** True while the user is in keyboard "drag" mode (Space → arrows → Enter/Esc). */
|
||||
const dragMode = ref(false)
|
||||
let pendingMove: { deltaMin: number; deltaLane: number; deltaStageIdx: number } | null = null
|
||||
|
||||
function focusBlock(id: string | null): void {
|
||||
if (!id || !args.rootEl.value)
|
||||
return
|
||||
const el = args.rootEl.value.querySelector<HTMLElement>(`[data-perf-id="${id}"]`)
|
||||
if (el) {
|
||||
el.focus()
|
||||
el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
const id = args.selectedId.value
|
||||
if (!id)
|
||||
return
|
||||
const perf = args.resolvePerformance(id)
|
||||
if (!perf)
|
||||
return
|
||||
|
||||
const stageMul = event.shiftKey ? 12 : 1 // Shift+Arrow ←/→ = ±60 min when SNAP_MIN=5
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault()
|
||||
if (dragMode.value)
|
||||
accumulate(-SNAP_MIN * stageMul, 0, 0)
|
||||
else
|
||||
void args.callbacks.nudge(perf, -SNAP_MIN * stageMul, 0, 0, generateIdempotencyKey())
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
if (dragMode.value)
|
||||
accumulate(SNAP_MIN * stageMul, 0, 0)
|
||||
else
|
||||
void args.callbacks.nudge(perf, SNAP_MIN * stageMul, 0, 0, generateIdempotencyKey())
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
if (dragMode.value)
|
||||
accumulate(0, 0, -1)
|
||||
else
|
||||
void args.callbacks.nudge(perf, 0, 0, -1, generateIdempotencyKey())
|
||||
}
|
||||
else if (dragMode.value) {
|
||||
accumulate(0, -1, 0)
|
||||
}
|
||||
else {
|
||||
void args.callbacks.nudge(perf, 0, -1, 0, generateIdempotencyKey())
|
||||
}
|
||||
break
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
if (dragMode.value)
|
||||
accumulate(0, 0, 1)
|
||||
else
|
||||
void args.callbacks.nudge(perf, 0, 0, 1, generateIdempotencyKey())
|
||||
}
|
||||
else if (dragMode.value) {
|
||||
accumulate(0, 1, 0)
|
||||
}
|
||||
else {
|
||||
void args.callbacks.nudge(perf, 0, 1, 0, generateIdempotencyKey())
|
||||
}
|
||||
break
|
||||
case '[':
|
||||
event.preventDefault()
|
||||
void args.callbacks.nudge(perf, 0, 0, -1, generateIdempotencyKey())
|
||||
break
|
||||
case ']':
|
||||
event.preventDefault()
|
||||
void args.callbacks.nudge(perf, 0, 0, 1, generateIdempotencyKey())
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (dragMode.value && pendingMove) {
|
||||
event.preventDefault()
|
||||
|
||||
const { deltaMin, deltaLane, deltaStageIdx } = pendingMove
|
||||
|
||||
dragMode.value = false
|
||||
pendingMove = null
|
||||
announce.value = 'Verplaatsing bevestigd.'
|
||||
void args.callbacks.nudge(perf, deltaMin, deltaLane, deltaStageIdx, generateIdempotencyKey())
|
||||
}
|
||||
else if (event.key === ' ') {
|
||||
event.preventDefault()
|
||||
dragMode.value = true
|
||||
pendingMove = { deltaMin: 0, deltaLane: 0, deltaStageIdx: 0 }
|
||||
announce.value = 'Toetsenbord-verplaatsing actief. Gebruik pijltjes, Enter bevestigt, Esc annuleert.'
|
||||
}
|
||||
else {
|
||||
event.preventDefault()
|
||||
|
||||
const el = args.rootEl.value?.querySelector<HTMLElement>(`[data-perf-id="${id}"]`)
|
||||
if (el)
|
||||
args.callbacks.openPopover(perf, el)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
if (dragMode.value) {
|
||||
event.preventDefault()
|
||||
dragMode.value = false
|
||||
pendingMove = null
|
||||
announce.value = 'Verplaatsing geannuleerd.'
|
||||
}
|
||||
break
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
event.preventDefault()
|
||||
void args.callbacks.remove(perf)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function accumulate(deltaMin: number, deltaLane: number, deltaStageIdx: number): void {
|
||||
if (!pendingMove)
|
||||
return
|
||||
pendingMove = {
|
||||
deltaMin: pendingMove.deltaMin + deltaMin,
|
||||
deltaLane: pendingMove.deltaLane + deltaLane,
|
||||
deltaStageIdx: pendingMove.deltaStageIdx + deltaStageIdx,
|
||||
}
|
||||
announce.value = `Voorlopig +${pendingMove.deltaMin} min, ${pendingMove.deltaLane} lanes, ${pendingMove.deltaStageIdx} stages.`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
args.rootEl.value?.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
args.rootEl.value?.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
return { announce, focusSelected: () => focusBlock(args.selectedId.value) } as { announce: Ref<string> }
|
||||
}
|
||||
645
apps/app/src/pages/events/[id]/timetable/index.vue
Normal file
645
apps/app/src/pages/events/[id]/timetable/index.vue
Normal file
@@ -0,0 +1,645 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||
import AddPerformanceDialog from '@/components/timetable/AddPerformanceDialog.vue'
|
||||
import EmptyDayState from '@/components/timetable/EmptyDayState.vue'
|
||||
import GridBg from '@/components/timetable/GridBg.vue'
|
||||
import LineupMatrix from '@/components/timetable/LineupMatrix.vue'
|
||||
import PerformancePopover from '@/components/timetable/PerformancePopover.vue'
|
||||
import StageEditor from '@/components/timetable/StageEditor.vue'
|
||||
import StageHeaderCell from '@/components/timetable/StageHeaderCell.vue'
|
||||
import StageRow from '@/components/timetable/StageRow.vue'
|
||||
import TimeAxis from '@/components/timetable/TimeAxis.vue'
|
||||
import Wachtrij from '@/components/timetable/Wachtrij.vue'
|
||||
import { useEventChildren, useEventDetail } from '@/composables/api/useEvents'
|
||||
import { useTimetable } from '@/composables/api/useTimetable'
|
||||
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
|
||||
import { useDragOrClick } from '@/composables/timetable/useDragOrClick'
|
||||
import { useTimetableKeyboard } from '@/composables/timetable/useTimetableKeyboard'
|
||||
import { generateIdempotencyKey } from '@/lib/idempotencyKey'
|
||||
import { findB2BSides } from '@/lib/timetable/b2b'
|
||||
import { findConflicts } from '@/lib/timetable/conflict'
|
||||
import { resolveLanes } from '@/lib/timetable/lane'
|
||||
import { SNAP_MIN, snap } from '@/lib/timetable/snap'
|
||||
import { isoToMinutes, minutesToIso, pxToMinutes } from '@/lib/timetable/time-grid'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useTimetableStore } from '@/stores/useTimetableStore'
|
||||
import {
|
||||
ArtistEngagementStatus,
|
||||
type Performance,
|
||||
type Stage,
|
||||
} from '@/types/timetable'
|
||||
|
||||
definePage({
|
||||
meta: {
|
||||
context: 'organizer',
|
||||
navActiveLink: 'events',
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const store = useTimetableStore()
|
||||
|
||||
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
||||
const eventId = computed(() => String((route.params as { id: string }).id))
|
||||
const orgIdRef = orgId
|
||||
const eventIdRef = eventId
|
||||
|
||||
const { data: eventDetail } = useEventDetail(orgId, eventId)
|
||||
const { data: subEvents } = useEventChildren(orgId, eventId)
|
||||
|
||||
const isFlatEvent = computed(() => !subEvents.value || subEvents.value.length === 0)
|
||||
|
||||
const dayOptions = computed(() => {
|
||||
if (isFlatEvent.value && eventDetail.value)
|
||||
return [{ id: eventDetail.value.id, name: eventDetail.value.name, start_date: null }]
|
||||
|
||||
return (subEvents.value ?? []).map(c => ({ id: c.id, name: c.name, start_date: null }))
|
||||
})
|
||||
|
||||
const dayIdRef = computed<string | null>(() => store.activeDayId)
|
||||
|
||||
watch(() => route.query.day, raw => {
|
||||
const value = typeof raw === 'string' ? raw : null
|
||||
if (value && value !== store.activeDayId)
|
||||
store.setActiveDay(value)
|
||||
}, { immediate: true })
|
||||
|
||||
watch(dayOptions, opts => {
|
||||
if (!store.activeDayId && opts.length > 0)
|
||||
store.setActiveDay(opts[0].id)
|
||||
})
|
||||
|
||||
watch(() => store.activeDayId, id => {
|
||||
if (id && id !== route.query.day)
|
||||
router.replace({ query: { ...route.query, day: id } })
|
||||
})
|
||||
|
||||
const tt = useTimetable(orgId, eventId, dayIdRef)
|
||||
const mutations = useTimetableMutations({ orgId, eventId, dayId: dayIdRef })
|
||||
|
||||
// ─── Grid + lane geometry ─────────────────────────────────────────────
|
||||
|
||||
const pxPerMin = ref(2.4) // ~144px per hour
|
||||
const totalMinutes = computed(() => 13 * 60) // 13h day
|
||||
|
||||
const gridStartIso = computed(() => {
|
||||
// Pick the earliest performance start_at (rounded down to the hour) as the
|
||||
// canvas anchor. Falls back to "today 14:00" if no performances yet.
|
||||
const perfs = tt.performances.value ?? []
|
||||
if (perfs.length > 0) {
|
||||
const min = Math.min(...perfs.map(p => p.start_at ? Date.parse(p.start_at) : Number.POSITIVE_INFINITY))
|
||||
if (Number.isFinite(min)) {
|
||||
const d = new Date(min)
|
||||
|
||||
d.setMinutes(0, 0, 0)
|
||||
|
||||
return d.toISOString()
|
||||
}
|
||||
}
|
||||
const d = new Date()
|
||||
|
||||
d.setHours(14, 0, 0, 0)
|
||||
|
||||
return d.toISOString()
|
||||
})
|
||||
|
||||
// Filter stages active on the active day via stage_days pivot.
|
||||
const activeStages = computed<Stage[]>(() => {
|
||||
const all = tt.stages.value ?? []
|
||||
const did = dayIdRef.value
|
||||
|
||||
return did ? all.filter(s => (s.stage_days ?? []).includes(did)) : all
|
||||
})
|
||||
|
||||
interface StageRowModel {
|
||||
stage: Stage
|
||||
performances: Performance[]
|
||||
laneCount: number
|
||||
conflictCount: number
|
||||
}
|
||||
|
||||
const stageRows = computed<StageRowModel[]>(() => {
|
||||
const performances = tt.performances.value ?? []
|
||||
|
||||
return activeStages.value.map(stage => {
|
||||
const onStage = performances.filter(p => p.stage_id === stage.id)
|
||||
|
||||
const subjects = onStage
|
||||
.filter(p => p.start_at && p.end_at)
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
lane: p.lane,
|
||||
start_at: p.start_at as string,
|
||||
end_at: p.end_at as string,
|
||||
cancelled: p.engagement?.booking_status?.value === ArtistEngagementStatus.CANCELLED,
|
||||
}))
|
||||
|
||||
const { laneCount } = resolveLanes(subjects)
|
||||
const conflictIds = findConflicts(onStage)
|
||||
const visiblePerfs = onStage.filter(p => store.isStatusVisible(p.engagement?.booking_status?.value))
|
||||
|
||||
return { stage, performances: visiblePerfs, laneCount, conflictCount: conflictIds.size }
|
||||
})
|
||||
})
|
||||
|
||||
const allConflicts = computed(() => findConflicts(tt.performances.value ?? []))
|
||||
const b2bSides = computed(() => findB2BSides(tt.performances.value ?? []))
|
||||
|
||||
// ─── Selection / popover ──────────────────────────────────────────────
|
||||
|
||||
const popoverAnchor = ref<DOMRect | null>(null)
|
||||
|
||||
const popoverOpen = computed({
|
||||
get: () => store.selectedPerformanceId !== null && popoverAnchor.value !== null,
|
||||
set: open => {
|
||||
if (!open) {
|
||||
store.selectPerformance(null)
|
||||
popoverAnchor.value = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const popoverPerformance = computed<Performance | null>(() => {
|
||||
if (!store.selectedPerformanceId)
|
||||
return null
|
||||
const id = store.selectedPerformanceId
|
||||
|
||||
return (tt.performances.value ?? []).find(p => p.id === id)
|
||||
?? (tt.wachtrij.value ?? []).find(p => p.id === id)
|
||||
?? null
|
||||
})
|
||||
|
||||
function onBlockSelect(perf: Performance, rect: DOMRect): void {
|
||||
store.selectPerformance(perf.id)
|
||||
popoverAnchor.value = rect
|
||||
}
|
||||
|
||||
// ─── Pulse set (cascade visualisation) ────────────────────────────────
|
||||
|
||||
const pulseSet = ref<Set<string>>(new Set())
|
||||
function flashPulse(ids: string[]): void {
|
||||
if (ids.length === 0)
|
||||
return
|
||||
pulseSet.value = new Set(ids)
|
||||
window.setTimeout(() => {
|
||||
pulseSet.value = new Set()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// ─── Drag handler (mouse/touch) ───────────────────────────────────────
|
||||
|
||||
interface DragContext {
|
||||
perf: Performance
|
||||
startStartAt: string
|
||||
startEndAt: string
|
||||
startLane: number
|
||||
durationMin: number
|
||||
}
|
||||
|
||||
let dragCtx: DragContext | null = null
|
||||
|
||||
const dragController = useDragOrClick({
|
||||
onClick: () => { /* clicks are emitted by the block as @select */ },
|
||||
onDragStart: () => {
|
||||
if (!dragCtx)
|
||||
return
|
||||
store.startDrag(dragCtx.perf)
|
||||
},
|
||||
onDragMove: state => {
|
||||
if (!dragCtx)
|
||||
return
|
||||
const deltaMin = snap(pxToMinutes(state.deltaX, pxPerMin.value), SNAP_MIN)
|
||||
const newStart = minutesToIso(isoToMinutes(dragCtx.startStartAt, gridStartIso.value) + deltaMin, gridStartIso.value)
|
||||
const newEnd = minutesToIso(isoToMinutes(dragCtx.startEndAt, gridStartIso.value) + deltaMin, gridStartIso.value)
|
||||
|
||||
store.updateDragGhost({
|
||||
stageId: dragCtx.perf.stage_id,
|
||||
startAt: newStart,
|
||||
endAt: newEnd,
|
||||
lane: dragCtx.startLane,
|
||||
})
|
||||
},
|
||||
onDragEnd: async (state, cancelled) => {
|
||||
if (!dragCtx) {
|
||||
store.endDrag()
|
||||
|
||||
return
|
||||
}
|
||||
const ctx = dragCtx
|
||||
|
||||
dragCtx = null
|
||||
|
||||
if (cancelled) {
|
||||
store.endDrag()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const deltaMin = snap(pxToMinutes(state.deltaX, pxPerMin.value), SNAP_MIN)
|
||||
if (deltaMin === 0) {
|
||||
store.endDrag()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const newStart = minutesToIso(isoToMinutes(ctx.startStartAt, gridStartIso.value) + deltaMin, gridStartIso.value)
|
||||
const newEnd = minutesToIso(isoToMinutes(ctx.startEndAt, gridStartIso.value) + deltaMin, gridStartIso.value)
|
||||
|
||||
try {
|
||||
const result = await mutations.move.mutateAsync({
|
||||
payload: {
|
||||
performance_id: ctx.perf.id,
|
||||
target_stage_id: ctx.perf.stage_id,
|
||||
target_start_at: toMysqlDatetime(newStart),
|
||||
target_end_at: toMysqlDatetime(newEnd),
|
||||
target_lane: ctx.startLane,
|
||||
version: ctx.perf.version,
|
||||
},
|
||||
idempotencyKey: generateIdempotencyKey(),
|
||||
optimistic: { ...ctx.perf, start_at: newStart, end_at: newEnd },
|
||||
})
|
||||
|
||||
flashPulse(result.cascaded.map(p => p.id))
|
||||
}
|
||||
catch {
|
||||
tt.refetch()
|
||||
}
|
||||
finally {
|
||||
store.endDrag()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function onBlockPointerdown(event: PointerEvent, perf: Performance): void {
|
||||
if (!perf.start_at || !perf.end_at)
|
||||
return
|
||||
dragCtx = {
|
||||
perf,
|
||||
startStartAt: perf.start_at,
|
||||
startEndAt: perf.end_at,
|
||||
startLane: perf.lane_resolved,
|
||||
durationMin: (Date.parse(perf.end_at) - Date.parse(perf.start_at)) / 60_000,
|
||||
}
|
||||
dragController.begin(event)
|
||||
}
|
||||
|
||||
// Backend wants Y-m-d H:i:s for start/end on move endpoint — convert ISO.
|
||||
function toMysqlDatetime(iso: string): string {
|
||||
return iso.replace('T', ' ').replace(/\..*$/, '').replace('Z', '')
|
||||
}
|
||||
|
||||
// ─── Resize, delete, popover-bound handlers ───────────────────────────
|
||||
|
||||
async function onBlockDelete(perf: Performance): Promise<void> {
|
||||
if (!window.confirm(`"${perf.engagement?.artist?.name ?? 'Optreden'}" verwijderen?`))
|
||||
return
|
||||
try {
|
||||
await mutations.remove.mutateAsync(perf.id)
|
||||
if (store.selectedPerformanceId === perf.id) {
|
||||
store.selectPerformance(null)
|
||||
popoverAnchor.value = null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
tt.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modals ───────────────────────────────────────────────────────────
|
||||
|
||||
const addOpen = ref(false)
|
||||
const stageEditorOpen = ref(false)
|
||||
const stageBeingEdited = ref<Stage | null>(null)
|
||||
const matrixOpen = ref(false)
|
||||
|
||||
function openAdd(): void {
|
||||
addOpen.value = true
|
||||
}
|
||||
|
||||
function openStageEditor(stage: Stage | null): void {
|
||||
stageBeingEdited.value = stage
|
||||
stageEditorOpen.value = true
|
||||
}
|
||||
|
||||
function openMatrix(): void {
|
||||
matrixOpen.value = true
|
||||
}
|
||||
|
||||
// ─── Keyboard composable ──────────────────────────────────────────────
|
||||
|
||||
const canvasRoot = ref<HTMLElement | null>(null)
|
||||
const stagesRef = computed(() => activeStages.value)
|
||||
const selectedRef = computed({ get: () => store.selectedPerformanceId, set: v => store.selectPerformance(v) })
|
||||
|
||||
const { announce } = useTimetableKeyboard({
|
||||
rootEl: canvasRoot,
|
||||
selectedId: selectedRef,
|
||||
resolvePerformance: id => (tt.performances.value ?? []).find(p => p.id === id)
|
||||
?? (tt.wachtrij.value ?? []).find(p => p.id === id) ?? null,
|
||||
stages: stagesRef,
|
||||
callbacks: {
|
||||
nudge: async (perf, deltaMin, deltaLane, deltaStageIdx, idempotencyKey) => {
|
||||
if (!perf.start_at || !perf.end_at)
|
||||
return
|
||||
const stages = stagesRef.value
|
||||
const currentStageIdx = stages.findIndex(s => s.id === perf.stage_id)
|
||||
const nextStage = stages[Math.max(0, Math.min(stages.length - 1, currentStageIdx + deltaStageIdx))] ?? stages[0]
|
||||
const newStart = minutesToIso(isoToMinutes(perf.start_at, gridStartIso.value) + deltaMin, gridStartIso.value)
|
||||
const newEnd = minutesToIso(isoToMinutes(perf.end_at, gridStartIso.value) + deltaMin, gridStartIso.value)
|
||||
|
||||
try {
|
||||
const result = await mutations.move.mutateAsync({
|
||||
payload: {
|
||||
performance_id: perf.id,
|
||||
target_stage_id: nextStage?.id ?? perf.stage_id,
|
||||
target_start_at: toMysqlDatetime(newStart),
|
||||
target_end_at: toMysqlDatetime(newEnd),
|
||||
target_lane: Math.max(0, perf.lane_resolved + deltaLane),
|
||||
version: perf.version,
|
||||
},
|
||||
idempotencyKey,
|
||||
optimistic: { ...perf, start_at: newStart, end_at: newEnd, stage_id: nextStage?.id ?? perf.stage_id, lane: perf.lane + deltaLane },
|
||||
})
|
||||
|
||||
flashPulse(result.cascaded.map(p => p.id))
|
||||
}
|
||||
catch {
|
||||
tt.refetch()
|
||||
}
|
||||
},
|
||||
openPopover: (perf, anchor) => {
|
||||
onBlockSelect(perf, anchor.getBoundingClientRect())
|
||||
},
|
||||
remove: onBlockDelete,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Fire watch immediately to bind ?day → store.
|
||||
if (typeof route.query.day === 'string')
|
||||
store.setActiveDay(route.query.day)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EventTabsNav>
|
||||
<template #default>
|
||||
<div class="tt-page">
|
||||
<div class="tt-page__toolbar">
|
||||
<VTabs
|
||||
:model-value="store.activeDayId"
|
||||
density="compact"
|
||||
@update:model-value="v => store.setActiveDay(typeof v === 'string' ? v : null)"
|
||||
>
|
||||
<VTab
|
||||
v-for="d in dayOptions"
|
||||
:key="d.id"
|
||||
:value="d.id"
|
||||
>
|
||||
{{ d.name }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
<VSpacer />
|
||||
<VChip
|
||||
v-if="allConflicts.size > 0"
|
||||
color="error"
|
||||
size="small"
|
||||
prepend-icon="tabler-alert-triangle"
|
||||
>
|
||||
{{ allConflicts.size }} conflicten
|
||||
</VChip>
|
||||
<VBtn
|
||||
prepend-icon="tabler-plus"
|
||||
size="small"
|
||||
@click="openAdd"
|
||||
>
|
||||
Optreden
|
||||
</VBtn>
|
||||
<VBtn
|
||||
prepend-icon="tabler-square-plus"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
@click="openStageEditor(null)"
|
||||
>
|
||||
Stage
|
||||
</VBtn>
|
||||
<VBtn
|
||||
prepend-icon="tabler-grid-3x3"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="openMatrix"
|
||||
>
|
||||
Lineup-matrix
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<VAlert
|
||||
v-if="tt.isError.value"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="ma-4"
|
||||
>
|
||||
Kon timetable niet laden.
|
||||
<template #append>
|
||||
<VBtn
|
||||
size="small"
|
||||
@click="tt.refetch()"
|
||||
>
|
||||
Probeer opnieuw
|
||||
</VBtn>
|
||||
</template>
|
||||
</VAlert>
|
||||
|
||||
<VSkeletonLoader
|
||||
v-if="tt.isLoading.value"
|
||||
type="table"
|
||||
class="ma-4"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="activeStages.length === 0"
|
||||
class="ma-4"
|
||||
>
|
||||
<EmptyDayState @open-lineup-matrix="openMatrix" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="tt-page__body"
|
||||
>
|
||||
<div
|
||||
ref="canvasRoot"
|
||||
class="tt-page__canvas"
|
||||
role="application"
|
||||
aria-label="Timetable canvas"
|
||||
>
|
||||
<div class="tt-page__corner">
|
||||
Stages · {{ activeStages.length }}
|
||||
</div>
|
||||
<div class="tt-page__axis">
|
||||
<TimeAxis
|
||||
:grid-start-iso="gridStartIso"
|
||||
:total-minutes="totalMinutes"
|
||||
:px-per-min="pxPerMin"
|
||||
/>
|
||||
</div>
|
||||
<div class="tt-page__stages">
|
||||
<StageHeaderCell
|
||||
v-for="row in stageRows"
|
||||
:key="row.stage.id"
|
||||
:stage="row.stage"
|
||||
:conflict-count="row.conflictCount"
|
||||
@edit="s => openStageEditor(s)"
|
||||
@delete="s => openStageEditor(s)"
|
||||
/>
|
||||
</div>
|
||||
<div class="tt-page__rows">
|
||||
<GridBg
|
||||
:total-minutes="totalMinutes"
|
||||
:px-per-min="pxPerMin"
|
||||
:total-height="0"
|
||||
/>
|
||||
<StageRow
|
||||
v-for="row in stageRows"
|
||||
:key="row.stage.id"
|
||||
:stage="row.stage"
|
||||
:performances="row.performances"
|
||||
:grid-start-iso="gridStartIso"
|
||||
:total-minutes="totalMinutes"
|
||||
:px-per-min="pxPerMin"
|
||||
:lane-count="row.laneCount"
|
||||
:b2b-left-set="b2bSides.leftSet"
|
||||
:b2b-right-set="b2bSides.rightSet"
|
||||
:pulse-set="pulseSet"
|
||||
:selected-id="store.selectedPerformanceId"
|
||||
:drag-origin-id="store.dragPerformanceId"
|
||||
@block-select="(p, r) => onBlockSelect(p, r)"
|
||||
@block-pointerdown="(e, p) => onBlockPointerdown(e, p)"
|
||||
@block-resize-pointerdown="(e, p) => onBlockPointerdown(e, p)"
|
||||
@block-delete="p => onBlockDelete(p)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="tt-page__sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ announce }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Wachtrij
|
||||
:performances="tt.wachtrij.value ?? []"
|
||||
:selected-id="store.selectedPerformanceId"
|
||||
@card-select="(p, r) => onBlockSelect(p, r)"
|
||||
@card-pointerdown="(e, p) => onBlockPointerdown(e, p)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PerformancePopover
|
||||
v-model="popoverOpen"
|
||||
:anchor-rect="popoverAnchor"
|
||||
:performance="popoverPerformance"
|
||||
:org-id="orgIdRef"
|
||||
@open-engagement="(id) => router.push(`/events/${eventId}/artists/${id}`)"
|
||||
@delete="onBlockDelete"
|
||||
/>
|
||||
<AddPerformanceDialog
|
||||
v-model="addOpen"
|
||||
:org-id="orgIdRef"
|
||||
:event-id="eventIdRef"
|
||||
:day-id="dayIdRef"
|
||||
:stages="activeStages"
|
||||
:engagements="[]"
|
||||
/>
|
||||
<StageEditor
|
||||
v-model="stageEditorOpen"
|
||||
:org-id="orgIdRef"
|
||||
:event-id="eventIdRef"
|
||||
:day-id="dayIdRef"
|
||||
:stage="stageBeingEdited"
|
||||
/>
|
||||
<LineupMatrix
|
||||
v-model="matrixOpen"
|
||||
:org-id="orgIdRef"
|
||||
:event-id="eventIdRef"
|
||||
:day-id="dayIdRef"
|
||||
:stages="tt.stages.value ?? []"
|
||||
:sub-events="dayOptions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EventTabsNav>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tt-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: calc(100vh - 200px);
|
||||
min-block-size: 600px;
|
||||
|
||||
&__toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
border-block-end: 1px solid var(--tt-row-divider);
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
&__canvas {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template: "corner axis" 28px "stages rows" 1fr / 200px 1fr;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__corner {
|
||||
grid-area: corner;
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px;
|
||||
font-size: 11px;
|
||||
background-color: #fff;
|
||||
border-block-end: 1px solid var(--tt-row-divider);
|
||||
border-inline-end: 1px solid var(--tt-row-divider);
|
||||
}
|
||||
|
||||
&__axis {
|
||||
grid-area: axis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__stages {
|
||||
grid-area: stages;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
&__rows {
|
||||
grid-area: rows;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__sr-only {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user