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>
241 lines
6.2 KiB
Vue
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>
|