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