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:
238
apps/app/src/components/timetable/PerformancePopover.vue
Normal file
238
apps/app/src/components/timetable/PerformancePopover.vue
Normal 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>
|
||||
Reference in New Issue
Block a user