T8: Service List Page - ServiceController with index, finalize, reopen actions - Services/Index.vue with status indicators (songs mapped/arranged, slides uploaded) - German UI with finalize/reopen toggle buttons - Status aggregation via SQL subqueries for efficiency - Tests: 3 passing (46 assertions) T9: Song CRUD Backend - SongController with full REST API (index, store, show, update, destroy) - SongService for default groups/arrangements creation - SongRequest validation (title required, ccli_id unique) - Search by title and CCLI ID - last_used_in_service accessor via service_songs join - Tests: 20 passing (85 assertions) T10: Slide Upload Component - SlideController with store, destroy, updateExpireDate - SlideUploader.vue with vue3-dropzone drag-and-drop - SlideGrid.vue with thumbnail grid and inline expire date editing - Multi-format support: images (sync), PPT (async job), ZIP (extract) - Type validation: information (global), moderation/sermon (service-specific) - Tests: 15 passing (37 assertions) T11: Arrangement Configurator - ArrangementController with store, clone, update, destroy - ArrangementConfigurator.vue with vue-draggable-plus - Drag-and-drop arrangement editor with colored group pills - Clone from default or existing arrangement - Color picker for group customization - Prevent deletion of last arrangement - Tests: 4 passing (17 assertions) T12: Song Matching Service - SongMatchingService with autoMatch, manualAssign, requestCreation, unassign - ServiceSongController API endpoints for song assignment - Auto-match by CCLI ID during CTS sync - Manual assignment with searchable song select - Email request for missing songs (MissingSongRequest mailable) - Tests: 14 passing (33 assertions) T13: Translation Service - TranslationService with fetchFromUrl, importTranslation, removeTranslation - TranslationController API endpoints - URL scraping (best-effort HTTP fetch with strip_tags) - Line-count distribution algorithm (match original slide line counts) - Mark song as translated, remove translation - Tests: 18 passing (18 assertions) All tests passing: 103/103 (488 assertions) Build: ✓ Vite production build successful German UI: All user-facing text in German with 'Du' form
297 lines
8.7 KiB
Vue
297 lines
8.7 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 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 },
|
|
)
|
|
|
|
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>
|