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:
29
apps/app/src/components/timetable/EmptyDayState.vue
Normal file
29
apps/app/src/components/timetable/EmptyDayState.vue
Normal 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>
|
||||||
47
apps/app/src/components/timetable/GridBg.vue
Normal file
47
apps/app/src/components/timetable/GridBg.vue
Normal 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>
|
||||||
338
apps/app/src/components/timetable/PerformanceBlock.vue
Normal file
338
apps/app/src/components/timetable/PerformanceBlock.vue
Normal 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>
|
||||||
117
apps/app/src/components/timetable/StageHeaderCell.vue
Normal file
117
apps/app/src/components/timetable/StageHeaderCell.vue
Normal 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>
|
||||||
93
apps/app/src/components/timetable/StageRow.vue
Normal file
93
apps/app/src/components/timetable/StageRow.vue
Normal 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>
|
||||||
62
apps/app/src/components/timetable/TimeAxis.vue
Normal file
62
apps/app/src/components/timetable/TimeAxis.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user