pp-planer/resources/js/Components/Blocks/SongsBlock.vue
Thorsten Bus 6e48779259 feat(songs): add preview, searchable combo select, import toast, auto-select arrangement
- Hide translate button for already-translated songs in SongDB
- Auto-select newly created/cloned arrangement via onSuccess + nextTick
- Add preview button to SongDB list (fetches default arrangement preview)
- Show success toast with count after .pro file import
- Replace search+select with single searchable combo/autocomplete field
- Wire preview modal and PDF download to real endpoints in service songs
- Disable preview/PDF buttons when no arrangement selected
2026-03-02 14:10:59 +01:00

475 lines
19 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 ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue'
import { router } from '@inertiajs/vue3'
import { reactive, ref, watch } from 'vue'
const props = defineProps({
serviceSongs: {
type: Array,
default: () => [],
},
songsCatalog: {
type: Array,
default: () => [],
},
})
const toastMessage = ref('')
const toastVariant = ref('info')
const selectedSongIds = reactive({})
const searchTerms = reactive({})
const translationValues = reactive({})
const dropdownOpen = reactive({})
const showToast = (message, variant = 'info') => {
toastMessage.value = message
toastVariant.value = variant
setTimeout(() => {
toastMessage.value = ''
}, 2500)
}
const normalize = (value) => (value ?? '').toString().toLowerCase().trim()
const initLocalState = () => {
props.serviceSongs.forEach((serviceSong) => {
selectedSongIds[serviceSong.id] = selectedSongIds[serviceSong.id] ?? ''
searchTerms[serviceSong.id] = searchTerms[serviceSong.id] ?? ''
translationValues[serviceSong.id] = serviceSong.use_translation
})
}
watch(
() => props.serviceSongs,
() => {
initLocalState()
},
{ immediate: true, deep: true },
)
const sortedSongs = () => {
return [...props.serviceSongs].sort((a, b) => a.order - b.order)
}
const filteredCatalog = (serviceSongId) => {
const term = normalize(searchTerms[serviceSongId])
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)
}
const assignSong = async (serviceSongId) => {
const selectedSongId = Number(selectedSongIds[serviceSongId])
if (!selectedSongId) {
showToast('Bitte waehle zuerst einen Song aus.', 'warning')
return
}
try {
await window.axios.post(`/api/service-songs/${serviceSongId}/assign`, {
song_id: selectedSongId,
})
selectedSongIds[serviceSongId] = ''
searchTerms[serviceSongId] = ''
showToast('Song wurde zugeordnet.', 'success')
router.reload({
only: ['serviceSongs'],
preserveScroll: true,
preserveState: true,
})
} catch {
showToast('Zuordnung fehlgeschlagen.', 'error')
}
}
const requestCreation = async (serviceSongId) => {
try {
await window.axios.post(`/api/service-songs/${serviceSongId}/request`)
showToast('Anfrage wurde gesendet.', 'success')
router.reload({
only: ['serviceSongs'],
preserveScroll: true,
preserveState: true,
})
} catch {
showToast('Anfrage konnte nicht gesendet werden.', 'error')
}
}
const updateUseTranslation = async (serviceSongId) => {
try {
await window.axios.patch(`/api/service-songs/${serviceSongId}`, {
use_translation: Boolean(translationValues[serviceSongId]),
})
showToast('Uebersetzung wurde gespeichert.', 'success')
} catch {
showToast('Speichern fehlgeschlagen.', 'error')
}
}
const updateArrangementSelection = async (serviceSongId, arrangementId) => {
try {
await window.axios.patch(`/api/service-songs/${serviceSongId}`, {
song_arrangement_id: arrangementId,
})
showToast('Arrangement wurde gespeichert.', 'success')
} catch {
showToast('Arrangement konnte nicht gespeichert werden.', 'error')
}
}
const openDropdown = (serviceSongId) => {
dropdownOpen[serviceSongId] = true
}
const closeDropdown = (serviceSongId) => {
setTimeout(() => { dropdownOpen[serviceSongId] = false }, 200)
}
const selectSong = (serviceSongId, song) => {
selectedSongIds[serviceSongId] = song.id
searchTerms[serviceSongId] = song.title
dropdownOpen[serviceSongId] = false
}
// Preview / PDF
const previewData = ref(null)
const showPreview = ref(false)
const previewLoading = ref(false)
const openSongPreview = async (serviceSong) => {
if (!serviceSong.song_id || !serviceSong.song_arrangement_id) {
showToast('Bitte zuerst ein Arrangement auswählen.', 'warning')
return
}
previewLoading.value = true
showPreview.value = true
try {
const response = await window.axios.get(
`/songs/${serviceSong.song_id}/arrangements/${serviceSong.song_arrangement_id}/preview`
)
previewData.value = response.data
} catch {
showToast('Vorschau konnte nicht geladen werden.', 'error')
showPreview.value = false
} finally {
previewLoading.value = false
}
}
const closePreview = () => {
showPreview.value = false
previewData.value = null
}
const downloadSongPdf = (serviceSong) => {
if (!serviceSong.song_id || !serviceSong.song_arrangement_id) {
showToast('Bitte zuerst ein Arrangement auswählen.', 'warning')
return
}
window.location.href = `/songs/${serviceSong.song_id}/arrangements/${serviceSong.song_arrangement_id}/pdf`
}
const formatDateTime = (value) => {
if (!value) {
return null
}
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const toastClasses = () => {
if (toastVariant.value === 'success') {
return 'border-emerald-300 bg-emerald-50 text-emerald-700'
}
if (toastVariant.value === 'warning') {
return 'border-amber-300 bg-amber-50 text-amber-700'
}
if (toastVariant.value === 'error') {
return 'border-red-300 bg-red-50 text-red-700'
}
return 'border-slate-300 bg-slate-50 text-slate-700'
}
</script>
<template>
<div data-testid="songs-block" class="space-y-4">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="toastMessage"
class="rounded-lg border px-4 py-3 text-sm font-medium"
:class="toastClasses()"
role="status"
>
{{ toastMessage }}
</div>
</Transition>
<div
data-testid="songs-block-song-card"
v-for="serviceSong in sortedSongs()"
:key="serviceSong.id"
class="space-y-4 rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Song {{ serviceSong.order }}
</p>
<h4 class="mt-1 text-lg font-semibold text-gray-900">
{{ serviceSong.cts_song_name || '-' }}
</h4>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-600">
<span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium">
CCLI: {{ serviceSong.cts_ccli_id || '-' }}
</span>
<span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium">
Hat Uebersetzung:
{{ serviceSong.song?.has_translation ? 'Ja' : 'Nein' }}
</span>
</div>
</div>
<span
v-if="serviceSong.song_id"
class="inline-flex items-center rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700"
>
Zugeordnet
</span>
<span
v-else
class="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
>
Nicht zugeordnet
</span>
</div>
<div
v-if="!serviceSong.song_id"
class="space-y-4 rounded-lg border border-amber-200 bg-amber-50/40 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-amber-900">
Dieser CTS-Song ist noch nicht in der Song-DB verknuepft.
</p>
<p
v-if="serviceSong.request_sent_at"
class="mt-1 text-xs text-amber-700"
>
Anfrage gesendet am {{ formatDateTime(serviceSong.request_sent_at) }}
</p>
</div>
<button
data-testid="songs-block-request-button"
type="button"
class="inline-flex items-center rounded-md border border-amber-300 bg-white px-3 py-2 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
@click="requestCreation(serviceSong.id)"
>
Erstellung anfragen
</button>
</div>
<div class="grid gap-3 md:grid-cols-[1fr,auto] md:items-end">
<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
data-testid="songs-block-search-input"
v-model="searchTerms[serviceSong.id]"
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"
@focus="openDropdown(serviceSong.id)"
@input="openDropdown(serviceSong.id)"
@blur="closeDropdown(serviceSong.id)"
>
<!-- Selected indicator -->
<div v-if="selectedSongIds[serviceSong.id]" class="mt-1 flex items-center gap-2">
<span class="text-xs text-emerald-700 font-medium">
Ausgewählt: {{ songsCatalog.find(s => s.id === Number(selectedSongIds[serviceSong.id]))?.title || '' }}
</span>
<button type="button" class="text-xs text-gray-400 hover:text-red-500" @click="selectedSongIds[serviceSong.id] = ''; searchTerms[serviceSong.id] = ''">&#10005;</button>
</div>
<!-- Dropdown -->
<div
v-if="dropdownOpen[serviceSong.id] && filteredCatalog(serviceSong.id).length > 0"
class="absolute z-30 mt-1 max-h-48 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
<button
v-for="song in filteredCatalog(serviceSong.id)"
: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(serviceSong.id, 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>
<button
data-testid="songs-block-assign-button"
type="button"
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700"
@click="assignSong(serviceSong.id)"
>
Zuordnen
</button>
</div>
</div>
<div v-else class="space-y-4">
<div class="flex flex-wrap items-center gap-4">
<p class="text-sm text-gray-600">
Zugeordnet zu:
<span class="font-semibold text-gray-900">
{{ serviceSong.song?.title }}
</span>
</p>
<label
v-if="serviceSong.song?.has_translation"
class="inline-flex items-center gap-2 text-sm font-medium text-gray-800"
>
<input
data-testid="songs-block-translation-checkbox"
v-model="translationValues[serviceSong.id]"
type="checkbox"
class="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
@change="updateUseTranslation(serviceSong.id)"
>
Uebersetzung verwenden
</label>
</div>
<ArrangementConfigurator
:song-id="serviceSong.song.id"
:arrangements="serviceSong.song.arrangements || []"
:available-groups="serviceSong.song.groups || []"
:selected-arrangement-id="serviceSong.song_arrangement_id"
@arrangement-selected="(arrangementId) => updateArrangementSelection(serviceSong.id, arrangementId)"
/>
<div class="flex flex-wrap gap-2">
<button
data-testid="songs-block-preview-button"
type="button"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
:disabled="!serviceSong.song_arrangement_id"
:class="{ 'opacity-50 cursor-not-allowed': !serviceSong.song_arrangement_id }"
@click="openSongPreview(serviceSong)"
>
Vorschau
</button>
<button
data-testid="songs-block-download-button"
type="button"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
:disabled="!serviceSong.song_arrangement_id"
:class="{ 'opacity-50 cursor-not-allowed': !serviceSong.song_arrangement_id }"
@click="downloadSongPdf(serviceSong)"
>
PDF herunterladen
</button>
</div>
</div>
</div>
<div
v-if="serviceSongs.length === 0"
class="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-10 text-center"
>
<p class="text-sm font-medium text-gray-600">
Fuer diesen Service sind aktuell keine Songs vorhanden.
</p>
</div>
<!-- Song Preview Modal -->
<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
v-if="showPreview"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
@click.self="closePreview"
>
<div class="w-full max-w-2xl max-h-[80vh] overflow-auto rounded-2xl bg-white p-6 shadow-2xl">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">
{{ previewData?.song?.title || 'Vorschau' }}
</h3>
<button type="button" class="text-gray-400 hover:text-gray-600" @click="closePreview">
<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>
<div v-if="previewLoading" class="flex items-center justify-center py-12">
<span class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"></span>
</div>
<div v-else-if="previewData" class="space-y-4">
<div v-for="(group, idx) in previewData.groups" :key="idx" class="rounded-lg border border-gray-200 p-4">
<div class="mb-2 flex items-center gap-2">
<span
class="inline-flex rounded-full px-3 py-1 text-xs font-semibold text-white"
:style="{ backgroundColor: group.color || '#6b7280' }"
>
{{ group.name }}
</span>
</div>
<div v-for="(slide, sIdx) in group.slides" :key="sIdx" class="mt-2 whitespace-pre-wrap text-sm text-gray-700 leading-relaxed">{{ slide.text_content }}</div>
</div>
<div v-if="previewData.song?.copyright_text" class="border-t pt-3 text-xs text-gray-400">
{{ previewData.song.copyright_text }}
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>