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,195 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { VForm } from 'vuetify/components/VForm'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
import { createPerformancePayloadSchema } from '@/schemas/timetable'
import type { ArtistEngagement, Stage } from '@/types/timetable'
import { requiredValidator } from '@core/utils/validators'
const props = defineProps<{
modelValue: boolean
orgId: Ref<string>
eventId: Ref<string>
dayId: Ref<string | null>
stages: Stage[]
/** Engagements available to schedule (filtered to this event). */
engagements: ArtistEngagement[]
/** Pre-fill from a drag-out-on-empty-cell (optional). */
prefill?: { stageId: string; startAt: string; endAt: string; lane: number } | null
}>()
const emit = defineEmits<{
'update:modelValue': [open: boolean]
'created': []
}>()
const refForm = ref<VForm>()
const errors = ref<Record<string, string>>({})
const form = ref({
engagement_id: '',
stage_id: null as string | null,
start_at: '',
end_at: '',
lane: 0,
notes: '',
})
watch(() => props.modelValue, open => {
if (open) {
form.value = {
engagement_id: '',
stage_id: props.prefill?.stageId ?? null,
start_at: props.prefill?.startAt ?? '',
end_at: props.prefill?.endAt ?? '',
lane: props.prefill?.lane ?? 0,
notes: '',
}
errors.value = {}
}
})
const mutations = useTimetableMutations({ orgId: props.orgId, eventId: props.eventId, dayId: props.dayId })
const isPending = computed(() => mutations.create.isPending.value)
const engagementOptions = computed(() =>
props.engagements.map(e => ({
title: e.artist?.name ?? '—',
subtitle: e.booking_status?.label ?? '',
value: e.id,
})),
)
async function submit(): Promise<void> {
errors.value = {}
const eventIdValue = props.dayId.value ?? props.eventId.value
const payload = {
engagement_id: form.value.engagement_id,
event_id: eventIdValue,
stage_id: form.value.stage_id,
start_at: form.value.start_at,
end_at: form.value.end_at,
lane: form.value.lane,
notes: form.value.notes || null,
}
const parsed = createPerformancePayloadSchema.safeParse(payload)
if (!parsed.success) {
for (const issue of parsed.error.issues)
errors.value[String(issue.path[0] ?? '_')] = issue.message
return
}
try {
await mutations.create.mutateAsync(parsed.data)
emit('created')
emit('update:modelValue', false)
}
catch (err) {
errors.value._ = (err as Error).message ?? 'Onbekende fout'
}
}
</script>
<template>
<VDialog
:model-value="modelValue"
max-width="520"
persistent
@update:model-value="emit('update:modelValue', $event)"
>
<VCard>
<VCardTitle class="d-flex justify-space-between align-center">
Nieuw optreden plannen
<VBtn
icon="tabler-x"
variant="text"
size="small"
@click="emit('update:modelValue', false)"
/>
</VCardTitle>
<VForm
ref="refForm"
@submit.prevent="submit"
>
<VCardText class="d-flex flex-column gap-4">
<VAlert
v-if="errors._"
type="error"
density="compact"
variant="tonal"
>
{{ errors._ }}
</VAlert>
<AppAutocomplete
v-model="form.engagement_id"
label="Artiest / engagement"
:items="engagementOptions"
item-title="title"
item-value="value"
:rules="[requiredValidator]"
:error-messages="errors.engagement_id"
autofocus
/>
<AppSelect
v-model="form.stage_id"
label="Stage"
:items="stages.map(s => ({ title: s.name, value: s.id }))"
clearable
:error-messages="errors.stage_id"
/>
<div class="d-flex gap-3">
<AppDateTimePicker
v-model="form.start_at"
label="Start"
:rules="[requiredValidator]"
:error-messages="errors.start_at"
:config="{ enableTime: true, dateFormat: 'Y-m-d H:i', time_24hr: true }"
/>
<AppDateTimePicker
v-model="form.end_at"
label="Einde"
:rules="[requiredValidator]"
:error-messages="errors.end_at"
:config="{ enableTime: true, dateFormat: 'Y-m-d H:i', time_24hr: true }"
/>
</div>
<AppTextField
v-model.number="form.lane"
label="Lane"
type="number"
min="0"
max="9"
:error-messages="errors.lane"
/>
<AppTextarea
v-model="form.notes"
label="Notities"
rows="2"
:error-messages="errors.notes"
/>
</VCardText>
<VCardActions class="px-4 pb-4">
<VSpacer />
<VBtn
variant="tonal"
@click="emit('update:modelValue', false)"
>
Annuleer
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Plannen
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
import type { Stage } from '@/types/timetable'
interface SubEvent {
id: string
name: string
start_date?: string | null
}
const props = defineProps<{
modelValue: boolean
orgId: Ref<string>
eventId: Ref<string>
dayId: Ref<string | null>
stages: Stage[]
subEvents: SubEvent[]
}>()
const emit = defineEmits<{
'update:modelValue': [open: boolean]
'saved': []
}>()
const matrix = ref<Record<string, Set<string>>>({})
const errors = ref<Record<string, string>>({})
watch(() => props.modelValue, open => {
if (open) {
matrix.value = {}
for (const stage of props.stages)
matrix.value[stage.id] = new Set(stage.stage_days ?? [])
errors.value = {}
}
})
function toggle(stageId: string, eventId: string): void {
const set = matrix.value[stageId] ?? new Set<string>()
if (set.has(eventId))
set.delete(eventId)
else set.add(eventId)
matrix.value = { ...matrix.value, [stageId]: set }
}
function isOn(stageId: string, eventId: string): boolean {
return matrix.value[stageId]?.has(eventId) ?? false
}
const mutations = useTimetableMutations({ orgId: props.orgId, eventId: props.eventId, dayId: props.dayId })
const isPending = ref(false)
const dirtyStages = computed(() =>
props.stages.filter(stage => {
const before = new Set(stage.stage_days ?? [])
const after = matrix.value[stage.id] ?? new Set<string>()
if (before.size !== after.size)
return true
for (const id of before) {
if (!after.has(id))
return true
}
return false
}),
)
async function submit(): Promise<void> {
errors.value = {}
if (dirtyStages.value.length === 0) {
emit('update:modelValue', false)
return
}
isPending.value = true
try {
for (const stage of dirtyStages.value) {
const after = matrix.value[stage.id] ?? new Set<string>()
await mutations.replaceStageDays.mutateAsync({
stageId: stage.id,
payload: { event_ids: [...after] },
})
}
emit('saved')
emit('update:modelValue', false)
}
catch (err) {
errors.value._ = (err as Error).message ?? 'Opslaan mislukt'
}
finally {
isPending.value = false
}
}
</script>
<template>
<VDialog
:model-value="modelValue"
max-width="640"
persistent
@update:model-value="emit('update:modelValue', $event)"
>
<VCard>
<VCardTitle class="d-flex justify-space-between align-center">
Lineup-matrix
<VBtn
icon="tabler-x"
variant="text"
size="small"
@click="emit('update:modelValue', false)"
/>
</VCardTitle>
<VCardText>
<VAlert
v-if="errors._"
type="error"
density="compact"
variant="tonal"
class="mb-4"
>
{{ errors._ }}
</VAlert>
<p class="text-body-2 text-medium-emphasis mb-4">
Vink aan op welke dagen elke stage actief is.
</p>
<div class="tt-matrix">
<div class="tt-matrix__row tt-matrix__row--head">
<div class="tt-matrix__cell tt-matrix__cell--head">
Stage
</div>
<div
v-for="se in subEvents"
:key="se.id"
class="tt-matrix__cell tt-matrix__cell--head"
>
{{ se.name }}
</div>
</div>
<div
v-for="stage in stages"
:key="stage.id"
class="tt-matrix__row"
>
<div class="tt-matrix__cell tt-matrix__cell--label">
<span
class="tt-matrix__swatch"
:style="{ backgroundColor: stage.color }"
aria-hidden="true"
/>
{{ stage.name }}
</div>
<div
v-for="se in subEvents"
:key="se.id"
class="tt-matrix__cell"
>
<VCheckbox
:model-value="isOn(stage.id, se.id)"
density="compact"
hide-details
:aria-label="`${stage.name} actief op ${se.name}`"
@update:model-value="toggle(stage.id, se.id)"
/>
</div>
</div>
</div>
</VCardText>
<VCardActions class="px-4 pb-4">
<VSpacer />
<VBtn
variant="tonal"
@click="emit('update:modelValue', false)"
>
Annuleer
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="submit"
>
Opslaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped lang="scss">
.tt-matrix {
display: flex;
flex-direction: column;
gap: 4px;
&__row {
display: grid;
grid-template-columns: 200px repeat(auto-fill, minmax(80px, 1fr));
gap: 4px;
align-items: center;
&--head { font-size: 12px; font-weight: 600; color: var(--tt-axis-label-fg); }
}
&__cell {
padding: 4px 8px;
&--head { padding-block: 8px; border-block-end: 1px solid var(--tt-row-divider); }
&--label { display: flex; align-items: center; gap: 8px; }
}
&__swatch {
inline-size: 8px;
block-size: 16px;
border-radius: 2px;
}
}
</style>

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>

