diff --git a/resources/js/Components/AgendaItemRow.vue b/resources/js/Components/AgendaItemRow.vue index d497684..525aed7 100644 --- a/resources/js/Components/AgendaItemRow.vue +++ b/resources/js/Components/AgendaItemRow.vue @@ -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() { diff --git a/resources/js/Components/ArrangementDialog.vue b/resources/js/Components/ArrangementDialog.vue index 6ac6593..514febb 100644 --- a/resources/js/Components/ArrangementDialog.vue +++ b/resources/js/Components/ArrangementDialog.vue @@ -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) { >
- -
+ +
+

Song zuordnen

+ +
+ + +
+ +
+ + Ausgewählt: {{ songsCatalog.find((s) => s.id === Number(selectedSongId))?.title || '' }} + + +
+ +
+ +
+
+ +

{{ assignError }}

+ + +
+
+ + +
-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) { diff --git a/resources/js/Pages/Services/Edit.vue b/resources/js/Pages/Services/Edit.vue index 846486b..aba4b82 100644 --- a/resources/js/Pages/Services/Edit.vue +++ b/resources/js/Pages/Services/Edit.vue @@ -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.
- -
- + +
@@ -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" />