pp-planer/resources/js/Components/ArrangementConfigurator.vue
Thorsten Bus b2d230e991 feat: Wave 3 (partial) - Service Edit page + 4 blocks (T14-T18)
T14: Service Edit Page Layout + Routing
- ServiceController::edit() with eager-loaded relationships
- Services/Edit.vue with 4 collapsible accordion blocks
- Route: GET /services/{service}/edit
- Information slides query: global + service-specific with expire_date filtering
- Tests: 2 new (edit page render + auth guard)

T15: Information Block (Slides + Expire Dates)
- InformationBlock.vue with dynamic expire_date filtering
- Shows slides where type='information' AND expire_date >= service.date
- Global visibility across services (not service-specific)
- SlideUploader with showExpireDate=true
- SlideGrid with prominent expire date + inline editing
- Badge showing slide count + 'expiring soon' warning (within 3 days)
- Tests: 7 new (105 assertions)

T16: Moderation Block (Service-Specific)
- ModerationBlock.vue (service-specific slides)
- Filters: type='moderation' AND service_id = current_service
- No expire date field (unlike Information block)
- Service isolation (slides from Service A don't appear in Service B)
- Tests: 5 new (14 assertions)

T17: Sermon Block (Service-Specific)
- SermonBlock.vue (identical to Moderation but type='sermon')
- Service-specific slides, no expire date
- Tests: 5 new (14 assertions)

T18: Songs Block (Matching + Arrangement + Translation)
- SongsBlock.vue with conditional UI (unmatched vs matched states)
- Unmatched: 'Erstellung anfragen' button + searchable select for manual assign
- Matched: ArrangementConfigurator + translation checkbox + preview/download buttons
- ServiceSongController::update() for use_translation and song_arrangement_id
- ArrangementConfigurator emits 'arrangement-selected' for auto-save
- ServiceController::edit() provides songsCatalog for matching search
- Tests: 2 new (45 assertions)

T19: Song PDF (INCOMPLETE - timeout)
- SongPdfController.php created (partial)
- resources/views/pdf/song.blade.php created (partial)
- SongPreviewModal.vue MISSING
- Tests MISSING
- Will be completed in next commit

All tests passing: 124/124 (703 assertions)
Build: ✓ Vite production build successful
German UI: All user-facing text in German with 'Du' form
Dependencies: barryvdh/laravel-dompdf added for PDF generation
2026-03-01 20:09:47 +01:00

306 lines
8.9 KiB
Vue

<script setup>
import { computed, ref, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus'
const props = defineProps({
songId: {
type: Number,
required: true,
},
arrangements: {
type: Array,
required: true,
},
availableGroups: {
type: Array,
required: true,
},
selectedArrangementId: {
type: Number,
default: null,
},
})
const emit = defineEmits(['arrangement-selected'])
const selectedId = ref(
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? null,
)
const arrangementGroups = ref([])
const poolGroups = ref([])
const selectedArrangement = computed(() =>
props.arrangements.find((arrangement) => arrangement.id === Number(selectedId.value)) ?? null,
)
watch(
() => props.availableGroups,
(groups) => {
poolGroups.value = groups.map((group) => ({ ...group }))
},
{ immediate: true, deep: true },
)
watch(
selectedArrangement,
(arrangement) => {
arrangementGroups.value = arrangement?.groups?.map((group) => ({ ...group })) ?? []
},
{ immediate: true },
)
watch(
selectedId,
(arrangementId) => {
emit('arrangement-selected', Number(arrangementId))
},
)
const colorMap = computed(() => {
const map = {}
poolGroups.value.forEach((group) => {
map[group.id] = group.color
})
arrangementGroups.value.forEach((group) => {
map[group.id] = group.color
})
return map
})
const groupPillStyle = (group) => ({
backgroundColor: group.color,
})
const addArrangement = () => {
const name = window.prompt('Name des neuen Arrangements')
if (!name) {
return
}
router.post(
`/songs/${props.songId}/arrangements`,
{ name },
{
preserveScroll: true,
},
)
}
const cloneArrangement = () => {
if (!selectedArrangement.value) {
return
}
const name = window.prompt('Name des neuen Arrangements', `${selectedArrangement.value.name} Kopie`)
if (!name) {
return
}
router.post(
`/arrangements/${selectedArrangement.value.id}/clone`,
{ name },
{
preserveScroll: true,
},
)
}
const removeGroupAt = (index) => {
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
const syncPoolColor = (groupId, color) => {
poolGroups.value = poolGroups.value.map((group) => {
if (group.id !== groupId) {
return group
}
return {
...group,
color,
}
})
}
const updateGroupColor = (group, color) => {
group.color = color
syncPoolColor(group.id, color)
saveArrangement()
}
const saveArrangement = () => {
if (!selectedArrangement.value) {
return
}
router.put(
`/arrangements/${selectedArrangement.value.id}`,
{
groups: arrangementGroups.value.map((group, index) => ({
song_group_id: group.id,
order: index + 1,
})),
group_colors: colorMap.value,
},
{
preserveScroll: true,
preserveState: true,
},
)
}
const deleteArrangement = () => {
if (!selectedArrangement.value) {
return
}
router.delete(`/arrangements/${selectedArrangement.value.id}`, {
preserveScroll: true,
})
}
</script>
<template>
<div class="space-y-4 rounded-lg border border-gray-200 bg-white p-4">
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-64 flex-1">
<label
for="arrangement-select"
class="mb-1 block text-sm font-medium text-gray-700"
>
Arrangement
</label>
<select
id="arrangement-select"
v-model="selectedId"
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"
:key="arrangement.id"
:value="arrangement.id"
>
{{ arrangement.name }}
</option>
</select>
</div>
<button
type="button"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700"
@click="addArrangement"
>
Hinzufügen
</button>
<button
type="button"
class="rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
@click="cloneArrangement"
>
Klonen
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-700"
@click="deleteArrangement"
>
Löschen
</button>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="space-y-2">
<h4 class="text-sm font-semibold uppercase tracking-wide text-gray-600">
Verfügbare Gruppen
</h4>
<VueDraggable
v-model="poolGroups"
:group="{ name: 'song-groups', pull: 'clone', put: false }"
:sort="false"
class="min-h-24 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-3"
>
<div
v-for="group in poolGroups"
:key="group.id"
class="mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2"
>
<span
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white"
:style="groupPillStyle(group)"
>
{{ group.name }}
</span>
<label class="ml-auto flex items-center gap-2 text-xs text-gray-600">
Farbe ändern
<input
type="color"
class="h-8 w-8 cursor-pointer rounded border border-gray-300"
:value="group.color"
@input="updateGroupColor(group, $event.target.value)"
>
</label>
</div>
</VueDraggable>
</div>
<div class="space-y-2">
<h4 class="text-sm font-semibold uppercase tracking-wide text-gray-600">
Gruppenfolge
</h4>
<VueDraggable
v-model="arrangementGroups"
:group="{ name: 'song-groups', pull: true, put: true }"
class="min-h-24 rounded-lg border border-gray-200 bg-gray-50 p-3"
@end="saveArrangement"
>
<div
v-for="(group, index) in arrangementGroups"
:key="`${group.id}-${index}`"
class="mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2"
>
<span class="cursor-move text-gray-500">⋮⋮</span>
<span
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white"
:style="groupPillStyle(group)"
>
{{ group.name }}
</span>
<label class="ml-auto flex items-center gap-2 text-xs text-gray-600">
Farbe ändern
<input
type="color"
class="h-8 w-8 cursor-pointer rounded border border-gray-300"
:value="group.color"
@input="updateGroupColor(group, $event.target.value)"
>
</label>
<button
type="button"
class="rounded px-2 py-1 text-xs font-semibold text-red-600 hover:bg-red-50"
@click="removeGroupAt(index)"
>
Entfernen
</button>
</div>
</VueDraggable>
</div>
</div>
</div>
</template>