View File

@@ -0,0 +1,215 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { VForm } from 'vuetify/components/VForm'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
import { createStagePayloadSchema } from '@/schemas/timetable'
import type { Stage } from '@/types/timetable'
import { requiredValidator } from '@core/utils/validators'
const props = defineProps<{
modelValue: boolean
orgId: Ref<string>
eventId: Ref<string>
dayId: Ref<string | null>
/** Pass an existing stage to edit; null = create. */
stage: Stage | null
}>()
const emit = defineEmits<{
'update:modelValue': [open: boolean]
'saved': [stage: Stage]
'deleted': [stage: Stage]
}>()
const STAGE_PALETTE = [
'#e85d75',
'#d9a93c',
'#8b5cd0',
'#2fa66a',
'#2a78c8',
'#0bb1b1',
'#75162a',
'#3a3830',
'#b56331',
'#5d4612',
]
const refForm = ref<VForm>()
const errors = ref<Record<string, string>>({})
const form = ref({
name: '',
color: STAGE_PALETTE[0],
capacity: null as number | null,
})
const isEdit = computed(() => props.stage !== null)
watch(() => props.modelValue, open => {
if (open) {
form.value = {
name: props.stage?.name ?? '',
color: props.stage?.color ?? STAGE_PALETTE[0],
capacity: props.stage?.capacity ?? null,
}
errors.value = {}
}
})
const mutations = useTimetableMutations({ orgId: props.orgId, eventId: props.eventId, dayId: props.dayId })
const isPending = computed(() => mutations.createStage.isPending.value || mutations.updateStage.isPending.value)
const isDeleting = computed(() => mutations.deleteStage.isPending.value)
async function submit(): Promise<void> {
errors.value = {}
const payload = {
name: form.value.name.trim(),
color: form.value.color,
capacity: form.value.capacity,
}
const parsed = createStagePayloadSchema.safeParse(payload)
if (!parsed.success) {
for (const issue of parsed.error.issues)
errors.value[String(issue.path[0] ?? '_')] = issue.message
return
}
try {
const result = props.stage
? await mutations.updateStage.mutateAsync({ id: props.stage.id, payload: parsed.data })
: await mutations.createStage.mutateAsync(parsed.data)
emit('saved', result)
emit('update:modelValue', false)
}
catch (err) {
errors.value._ = (err as Error).message ?? 'Onbekende fout'
}
}
async function remove(): Promise<void> {
if (!props.stage)
return
if (!window.confirm(`"${props.stage.name}" verwijderen? Geplande optredens worden naar de wachtrij verplaatst.`))
return
try {
await mutations.deleteStage.mutateAsync(props.stage.id)
emit('deleted', props.stage)
emit('update:modelValue', false)
}
catch (err) {
errors.value._ = (err as Error).message ?? 'Verwijderen mislukt'
}
}
</script>
<template>
<VDialog
:model-value="modelValue"
max-width="480"
persistent
@update:model-value="emit('update:modelValue', $event)"
>
<VCard>
<VCardTitle class="d-flex justify-space-between align-center">
{{ isEdit ? 'Stage bewerken' : 'Nieuwe stage' }}
<VBtn
icon="tabler-x"
variant="text"
size="small"
@click="emit('update:modelValue', false)"
/>
</VCardTitle>
<VForm
ref="refForm"
@submit.prevent="submit"
>
<VCardText class="d-flex flex-column gap-4">
<VAlert
v-if="errors._"
type="error"
density="compact"
variant="tonal"
>
{{ errors._ }}
</VAlert>
<AppTextField
v-model="form.name"
label="Naam"
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
/>
<AppTextField
v-model.number="form.capacity"
label="Capaciteit"
type="number"
min="0"
hint="Gebruikt voor capaciteits-waarschuwingen op blokken."
persistent-hint
:error-messages="errors.capacity"
/>
<div>
<label class="text-body-2 d-block mb-2">Kleur</label>
<div class="d-flex flex-wrap gap-2">
<button
v-for="c in STAGE_PALETTE"
:key="c"
type="button"
class="tt-stage-editor__swatch"
:class="{ 'tt-stage-editor__swatch--active': form.color === c }"
:style="{ backgroundColor: c }"
:aria-label="`Kies kleur ${c}`"
:aria-pressed="form.color === c"
@click="form.color = c"
/>
</div>
</div>
</VCardText>
<VCardActions class="px-4 pb-4">
<VBtn
v-if="isEdit"
variant="text"
color="error"
:loading="isDeleting"
@click="remove"
>
Verwijder
</VBtn>
<VSpacer />
<VBtn
variant="tonal"
@click="emit('update:modelValue', false)"
>
Annuleer
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
{{ isEdit ? 'Opslaan' : 'Aanmaken' }}
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
</template>
<style scoped lang="scss">
.tt-stage-editor__swatch {
inline-size: 28px;
block-size: 28px;
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
&--active {
border-color: var(--tt-focus-ring);
}
}
</style>

