feat(timetable): leaf visual components — TimeAxis, GridBg, StageHeaderCell, PerformanceBlock, StageRow, EmptyDayState (Session 4 step 8)

PerformanceBlock is the heart of the canvas:
- Status palette via CSS tokens (D21) — one class per booking_status enum value
- Cancelled hatch overlay + line-through (D5)
- Trashed-artist dashed border + ⌂ overlay icon (D27)
- Conflict ring + glow when warnings.includes('overlap') (D5)
- Capacity icon driven by evaluateCapacity() with warn/critical levels (D25)
- B2B left/right dots (D26 — 3-min threshold)
- Cascade-pulse class fired by parent on cascaded[] non-empty (D18)
- aria-label structure per D20: artist, stage, time window, status, advancing
- tabindex 0 + Enter/Space → select; Delete → emit delete

StageRow positions blocks by lane_resolved (D19) — server is authoritative.
StageHeaderCell uses Vuexy VMenu pattern for the per-stage actions.
EmptyDayState routes the user to LineupMatrix when no stages are active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 01:44:59 +02:00
parent 6eb8ae7aa4
commit 4ed470ac35
6 changed files with 686 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
const emit = defineEmits<{
openLineupMatrix: []
}>()
</script>
<template>
<VCard class="tt-empty-day">
<VCardText class="text-center pa-8">
<VIcon
icon="tabler-calendar-off"
size="48"
class="mb-4 text-disabled"
/>
<h3 class="text-h6 mb-2">
Geen stages actief op deze dag
</h3>
<p class="text-body-2 text-medium-emphasis mb-4">
Open de lineup-matrix om stages aan te zetten voor deze dag.
</p>
<VBtn
prepend-icon="tabler-grid-3x3"
@click="emit('openLineupMatrix')"
>
Lineup-matrix openen
</VBtn>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
totalMinutes: number
pxPerMin: number
/** Major gridline every N minutes (default 60). */
majorIntervalMin?: number
/** Minor gridline every N minutes (default 30). */
minorIntervalMin?: number
/** Total height of the grid (matches sum of stage row heights). */
totalHeight: number
}>()
const major = computed(() => props.majorIntervalMin ?? 60)
const minor = computed(() => props.minorIntervalMin ?? 30)
const totalWidth = computed(() => props.totalMinutes * props.pxPerMin)
const majorPx = computed(() => major.value * props.pxPerMin)
const minorPx = computed(() => minor.value * props.pxPerMin)
</script>
<template>
<div
class="tt-grid-bg"
:style="{
width: `${totalWidth}px`,
height: `${totalHeight}px`,
backgroundImage: `
repeating-linear-gradient(to right, var(--tt-canvas-grid-major) 0 1px, transparent 1px ${majorPx}px),
repeating-linear-gradient(to right, var(--tt-canvas-grid-minor) 0 1px, transparent 1px ${minorPx}px)
`,
}"
role="presentation"
aria-hidden="true"
/>
</template>
<style scoped lang="scss">
.tt-grid-bg {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
background-color: var(--tt-canvas-bg);
pointer-events: none;
z-index: 0;
}
</style>

View File

