feat(ui): add AgendaItemRow and SongAgendaItem components
This commit is contained in:
parent
2d90621cca
commit
e9901a6f9b
103
resources/js/Components/AgendaItemRow.vue
Normal file
103
resources/js/Components/AgendaItemRow.vue
Normal 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>
|
||||||
334
resources/js/Components/SongAgendaItem.vue
Normal file
334
resources/js/Components/SongAgendaItem.vue
Normal 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 = ''"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</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>
|
||||||
Loading…
Reference in a new issue