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:
parent
6d337d8b6a
commit
78b8fc2e3d
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = ''"
|
||||
>
|
||||
✕
|
||||
</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"
|
||||
|
|
|
|||
|
|
@ -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 = ''"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue