pp-planer/resources/js/Components/ArrangementDialog.vue
Thorsten Bus 6d337d8b6a fix(ui): fix ArrangementDialog data mapping and close behavior
- Transform arrangement data in Edit.vue to flat {id, name, color, slides}
  format expected by ArrangementDialog (was passing raw Eloquent structure
  with nested arrangement_groups[].group instead of flat .groups[])
- Stop arrangement select change from closing the dialog (only close on X)
- Fallback to all available groups when no arrangement selected (Master)
2026-03-29 15:39:38 +02:00

498 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus'
const props = defineProps({
songId: {
type: Number,
required: true,
},
arrangements: {
type: Array,
default: () => [],
},
availableGroups: {
type: Array,
default: () => [],
},
selectedArrangementId: {
type: Number,
default: null,
},
})
const emit = defineEmits(['close', 'arrangement-selected'])
/* ── State ── */
const currentArrangementId = ref(
props.selectedArrangementId ?? props.arrangements.find((a) => a.is_default)?.id ?? props.arrangements[0]?.id ?? null,
)
const currentArrangement = computed(() =>
props.arrangements.find((a) => a.id === Number(currentArrangementId.value)) ?? null,
)
const arrangementGroups = ref([])
watch(
currentArrangementId,
(id) => {
const arr = props.arrangements.find((a) => a.id === Number(id))
if (arr?.groups?.length) {
arrangementGroups.value = arr.groups.map((g, i) => ({ ...g, _uid: `${g.id}-${i}-${Date.now()}` }))
} else {
// Fallback: show all available groups in order (Master)
arrangementGroups.value = props.availableGroups.map((g, i) => ({ ...g, _uid: `${g.id}-master-${i}-${Date.now()}` }))
}
},
{ immediate: true },
)
const pendingAutoSelect = ref(false)
watch(
() => props.arrangements.length,
(newLen, oldLen) => {
if (pendingAutoSelect.value && newLen > oldLen) {
const newest = props.arrangements.reduce((a, b) => (a.id > b.id ? a : b))
currentArrangementId.value = newest.id
pendingAutoSelect.value = false
}
},
)
/* ── Scroll sync ── */
const leftCol = ref(null)
const rightCol = ref(null)
let syncing = false
function syncScroll(from) {
if (syncing) return
syncing = true
if (from === 'left' && rightCol.value) {
rightCol.value.scrollTop = leftCol.value.scrollTop
} else if (from === 'right' && leftCol.value) {
leftCol.value.scrollTop = rightCol.value.scrollTop
}
nextTick(() => {
syncing = false
})
}
/* ── Group picker ── */
const groupPickerOpen = ref(null)
function toggleGroupPicker(index) {
groupPickerOpen.value = groupPickerOpen.value === index ? null : index
}
/* ── Close on outside click for group picker ── */
function onBodyClick(e) {
if (groupPickerOpen.value !== null && !e.target.closest('[data-group-picker]')) {
groupPickerOpen.value = null
}
}
/* ── Keyboard: Escape ── */
function closeOnEscape(e) {
if (e.key === 'Escape') {
if (groupPickerOpen.value !== null) {
groupPickerOpen.value = null
} else {
emit('close')
}
}
}
onMounted(() => {
document.addEventListener('keydown', closeOnEscape)
document.addEventListener('click', onBodyClick)
})
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape)
document.removeEventListener('click', onBodyClick)
})
/* ── Arrangement CRUD (Inertia router, matching ArrangementConfigurator patterns) ── */
function createArrangement() {
const name = window.prompt('Name für das neue Arrangement:')
if (!name?.trim()) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, { preserveScroll: true })
}
function cloneArrangement() {
if (!currentArrangementId.value) return
const name = window.prompt('Name für das geklonte Arrangement:', `${currentArrangement.value?.name ?? ''} Kopie`)
if (!name?.trim()) return
pendingAutoSelect.value = true
router.post(`/arrangements/${currentArrangementId.value}/clone`, { name: name.trim() }, { preserveScroll: true })
}
function deleteArrangement() {
if (!currentArrangementId.value) return
if (props.arrangements.length <= 1) {
alert('Das letzte Arrangement kann nicht gelöscht werden.')
return
}
if (!confirm('Arrangement wirklich löschen?')) return
router.delete(`/arrangements/${currentArrangementId.value}`, { preserveScroll: true })
}
function saveArrangement() {
if (!currentArrangement.value) return
router.put(
`/arrangements/${currentArrangement.value.id}`,
{
groups: arrangementGroups.value.map((group, index) => ({
song_group_id: group.id,
order: index + 1,
})),
},
{
preserveScroll: true,
preserveState: true,
},
)
}
/* ── Group operations ── */
function duplicateGroup(index) {
const item = arrangementGroups.value[index]
const newItem = { ...item, _uid: `${item.id}-dup-${Date.now()}` }
arrangementGroups.value.splice(index + 1, 0, newItem)
saveArrangement()
}
function addGroupAt(index, group) {
const newItem = {
id: group.id,
name: group.name,
color: group.color,
slides: group.slides ?? [],
_uid: `${group.id}-add-${Date.now()}`,
}
arrangementGroups.value.splice(index + 1, 0, newItem)
groupPickerOpen.value = null
saveArrangement()
}
function removeGroupAt(index) {
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
/* ── Selection emit ── */
watch(currentArrangementId, (id) => {
emit('arrangement-selected', Number(id))
})
/* ── Close on backdrop ── */
function closeOnBackdrop(e) {
if (e.target === e.currentTarget) {
emit('close')
}
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click="closeOnBackdrop"
>
<div
data-testid="arrangement-dialog"
class="flex h-[80vh] w-full max-w-5xl flex-col rounded-xl bg-white shadow-2xl"
>
<!-- Header -->
<div class="flex items-center gap-3 border-b border-gray-200 px-6 py-4">
<div class="min-w-48 flex-1">
<select
v-model="currentArrangementId"
data-testid="arrangement-select"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option
v-for="arr in arrangements"
:key="arr.id"
:value="arr.id"
>
{{ arr.name }}{{ arr.is_default ? ' (Standard)' : '' }}
</option>
</select>
</div>
<button
data-testid="arrangement-new-btn"
type="button"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700"
@click="createArrangement"
>
Neu
</button>
<button
data-testid="arrangement-clone-btn"
type="button"
class="rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
@click="cloneArrangement"
>
Duplizieren
</button>
<button
data-testid="arrangement-delete-btn"
type="button"
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-red-700"
@click="deleteArrangement"
>
Löschen
</button>
<button
data-testid="arrangement-dialog-close-btn"
type="button"
class="ml-auto rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="emit('close')"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Two-column body -->
<div class="flex flex-1 overflow-hidden">
<!-- Left: Pill list with DnD -->
<div
ref="leftCol"
data-testid="arrangement-pill-list"
class="flex w-1/2 flex-col gap-2 overflow-y-auto border-r border-gray-200 p-4"
@scroll="syncScroll('left')"
>
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Gruppenfolge
</h4>
<VueDraggable
v-model="arrangementGroups"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
handle=".drag-handle"
class="flex flex-col gap-2"
@end="saveArrangement"
>
<div
v-for="(element, index) in arrangementGroups"
:key="element._uid"
data-testid="arrangement-pill"
class="flex items-center gap-2 rounded-lg border-2 px-3 py-2"
:style="{
borderColor: element.color ?? '#6b7280',
backgroundColor: (element.color ?? '#6b7280') + '20',
}"
>
<!-- Drag handle -->
<span class="drag-handle cursor-grab text-gray-400 hover:text-gray-600">
<svg
class="h-4 w-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M7 2a2 2 0 10.001 4.001A2 2 0 007 2zm0 6a2 2 0 10.001 4.001A2 2 0 007 8zm0 6a2 2 0 10.001 4.001A2 2 0 007 14zm6-8a2 2 0 10-.001-4.001A2 2 0 0013 6zm0 2a2 2 0 10.001 4.001A2 2 0 0013 8zm0 6a2 2 0 10.001 4.001A2 2 0 0013 14z" />
</svg>
</span>
<!-- Group name -->
<span class="flex-1 text-sm font-medium">
{{ element.name }}
</span>
<!-- Duplicate button (2×) -->
<button
data-testid="arrangement-duplicate-btn"
type="button"
class="rounded bg-gray-100 px-2 py-0.5 text-xs hover:bg-gray-200"
@click="duplicateGroup(index)"
>
2×
</button>
<!-- Add group button (+) -->
<div
class="relative"
data-group-picker
>
<button
data-testid="arrangement-add-group-btn"
type="button"
class="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-200"
@click.stop="toggleGroupPicker(index)"
>
+
</button>
<!-- Quick select dropdown -->
<div
v-if="groupPickerOpen === index"
class="absolute right-0 top-8 z-10 min-w-36 rounded-lg border border-gray-200 bg-white shadow-lg"
>
<button
v-for="g in availableGroups"
:key="g.id"
type="button"
class="block w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
@click="addGroupAt(index, g)"
>
<span
class="mr-1.5 inline-block h-2.5 w-2.5 rounded-full"
:style="{ backgroundColor: g.color }"
/>
{{ g.name }}
</button>
</div>
</div>
<!-- Remove button -->
<button
data-testid="arrangement-remove-btn"
type="button"
class="rounded px-1.5 py-0.5 text-xs text-red-400 hover:bg-red-50 hover:text-red-600"
@click="removeGroupAt(index)"
>
×
</button>
</div>
</VueDraggable>
<!-- Empty state -->
<div
v-if="arrangementGroups.length === 0"
class="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 p-8 text-center text-sm text-gray-400"
>
Noch keine Gruppen zugeordnet.
</div>
</div>
<!-- Right: Lyric preview (scroll-synced) -->
<div
ref="rightCol"
data-testid="arrangement-lyric-preview"
class="flex w-1/2 flex-col gap-2 overflow-y-auto bg-gray-50 p-4"
@scroll="syncScroll('right')"
>
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Textvorschau
</h4>
<div
v-for="element in arrangementGroups"
:key="element._uid"
class="rounded-r-lg border-l-4 bg-white p-3 shadow-sm"
:style="{ borderColor: element.color ?? '#6b7280' }"
>
<!-- Group name pill -->
<span
class="mb-2 inline-block rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: element.color ?? '#6b7280' }"
>
{{ element.name }}
</span>
<!-- Slides -->
<template v-if="element.slides?.length > 0">
<div
v-for="(slide, si) in element.slides"
:key="slide.id ?? si"
>
<div
v-if="si > 0"
class="my-1.5 border-t border-gray-200"
/>
<p class="whitespace-pre-wrap text-sm">
{{ slide.text_content }}
</p>
<p
v-if="slide.text_content_translated"
class="mt-0.5 whitespace-pre-wrap text-xs italic text-gray-400"
>
{{ slide.text_content_translated }}
</p>
</div>
</template>
<!-- No slides -->
<p
v-else
class="text-xs italic text-gray-300"
>
Kein Text vorhanden
</p>
</div>
<!-- Empty state -->
<div
v-if="arrangementGroups.length === 0"
class="flex flex-1 items-center justify-center text-sm text-gray-400"
>
Wähle ein Arrangement aus, um die Vorschau zu sehen.
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
:deep(.drag-ghost) {
opacity: 0.4;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 0 0 4px rgba(99, 102, 241, 0.6);
border-radius: 0.5rem;
}
:deep(.drag-chosen) {
opacity: 0.7;
}
:deep(.drag-active) {
opacity: 1 !important;
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 2px rgba(255, 255, 255, 0.8);
border-radius: 0.5rem;
z-index: 50;
}
</style>