Files
crewli/apps/app/src/components/timetable/PerformancePopover.vue
bert.hausmans 43572a7812 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>
2026-05-09 01:58:56 +02:00

241 lines
6.2 KiB
Vue

<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useEngagement } from '@/composables/api/useTimetable'
import { ArtistEngagementStatus, type Performance } from '@/types/timetable'
const props = defineProps<{
modelValue: boolean
/** Anchor element rect (from PerformanceBlock click). */
anchorRect: DOMRect | null
performance: Performance | null
orgId: string
}>()
const emit = defineEmits<{
'update:modelValue': [open: boolean]
'openEngagement': [engagementId: string]
'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(orgIdRef, engagementIdRef)
const advancing = computed(() => {
const e = engagement.value ?? props.performance?.engagement
if (!e || e.advancing_total_count === 0)
return null
return { done: e.advancing_completed_count, total: e.advancing_total_count, pct: Math.round((e.advancing_completed_count / e.advancing_total_count) * 100) }
})
const cancelled = computed(() =>
(engagement.value ?? props.performance?.engagement)?.booking_status?.value === ArtistEngagementStatus.CANCELLED,
)
const positionStyle = computed(() => {
if (!props.anchorRect)
return { display: 'none' }
const POP_W = 340
const MARGIN = 12
const right = props.anchorRect.right
const space = window.innerWidth - right
const useLeft = space < POP_W + MARGIN
const top = Math.max(MARGIN, Math.min(window.innerHeight - 200, props.anchorRect.top))
return {
insetBlockStart: `${top}px`,
insetInlineStart: useLeft ? `${Math.max(MARGIN, props.anchorRect.left - POP_W - MARGIN)}px` : `${right + MARGIN}px`,
inlineSize: `${POP_W}px`,
}
})
function close(): void {
emit('update:modelValue', false)
}
</script>
<template>
<Teleport to="body">
<div
v-if="modelValue && performance"
class="tt-popover"
:style="positionStyle"
role="dialog"
:aria-label="`Detail van ${performance.engagement?.artist?.name ?? 'optreden'}`"
@keydown.esc="close"
>
<div class="tt-popover__header">
<div>
<div class="tt-popover__name">
{{ performance.engagement?.artist?.name ?? 'Onbekend' }}
</div>
<div class="tt-popover__meta">
<VChip
size="x-small"
:color="cancelled ? 'default' : 'primary'"
>
{{ performance.engagement?.booking_status?.label ?? '—' }}
</VChip>
<span v-if="performance.stage">· {{ performance.stage.name }}</span>
<span v-else>· In wachtrij</span>
</div>
</div>
<VBtn
icon="tabler-x"
variant="text"
size="x-small"
aria-label="Popover sluiten"
@click="close"
/>
</div>
<div class="tt-popover__body">
<VAlert
v-if="isLoading"
density="compact"
type="info"
variant="tonal"
>
Bezig met laden
</VAlert>
<div
v-if="advancing"
class="tt-popover__advancing"
>
<div class="tt-popover__row">
<span>Advancing</span>
<span>{{ advancing.done }}/{{ advancing.total }} ({{ advancing.pct }}%)</span>
</div>
<VProgressLinear
:model-value="advancing.pct"
color="primary"
height="6"
rounded
/>
</div>
<div
v-if="engagement?.computed"
class="tt-popover__deal"
>
<div class="tt-popover__row">
<span>Fee</span>
<span>{{ (engagement.fee_amount ?? 0).toFixed(2) }}</span>
</div>
<div class="tt-popover__row">
<span>Buma</span>
<span>{{ engagement.computed.buma_amount.toFixed(2) }}</span>
</div>
<div class="tt-popover__row">
<span>BTW</span>
<span>{{ engagement.computed.vat_amount.toFixed(2) }}</span>
</div>
<div class="tt-popover__row tt-popover__row--total">
<span>Totaal</span>
<span>{{ engagement.computed.total_cost.toFixed(2) }}</span>
</div>
</div>
</div>
<div class="tt-popover__footer">
<VBtn
variant="tonal"
color="error"
size="small"
prepend-icon="tabler-trash"
@click="emit('delete', performance)"
>
Verwijder
</VBtn>
<VBtn
color="primary"
size="small"
append-icon="tabler-arrow-right"
@click="performance && emit('openEngagement', performance.engagement_id)"
>
Open detail
</VBtn>
</div>
</div>
</Teleport>
</template>
<style scoped lang="scss">
.tt-popover {
position: fixed;
z-index: 2000;
background-color: #fff;
border: 1px solid var(--tt-row-divider);
border-radius: 8px;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px 12px 8px;
border-block-end: 1px solid var(--tt-row-divider);
}
&__name {
font-weight: 600;
font-size: 14px;
}
&__meta {
display: flex;
align-items: center;
gap: 6px;
margin-block-start: 4px;
font-size: 12px;
color: var(--tt-axis-label-fg);
}
&__body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
&__advancing {
display: flex;
flex-direction: column;
gap: 6px;
}
&__deal {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background-color: #faf9f6;
border-radius: 6px;
}
&__row {
display: flex;
justify-content: space-between;
font-size: 12px;
&--total {
padding-block-start: 4px;
font-weight: 600;
border-block-start: 1px solid var(--tt-row-divider);
}
}
&__footer {
display: flex;
justify-content: space-between;
padding: 8px 12px;
border-block-start: 1px solid var(--tt-row-divider);
}
}
</style>