@@ -0,0 +1,338 @@
<script setup lang="ts">
import { computed } from 'vue'
import { evaluateCapacity } from '@/lib/timetable/capacity'
import {
ArtistEngagementStatus,
type Performance,
} from '@/types/timetable'
const props = defineProps<{
performance: Performance
/** Pixel position derived by parent StageRow. */
leftPx: number
widthPx: number
/** Absolute lane top within the stage row. */
topPx: number
heightPx: number
/** Renders the cascade-pulse animation when true. */
pulse?: boolean
/** B2B continuation indicators driven by the page-level findB2BSides. */
b2bLeft?: boolean
b2bRight?: boolean
/** Currently selected (drives focus ring). */
selected?: boolean
/** While dragging this block — render at half opacity for ghost effect. */
isDragOrigin?: boolean
}>()
const emit = defineEmits<{
select: [perf: Performance, rect: DOMRect]
pointerdown: [event: PointerEvent, perf: Performance]
resizePointerdown: [event: PointerEvent, perf: Performance]
delete: [perf: Performance]
}>()
const status = computed(() => props.performance.engagement?.booking_status?.value ?? null)
const statusKey = computed(() => status.value ?? 'draft')
const isCancelled = computed(() => status.value === ArtistEngagementStatus.CANCELLED)
const isTrashedArtist = computed(() => !!props.performance.engagement?.artist?.deleted_at)
const artistName = computed(() => props.performance.engagement?.artist?.name ?? 'Onbekende artiest')
const stageName = computed(() => props.performance.stage?.name ?? 'In wachtrij')
const startLabel = computed(() => formatTime(props.performance.start_at))
const endLabel = computed(() => formatTime(props.performance.end_at))
const durationLabel = computed(() => {
if (!props.performance.start_at || !props.performance.end_at)
return ''
const mins = (Date.parse(props.performance.end_at) - Date.parse(props.performance.start_at)) / 60_000
return formatDurationShort(mins)
})
const advancing = computed(() => {
const e = props.performance.engagement
if (!e)
return null
return { done: e.advancing_completed_count, total: e.advancing_total_count }
})
const capacity = computed(() =>
evaluateCapacity(props.performance, props.performance.stage, props.performance.engagement),
)
const hasOverlap = computed(() => props.performance.warnings.includes('overlap'))
const ariaLabel = computed(() => {
const adv = advancing.value
const advanceText = adv && adv.total > 0 ? `, advancing ${adv.done} van ${adv.total}` : ''
const statusLabel = props.performance.engagement?.booking_status?.label ?? 'onbekend'
return `${artistName.value}, ${stageName.value}, ${startLabel.value} tot ${endLabel.value}, status ${statusLabel}${advanceText}`
})
const showTime = computed(() => props.widthPx > 64)
const showAdvancing = computed(() => props.widthPx > 86 && advancing.value && advancing.value.total > 0)
function formatTime(iso: string | null): string {
if (!iso)
return ''
return new Date(iso).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function formatDurationShort(min: number): string {
if (min < 60)
return `${Math.round(min)}m`
const h = Math.floor(min / 60)
const m = Math.round(min % 60)
return m === 0 ? `${h}u` : `${h}u${m}`
}
function onClick(event: MouseEvent): void {
const target = event.currentTarget as HTMLElement
emit('select', props.performance, target.getBoundingClientRect())
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
const target = event.currentTarget as HTMLElement
emit('select', props.performance, target.getBoundingClientRect())
}
else if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
emit('delete', props.performance)
}
}
function onPointerDown(event: PointerEvent): void {
emit('pointerdown', event, props.performance)
}
function onResizePointerDown(event: PointerEvent): void {
event.stopPropagation()
emit('resizePointerdown', event, props.performance)
}
</script>
<template>
<div
class="tt-perf-block"
:class="[
`tt-perf-block--status-${statusKey}`,
{
'tt-perf-block--cancelled': isCancelled,
'tt-perf-block--trashed-artist': isTrashedArtist,
'tt-perf-block--conflict': hasOverlap,
'tt-perf-block--selected': selected,
'tt-perf-block--drag-origin': isDragOrigin,
'tt-cascade-pulse': pulse,
},
]"
:style="{
insetBlockStart: `${topPx}px`,
insetInlineStart: `${leftPx}px`,
inlineSize: `${widthPx}px`,
blockSize: `${heightPx}px`,
}"
role="button"
tabindex="0"
:aria-label="ariaLabel"
:aria-pressed="selected"
:data-perf-id="performance.id"
:data-status="statusKey"
@click="onClick"
@keydown="onKeydown"
@pointerdown="onPointerDown"
>
<div class="tt-perf-block__row tt-perf-block__row--top">
<span class="tt-perf-block__name">{{ artistName }}</span>
<span
v-if="capacity"
class="tt-perf-block__capacity"
:class="`tt-perf-block__capacity--${capacity.level}`"
:title="`Verwachte trekkracht (${capacity.expected}) overschrijdt capaciteit (${capacity.capacity}).`"
aria-hidden="true"
></span>
</div>
<div
v-if="showTime || showAdvancing"
class="tt-perf-block__row tt-perf-block__row--bottom"
>
<span
v-if="showTime"
class="tt-perf-block__time"
>{{ startLabel }}{{ endLabel }} · {{ durationLabel }}</span>
<span
v-if="showAdvancing && advancing"
class="tt-perf-block__advancing"
>{{ advancing.done }}/{{ advancing.total }}</span>
</div>
<span
v-if="b2bLeft"
class="tt-perf-block__b2b tt-perf-block__b2b--left"
aria-hidden="true"
/>
<span
v-if="b2bRight"
class="tt-perf-block__b2b tt-perf-block__b2b--right"
aria-hidden="true"
/>
<span
class="tt-perf-block__resize"
role="separator"
aria-orientation="vertical"
aria-label="Verleng of verkort blok"
@pointerdown="onResizePointerDown"
/>
</div>
</template>
<style scoped lang="scss">
.tt-perf-block {
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
padding-inline: var(--tt-block-pad-x);
padding-block: var(--tt-block-pad-y);
font-size: 12px;
background-color: var(--tt-status-draft-bg);
color: var(--tt-status-draft-fg);
border: 1px solid var(--tt-status-draft-border);
border-radius: var(--tt-block-radius);
cursor: grab;
user-select: none;
// Status palettes — selector class names match enum string values.
&--status-draft { background-color: var(--tt-status-draft-bg); color: var(--tt-status-draft-fg); border-color: var(--tt-status-draft-border); }
&--status-requested { background-color: var(--tt-status-requested-bg); color: var(--tt-status-requested-fg); border-color: var(--tt-status-requested-border); }
&--status-option { background-color: var(--tt-status-option-bg); color: var(--tt-status-option-fg); border-color: var(--tt-status-option-border); }
&--status-offered { background-color: var(--tt-status-offered-bg); color: var(--tt-status-offered-fg); border-color: var(--tt-status-offered-border); }
&--status-confirmed { background-color: var(--tt-status-confirmed-bg); color: var(--tt-status-confirmed-fg); border-color: var(--tt-status-confirmed-border); }
&--status-contracted { background-color: var(--tt-status-contracted-bg); color: var(--tt-status-contracted-fg); border-color: var(--tt-status-contracted-border); }
&--status-cancelled { background-color: var(--tt-status-cancelled-bg); color: var(--tt-status-cancelled-fg); border-color: var(--tt-status-cancelled-border); }
&--status-rejected { background-color: var(--tt-status-rejected-bg); color: var(--tt-status-rejected-fg); border-color: var(--tt-status-rejected-border); }
&--status-declined { background-color: var(--tt-status-declined-bg); color: var(--tt-status-declined-fg); border-color: var(--tt-status-declined-border); }
&--cancelled {
background-image: var(--tt-cancelled-hatch);
text-decoration: line-through;
}
&--trashed-artist {
border-style: dashed;
&::after {
content: "⌂";
position: absolute;
inset-block-start: 2px;
inset-inline-end: 4px;
font-size: 12px;
color: var(--tt-trashed-icon);
pointer-events: none;
}
}
&--conflict {
border-color: var(--tt-conflict-border);
border-width: 1.5px;
box-shadow: 0 0 0 2px var(--tt-conflict-glow);
}
&--selected {
outline: 2px solid var(--tt-focus-ring);
outline-offset: 2px;
}
&--drag-origin {
opacity: 0.45;
}
&:focus-visible {
outline: 2px solid var(--tt-focus-ring);
outline-offset: 2px;
}
&:active {
cursor: grabbing;
}
&__row {
display: flex;
gap: 4px;
align-items: center;
overflow: hidden;
}
&__row--top {
justify-content: space-between;
}
&__row--bottom {
justify-content: space-between;
margin-block-start: 2px;
font-size: 11px;
opacity: 0.85;
}
&__name {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
&__capacity {
flex-shrink: 0;
font-size: 12px;
line-height: 1;
&--warn { color: var(--tt-capacity-warn); }
&--critical { color: var(--tt-capacity-critical); }
}
&__time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__advancing {
flex-shrink: 0;
padding-inline: 4px;
background-color: rgb(255 255 255 / 60%);
border-radius: 4px;
}
&__b2b {
position: absolute;
inset-block-start: 50%;
inline-size: var(--tt-b2b-dot-size);
block-size: var(--tt-b2b-dot-size);
background-color: var(--tt-b2b-dot);
border-radius: 50%;
transform: translateY(-50%);
&--left { inset-inline-start: calc(var(--tt-b2b-dot-size) * -0.5); }
&--right { inset-inline-end: calc(var(--tt-b2b-dot-size) * -0.5); }
}
&__resize {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
inline-size: 7px;
cursor: ew-resize;
}
}
</style>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { Stage } from '@/types/timetable'
defineProps<{
stage: Stage
/** Number of conflicts on this stage row for the active day. */
conflictCount?: number
}>()
const emit = defineEmits<{
edit: [stage: Stage]
delete: [stage: Stage]
}>()
</script>
<template>
<div
class="tt-stage-header"
:data-stage-id="stage.id"
>
<span
class="tt-stage-header__swatch"
:style="{ backgroundColor: stage.color }"
aria-hidden="true"
/>
<div class="tt-stage-header__body">
<div class="tt-stage-header__name">
{{ stage.name }}
</div>
<div class="tt-stage-header__meta">
<span v-if="stage.capacity !== null">
Cap. {{ stage.capacity.toLocaleString('nl-NL') }}
</span>
<span
v-if="conflictCount && conflictCount > 0"
class="tt-stage-header__conflict"
:title="`${conflictCount} conflicten op deze stage`"
>
{{ conflictCount }}
</span>
</div>
</div>
<VMenu>
<template #activator="{ props: activator }">
<VBtn
v-bind="activator"
icon="tabler-dots-vertical"
size="x-small"
variant="text"
:aria-label="`Stage acties voor ${stage.name}`"
/>
</template>
<VList density="compact">
<VListItem
prepend-icon="tabler-edit"
@click="emit('edit', stage)"
>
<VListItemTitle>Bewerken</VListItemTitle>
</VListItem>
<VListItem
prepend-icon="tabler-trash"
base-color="error"
@click="emit('delete', stage)"
>
<VListItemTitle>Verwijderen</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
</template>
<style scoped lang="scss">
.tt-stage-header {
display: flex;
align-items: center;
gap: 8px;
padding-block: 8px;
padding-inline: 12px;
block-size: 100%;
background-color: #fff;
border-block-end: 1px solid var(--tt-row-divider);
border-inline-end: 1px solid var(--tt-row-divider);
&__swatch {
flex-shrink: 0;
inline-size: 6px;
block-size: 100%;
border-radius: 3px;
}
&__body {
flex: 1;
min-inline-size: 0;
}
&__name {
overflow: hidden;
font-weight: 600;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
&__meta {
display: flex;
gap: 8px;
margin-block-start: 2px;
font-size: 11px;
color: var(--tt-axis-label-fg);
}
&__conflict {
color: var(--tt-conflict-border);
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed } from 'vue'
import PerformanceBlock from './PerformanceBlock.vue'
import { isoToMinutes, minutesToPx } from '@/lib/timetable/time-grid'
import type { Performance, Stage } from '@/types/timetable'
const props = defineProps<{
stage: Stage
performances: Performance[]
gridStartIso: string
totalMinutes: number
pxPerMin: number
laneCount: number
/** B2B left/right side sets from page-level findB2BSides. */
b2bLeftSet: Set<string>
b2bRightSet: Set<string>
/** IDs that should pulse this render (cascaded[] from a recent move). */
pulseSet: Set<string>
selectedId: string | null
dragOriginId: string | null
}>()
const emit = defineEmits<{
blockSelect: [perf: Performance, rect: DOMRect]
blockPointerdown: [event: PointerEvent, perf: Performance]
blockResizePointerdown: [event: PointerEvent, perf: Performance]
blockDelete: [perf: Performance]
}>()
const laneHeightPx = 44
const lanePadPx = 4
const totalWidthPx = computed(() => props.totalMinutes * props.pxPerMin)
const rowHeightPx = computed(() => Math.max(1, props.laneCount) * (laneHeightPx + lanePadPx) + lanePadPx)
interface PositionedBlock {
perf: Performance
leftPx: number
widthPx: number
topPx: number
heightPx: number
}
const positioned = computed<PositionedBlock[]>(() =>
props.performances
.filter(p => p.start_at !== null && p.end_at !== null)
.map(perf => {
const startMin = isoToMinutes(perf.start_at as string, props.gridStartIso)
const endMin = isoToMinutes(perf.end_at as string, props.gridStartIso)
const leftPx = minutesToPx(startMin, props.pxPerMin)
const widthPx = Math.max(8, minutesToPx(endMin - startMin, props.pxPerMin))
const topPx = lanePadPx + perf.lane_resolved * (laneHeightPx + lanePadPx)
return { perf, leftPx, widthPx, topPx, heightPx: laneHeightPx }
}),
)
</script>
<template>
<div
class="tt-stage-row"
:data-stage-id="stage.id"
:style="{
width: `${totalWidthPx}px`,
height: `${rowHeightPx}px`,
}"
>
<PerformanceBlock
v-for="b in positioned"
:key="b.perf.id"
:performance="b.perf"
:left-px="b.leftPx"
:width-px="b.widthPx"
:top-px="b.topPx"
:height-px="b.heightPx"
:pulse="pulseSet.has(b.perf.id)"
:b2b-left="b2bLeftSet.has(b.perf.id)"
:b2b-right="b2bRightSet.has(b.perf.id)"
:selected="selectedId === b.perf.id"
:is-drag-origin="dragOriginId === b.perf.id"
@select="(p, r) => emit('blockSelect', p, r)"
@pointerdown="(e, p) => emit('blockPointerdown', e, p)"
@resize-pointerdown="(e, p) => emit('blockResizePointerdown', e, p)"
@delete="p => emit('blockDelete', p)"
/>
</div>
</template>
<style scoped lang="scss">
.tt-stage-row {
position: relative;
border-block-end: 1px solid var(--tt-row-divider);
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatTickLabel, generateTicks } from '@/lib/timetable/time-grid'
const props = defineProps<{
gridStartIso: string
totalMinutes: number
pxPerMin: number
/** Tick interval in minutes (default 60). */
tickIntervalMin?: number
/** Sub-tick interval in minutes (default 30 for half-hour gridlines). */
subTickIntervalMin?: number
}>()
const tickInterval = computed(() => props.tickIntervalMin ?? 60)
const ticks = computed(() => generateTicks(props.totalMinutes, tickInterval.value))
const totalWidth = computed(() => props.totalMinutes * props.pxPerMin)
</script>
<template>
<div
class="tt-time-axis"
:style="{ width: `${totalWidth}px` }"
role="presentation"
>
<div
v-for="m in ticks"
:key="m"
class="tt-time-axis__tick"
:style="{ insetInlineStart: `${m * pxPerMin}px` }"
>
<span class="tt-time-axis__label">
{{ formatTickLabel(m, gridStartIso) }}
</span>
</div>
</div>
</template>
<style scoped lang="scss">
.tt-time-axis {
position: relative;
block-size: 28px;
border-block-end: 1px solid var(--tt-row-divider);
background-color: var(--tt-canvas-bg);
&__tick {
position: absolute;
inset-block: 0;
inline-size: 1px;
background-color: var(--tt-axis-tick-major);
}
&__label {
position: absolute;
inset-block-end: 4px;
inset-inline-start: 4px;
font-size: 11px;
color: var(--tt-axis-label-fg);
white-space: nowrap;
}
}
</style>