pp-planer/resources/js/Components/SongAgendaItem.vue

352 lines
14 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, 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)
})
const responsibleNames = computed(() => {
const r = props.agendaItem.responsible
if (!r || !Array.isArray(r) || r.length === 0) return ''
return r.map((p) => p.name || p.title || p.personName || '').filter(Boolean).join(', ')
})
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.position || 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 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-400">
<span v-if="agendaItem.start">
🕐 {{ agendaItem.start }}
</span>
<span v-if="agendaItem.duration">
⏱ {{ agendaItem.duration }}
</span>
<span v-if="responsibleNames">
👤 {{ responsibleNames }}
</span>
</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 = ''"
>
&#10005;
</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">{{ serviceSong?.arrangement?.name || '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>