View File

@@ -0,0 +1,215 @@
<script setup lang="ts">
import { computed } from 'vue'
import WachtrijCard from './WachtrijCard.vue'
import { useTimetableStore } from '@/stores/useTimetableStore'
import {
ArtistEngagementStatus,
type ArtistEngagementStatus as ArtistEngagementStatusType,
type Performance,
} from '@/types/timetable'
const props = defineProps<{
performances: Performance[]
selectedId: string | null
}>()
const emit = defineEmits<{
cardSelect: [perf: Performance, rect: DOMRect]
cardPointerdown: [event: PointerEvent, perf: Performance]
}>()
const store = useTimetableStore()
const allStatuses: ArtistEngagementStatusType[] = [
ArtistEngagementStatus.DRAFT,
ArtistEngagementStatus.REQUESTED,
ArtistEngagementStatus.OPTION,
ArtistEngagementStatus.OFFERED,
ArtistEngagementStatus.CONFIRMED,
ArtistEngagementStatus.CONTRACTED,
ArtistEngagementStatus.CANCELLED,
ArtistEngagementStatus.REJECTED,
ArtistEngagementStatus.DECLINED,
]
const filtered = computed(() => {
const search = store.searchQuery.trim().toLowerCase()
return props.performances.filter(p => {
const status = p.engagement?.booking_status?.value
if (!store.isStatusVisible(status))
return false
if (search && !(p.engagement?.artist?.name ?? '').toLowerCase().includes(search))
return false
return true
})
})
const counts = computed(() => {
const counts = new Map<ArtistEngagementStatusType, number>()
for (const p of props.performances) {
const s = p.engagement?.booking_status?.value
if (!s)
continue
counts.set(s, (counts.get(s) ?? 0) + 1)
}
return counts
})
</script>
<template>
<aside
class="tt-wachtrij"
aria-label="Wachtrij geparkeerde optredens"
>
<div class="tt-wachtrij__header">
<div class="tt-wachtrij__title">
Wachtrij
<span class="tt-wachtrij__count">{{ filtered.length }}</span>
</div>
</div>
<div class="tt-wachtrij__filters">
<AppTextField
:model-value="store.searchQuery"
density="compact"
prepend-inner-icon="tabler-search"
placeholder="Zoek artiest…"
clearable
hide-details
@update:model-value="(v: unknown) => store.setSearchQuery(String(v ?? ''))"
/>
<div class="tt-wachtrij__status-row">
<button
v-for="s in allStatuses"
:key="s"
type="button"
class="tt-wachtrij__status-chip"
:class="{ 'tt-wachtrij__status-chip--off': !store.isStatusVisible(s) }"
:aria-pressed="store.isStatusVisible(s)"
@click="store.toggleStatus(s)"
>
<span
class="tt-wachtrij__status-dot"
:style="{ backgroundColor: `var(--tt-status-${s}-dot)` }"
aria-hidden="true"
/>
{{ s }}
<span class="tt-wachtrij__status-count">({{ counts.get(s) ?? 0 }})</span>
</button>
</div>
</div>
<div
class="tt-wachtrij__body"
role="list"
>
<div
v-if="filtered.length === 0"
class="tt-wachtrij__empty"
>
Geen optredens in de wachtrij voldoen aan deze filters.
</div>
<div
v-for="p in filtered"
:key="p.id"
role="listitem"
>
<WachtrijCard
:performance="p"
:selected="selectedId === p.id"
@select="(perf, rect) => emit('cardSelect', perf, rect)"
@pointerdown="(event, perf) => emit('cardPointerdown', event, perf)"
/>
</div>
</div>
</aside>
</template>
<style scoped lang="scss">
.tt-wachtrij {
display: flex;
flex-direction: column;
inline-size: 320px;
block-size: 100%;
background-color: #faf9f6;
border-inline-start: 1px solid var(--tt-row-divider);
&__header {
padding: 12px;
border-block-end: 1px solid var(--tt-row-divider);
}
&__title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
&__count {
padding-block: 1px;
padding-inline: 6px;
font-size: 12px;
background-color: var(--tt-row-divider);
border-radius: 8px;
}
&__filters {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-block-end: 1px solid var(--tt-row-divider);
}
&__status-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
&__status-chip {
display: inline-flex;
gap: 4px;
align-items: center;
padding-block: 2px;
padding-inline: 6px;
font-size: 11px;
background-color: #fff;
border: 1px solid var(--tt-row-divider);
border-radius: 12px;
cursor: pointer;
&--off {
opacity: 0.4;
}
}
&__status-dot {
inline-size: 6px;
block-size: 6px;
border-radius: 50%;
}
&__status-count {
color: var(--tt-axis-label-fg);
}
&__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
padding: 12px;
overflow: auto;
}
&__empty {
padding-block: 16px;
font-size: 12px;
text-align: center;
color: var(--tt-axis-label-fg);
}
}
</style>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
ArtistEngagementStatus,
type Performance,
} from '@/types/timetable'
const props = defineProps<{
performance: Performance
selected?: boolean
}>()
const emit = defineEmits<{
select: [perf: Performance, rect: DOMRect]
pointerdown: [event: PointerEvent, perf: Performance]
}>()
const artistName = computed(() => props.performance.engagement?.artist?.name ?? 'Onbekend')
const status = computed(() => props.performance.engagement?.booking_status?.value ?? null)
const statusKey = computed(() => status.value ?? 'draft')
const statusLabel = computed(() => props.performance.engagement?.booking_status?.label ?? '—')
const isCancelled = computed(() => status.value === ArtistEngagementStatus.CANCELLED)
const initials = computed(() => {
const name = artistName.value
const parts = name.split(/\s+/).filter(Boolean)
const a = parts[0]?.[0] ?? ''
const b = parts[1]?.[0] ?? ''
return (a + b).toUpperCase()
})
function onClick(event: MouseEvent): void {
emit('select', props.performance, (event.currentTarget as HTMLElement).getBoundingClientRect())
}
function onPointerDown(event: PointerEvent): void {
emit('pointerdown', event, props.performance)
}
</script>
<template>
<div
class="tt-wachtrij-card"
:class="[`tt-wachtrij-card--status-${statusKey}`, { 'tt-wachtrij-card--cancelled': isCancelled, 'tt-wachtrij-card--selected': selected }]"
role="button"
tabindex="0"
:aria-label="`Wachtrij-kaart ${artistName}, status ${statusLabel}`"
:data-perf-id="performance.id"
@click="onClick"
@pointerdown="onPointerDown"
>
<div
class="tt-wachtrij-card__avatar"
aria-hidden="true"
>
{{ initials }}
</div>
<div class="tt-wachtrij-card__body">
<div class="tt-wachtrij-card__name">
{{ artistName }}
</div>
<div class="tt-wachtrij-card__meta">
<span
class="tt-wachtrij-card__dot"
:style="{ backgroundColor: `var(--tt-status-${statusKey}-dot)` }"
aria-hidden="true"
/>
{{ statusLabel }}
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.tt-wachtrij-card {
display: flex;
gap: 8px;
align-items: center;
padding: 8px;
background-color: #fff;
border: 1px solid var(--tt-row-divider);
border-radius: 6px;
cursor: grab;
user-select: none;
&:focus-visible {
outline: 2px solid var(--tt-focus-ring);
outline-offset: 2px;
}
&--cancelled .tt-wachtrij-card__name {
color: var(--tt-status-cancelled-fg);
text-decoration: line-through;
}
&--selected {
background-color: rgb(31 122 209 / 6%);
border-color: var(--tt-focus-ring);
}
&__avatar {
flex-shrink: 0;
inline-size: 32px;
block-size: 32px;
font-size: 11px;
font-weight: 700;
line-height: 32px;
text-align: center;
color: #fff;
background-color: var(--tt-axis-label-fg);
border-radius: 50%;
}
&__body {
min-inline-size: 0;
flex: 1;
}
&__name {
overflow: hidden;
font-weight: 600;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
&__meta {
display: flex;
gap: 6px;
align-items: center;
margin-block-start: 2px;
font-size: 11px;
color: var(--tt-axis-label-fg);
}
&__dot {
inline-size: 8px;
block-size: 8px;
border-radius: 50%;
}
}
</style>