feat: confirm-state for service songs + service-edit UX fixes

- add explicit confirmed_at assignment state (red/amber/green) with confirm/unconfirm endpoints
- clone leader arrangement before opening dialog to avoid flicker
- agenda slide strip: show all previews, drag-reorder, number badges, auto-hide uploader
- fix info-slide expire-date saving via axios (was rendering raw JSON modal)
- point top-left logo to / instead of /dashboard
This commit is contained in:
Thorsten Bus 2026-06-21 11:53:55 +02:00
parent 42b8b5f428
commit 7a29a21822
13 changed files with 487 additions and 98 deletions

View file

@ -308,6 +308,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
'song_id' => $ss->song_id, 'song_id' => $ss->song_id,
'song_arrangement_id' => $ss->song_arrangement_id, 'song_arrangement_id' => $ss->song_arrangement_id,
'matched_at' => $ss->matched_at?->toJSON(), 'matched_at' => $ss->matched_at?->toJSON(),
'confirmed_at' => $ss->confirmed_at?->toJSON(),
'request_sent_at' => $ss->request_sent_at?->toJSON(), 'request_sent_at' => $ss->request_sent_at?->toJSON(),
'has_content_slides' => $ss->song ? $this->defaultArrangementHasContentSlides($ss->song) : null, 'has_content_slides' => $ss->song ? $this->defaultArrangementHasContentSlides($ss->song) : null,
'song' => $ss->song ? [ 'song' => $ss->song ? [

View file

@ -7,6 +7,7 @@
use App\Services\SongMatchingService; use App\Services\SongMatchingService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class ServiceSongController extends Controller class ServiceSongController extends Controller
{ {
@ -92,6 +93,12 @@ public function update(int $serviceSongId, Request $request): JsonResponse
} }
} }
// Bei tatsaechlicher Aenderung des Arrangements muss neu bestaetigt werden.
if (array_key_exists('song_arrangement_id', $validated)
&& $validated['song_arrangement_id'] !== $serviceSong->song_arrangement_id) {
$validated['confirmed_at'] = null;
}
$serviceSong->update($validated); $serviceSong->update($validated);
return response()->json([ return response()->json([
@ -99,4 +106,38 @@ public function update(int $serviceSongId, Request $request): JsonResponse
'service_song' => $serviceSong->fresh(), 'service_song' => $serviceSong->fresh(),
]); ]);
} }
/**
* Zuordnung eines Service-Songs explizit bestaetigen.
*/
public function confirm(int $serviceSongId): JsonResponse
{
$serviceSong = ServiceSong::findOrFail($serviceSongId);
$serviceSong->update([
'confirmed_at' => Carbon::now(),
]);
return response()->json([
'success' => true,
'message' => 'Zuordnung wurde bestaetigt.',
]);
}
/**
* Bestaetigung eines Service-Songs zuruecknehmen.
*/
public function unconfirm(int $serviceSongId): JsonResponse
{
$serviceSong = ServiceSong::findOrFail($serviceSongId);
$serviceSong->update([
'confirmed_at' => null,
]);
return response()->json([
'success' => true,
'message' => 'Bestaetigung wurde zurueckgenommen.',
]);
}
} }

View file

@ -20,6 +20,7 @@ class ServiceSong extends Model
'cts_ccli_id', 'cts_ccli_id',
'cts_song_id', 'cts_song_id',
'matched_at', 'matched_at',
'confirmed_at',
'request_sent_at', 'request_sent_at',
]; ];
@ -28,6 +29,7 @@ protected function casts(): array
return [ return [
'use_translation' => 'boolean', 'use_translation' => 'boolean',
'matched_at' => 'datetime', 'matched_at' => 'datetime',
'confirmed_at' => 'datetime',
'request_sent_at' => 'datetime', 'request_sent_at' => 'datetime',
]; ];
} }

View file

