refactor(ui): convert agenda list to table with proper data formatting

- Replace card-based agenda layout with a 6-column table (Nr, Zeit, Dauer, Titel, Verantwortlich, Aktionen)
- Fix isHeaderType to only match type=header, not all non-song items
- Format start time from ISO 8601 to HH:MM (Europe/Berlin)
- Format duration from seconds string to H:MM
- Fix responsible parsing for CTS data structure ({text, persons[{person:{title}}]})
- Move unmatched song search/assign UI into ArrangementDialog popup
- Add colored row backgrounds (song/sermon/announcement/slides) with left border
- Invert header rows to dark grey background with white text
This commit is contained in:
Thorsten Bus 2026-03-29 16:32:30 +02:00
parent 6d337d8b6a
commit 78b8fc2e3d
4 changed files with 471 additions and 352 deletions

View file

@ -14,8 +14,53 @@ const showUploader = ref(false)
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(', ')
if (!r) return ''
// CTS returns {text: "...", persons: [{person: {title: "Name"}, service: "[Role]"}]}
if (r.persons && Array.isArray(r.persons)) {
const names = r.persons
.map((p) => p.person?.title || p.name || '')
.filter(Boolean)
if (names.length > 0) return [...new Set(names)].join(', ')
}
// Fallback: use text field
if (r.text) return r.text
// Legacy: array of objects
if (Array.isArray(r)) {
return r.map((p) => p.person?.title || p.name || p.title || p.personName || '').filter(Boolean).join(', ')
}
return ''
})
const formattedStart = computed(() => {
const s = props.agendaItem.start
if (!s) return ''
const d = new Date(s)
if (isNaN(d.getTime())) return s
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' })
})
const formattedDuration = computed(() => {
const dur = props.agendaItem.duration
if (!dur || dur === '0') return ''
const totalSeconds = parseInt(dur, 10)
if (isNaN(totalSeconds) || totalSeconds <= 0) return dur
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}` : `0:${String(minutes).padStart(2, '0')}`
})
const rowBgClass = computed(() => {
if (props.agendaItem.is_announcement_position) return 'bg-blue-50'
if (props.agendaItem.is_sermon) return 'bg-purple-50'
if (props.agendaItem.slides?.length > 0) return 'bg-emerald-50'
return ''
})
const borderClass = computed(() => {
if (props.agendaItem.is_announcement_position) return 'border-l-4 border-l-blue-400'
if (props.agendaItem.is_sermon) return 'border-l-4 border-l-purple-400'
if (props.agendaItem.slides?.length > 0) return 'border-l-4 border-l-emerald-500'
return ''
})
function onUploaded() {
@ -25,91 +70,96 @@ function onUploaded() {
</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,
}"
<!-- Main row -->
<tr
class="border-b border-gray-100"
:class="rowBgClass || 'hover:bg-gray-50'"
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.position || agendaItem.sort_order }}.</span>
<h4 class="font-medium text-gray-900" data-testid="agenda-item-title">
{{ agendaItem.title }}
</h4>
</div>
<!-- Nr -->
<td class="py-2.5 pr-3 align-top" :class="borderClass">
<span class="text-xs text-gray-400 tabular-nums">{{ agendaItem.position || agendaItem.sort_order }}</span>
</td>
<!-- Meta: Zeit, Dauer, Verantwortlich -->
<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>
<!-- Zeit -->
<td class="py-2.5 pr-3 align-top">
<span v-if="formattedStart" class="text-xs text-gray-500 tabular-nums">{{ formattedStart }}</span>
</td>
<!-- Notiz (optional) -->
<p v-if="agendaItem.note" class="mt-1 text-xs text-gray-500">
{{ agendaItem.note }}
</p>
<!-- Dauer -->
<td class="py-2.5 pr-3 align-top">
<span v-if="formattedDuration" class="text-xs text-gray-500 tabular-nums">{{ formattedDuration }}</span>
</td>
<!-- 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>
<!-- Titel -->
<td class="py-2.5 pr-3 align-top">
<span class="font-medium text-gray-900" data-testid="agenda-item-title">{{ agendaItem.title }}</span>
<!-- 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"
<!-- Badges inline -->
<span
v-if="agendaItem.is_announcement_position"
class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700"
>
{{ showUploader ? '×' : '+' }}
</button>
</div>
Ankündigungen
</span>
<span
v-if="agendaItem.is_sermon"
class="ml-2 inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-[11px] font-medium text-purple-700"
>
Predigt
</span>
<!-- Folien-Upload (umschaltbar) -->
<div v-if="showUploader" class="mt-3 border-t pt-3">
<!-- Note -->
<p v-if="agendaItem.note" class="mt-0.5 text-xs text-gray-400">
{{ agendaItem.note }}
</p>
<!-- Slide thumbnails -->
<div v-if="agendaItem.slides?.length" class="mt-1 flex gap-1">
<img
v-for="slide in agendaItem.slides.slice(0, 4)"
:key="slide.id"
:src="`/storage/slides/${slide.thumbnail_filename}`"
class="h-8 w-14 rounded border border-gray-200 object-cover"
:alt="slide.original_filename"
/>
<span
v-if="agendaItem.slides.length > 4"
class="flex h-8 w-14 items-center justify-center rounded border border-gray-200 text-[10px] text-gray-500"
>
+{{ agendaItem.slides.length - 4 }}
</span>
</div>
</td>
<!-- Verantwortlich -->
<td class="py-2.5 pr-3 align-top">
<span v-if="responsibleNames" class="text-xs text-gray-500">{{ responsibleNames }}</span>
</td>
<!-- Aktionen -->
<td class="py-2.5 align-top">
<div class="flex items-center justify-end gap-1">
<button
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-blue-500"
data-testid="agenda-item-add-slides"
:title="showUploader ? 'Schließen' : 'Folien hinzufügen'"
@click="showUploader = !showUploader"
>
<svg v-if="!showUploader" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</td>
</tr>
<!-- Uploader row (spanning all columns) -->
<tr v-if="showUploader">
<td colspan="6" class="border-b border-gray-100 px-3 pb-3">
<SlideUploader
type="agenda_item"
:service-id="serviceId"
@ -118,6 +168,6 @@ function onUploaded() {
:inline="true"
@uploaded="onUploaded"
/>
</div>
</div>
</td>
</tr>
</template>

View file

@ -6,7 +6,7 @@ import { VueDraggable } from 'vue-draggable-plus'
const props = defineProps({
songId: {
type: Number,
required: true,
default: null,
},
arrangements: {
type: Array,
@ -20,10 +20,73 @@ const props = defineProps({
type: Number,
default: null,
},
serviceSongId: {
type: Number,
default: null,
},
songsCatalog: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['close', 'arrangement-selected'])
/* ── Song assignment (unmatched songs) ── */
const isUnmatched = computed(() => !props.songId)
const searchQuery = ref('')
const selectedSongId = ref('')
const dropdownOpen = ref(false)
const assignError = ref('')
function normalize(value) {
return (value ?? '').toString().toLowerCase().trim()
}
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)
})
function openSearchDropdown() {
dropdownOpen.value = true
}
function closeSearchDropdown() {
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) {
assignError.value = 'Bitte wähle zuerst einen Song aus.'
return
}
assignError.value = ''
try {
await window.axios.post(`/api/service-songs/${props.serviceSongId}/assign`, {
song_id: songId,
})
emit('close')
} catch {
assignError.value = 'Zuordnung fehlgeschlagen.'
}
}
/* ── State ── */
const currentArrangementId = ref(
@ -222,10 +285,27 @@ function closeOnBackdrop(e) {
>
<div
data-testid="arrangement-dialog"
class="flex h-[80vh] w-full max-w-5xl flex-col rounded-xl bg-white shadow-2xl"
:class="isUnmatched
? 'flex w-full max-w-lg flex-col rounded-xl bg-white shadow-2xl'
: 'flex h-[80vh] w-full max-w-5xl flex-col rounded-xl bg-white shadow-2xl'"
>
<!-- Header -->
<div class="flex items-center gap-3 border-b border-gray-200 px-6 py-4">
<!-- Header: Unmatched song assignment -->
<div v-if="isUnmatched" class="flex items-center gap-3 border-b border-gray-200 px-6 py-4">
<h3 class="text-base font-semibold text-gray-900">Song zuordnen</h3>
<button
data-testid="arrangement-dialog-close-btn"
type="button"
class="ml-auto rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="emit('close')"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Header: Arrangement editor -->
<div v-else class="flex items-center gap-3 border-b border-gray-200 px-6 py-4">
<div class="min-w-48 flex-1">
<select
v-model="currentArrangementId"
@ -291,8 +371,73 @@ function closeOnBackdrop(e) {
</button>
</div>
<!-- Two-column body -->
<div class="flex flex-1 overflow-hidden">
<!-- Body: Unmatched song search & assign -->
<div v-if="isUnmatched" class="flex-1 overflow-y-auto p-6">
<div class="mx-auto max-w-lg space-y-4">
<p class="text-sm text-gray-600">
Dieser CTS-Song ist noch nicht in der Song-DB verknüpft. Suche den passenden Song und ordne ihn zu.
</p>
<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="openSearchDropdown"
@input="openSearchDropdown"
@blur="closeSearchDropdown"
/>
<!-- Selected indicator -->
<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-64 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>
<p v-if="assignError" class="text-sm text-red-600">{{ assignError }}</p>
<button
type="button"
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-5 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>
<!-- Body: Two-column arrangement editor -->
<div v-else class="flex flex-1 overflow-hidden">
<!-- Left: Pill list with DnD -->
<div
ref="leftCol"

View file

@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { router } from '@inertiajs/vue3'
const props = defineProps({
@ -14,9 +14,6 @@ const serviceSong = computed(() => props.agendaItem.serviceSong ?? props.agendaI
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')
@ -36,10 +33,6 @@ function toastClasses() {
return 'border-slate-300 bg-slate-50 text-slate-700'
}
function normalize(value) {
return (value ?? '').toString().toLowerCase().trim()
}
watch(
() => serviceSong.value,
(ss) => {
@ -50,60 +43,54 @@ watch(
{ 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(', ')
if (!r) return ''
// CTS returns {text: "...", persons: [{person: {title: "Name"}, service: "[Role]"}]}
if (r.persons && Array.isArray(r.persons)) {
const names = r.persons
.map((p) => p.person?.title || p.name || '')
.filter(Boolean)
if (names.length > 0) return [...new Set(names)].join(', ')
}
// Fallback: use text field
if (r.text) return r.text
// Legacy: array of objects
if (Array.isArray(r)) {
return r.map((p) => p.person?.title || p.name || p.title || p.personName || '').filter(Boolean).join(', ')
}
return ''
})
function openDropdown() {
dropdownOpen.value = true
}
const formattedStart = computed(() => {
const s = props.agendaItem.start
if (!s) return ''
const d = new Date(s)
if (isNaN(d.getTime())) return s
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' })
})
function closeDropdown() {
setTimeout(() => {
dropdownOpen.value = false
}, 200)
}
const formattedDuration = computed(() => {
const dur = props.agendaItem.duration
if (!dur || dur === '0') return ''
const totalSeconds = parseInt(dur, 10)
if (isNaN(totalSeconds) || totalSeconds <= 0) return dur
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}` : `0:${String(minutes).padStart(2, '0')}`
})
function selectSong(song) {
selectedSongId.value = song.id
searchQuery.value = song.title
dropdownOpen.value = false
}
const rowBgClass = computed(() => {
if (!isMatched.value) return 'bg-amber-50'
if (serviceSong.value?.song_arrangement_id) return 'bg-emerald-50'
return 'bg-yellow-50'
})
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')
}
}
const borderClass = computed(() => {
if (!isMatched.value) return 'border-l-4 border-l-amber-400'
if (serviceSong.value?.song_arrangement_id) return 'border-l-4 border-l-emerald-500'
return 'border-l-4 border-l-yellow-400'
})
async function requestCreation() {
try {
@ -139,213 +126,135 @@ function formatDateTime(value) {
</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"
>
<!-- Toast row -->
<tr v-if="toastMessage">
<td colspan="6" class="px-3 py-2">
<div
v-if="toastMessage"
class="mb-3 rounded-lg border px-4 py-3 text-sm font-medium"
class="rounded-lg border px-4 py-2 text-sm font-medium"
:class="toastClasses()"
role="status"
>
{{ toastMessage }}
</div>
</Transition>
</td>
</tr>
<!-- 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>
<!-- Main song row -->
<tr
class="border-b border-gray-100"
:class="rowBgClass"
data-testid="song-agenda-item"
>
<!-- Nr -->
<td class="py-2.5 pr-3 align-top" :class="borderClass">
<span class="text-xs text-gray-400 tabular-nums">{{ agendaItem.position || agendaItem.sort_order }}</span>
</td>
<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>
<!-- Zeit -->
<td class="py-2.5 pr-3 align-top">
<span v-if="formattedStart" class="text-xs text-gray-500 tabular-nums">{{ formattedStart }}</span>
</td>
<!-- 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>
<!-- Dauer -->
<td class="py-2.5 pr-3 align-top">
<span v-if="formattedDuration" class="text-xs text-gray-500 tabular-nums">{{ formattedDuration }}</span>
</td>
<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>
<!-- Titel -->
<td class="py-2.5 pr-3 align-top">
<div class="flex items-center gap-2 flex-wrap">
<!-- Song title -->
<span class="font-medium text-gray-900" data-testid="song-agenda-title">
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
</span>
<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>
<!-- CCLI pill -->
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500">
CCLI: {{ serviceSong?.song?.ccli_id || serviceSong?.cts_ccli_id || '-' }}
</span>
<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>
<!-- Note -->
<p v-if="agendaItem.note" class="mt-0.5 basis-full text-xs text-gray-400">
{{ agendaItem.note }}
</p>
</div>
</td>
<label
<!-- Verantwortlich -->
<td class="py-2.5 pr-3 align-top">
<span v-if="responsibleNames" class="text-xs text-gray-500">{{ responsibleNames }}</span>
</td>
<!-- Aktionen -->
<td class="py-2.5 align-top">
<div class="flex items-center justify-end gap-1">
<!-- Assign status -->
<span
v-if="isMatched"
class="inline-flex h-2 w-2 rounded-full bg-emerald-500"
title="Zugeordnet"
></span>
<span
v-else
class="inline-flex h-2 w-2 rounded-full bg-amber-400"
title="Nicht zugeordnet"
></span>
<!-- Translation flag -->
<span
v-if="serviceSong?.song?.has_translation"
class="inline-flex items-center gap-2 text-sm font-medium text-gray-800"
class="text-sm leading-none"
title="Mit Übersetzung"
>🇩🇪🇬🇧</span>
<span
v-else
class="text-sm leading-none"
title="Nur Deutsch"
>🇩🇪</span>
<!-- Translation checkbox (only when matched + has translation) -->
<label
v-if="isMatched && serviceSong?.song?.has_translation"
class="inline-flex items-center"
title="Übersetzung verwenden"
>
<input
v-model="useTranslation"
type="checkbox"
class="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
class="h-3.5 w-3.5 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">
<!-- Edit / assign song opens dialog for both matched and unmatched -->
<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"
class="flex h-7 w-7 items-center justify-center rounded text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
data-testid="song-edit-arrangement"
:title="isMatched ? 'Arrangement bearbeiten' : 'Song zuordnen'"
@click="emit('arrangement-selected', serviceSong)"
>
Arrangement bearbeiten
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<!-- Request creation button (only when not matched) -->
<button
v-if="!isMatched && serviceSong"
type="button"
class="flex h-7 w-7 items-center justify-center rounded text-amber-500 transition hover:bg-amber-50 hover:text-amber-700"
data-testid="song-request-creation"
:title="serviceSong.request_sent_at ? 'Anfrage gesendet am ' + formatDateTime(serviceSong.request_sent_at) : 'Erstellung anfragen'"
@click="requestCreation"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</button>
</div>
</div>
</div>
</td>
</tr>
</template>

View file

@ -115,8 +115,7 @@ function getSelectedArrangementId(item) {
}
function isHeaderType(item) {
return item.type?.toLowerCase() === 'header' ||
(item.service_song_id === null && item.type !== 'Song' && item.type !== 'song' && item.type !== 'Default' && item.type !== 'default')
return item.type?.toLowerCase() === 'header'
}
function onArrangementDialogClosed() {
@ -386,38 +385,52 @@ async function downloadService() {
Keine Ablauf-Elemente vorhanden. Bitte synchronisiere die Daten zuerst.
</div>
<!-- Agenda items list -->
<div v-else class="flex flex-col gap-2" data-testid="agenda-section">
<template v-for="item in agendaItems" :key="item.id">
<!-- Title/header items -->
<div v-if="isHeaderType(item)"
class="border-b border-gray-100 pb-1 pt-3"
data-testid="agenda-header-item">
<h4 class="text-xs font-semibold uppercase tracking-wider text-gray-400">
{{ item.title }}
</h4>
</div>
<!-- Agenda table -->
<table v-else class="w-full text-sm" data-testid="agenda-section">
<thead>
<tr class="border-b border-gray-200 text-left text-xs font-semibold uppercase tracking-wider text-gray-400">
<th class="py-2 pr-3 w-12">Nr</th>
<th class="py-2 pr-3 w-20">Zeit</th>
<th class="py-2 pr-3 w-20">Dauer</th>
<th class="py-2 pr-3">Titel</th>
<th class="py-2 pr-3 w-40">Verantwortlich</th>
<th class="py-2 w-24"></th>
</tr>
</thead>
<tbody>
<template v-for="item in agendaItems" :key="item.id">
<!-- Title/header items -->
<tr v-if="isHeaderType(item)"
class="bg-gray-600"
data-testid="agenda-header-item">
<td colspan="6" class="px-3 py-1.5">
<h4 class="text-xs font-semibold uppercase tracking-wider text-white">
{{ item.title }}
</h4>
</td>
</tr>
<!-- Song items -->
<SongAgendaItem
v-else-if="item.service_song_id !== null || item.type === 'Song'"
:agenda-item="item"
:songs-catalog="songsCatalog"
:service-id="service.id"
@arrangement-selected="openArrangementDialog(item)"
@slides-updated="refreshPage"
/>
<!-- Song items -->
<SongAgendaItem
v-else-if="item.service_song_id !== null || item.type === 'Song'"
:agenda-item="item"
:songs-catalog="songsCatalog"
:service-id="service.id"
@arrangement-selected="openArrangementDialog(item)"
@slides-updated="refreshPage"
/>
<!-- Generic agenda items -->
<AgendaItemRow
v-else
:agenda-item="item"
:service-id="service.id"
:service-date="service.date"
@slides-updated="refreshPage"
/>
</template>
</div>
<!-- Generic agenda items -->
<AgendaItemRow
v-else
:agenda-item="item"
:service-id="service.id"
:service-date="service.date"
@slides-updated="refreshPage"
/>
</template>
</tbody>
</table>
</div>
</div>
@ -428,6 +441,8 @@ async function downloadService() {
:arrangements="getArrangements(arrangementDialogItem)"
:available-groups="getAvailableGroups(arrangementDialogItem)"
:selected-arrangement-id="getSelectedArrangementId(arrangementDialogItem)"
:service-song-id="arrangementDialogItem.service_song?.id ?? arrangementDialogItem.serviceSong?.id"
:songs-catalog="songsCatalog"
@close="onArrangementDialogClosed"
/>