feat(ui): add AgendaItemRow and SongAgendaItem components

This commit is contained in:
Thorsten Bus 2026-03-29 12:07:15 +02:00
parent 2d90621cca
commit e9901a6f9b
2 changed files with 437 additions and 0 deletions

View file

@ -0,0 +1,103 @@
<script setup>
import { ref } from 'vue'
import SlideUploader from '@/Components/SlideUploader.vue'
const props = defineProps({
agendaItem: { type: Object, required: true },
serviceId: { type: Number, required: true },
serviceDate: { type: String, default: null },
})
const emit = defineEmits(['slides-updated'])
const showUploader = ref(false)
function onUploaded() {
showUploader.value = false
emit('slides-updated')
}
</script>
<template>
<div
class="rounded-lg border bg-white p-4"
:class="{
'border-l-4 border-emerald-500': agendaItem.slides?.length > 0,
'border-l-4 border-blue-400': agendaItem.is_announcement_position,
'border-l-4 border-purple-400': agendaItem.is_sermon && !agendaItem.is_announcement_position,
'border-gray-200': !agendaItem.slides?.length && !agendaItem.is_announcement_position && !agendaItem.is_sermon,
}"
data-testid="agenda-item-row"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<!-- Position + Titel -->
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 tabular-nums">{{ agendaItem.sort_order }}.</span>
<h4 class="font-medium text-gray-900" data-testid="agenda-item-title">
{{ agendaItem.title }}
</h4>
</div>
<!-- Notiz (optional) -->
<p v-if="agendaItem.note" class="mt-1 text-xs text-gray-500">
{{ agendaItem.note }}
</p>
<!-- Badges -->
<div class="mt-1.5 flex gap-2">
<span
v-if="agendaItem.is_announcement_position"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"
>
📢 Ankündigungen hier
</span>
<span
v-if="agendaItem.is_sermon"
class="inline-flex items-center gap-1 rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700"
>
📖 Predigt
</span>
</div>
<!-- Folien-Thumbnails (kompakt) -->
<div v-if="agendaItem.slides?.length" class="mt-2 flex gap-1">
<img
v-for="slide in agendaItem.slides.slice(0, 4)"
:key="slide.id"
:src="`/storage/slides/${slide.thumbnail_filename}`"
class="h-10 w-16 rounded border border-gray-200 object-cover"
:alt="slide.original_filename"
/>
<span
v-if="agendaItem.slides.length > 4"
class="flex h-10 w-16 items-center justify-center rounded border border-gray-200 text-xs text-gray-500"
>
+{{ agendaItem.slides.length - 4 }}
</span>
</div>
</div>
<!-- + Button -->
<button
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg border border-dashed border-gray-300 text-gray-400 transition-colors hover:border-blue-400 hover:text-blue-500"
data-testid="agenda-item-add-slides"
:title="showUploader ? 'Schließen' : 'Folien hinzufügen'"
@click="showUploader = !showUploader"
>
{{ showUploader ? '×' : '+' }}
</button>
</div>
<!-- Folien-Upload (umschaltbar) -->
<div v-if="showUploader" class="mt-3 border-t pt-3">
<SlideUploader
type="sermon"
:service-id="serviceId"
:show-expire-date="false"
:inline="true"
@uploaded="onUploaded"
/>
</div>
</div>
</template>

View file

@ -0,0 +1,334 @@
<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 = ''"
>
&#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">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>