335 lines
13 KiB
Vue
335 lines
13 KiB
Vue
<script setup>
|
||
import { computed, reactive, ref, watch } from 'vue'
|
||
import { router } from '@inertiajs/vue3'
|
||
|
||
const props = defineProps({
|
||
agendaItem: { type: Object, required: true },
|
||
songsCatalog: { type: Array, default: () => [] },
|
||
serviceId: { type: Number, required: true },
|
||
})
|
||
|
||
const emit = defineEmits(['arrangement-selected', 'slides-updated'])
|
||
|
||
const serviceSong = computed(() => props.agendaItem.serviceSong ?? props.agendaItem.service_song ?? null)
|
||
|
||
const isMatched = computed(() => serviceSong.value?.song_id != null)
|
||
|
||
const searchQuery = ref('')
|
||
const selectedSongId = ref('')
|
||
const dropdownOpen = ref(false)
|
||
const useTranslation = ref(false)
|
||
const toastMessage = ref('')
|
||
const toastVariant = ref('info')
|
||
|
||
function showToast(message, variant = 'info') {
|
||
toastMessage.value = message
|
||
toastVariant.value = variant
|
||
setTimeout(() => {
|
||
toastMessage.value = ''
|
||
}, 2500)
|
||
}
|
||
|
||
function 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'
|
||
}
|
||
|
||
function normalize(value) {
|
||
return (value ?? '').toString().toLowerCase().trim()
|
||
}
|
||
|
||
watch(
|
||
() => serviceSong.value,
|
||
(ss) => {
|
||
if (ss) {
|
||
useTranslation.value = ss.use_translation ?? false
|
||
}
|
||
},
|
||
{ immediate: true, deep: true },
|
||
)
|
||
|
||
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)
|
||
})
|
||
|
||
const arrangementPills = computed(() => {
|
||
const groups = serviceSong.value?.arrangement?.arrangement_groups ?? []
|
||
return [...groups].sort((a, b) => a.order - b.order)
|
||
})
|
||
|
||
function openDropdown() {
|
||
dropdownOpen.value = true
|
||
}
|
||
|
||
function closeDropdown() {
|
||
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) {
|
||
showToast('Bitte wähle zuerst einen Song aus.', 'warning')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await window.axios.post(`/api/service-songs/${serviceSong.value.id}/assign`, {
|
||
song_id: songId,
|
||
})
|
||
selectedSongId.value = ''
|
||
searchQuery.value = ''
|
||
showToast('Song wurde zugeordnet.', 'success')
|
||
router.reload({ preserveScroll: true, preserveState: true })
|
||
} catch {
|
||
showToast('Zuordnung fehlgeschlagen.', 'error')
|
||
}
|
||
}
|
||
|
||
async function requestCreation() {
|
||
try {
|
||
await window.axios.post(`/api/service-songs/${serviceSong.value.id}/request`)
|
||
showToast('Anfrage wurde gesendet.', 'success')
|
||
router.reload({ preserveScroll: true, preserveState: true })
|
||
} catch {
|
||
showToast('Anfrage konnte nicht gesendet werden.', 'error')
|
||
}
|
||
}
|
||
|
||
async function updateUseTranslation() {
|
||
try {
|
||
await window.axios.patch(`/api/service-songs/${serviceSong.value.id}`, {
|
||
use_translation: Boolean(useTranslation.value),
|
||
})
|
||
showToast('Übersetzung wurde gespeichert.', 'success')
|
||
} catch {
|
||
showToast('Speichern fehlgeschlagen.', 'error')
|
||
}
|
||
}
|
||
|
||
function 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',
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="rounded-lg border bg-white p-4"
|
||
:class="{
|
||
'border-l-4 border-amber-400': !isMatched,
|
||
'border-l-4 border-emerald-500': isMatched && serviceSong?.song_arrangement_id,
|
||
'border-l-4 border-yellow-400': isMatched && !serviceSong?.song_arrangement_id,
|
||
}"
|
||
data-testid="song-agenda-item"
|
||
>
|
||
<!-- Toast -->
|
||
<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="mb-3 rounded-lg border px-4 py-3 text-sm font-medium"
|
||
:class="toastClasses()"
|
||
role="status"
|
||
>
|
||
{{ toastMessage }}
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- Kopfzeile -->
|
||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xs text-gray-400 tabular-nums">{{ agendaItem.sort_order }}.</span>
|
||
<h4 class="text-lg font-semibold text-gray-900" data-testid="song-agenda-title">
|
||
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
|
||
</h4>
|
||
</div>
|
||
<div class="mt-1.5 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?.song?.ccli_id || serviceSong?.cts_ccli_id || '-' }}
|
||
</span>
|
||
<span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium">
|
||
Übersetzung: {{ serviceSong?.song?.has_translation ? 'Ja' : 'Nein' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<span
|
||
v-if="isMatched"
|
||
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>
|
||
|
||
<!-- Nicht zugeordnet: Suche + Anfrage -->
|
||
<div
|
||
v-if="!isMatched && serviceSong"
|
||
class="mt-4 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 verknüpft.
|
||
</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
|
||
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"
|
||
data-testid="song-request-creation"
|
||
@click="requestCreation"
|
||
>
|
||
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
|
||
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="openDropdown"
|
||
@input="openDropdown"
|
||
@blur="closeDropdown"
|
||
/>
|
||
<!-- Ausgewählt-Indikator -->
|
||
<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 = ''"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<!-- Dropdown -->
|
||
<div
|
||
v-if="dropdownOpen && filteredCatalog.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"
|
||
: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>
|
||
|
||
<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"
|
||
data-testid="song-assign-button"
|
||
@click="assignSong"
|
||
>
|
||
Zuordnen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Zugeordnet: Arrangement + Übersetzung -->
|
||
<div v-if="isMatched" class="mt-4 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
|
||
v-model="useTranslation"
|
||
type="checkbox"
|
||
class="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
|
||
data-testid="song-translation-checkbox"
|
||
@change="updateUseTranslation"
|
||
/>
|
||
Übersetzung verwenden
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Arrangement-Pills -->
|
||
<div v-if="arrangementPills.length > 0" class="space-y-2">
|
||
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Arrangement</p>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<span
|
||
v-for="ag in arrangementPills"
|
||
:key="ag.id"
|
||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium text-white"
|
||
:style="{ backgroundColor: ag.group?.color || '#6b7280' }"
|
||
data-testid="arrangement-pill"
|
||
>
|
||
{{ ag.group?.name || '-' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Aktionen -->
|
||
<div class="flex flex-wrap gap-2">
|
||
<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"
|
||
data-testid="song-edit-arrangement"
|
||
@click="emit('arrangement-selected', serviceSong)"
|
||
>
|
||
Arrangement bearbeiten
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|