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:
195
apps/app/src/components/timetable/AddPerformanceDialog.vue
Normal file
195
apps/app/src/components/timetable/AddPerformanceDialog.vue
Normal 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>
|
||||
218
apps/app/src/components/timetable/LineupMatrix.vue
Normal file
218
apps/app/src/components/timetable/LineupMatrix.vue
Normal 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>
|
||||
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>
|
||||
215
apps/app/src/components/timetable/StageEditor.vue
Normal file
215
apps/app/src/components/timetable/StageEditor.vue
Normal 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>
|
||||
215
apps/app/src/components/timetable/Wachtrij.vue
Normal file
215
apps/app/src/components/timetable/Wachtrij.vue
Normal 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>
|
||||
143
apps/app/src/components/timetable/WachtrijCard.vue
Normal file
143
apps/app/src/components/timetable/WachtrijCard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user