@ -66,6 +66,11 @@ public function manualAssign(ServiceSong $serviceSong, Song $song): void
'use_translation' => $song->has_translation, 'use_translation' => $song->has_translation,
]; ];
// Bei Aenderung des Songs muss die Zuordnung neu bestaetigt werden.
if ($serviceSong->song_id !== $song->id) {
$updateData['confirmed_at'] = null;
}
// Only set arrangement if currently null // Only set arrangement if currently null
if ($serviceSong->song_arrangement_id === null) { if ($serviceSong->song_arrangement_id === null) {
// Find default arrangement: is_default → name='normal' → first // Find default arrangement: is_default → name='normal' → first
@ -112,6 +117,7 @@ public function unassign(ServiceSong $serviceSong): void
$serviceSong->update([ $serviceSong->update([
'song_id' => null, 'song_id' => null,
'matched_at' => null, 'matched_at' => null,
'confirmed_at' => null,
]); ]);
} }
} }

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('service_songs', function (Blueprint $table) {
$table->timestamp('confirmed_at')->nullable()->after('matched_at');
});
}
public function down(): void
{
Schema::table('service_songs', function (Blueprint $table) {
$table->dropColumn('confirmed_at');
});
}
};

View file

@ -1,7 +1,8 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3' import { router } from '@inertiajs/vue3'
import axios from 'axios' import axios from 'axios'
import { VueDraggable } from 'vue-draggable-plus'
import SlideUploader from '@/Components/SlideUploader.vue' import SlideUploader from '@/Components/SlideUploader.vue'
import ConfirmDialog from '@/Components/ConfirmDialog.vue' import ConfirmDialog from '@/Components/ConfirmDialog.vue'
@ -16,6 +17,21 @@ const emit = defineEmits(['slides-updated', 'scroll-to-info'])
const showUploader = ref(false) const showUploader = ref(false)
const downloading = ref(false) const downloading = ref(false)
// Uploader outside-click handling
const toggleBtnRef = ref(null)
const uploaderRowRef = ref(null)
function onDocumentClick(event) {
if (!showUploader.value) return
const target = event.target
if (toggleBtnRef.value?.contains(target)) return
if (uploaderRowRef.value?.contains(target)) return
showUploader.value = false
}
onMounted(() => document.addEventListener('click', onDocumentClick))
onUnmounted(() => document.removeEventListener('click', onDocumentClick))
async function downloadBundle() { async function downloadBundle() {
downloading.value = true downloading.value = true
try { try {
@ -70,6 +86,29 @@ const hoverPosition = ref({ x: 0, y: 0 })
const allSlides = computed(() => props.agendaItem.slides ?? []) const allSlides = computed(() => props.agendaItem.slides ?? [])
// Draggable local slides (sorted by sort_order)
const localSlides = ref([])
watch(() => props.agendaItem.slides, (newSlides) => {
localSlides.value = [...(newSlides ?? [])].sort((a, b) => {
const orderA = a.sort_order ?? 0
const orderB = b.sort_order ?? 0
if (orderA !== orderB) return orderA - orderB
return new Date(a.uploaded_at) - new Date(b.uploaded_at)
})
}, { immediate: true, deep: true })
function onDragEnd() {
const reordered = localSlides.value.map((slide, index) => ({
id: slide.id,
sort_order: index + 1,
}))
axios.post(route('slides.reorder'), { slides: reordered })
.then(() => emit('slides-updated'))
.catch(console.error)
}
const responsibleNames = computed(() => { const responsibleNames = computed(() => {
const r = props.agendaItem.responsible const r = props.agendaItem.responsible
if (!r) return '' if (!r) return ''
@ -129,6 +168,7 @@ function fullImageUrl(slide) {
} }
function onUploaded() { function onUploaded() {
showUploader.value = false
emit('slides-updated') emit('slides-updated')
} }
@ -258,12 +298,18 @@ function scrollToInfo() {
{{ agendaItem.note }} {{ agendaItem.note }}
</p> </p>
<!-- Slide thumbnails --> <!-- Slide thumbnails (all, wrap + drag-reorder + number badges) -->
<div v-if="agendaItem.slides?.length" class="mt-1 flex gap-1"> <VueDraggable
v-if="localSlides.length"
v-model="localSlides"
class="mt-1 flex flex-wrap gap-1"
@end="onDragEnd"
item-key="id"
>
<div <div
v-for="slide in agendaItem.slides.slice(0, 4)" v-for="(slide, index) in localSlides"
:key="slide.id" :key="slide.id"
class="group/thumb relative" class="group/thumb relative cursor-grab active:cursor-grabbing"
> >
<img <img
:src="thumbnailUrl(slide)" :src="thumbnailUrl(slide)"
@ -274,6 +320,12 @@ function scrollToInfo() {
@mouseenter="onThumbMouseEnter(slide, $event)" @mouseenter="onThumbMouseEnter(slide, $event)"
@mouseleave="onThumbMouseLeave" @mouseleave="onThumbMouseLeave"
/> />
<!-- Number badge -->
<span
class="pointer-events-none absolute -left-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-white/90 px-1 text-[9px] font-semibold leading-none text-gray-700 shadow-sm ring-1 ring-gray-200 tabular-nums"
>
{{ index + 1 }}.
</span>
<!-- Delete X button --> <!-- Delete X button -->
<button <button
data-testid="agenda-slide-delete-btn" data-testid="agenda-slide-delete-btn"
@ -286,13 +338,7 @@ function scrollToInfo() {
</svg> </svg>
</button> </button>
</div> </div>
<span </VueDraggable>
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> </td>
<!-- Verantwortlich --> <!-- Verantwortlich -->
@ -338,6 +384,7 @@ function scrollToInfo() {
<!-- Normal rows: + / x toggle for uploader --> <!-- Normal rows: + / x toggle for uploader -->
<button <button
v-else v-else
ref="toggleBtnRef"
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" 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" data-testid="agenda-item-add-slides"
:title="showUploader ? 'Schließen' : 'Folien hinzufügen'" :title="showUploader ? 'Schließen' : 'Folien hinzufügen'"
@ -355,7 +402,7 @@ function scrollToInfo() {
</tr> </tr>
<!-- Uploader row (spanning all columns) not for announcement rows --> <!-- Uploader row (spanning all columns) not for announcement rows -->
<tr v-if="showUploader && !agendaItem.is_announcement_position"> <tr v-if="showUploader && !agendaItem.is_announcement_position" ref="uploaderRowRef">
<td colspan="6" class="border-b border-gray-100 px-3 pb-3"> <td colspan="6" class="border-b border-gray-100 px-3 pb-3">
<SlideUploader <SlideUploader
type="agenda_item" type="agenda_item"

View file

@ -308,70 +308,15 @@ async function selectArrangementForServiceSong(arrangementId) {
} }
} }
async function ensureLeaderArrangement() {
if (!props.worshipLeaderName || isUnmatched.value) return
// Only auto-act if the user hasn't manually chosen an arrangement
const isUnchanged =
props.serviceSongArrangementId === null ||
props.serviceSongArrangementId === props.defaultArrangementId
if (!isUnchanged) return
const leaderName = props.worshipLeaderName.trim()
if (!leaderName) return
// Check if an arrangement with this name already exists
const existing = props.arrangements.find(
(a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(),
)
if (existing) {
await selectArrangementForServiceSong(existing.id)
return
}
// Create by cloning the default arrangement
const defaultArr = props.arrangements.find((a) => a.is_default) ?? props.arrangements[0]
if (!defaultArr) return
try {
pendingAutoSelect.value = true
router.post(
`/arrangements/${defaultArr.id}/clone`,
{ name: leaderName },
{
preserveScroll: true,
onSuccess: async () => {
router.reload({
preserveScroll: true,
onSuccess: async () => {
const created = props.arrangements.find(
(a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(),
)
if (created) {
await selectArrangementForServiceSong(created.id)
}
},
})
},
},
)
} catch {
// Ignore
}
}
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', closeOnEscape) document.addEventListener('keydown', closeOnEscape)
document.addEventListener('click', onBodyClick) document.addEventListener('click', onBodyClick)
// For unmatched songs: show search results immediately (prefilled with the song name). // For unmatched songs: show search results immediately (prefilled with the song name).
// For matched songs the leader-arrangement clone is now performed in Edit.vue
// BEFORE the dialog mounts, so the dialog opens pre-selected (no flicker).
if (isUnmatched.value) { if (isUnmatched.value) {
dropdownOpen.value = true dropdownOpen.value = true
} else {
// For matched songs: auto-create/select worship-leader arrangement if un-changed.
ensureLeaderArrangement()
} }
}) })

View file

@ -131,18 +131,15 @@ function startEditExpire(slide) {
} }
function saveExpireDate(slide) { function saveExpireDate(slide) {
router.patch( axios.patch(
route('slides.update-expire-date', slide.id), route('slides.update-expire-date', slide.id),
{ expire_date: editingExpireValue.value || null }, { expire_date: editingExpireValue.value || null },
{
preserveScroll: true,
preserveState: true,
onSuccess: () => {
editingExpireId.value = null
emit('updated')
},
},
) )
.then(() => {
editingExpireId.value = null
emit('updated')
})
.catch(console.error)
} }
function cancelEditExpire() { function cancelEditExpire() {

View file

@ -14,6 +14,8 @@ const serviceSong = computed(() => props.agendaItem.serviceSong ?? props.agendaI
const isMatched = computed(() => serviceSong.value?.song_id != null) const isMatched = computed(() => serviceSong.value?.song_id != null)
const isConfirmed = computed(() => serviceSong.value?.confirmed_at != null)
// Matched song id for linking to the SongDB edit modal (Songs/Index reads #song-<id>). // Matched song id for linking to the SongDB edit modal (Songs/Index reads #song-<id>).
const matchedSongId = computed(() => serviceSong.value?.song?.id ?? serviceSong.value?.song_id ?? null) const matchedSongId = computed(() => serviceSong.value?.song?.id ?? serviceSong.value?.song_id ?? null)
@ -94,15 +96,15 @@ const formattedDuration = computed(() => {
}) })
const rowBgClass = computed(() => { const rowBgClass = computed(() => {
if (!isMatched.value) return 'bg-amber-50' if (!isMatched.value) return 'bg-red-50'
if (serviceSong.value?.song_arrangement_id) return 'bg-emerald-50' if (isConfirmed.value) return 'bg-emerald-50'
return 'bg-yellow-50' return 'bg-amber-50'
}) })
const borderClass = computed(() => { const borderClass = computed(() => {
if (!isMatched.value) return 'border-l-4 border-l-amber-400' if (!isMatched.value) return 'border-l-4 border-l-red-400'
if (serviceSong.value?.song_arrangement_id) return 'border-l-4 border-l-emerald-500' if (isConfirmed.value) return 'border-l-4 border-l-emerald-500'
return 'border-l-4 border-l-yellow-400' return 'border-l-4 border-l-amber-400'
}) })
async function requestCreation() { async function requestCreation() {
@ -177,6 +179,26 @@ async function downloadBundle() {
downloading.value = false downloading.value = false
} }
} }
async function confirmAssignment() {
try {
await window.axios.post(`/api/service-songs/${serviceSong.value.id}/confirm`)
showToast('Zuordnung bestätigt.', 'success')
router.reload({ preserveScroll: true, preserveState: true })
} catch {
showToast('Bestätigen fehlgeschlagen.', 'error')
}
}
async function unconfirmAssignment() {
try {
await window.axios.post(`/api/service-songs/${serviceSong.value.id}/unconfirm`)
showToast('Bestätigung aufgehoben.', 'success')
router.reload({ preserveScroll: true, preserveState: true })
} catch {
showToast('Aufheben fehlgeschlagen.', 'error')
}
}
</script> </script>
<template> <template>
@ -264,22 +286,51 @@ async function downloadBundle() {
<!-- Aktionen --> <!-- Aktionen -->
<td class="py-2.5 align-top"> <td class="py-2.5 align-top">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<!-- Assign status --> <!-- Additional red overlay: no content slides -->
<span <span
v-if="missingContentSlides" v-if="missingContentSlides"
class="inline-flex h-2 w-2 rounded-full bg-red-500" class="inline-flex h-2 w-2 rounded-full bg-red-500"
title="Keine Inhaltsfolien" title="Keine Inhaltsfolien"
></span> ></span>
<span
v-else-if="isMatched" <!-- 3-state status icon -->
class="inline-flex h-2 w-2 rounded-full bg-emerald-500" <!-- not matched -> link-slash, red -->
title="Zugeordnet" <svg
></span> v-if="!isMatched"
<span class="h-4 w-4 text-red-500"
data-testid="song-status-icon"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
title="Nicht zugeordnet Song zuordnen"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M13.181 8.68a4.5 4.5 0 011.044 7.158l-1.99 1.99a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-1.99 1.99M3 3l18 18" />
</svg>
<!-- matched but not confirmed -> outline check-circle, amber -->
<svg
v-else-if="!isConfirmed"
class="h-4 w-4 text-amber-500"
data-testid="song-status-icon"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
title="Zu bestätigen"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<!-- confirmed -> solid check-circle, emerald -->
<svg
v-else v-else
class="inline-flex h-2 w-2 rounded-full bg-amber-400" class="h-4 w-4 text-emerald-600"
title="Nicht zugeordnet" data-testid="song-status-icon"
></span> fill="currentColor"
viewBox="0 0 24 24"
title="Bestätigt"
>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" />
</svg>
<!-- Translation flag --> <!-- Translation flag -->
<span <span
@ -327,6 +378,36 @@ async function downloadBundle() {
</svg> </svg>
</button> </button>
<!-- Confirm assignment (matched + arrangement set + not yet confirmed) -->
<button
v-if="isMatched && serviceSong?.song_arrangement_id && !isConfirmed"
type="button"
class="inline-flex items-center gap-1 rounded-md border border-emerald-300 bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-700 transition hover:bg-emerald-100"
data-testid="song-confirm-assignment"
title="Zuordnung bestätigen"
@click="confirmAssignment"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Bestätigen
</button>
<!-- Unconfirm (revert) subtle, when confirmed -->
<button
v-else-if="isMatched && isConfirmed"
type="button"
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
data-testid="song-unconfirm-assignment"
title="Bestätigung aufheben"
@click="unconfirmAssignment"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42" />
</svg>
Bestätigt
</button>
<!-- Edit / assign song opens dialog for both matched and unmatched --> <!-- Edit / assign song opens dialog for both matched and unmatched -->
<button <button
type="button" type="button"

View file

@ -78,7 +78,7 @@ function triggerSync() {
<!-- Logo / App Name --> <!-- Logo / App Name -->
<Link <Link
data-testid="auth-layout-logo" data-testid="auth-layout-logo"
:href="route('dashboard')" href="/"
class="mr-6 flex items-center gap-2.5 transition-opacity hover:opacity-80" class="mr-6 flex items-center gap-2.5 transition-opacity hover:opacity-80"
> >
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-amber-500 to-orange-600 shadow-sm"> <div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-amber-500 to-orange-600 shadow-sm">

View file

@ -113,7 +113,88 @@ function scrollToInfoBlock() {
const arrangementDialogItem = ref(null) const arrangementDialogItem = ref(null)
function openArrangementDialog(item) { function openArrangementDialog(item) {
arrangementDialogItem.value = item const serviceSong = item.service_song ?? item.serviceSong ?? null
const songId = serviceSong?.song_id ?? null
// Unmatched -> open immediately (assignment flow).
if (!serviceSong || songId == null) {
arrangementDialogItem.value = item
return
}
const leaderName = (props.service.worship_leader_name ?? '').trim()
const arrangements = getArrangements(item)
const defaultArr = arrangements.find((a) => a.is_default) ?? arrangements[0] ?? null
const defaultArrId = defaultArr?.id ?? null
const currentArrId = serviceSong.song_arrangement_id ?? null
const isUnchanged = currentArrId === null || currentArrId === defaultArrId
// No leader name OR already has a non-default arrangement -> just open.
if (!leaderName || !isUnchanged || !defaultArrId) {
arrangementDialogItem.value = item
return
}
const serviceSongId = serviceSong.id
// If a leader-named arrangement already exists -> point service-song to it, then open.
const existing = arrangements.find(
(a) => (a.name ?? '').trim().toLowerCase() === leaderName.toLowerCase(),
)
if (existing) {
window.axios
.patch(`/api/service-songs/${serviceSongId}`, { song_arrangement_id: existing.id })
.catch(() => {})
.finally(() => {
router.reload({
preserveScroll: true,
onSuccess: () => {
arrangementDialogItem.value = findAgendaItem(item.id) ?? item
},
})
})
return
}
// Otherwise clone the default arrangement, reload, point service-song to it, THEN open.
router.post(
`/arrangements/${defaultArrId}/clone`,
{ name: leaderName },
{
preserveScroll: true,
onSuccess: () => {
router.reload({
preserveScroll: true,
onSuccess: () => {
const freshItem = findAgendaItem(item.id) ?? item
const freshArrs = getArrangements(freshItem)
const created = freshArrs.find(
(a) => (a.name ?? '').trim().toLowerCase() === leaderName.toLowerCase(),
)
if (created) {
window.axios
.patch(`/api/service-songs/${serviceSongId}`, { song_arrangement_id: created.id })
.catch(() => {})
.finally(() => {
router.reload({
preserveScroll: true,
onSuccess: () => {
arrangementDialogItem.value = findAgendaItem(item.id) ?? item
},
})
})
} else {
arrangementDialogItem.value = freshItem
}
},
})
},
},
)
}
function findAgendaItem(id) {
return props.agendaItems.find((it) => it.id === id) ?? null
} }
function getArrangements(item) { function getArrangements(item) {
@ -515,8 +596,28 @@ async function downloadPreview() {
Keine Ablauf-Elemente vorhanden. Bitte synchronisiere die Daten zuerst. Keine Ablauf-Elemente vorhanden. Bitte synchronisiere die Daten zuerst.
</div> </div>
<!-- Song-state legend -->
<div
v-if="agendaItems && agendaItems.length > 0"
class="mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500"
data-testid="song-state-legend"
>
<span class="inline-flex items-center gap-1.5">
<span class="inline-flex h-2.5 w-2.5 rounded-full bg-red-400"></span>
Nicht zugeordnet
</span>
<span class="inline-flex items-center gap-1.5">
<span class="inline-flex h-2.5 w-2.5 rounded-full bg-amber-400"></span>
Zu bestätigen
</span>
<span class="inline-flex items-center gap-1.5">
<span class="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500"></span>
Bestätigt
</span>
</div>
<!-- Agenda table --> <!-- Agenda table -->
<table v-else class="w-full text-sm" data-testid="agenda-section"> <table v-if="agendaItems && agendaItems.length > 0" class="w-full text-sm" data-testid="agenda-section">
<thead> <thead>
<tr class="border-b border-gray-200 text-left text-xs font-semibold uppercase tracking-wider text-gray-400"> <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-12">Nr</th>

View file

@ -29,6 +29,12 @@
Route::post('/service-songs/{serviceSongId}/unassign', [ServiceSongController::class, 'unassign']) Route::post('/service-songs/{serviceSongId}/unassign', [ServiceSongController::class, 'unassign'])
->name('api.service-songs.unassign'); ->name('api.service-songs.unassign');
Route::post('/service-songs/{serviceSongId}/confirm', [ServiceSongController::class, 'confirm'])
->name('api.service-songs.confirm');
Route::post('/service-songs/{serviceSongId}/unconfirm', [ServiceSongController::class, 'unconfirm'])
->name('api.service-songs.unconfirm');
Route::patch('/service-songs/{serviceSongId}', [ServiceSongController::class, 'update']) Route::patch('/service-songs/{serviceSongId}', [ServiceSongController::class, 'update'])
->name('api.service-songs.update'); ->name('api.service-songs.update');

View file

@ -0,0 +1,140 @@
<?php
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\User;
/*
|--------------------------------------------------------------------------
| ServiceSongController confirm/unconfirm + Reset-Logik
|--------------------------------------------------------------------------
*/
test('POST /api/service-songs/{id}/confirm setzt confirmed_at', function () {
$user = User::factory()->create();
$song = Song::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => $song->id,
'confirmed_at' => null,
]);
$response = $this->actingAs($user)
->postJson("/api/service-songs/{$serviceSong->id}/confirm");
$response->assertOk()
->assertJson(['success' => true]);
$serviceSong->refresh();
expect($serviceSong->confirmed_at)->not->toBeNull();
});
test('POST /api/service-songs/{id}/unconfirm setzt confirmed_at auf null', function () {
$user = User::factory()->create();
$song = Song::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => $song->id,
'confirmed_at' => now(),
]);
$response = $this->actingAs($user)
->postJson("/api/service-songs/{$serviceSong->id}/unconfirm");
$response->assertOk()
->assertJson(['success' => true]);
$serviceSong->refresh();
expect($serviceSong->confirmed_at)->toBeNull();
});
test('confirm/unconfirm erfordern Authentifizierung', function () {
$serviceSong = ServiceSong::factory()->create();
$this->postJson("/api/service-songs/{$serviceSong->id}/confirm")
->assertUnauthorized();
$this->postJson("/api/service-songs/{$serviceSong->id}/unconfirm")
->assertUnauthorized();
});
test('assign setzt confirmed_at zurueck', function () {
$user = User::factory()->create();
$oldSong = Song::factory()->create();
$newSong = Song::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => $oldSong->id,
'confirmed_at' => now(),
]);
$this->actingAs($user)
->postJson("/api/service-songs/{$serviceSong->id}/assign", [
'song_id' => $newSong->id,
])
->assertOk();
$serviceSong->refresh();
expect($serviceSong->song_id)->toBe($newSong->id);
expect($serviceSong->confirmed_at)->toBeNull();
});
test('unassign setzt confirmed_at zurueck', function () {
$user = User::factory()->create();
$song = Song::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => $song->id,
'confirmed_at' => now(),
]);
$this->actingAs($user)
->postJson("/api/service-songs/{$serviceSong->id}/unassign")
->assertOk();
$serviceSong->refresh();
expect($serviceSong->song_id)->toBeNull();
expect($serviceSong->confirmed_at)->toBeNull();
});
test('Aenderung des Arrangements setzt confirmed_at zurueck', function () {
$user = User::factory()->create();
$song = Song::factory()->create();
$arrA = SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'A']);
$arrB = SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'B']);
$serviceSong = ServiceSong::factory()->create([
'song_id' => $song->id,
'song_arrangement_id' => $arrA->id,
'confirmed_at' => now(),
]);
$this->actingAs($user)
->patchJson("/api/service-songs/{$serviceSong->id}", [
'song_arrangement_id' => $arrB->id,
])
->assertOk();
$serviceSong->refresh();
expect($serviceSong->song_arrangement_id)->toBe($arrB->id);
expect($serviceSong->confirmed_at)->toBeNull();
});
test('reine use_translation Aenderung setzt confirmed_at NICHT zurueck', function () {
$user = User::factory()->create();
$song = Song::factory()->create(['has_translation' => true]);
$arr = SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'A']);
$confirmedAt = now()->subHour();
$serviceSong = ServiceSong::factory()->create([
'song_id' => $song->id,
'song_arrangement_id' => $arr->id,
'use_translation' => false,
'confirmed_at' => $confirmedAt,
]);
$this->actingAs($user)
->patchJson("/api/service-songs/{$serviceSong->id}", [
'use_translation' => true,
])
->assertOk();
$serviceSong->refresh();
expect($serviceSong->use_translation)->toBeTrue();
expect($serviceSong->confirmed_at)->not->toBeNull();
});