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:
2026-05-09 01:58:56 +02:00
parent 288aebcd69
commit 43572a7812
7 changed files with 868 additions and 30 deletions

View File

@@ -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,

View File

@@ -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(() =>

View File

@@ -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

View File

@@ -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)

View File

@@ -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>

View 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> }
}

View 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>