pp-planer/resources/js/Components/ArrangementDialog.vue
Thorsten Bus 78b8fc2e3d refactor(ui): convert agenda list to table with proper data formatting
- Replace card-based agenda layout with a 6-column table (Nr, Zeit, Dauer, Titel, Verantwortlich, Aktionen)
- Fix isHeaderType to only match type=header, not all non-song items
- Format start time from ISO 8601 to HH:MM (Europe/Berlin)
- Format duration from seconds string to H:MM
- Fix responsible parsing for CTS data structure ({text, persons[{person:{title}}]})
- Move unmatched song search/assign UI into ArrangementDialog popup
- Add colored row backgrounds (song/sermon/announcement/slides) with left border
- Invert header rows to dark grey background with white text
2026-03-29 16:32:30 +02:00

643 lines
26 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,
default: null,
},
arrangements: {
type: Array,
default: () => [],
},
availableGroups: {
type: Array,
default: () => [],
},
selectedArrangementId: {
type: Number,
default: null,
},
serviceSongId: {
type: Number,
default: null,
},
songsCatalog: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['close', 'arrangement-selected'])
/* ── Song assignment (unmatched songs) ── */
const isUnmatched = computed(() => !props.songId)
const searchQuery = ref('')
const selectedSongId = ref('')
const dropdownOpen = ref(false)
const assignError = ref('')
function normalize(value) {
return (value ?? '').toString().toLowerCase().trim()
}
const filteredCatalog = computed(() => {
const term = normalize(searchQuery.value)
if (term === '') return props.songsCatalog.slice(0, 100)
return props.songsCatalog
.filter((song) => normalize(song.title).includes(term) || normalize(song.ccli_id).includes(term))
.slice(0, 100)
})
function openSearchDropdown() {
dropdownOpen.value = true
}
function closeSearchDropdown() {
setTimeout(() => {
dropdownOpen.value = false
}, 200)
}
function selectSong(song) {
selectedSongId.value = song.id
searchQuery.value = song.title
dropdownOpen.value = false
}
async function assignSong() {
const songId = Number(selectedSongId.value)
if (!songId) {
assignError.value = 'Bitte wähle zuerst einen Song aus.'
return
}
assignError.value = ''
try {
await window.axios.post(`/api/service-songs/${props.serviceSongId}/assign`, {
song_id: songId,
})
emit('close')
} catch {
assignError.value = 'Zuordnung fehlgeschlagen.'
}
}
/* ── 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="isUnmatched
? 'flex w-full max-w-lg flex-col rounded-xl bg-white shadow-2xl'
: 'flex h-[80vh] w-full max-w-5xl flex-col rounded-xl bg-white shadow-2xl'"
>
<!-- Header: Unmatched song assignment -->
<div v-if="isUnmatched" class="flex items-center gap-3 border-b border-gray-200 px-6 py-4">
<h3 class="text-base font-semibold text-gray-900">Song zuordnen</h3>
<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>
<!-- Header: Arrangement editor -->
<div v-else 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>
<!-- Body: Unmatched song search & assign -->
<div v-if="isUnmatched" class="flex-1 overflow-y-auto p-6">
<div class="mx-auto max-w-lg space-y-4">
<p class="text-sm text-gray-600">
Dieser CTS-Song ist noch nicht in der Song-DB verknüpft. Suche den passenden Song und ordne ihn zu.
</p>
<div class="relative">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Song suchen und auswählen
</label>
<input
v-model="searchQuery"
type="text"
placeholder="Titel oder CCLI eingeben…"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
data-testid="song-search-input"
@focus="openSearchDropdown"
@input="openSearchDropdown"
@blur="closeSearchDropdown"
/>
<!-- Selected indicator -->
<div v-if="selectedSongId" class="mt-1 flex items-center gap-2">
<span class="text-xs font-medium text-emerald-700">
Ausgewählt: {{ songsCatalog.find((s) => s.id === Number(selectedSongId))?.title || '' }}
</span>
<button
type="button"
class="text-xs text-gray-400 hover:text-red-500"
@click="selectedSongId = ''; searchQuery = ''"
>
&#10005;
</button>
</div>
<!-- Dropdown -->
<div
v-if="dropdownOpen && filteredCatalog.length > 0"
class="absolute z-30 mt-1 max-h-64 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
<button
v-for="song in filteredCatalog"
:key="song.id"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-emerald-50"
@mousedown.prevent="selectSong(song)"
>
<span class="font-medium text-gray-900">{{ song.title }}</span>
<span class="ml-auto text-xs text-gray-400">CCLI: {{ song.ccli_id || '' }}</span>
</button>
</div>
</div>
<p v-if="assignError" class="text-sm text-red-600">{{ assignError }}</p>
<button
type="button"
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700"
data-testid="song-assign-button"
@click="assignSong"
>
Zuordnen
</button>
</div>
</div>
<!-- Body: Two-column arrangement editor -->
<div v-else 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>