pp-planer/resources/js/Components/Blocks/SongsBlock.vue
Thorsten Bus 4520c1ce5f test(e2e): add data-testid attributes to all Vue components
- Add data-testid to 18 Vue components (Pages, Blocks, Features, Layouts, Primitives)
- Naming convention: {component-kebab}-{element-description}
- 98 total data-testid attributes added
- Target elements: buttons, links, inputs, modals, navigation
- No logic/styling changes - attributes only
2026-03-01 22:45:13 +01:00

365 lines
13 KiB
Vue

<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 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 showPlaceholder = () => {
showToast('Demnaechst verfuegbar')
}
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,1fr,auto] md:items-end">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Song suchen
</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"
>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Song aus DB auswaehlen
</label>
<select
data-testid="songs-block-song-select"
v-model="selectedSongIds[serviceSong.id]"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
>
<option value="">
Bitte auswaehlen
</option>
<option
v-for="song in filteredCatalog(serviceSong.id)"
:key="song.id"
:value="song.id"
>
{{ song.title }} (CCLI: {{ song.ccli_id || '-' }})
</option>
</select>
</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"
@click="showPlaceholder"
>
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"
@click="showPlaceholder"
>
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>
</div>
</template>