feat(timetable): interactive components — Popover, AddPerformanceDialog, StageEditor, LineupMatrix, Wachtrij + WachtrijCard (Session 4 step 10)

- PerformancePopover.vue — teleported floating panel; closes on Esc; shows
  status chip, advancing %, computed Buma/VAT/total cost; deal-summary +
  delete + open-detail buttons. Position math (340px wide, 12px margin,
  flip side if no room) ports prototype's pickPos verbatim.

- AddPerformanceDialog.vue — Vuetify VDialog + raw ref form pattern (matches
  CreateShiftDialog and the rest of the codebase). Uses createPerformancePayloadSchema
  for client-side validation; falls back to surface-level errors map per field.

- StageEditor.vue — single-stage CRUD modal with name + capacity + 10-swatch
  palette picker. Window.confirm cascade-park warning on delete.

- LineupMatrix.vue — stages × sub-events checkbox matrix; only dirty stages
  fire replaceStageDays (atomic per stage).

- Wachtrij.vue — sidebar with search + 9 toggleable status chips with counts;
  reads/writes useTimetableStore.statusFilter and searchQuery.

- WachtrijCard.vue — initials avatar + status dot + dot label + cancelled
  strike-through. role=button, tabindex=0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 01:53:02 +02:00
parent 5b812771de
commit 288aebcd69
6 changed files with 1224 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed } 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: import('vue').Ref<string>
}>()
const emit = defineEmits<{
'update:modelValue': [open: boolean]
'openEngagement': [engagementId: string]
'delete': [perf: Performance]
}>()
const engagementId = computed(() => props.performance?.engagement_id ?? null)
const engagementIdRef = computed<string | null>(() => engagementId.value)
const { data: engagement, isLoading } = useEngagement(props.orgId, 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>