feat(ui): add MASTER arrangement, fix slide upload/thumbnail bugs, add slide preview with navigation

- Add virtual MASTER arrangement to all songs (read-only, shows all groups in song order, clonable)
- Fix drop zone staying open after slide upload and thumbnail 403 (double slides/ path)
- Add click-to-preview overlay with download button, prev/next navigation, and slide counter
- Add X delete button with confirmation dialog and hover tooltip preview on agenda thumbnails
- Fix arrangement select not updating after add/clone (emit + re-fetch pattern)
- Move InformationBlock below agenda; announcement row scrolls to it instead of showing upload
- Create storage symlink (public/storage -> storage/app/public)
This commit is contained in:
Thorsten Bus 2026-03-29 17:41:26 +02:00
parent 852231ae01
commit b8b92f094e
5 changed files with 518 additions and 117 deletions

View file

@ -1,6 +1,9 @@
<script setup>
import { computed, ref } from 'vue'
import { router } from '@inertiajs/vue3'
import axios from 'axios'
import SlideUploader from '@/Components/SlideUploader.vue'
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
const props = defineProps({
agendaItem: { type: Object, required: true },
@ -8,23 +11,35 @@ const props = defineProps({
serviceDate: { type: String, default: null },
})
const emit = defineEmits(['slides-updated'])
const emit = defineEmits(['slides-updated', 'scroll-to-info'])
const showUploader = ref(false)
// Preview state
const previewSlide = ref(null)
const previewIndex = ref(-1)
// Delete confirmation state
const confirmingDelete = ref(false)
const slideToDelete = ref(null)
const deleting = ref(false)
// Hover preview state
const hoverSlide = ref(null)
const hoverPosition = ref({ x: 0, y: 0 })
const allSlides = computed(() => props.agendaItem.slides ?? [])
const responsibleNames = computed(() => {
const r = props.agendaItem.responsible
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(', ')
}
@ -63,10 +78,99 @@ const borderClass = computed(() => {
return ''
})
function thumbnailUrl(slide) {
if (!slide.thumbnail_filename) return null
return `/storage/${slide.thumbnail_filename}`
}
function fullImageUrl(slide) {
if (!slide.stored_filename) return null
return `/storage/${slide.stored_filename}`
}
function onUploaded() {
showUploader.value = false
emit('slides-updated')
}
// Preview with navigation
function openPreview(slide) {
previewSlide.value = slide
previewIndex.value = allSlides.value.findIndex((s) => s.id === slide.id)
}
function closePreview() {
previewSlide.value = null
previewIndex.value = -1
}
function prevPreview() {
if (previewIndex.value <= 0) return
previewIndex.value--
previewSlide.value = allSlides.value[previewIndex.value]
}
function nextPreview() {
if (previewIndex.value >= allSlides.value.length - 1) return
previewIndex.value++
previewSlide.value = allSlides.value[previewIndex.value]
}
const hasPrev = computed(() => previewIndex.value > 0)
const hasNext = computed(() => previewIndex.value < allSlides.value.length - 1)
// Delete
function promptDelete(slide, event) {
event.stopPropagation()
slideToDelete.value = slide
confirmingDelete.value = true
}
function cancelDelete() {
confirmingDelete.value = false
slideToDelete.value = null
}
function confirmDelete() {
if (!slideToDelete.value) return
deleting.value = true
const deletedId = slideToDelete.value.id
axios.delete(route('slides.destroy', deletedId))
.then(() => {
confirmingDelete.value = false
slideToDelete.value = null
deleting.value = false
if (previewSlide.value?.id === deletedId) {
previewSlide.value = null
previewIndex.value = -1
}
emit('slides-updated')
router.reload({ preserveScroll: true })
})
.catch(() => {
deleting.value = false
})
}
// Hover preview
function onThumbMouseEnter(slide, event) {
const rect = event.target.getBoundingClientRect()
hoverPosition.value = {
x: rect.left + rect.width / 2,
y: rect.top,
}
hoverSlide.value = slide
}
function onThumbMouseLeave() {
hoverSlide.value = null
}
// Scroll to info block
function scrollToInfo() {
emit('scroll-to-info')
}
</script>
<template>
@ -116,13 +220,32 @@ function onUploaded() {
<!-- Slide thumbnails -->
<div v-if="agendaItem.slides?.length" class="mt-1 flex gap-1">
<img
<div
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"
/>
class="group/thumb relative"
>
<img
:src="thumbnailUrl(slide)"
class="h-8 w-14 cursor-pointer rounded border border-gray-200 object-cover transition-all hover:border-amber-400 hover:ring-1 hover:ring-amber-400/40"
:alt="slide.original_filename"
data-testid="agenda-slide-thumbnail"
@click="openPreview(slide)"
@mouseenter="onThumbMouseEnter(slide, $event)"
@mouseleave="onThumbMouseLeave"
/>
<!-- Delete X button -->
<button
data-testid="agenda-slide-delete-btn"
class="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white shadow-sm transition hover:bg-red-600 group-hover/thumb:flex"
title="Folie entfernen"
@click="promptDelete(slide, $event)"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<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"
@ -140,7 +263,22 @@ function onUploaded() {
<!-- Aktionen -->
<td class="py-2.5 align-top">
<div class="flex items-center justify-end gap-1">
<!-- Announcement row: scroll to info block instead of + button -->
<button
v-if="agendaItem.is_announcement_position"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-blue-400 transition-colors hover:bg-blue-100 hover:text-blue-600"
data-testid="agenda-item-scroll-to-info"
title="Zu Info-Folien springen"
@click="scrollToInfo"
>
<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="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
</button>
<!-- Normal rows: + / x toggle for uploader -->
<button
v-else
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'"
@ -157,8 +295,8 @@ function onUploaded() {
</td>
</tr>
<!-- Uploader row (spanning all columns) -->
<tr v-if="showUploader">
<!-- Uploader row (spanning all columns) not for announcement rows -->
<tr v-if="showUploader && !agendaItem.is_announcement_position">
<td colspan="6" class="border-b border-gray-100 px-3 pb-3">
<SlideUploader
type="agenda_item"
@ -170,4 +308,130 @@ function onUploaded() {
/>
</td>
</tr>
<!-- Hover preview tooltip (doubled size: w-[512px]) -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="hoverSlide"
class="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full"
:style="{ left: hoverPosition.x + 'px', top: (hoverPosition.y - 8) + 'px' }"
>
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<img
:src="thumbnailUrl(hoverSlide)"
:alt="hoverSlide.original_filename"
class="h-auto w-[512px] object-contain"
/>
</div>
</div>
</Transition>
</Teleport>
<!-- Full preview overlay with prev/next navigation -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="previewSlide"
data-testid="agenda-slide-preview"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm"
@click.self="closePreview"
>
<!-- Image + controls -->
<div class="relative max-h-[90vh] max-w-[90vw]">
<img
:src="fullImageUrl(previewSlide)"
:alt="previewSlide.original_filename"
class="max-h-[85vh] max-w-[85vw] rounded-lg object-contain shadow-2xl"
/>
<!-- Previous arrow (left edge of image) -->
<button
v-if="hasPrev"
data-testid="agenda-slide-preview-prev"
class="absolute left-2 top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-lg transition hover:bg-black/70"
title="Vorherige Folie"
@click="prevPreview"
>
<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="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<!-- Next arrow (right edge of image) -->
<button
v-if="hasNext"
data-testid="agenda-slide-preview-next"
class="absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-lg transition hover:bg-black/70"
title="Nächste Folie"
@click="nextPreview"
>
<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="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
<!-- Slide counter -->
<div
v-if="allSlides.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-xs font-medium text-white tabular-nums"
>
{{ previewIndex + 1 }} / {{ allSlides.length }}
</div>
<div class="absolute -top-3 -right-3 flex gap-2">
<!-- Download button -->
<a
:href="fullImageUrl(previewSlide)"
:download="previewSlide.original_filename"
class="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-600 text-white shadow-lg transition hover:bg-emerald-700"
title="Herunterladen"
data-testid="agenda-slide-preview-download"
>
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</a>
<!-- Close button -->
<button
@click="closePreview"
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-800 text-white shadow-lg transition hover:bg-gray-700"
title="Schließen"
data-testid="agenda-slide-preview-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>
</div>
</div>
</Transition>
</Teleport>
<!-- Delete confirmation dialog -->
<ConfirmDialog
:show="confirmingDelete"
title="Folie löschen?"
:message="`Möchtest du die Folie '${slideToDelete?.original_filename || ''}' wirklich löschen?`"
confirm-label="Löschen"
cancel-label="Abbrechen"
variant="danger"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</template>

View file

@ -3,6 +3,8 @@ import { computed, ref, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus'
const MASTER_ID = 'master'
const props = defineProps({
songId: {
type: Number,
@ -17,22 +19,41 @@ const props = defineProps({
required: true,
},
selectedArrangementId: {
type: Number,
type: [Number, String],
default: null,
},
})
const emit = defineEmits(['arrangement-selected'])
const emit = defineEmits(['arrangement-selected', 'arrangements-changed'])
// Virtual MASTER arrangement always first, computed from availableGroups
const masterArrangement = computed(() => ({
id: MASTER_ID,
name: 'MASTER',
is_default: false,
is_master: true,
groups: props.availableGroups.map((g) => ({ ...g })),
}))
// All arrangements with MASTER prepended
const allArrangements = computed(() => [
masterArrangement.value,
...props.arrangements,
])
const selectedId = ref(
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? null,
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? MASTER_ID,
)
const isMasterSelected = computed(() => selectedId.value === MASTER_ID)
const arrangementGroups = ref([])
const poolGroups = ref([])
const selectedArrangement = computed(() =>
props.arrangements.find((arrangement) => arrangement.id === Number(selectedId.value)) ?? null,
allArrangements.value.find((arrangement) => arrangement.id === selectedId.value)
?? allArrangements.value.find((arrangement) => arrangement.id === Number(selectedId.value))
?? null,
)
watch(
@ -54,7 +75,7 @@ watch(
watch(
selectedId,
(arrangementId) => {
emit('arrangement-selected', Number(arrangementId))
emit('arrangement-selected', arrangementId === MASTER_ID ? null : Number(arrangementId))
},
)
@ -75,33 +96,60 @@ watch(
},
)
const addArrangement = () => {
function addArrangement() {
const name = window.prompt('Name des neuen Arrangements')
if (!name) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name }, { preserveScroll: true })
router.post(`/songs/${props.songId}/arrangements`, { name }, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
}
const cloneArrangement = () => {
function cloneArrangement() {
if (!selectedArrangement.value) return
// Cloning from MASTER = creating a new arrangement (store() already uses all groups in master order)
if (isMasterSelected.value) {
const name = window.prompt('Name des neuen Arrangements', 'MASTER Kopie')
if (!name) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name }, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
return
}
const name = window.prompt('Name des neuen Arrangements', `${selectedArrangement.value.name} Kopie`)
if (!name) return
pendingAutoSelect.value = true
router.post(`/arrangements/${selectedArrangement.value.id}/clone`, { name }, { preserveScroll: true })
router.post(`/arrangements/${selectedArrangement.value.id}/clone`, { name }, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
}
const addGroupFromPool = (group) => {
function addGroupFromPool(group) {
if (isMasterSelected.value) return
arrangementGroups.value.push({ ...group })
saveArrangement()
}
const removeGroupAt = (index) => {
function removeGroupAt(index) {
if (isMasterSelected.value) return
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
const saveArrangement = () => {
if (!selectedArrangement.value) {
function saveArrangement() {
if (!selectedArrangement.value || isMasterSelected.value) {
return
}
@ -120,13 +168,16 @@ const saveArrangement = () => {
)
}
const deleteArrangement = () => {
if (!selectedArrangement.value) {
function deleteArrangement() {
if (!selectedArrangement.value || isMasterSelected.value) {
return
}
router.delete(`/arrangements/${selectedArrangement.value.id}`, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
}
</script>
@ -148,11 +199,11 @@ const deleteArrangement = () => {
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option
v-for="arrangement in arrangements"
v-for="arrangement in allArrangements"
:key="arrangement.id"
:value="arrangement.id"
>
{{ arrangement.name }}
{{ arrangement.name }}{{ arrangement.is_default ? ' (Standard)' : '' }}
</option>
</select>
</div>
@ -178,7 +229,8 @@ const deleteArrangement = () => {
<button
data-testid="arrangement-delete-button"
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-700"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="isMasterSelected"
@click="deleteArrangement"
>
Löschen
@ -193,17 +245,20 @@ const deleteArrangement = () => {
<VueDraggable
v-model="poolGroups"
:group="{ name: 'song-groups', pull: 'clone', put: false }"
:group="{ name: 'song-groups', pull: isMasterSelected ? false : 'clone', put: false }"
:sort="false"
:disabled="isMasterSelected"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
class="flex flex-wrap gap-1.5 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-2"
:class="{ 'opacity-50': isMasterSelected }"
>
<span
v-for="group in poolGroups"
:key="group.id"
class="inline-flex cursor-grab rounded-full px-2.5 py-0.5 text-xs font-semibold text-white transition-opacity hover:opacity-80"
class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold text-white transition-opacity hover:opacity-80"
:class="isMasterSelected ? 'cursor-default' : 'cursor-grab'"
:style="groupPillStyle(group)"
@click="addGroupFromPool(group)"
>
@ -215,25 +270,30 @@ const deleteArrangement = () => {
<div class="space-y-1">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Gruppenfolge
<span v-if="isMasterSelected" class="ml-1 text-[10px] font-normal normal-case text-gray-400">(nicht editierbar)</span>
</h4>
<VueDraggable
v-model="arrangementGroups"
:group="{ name: 'song-groups', pull: true, put: true }"
:group="{ name: 'song-groups', pull: true, put: !isMasterSelected }"
:disabled="isMasterSelected"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
class="flex min-h-10 flex-wrap gap-1.5 rounded-lg border border-gray-200 bg-gray-50 p-2"
:class="{ 'opacity-60': isMasterSelected }"
@end="saveArrangement"
>
<span
v-for="(group, index) in arrangementGroups"
:key="`${group.id}-${index}`"
class="inline-flex cursor-grab items-center gap-1 rounded-full py-0.5 pl-2.5 pr-1 text-xs font-semibold text-white"
class="inline-flex items-center gap-1 rounded-full py-0.5 pl-2.5 text-xs font-semibold text-white"
:class="isMasterSelected ? 'cursor-default pr-2.5' : 'cursor-grab pr-1'"
:style="groupPillStyle(group)"
>
{{ group.name }}
<button
v-if="!isMasterSelected"
data-testid="arrangement-remove-button"
type="button"
class="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-white/70 transition-colors hover:bg-white/20 hover:text-white"

View file

@ -3,6 +3,8 @@ import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus'
const MASTER_ID = 'master'
const props = defineProps({
songId: {
type: Number,
@ -89,6 +91,26 @@ async function assignSong() {
/* ── State ── */
// Virtual MASTER arrangement always first, computed from availableGroups
const masterArrangement = computed(() => ({
id: MASTER_ID,
name: 'MASTER',
is_default: false,
is_master: true,
groups: props.availableGroups.map((g) => ({
...g,
slides: g.slides ?? [],
})),
}))
// All arrangements with MASTER prepended (for select dropdown)
const allArrangements = computed(() => [
masterArrangement.value,
...props.arrangements,
])
const isMasterSelected = computed(() => currentArrangementId.value === MASTER_ID)
// Local copy of arrangements so changes survive switching between arrangements
const localArrangements = ref(JSON.parse(JSON.stringify(props.arrangements)))
@ -109,12 +131,13 @@ watch(
)
const currentArrangementId = ref(
props.selectedArrangementId ?? props.arrangements.find((a) => a.is_default)?.id ?? props.arrangements[0]?.id ?? null,
props.selectedArrangementId ?? props.arrangements.find((a) => a.is_default)?.id ?? props.arrangements[0]?.id ?? MASTER_ID,
)
const currentArrangement = computed(() =>
localArrangements.value.find((a) => a.id === Number(currentArrangementId.value)) ?? null,
)
const currentArrangement = computed(() => {
if (currentArrangementId.value === MASTER_ID) return masterArrangement.value
return localArrangements.value.find((a) => a.id === Number(currentArrangementId.value)) ?? null
})
const arrangementGroups = ref([])
const hoveredIndex = ref(null)
@ -122,12 +145,16 @@ const hoveredIndex = ref(null)
watch(
currentArrangementId,
(id) => {
if (id === MASTER_ID) {
arrangementGroups.value = props.availableGroups.map((g, i) => ({ ...g, slides: g.slides ?? [], _uid: `${g.id}-master-${i}-${Date.now()}` }))
return
}
const arr = localArrangements.value.find((a) => a.id === Number(id))
if (arr?.groups?.length) {
arrangementGroups.value = arr.groups.map((g, i) => ({ ...g, _uid: `${g.id}-${i}-${Date.now()}` }))
} else {
// Fallback: show all available groups in order (Master)
arrangementGroups.value = props.availableGroups.map((g, i) => ({ ...g, _uid: `${g.id}-master-${i}-${Date.now()}` }))
arrangementGroups.value = props.availableGroups.map((g, i) => ({ ...g, slides: g.slides ?? [], _uid: `${g.id}-master-${i}-${Date.now()}` }))
}
},
{ immediate: true },
@ -209,29 +236,59 @@ function createArrangement() {
const name = window.prompt('Name für das neue Arrangement:')
if (!name?.trim()) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, { preserveScroll: true })
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, {
preserveScroll: true,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
}
function cloneArrangement() {
if (!currentArrangementId.value) return
// Cloning from MASTER = creating a new arrangement (store() already uses all groups in master order)
if (isMasterSelected.value) {
const name = window.prompt('Name für das geklonte Arrangement:', 'MASTER Kopie')
if (!name?.trim()) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, {
preserveScroll: true,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
return
}
const name = window.prompt('Name für das geklonte Arrangement:', `${currentArrangement.value?.name ?? ''} Kopie`)
if (!name?.trim()) return
pendingAutoSelect.value = true
router.post(`/arrangements/${currentArrangementId.value}/clone`, { name: name.trim() }, { preserveScroll: true })
router.post(`/arrangements/${currentArrangementId.value}/clone`, { name: name.trim() }, {
preserveScroll: true,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
}
function deleteArrangement() {
if (!currentArrangementId.value) return
if (!currentArrangementId.value || isMasterSelected.value) return
if (props.arrangements.length <= 1) {
alert('Das letzte Arrangement kann nicht gelöscht werden.')
return
}
if (!confirm('Arrangement wirklich löschen?')) return
router.delete(`/arrangements/${currentArrangementId.value}`, { preserveScroll: true })
router.delete(`/arrangements/${currentArrangementId.value}`, {
preserveScroll: true,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
}
function saveArrangement() {
if (!currentArrangement.value) return
if (!currentArrangement.value || isMasterSelected.value) return
// Update local arrangements copy so changes survive switching
const localArr = localArrangements.value.find((a) => a.id === currentArrangement.value.id)
@ -260,6 +317,7 @@ function saveArrangement() {
/* ── Group operations ── */
function duplicateGroup(index) {
if (isMasterSelected.value) return
const item = arrangementGroups.value[index]
const newItem = { ...item, _uid: `${item.id}-dup-${Date.now()}` }
arrangementGroups.value.splice(index + 1, 0, newItem)
@ -267,6 +325,7 @@ function duplicateGroup(index) {
}
function addGroupAt(index, group) {
if (isMasterSelected.value) return
const newItem = {
id: group.id,
name: group.name,
@ -280,6 +339,7 @@ function addGroupAt(index, group) {
}
function removeGroupAt(index) {
if (isMasterSelected.value) return
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
@ -287,7 +347,7 @@ function removeGroupAt(index) {
/* ── Selection emit ── */
watch(currentArrangementId, (id) => {
emit('arrangement-selected', Number(id))
emit('arrangement-selected', id === MASTER_ID ? null : Number(id))
})
/* ── Close on backdrop ── */
@ -343,7 +403,7 @@ function closeOnBackdrop(e) {
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option
v-for="arr in arrangements"
v-for="arr in allArrangements"
:key="arr.id"
:value="arr.id"
>
@ -373,7 +433,8 @@ function closeOnBackdrop(e) {
<button
data-testid="arrangement-delete-btn"
type="button"
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-red-700"
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="isMasterSelected"
@click="deleteArrangement"
>
Löschen
@ -477,22 +538,28 @@ function closeOnBackdrop(e) {
>
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Gruppenfolge
<span v-if="isMasterSelected" class="ml-1 text-[10px] font-normal normal-case text-gray-400">(nicht editierbar)</span>
</h4>
<VueDraggable
v-model="arrangementGroups"
:disabled="isMasterSelected"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
class="flex flex-col gap-2"
:class="{ 'opacity-60': isMasterSelected }"
@end="saveArrangement"
>
<div
v-for="(element, index) in arrangementGroups"
:key="element._uid"
data-testid="arrangement-pill"
class="flex cursor-grab items-center gap-2 rounded-lg border-2 px-3 py-2"
:class="{ 'ring-2 ring-indigo-400 ring-offset-1': hoveredIndex === index }"
class="flex items-center gap-2 rounded-lg border-2 px-3 py-2"
:class="[
{ 'ring-2 ring-indigo-400 ring-offset-1': hoveredIndex === index },
isMasterSelected ? 'cursor-default' : 'cursor-grab',
]"
:style="{
borderColor: element.color ?? '#6b7280',
backgroundColor: (element.color ?? '#6b7280') + '20',
@ -503,60 +570,62 @@ function closeOnBackdrop(e) {
{{ element.name }}
</span>
<!-- Duplicate button (2×) -->
<button
data-testid="arrangement-duplicate-btn"
type="button"
class="rounded bg-gray-100 px-2 py-0.5 text-xs hover:bg-gray-200"
@click="duplicateGroup(index)"
>
2×
</button>
<!-- Add group button (+) -->
<div
class="relative"
data-group-picker
>
<template v-if="!isMasterSelected">
<!-- Duplicate button (2×) -->
<button
data-testid="arrangement-add-group-btn"
data-testid="arrangement-duplicate-btn"
type="button"
class="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-200"
@click.stop="toggleGroupPicker(index)"
class="rounded bg-gray-100 px-2 py-0.5 text-xs hover:bg-gray-200"
@click="duplicateGroup(index)"
>
+
2×
</button>
<!-- Quick select dropdown -->
<!-- Add group button (+) -->
<div
v-if="groupPickerOpen === index"
class="absolute right-0 top-8 z-10 min-w-36 rounded-lg border border-gray-200 bg-white shadow-lg"
class="relative"
data-group-picker
>
<button
v-for="g in availableGroups"
:key="g.id"
data-testid="arrangement-add-group-btn"
type="button"
class="block w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
@click="addGroupAt(index, g)"
class="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-200"
@click.stop="toggleGroupPicker(index)"
>
<span
class="mr-1.5 inline-block h-2.5 w-2.5 rounded-full"
:style="{ backgroundColor: g.color }"
/>
{{ g.name }}
+
</button>
</div>
</div>
<!-- Remove button -->
<button
data-testid="arrangement-remove-btn"
type="button"
class="rounded px-1.5 py-0.5 text-xs text-red-400 hover:bg-red-50 hover:text-red-600"
@click="removeGroupAt(index)"
>
×
</button>
<!-- Quick select dropdown -->
<div
v-if="groupPickerOpen === index"
class="absolute right-0 top-8 z-10 min-w-36 rounded-lg border border-gray-200 bg-white shadow-lg"
>
<button
v-for="g in availableGroups"
:key="g.id"
type="button"
class="block w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
@click="addGroupAt(index, g)"
>
<span
class="mr-1.5 inline-block h-2.5 w-2.5 rounded-full"
:style="{ backgroundColor: g.color }"
/>
{{ g.name }}
</button>
</div>
</div>
<!-- Remove button -->
<button
data-testid="arrangement-remove-btn"
type="button"
class="rounded px-1.5 py-0.5 text-xs text-red-400 hover:bg-red-50 hover:text-red-600"
@click="removeGroupAt(index)"
>
×
</button>
</template>
</div>
</VueDraggable>

View file

@ -491,6 +491,7 @@ onUnmounted(() => {
:song-id="songData.id"
:arrangements="arrangements"
:available-groups="availableGroups"
@arrangements-changed="fetchSong"
/>
</div>
</div>

View file

@ -65,10 +65,16 @@ function goBack() {
router.get(route('services.index'))
}
const informationBlockRef = ref(null)
function refreshPage() {
router.reload({ preserveScroll: true })
}
function scrollToInfoBlock() {
informationBlockRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
/* ── Agenda helpers ──────────────────────────────────────── */
const arrangementDialogItem = ref(null)
@ -349,34 +355,9 @@ async function downloadService() {
</div>
</template>
<!-- Information Block -->
<div class="py-6">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-400 to-blue-500 shadow-sm">
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div>
<h3 class="text-[15px] font-semibold text-gray-900">Information</h3>
<p class="text-xs text-gray-500">Info-Folien fuer alle kommenden Services</p>
</div>
</div>
<InformationBlock
:service-id="service.id"
:service-date="service.date"
:slides="informationSlides"
@slides-updated="refreshPage"
/>
</div>
</div>
</div>
<!-- Ablauf (Agenda) -->
<div class="py-6">
<div class="mx-auto max-w-4xl px-4 pb-24 sm:px-6 lg:px-8">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<h3 class="mb-4 text-base font-semibold text-gray-900">Ablauf</h3>
<!-- Empty state -->
@ -427,6 +408,7 @@ async function downloadService() {
:service-id="service.id"
:service-date="service.date"
@slides-updated="refreshPage"
@scroll-to-info="scrollToInfoBlock"
/>
</template>
</tbody>
@ -434,6 +416,31 @@ async function downloadService() {
</div>
</div>
<!-- Information Block (below agenda, scrolled to from announcement row) -->
<div ref="informationBlockRef" class="py-6">
<div class="mx-auto max-w-4xl px-4 pb-24 sm:px-6 lg:px-8">
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-400 to-blue-500 shadow-sm">
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div>
<h3 class="text-[15px] font-semibold text-gray-900">Information</h3>
<p class="text-xs text-gray-500">Info-Folien für alle kommenden Services</p>
</div>
</div>
<InformationBlock
:service-id="service.id"
:service-date="service.date"
:slides="informationSlides"
@slides-updated="refreshPage"
/>
</div>
</div>
</div>
<!-- Arrangement Dialog -->
<ArrangementDialog
v-if="arrangementDialogItem"