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:
parent
42b8b5f428
commit
7a29a21822
|
|
@ -308,6 +308,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
'song_id' => $ss->song_id,
|
||||
'song_arrangement_id' => $ss->song_arrangement_id,
|
||||
'matched_at' => $ss->matched_at?->toJSON(),
|
||||
'confirmed_at' => $ss->confirmed_at?->toJSON(),
|
||||
'request_sent_at' => $ss->request_sent_at?->toJSON(),
|
||||
'has_content_slides' => $ss->song ? $this->defaultArrangementHasContentSlides($ss->song) : null,
|
||||
'song' => $ss->song ? [
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Services\SongMatchingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
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);
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -99,4 +106,38 @@ public function update(int $serviceSongId, Request $request): JsonResponse
|
|||
'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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class ServiceSong extends Model
|
|||
'cts_ccli_id',
|
||||
'cts_song_id',
|
||||
'matched_at',
|
||||
'confirmed_at',
|
||||
'request_sent_at',
|
||||
];
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ protected function casts(): array
|
|||
return [
|
||||
'use_translation' => 'boolean',
|
||||
'matched_at' => 'datetime',
|
||||
'confirmed_at' => 'datetime',
|
||||
'request_sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ public function manualAssign(ServiceSong $serviceSong, Song $song): void
|
|||
'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
|
||||
if ($serviceSong->song_arrangement_id === null) {
|
||||
// Find default arrangement: is_default → name='normal' → first
|
||||
|
|
@ -112,6 +117,7 @@ public function unassign(ServiceSong $serviceSong): void
|
|||
$serviceSong->update([
|
||||
'song_id' => null,
|
||||
'matched_at' => null,
|
||||
'confirmed_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import axios from 'axios'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import SlideUploader from '@/Components/SlideUploader.vue'
|
||||
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
|
||||
|
||||
|
|
@ -16,6 +17,21 @@ const emit = defineEmits(['slides-updated', 'scroll-to-info'])
|
|||
const showUploader = 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() {
|
||||
downloading.value = true
|
||||
try {
|
||||
|
|
@ -70,6 +86,29 @@ const hoverPosition = ref({ x: 0, y: 0 })
|
|||
|
||||
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 r = props.agendaItem.responsible
|
||||
if (!r) return ''
|
||||
|
|
@ -129,6 +168,7 @@ function fullImageUrl(slide) {
|
|||
}
|
||||
|
||||
function onUploaded() {
|
||||
showUploader.value = false
|
||||
emit('slides-updated')
|
||||
}
|
||||
|
||||
|
|
@ -258,12 +298,18 @@ function scrollToInfo() {
|
|||
{{ agendaItem.note }}
|
||||
</p>
|
||||
|
||||
<!-- Slide thumbnails -->
|
||||
<div v-if="agendaItem.slides?.length" class="mt-1 flex gap-1">
|
||||
<!-- Slide thumbnails (all, wrap + drag-reorder + number badges) -->
|
||||
<VueDraggable
|
||||
v-if="localSlides.length"
|
||||
v-model="localSlides"
|
||||
class="mt-1 flex flex-wrap gap-1"
|
||||
@end="onDragEnd"
|
||||
item-key="id"
|
||||
>
|
||||
<div
|
||||
v-for="slide in agendaItem.slides.slice(0, 4)"
|
||||
v-for="(slide, index) in localSlides"
|
||||
:key="slide.id"
|
||||
class="group/thumb relative"
|
||||
class="group/thumb relative cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<img
|
||||
:src="thumbnailUrl(slide)"
|
||||
|
|
@ -274,6 +320,12 @@ function scrollToInfo() {
|
|||
@mouseenter="onThumbMouseEnter(slide, $event)"
|
||||
@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 -->
|
||||
<button
|
||||
data-testid="agenda-slide-delete-btn"
|
||||
|
|
@ -286,13 +338,7 @@ function scrollToInfo() {
|
|||
</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"
|
||||
>
|
||||
+{{ agendaItem.slides.length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</td>
|
||||
|
||||
<!-- Verantwortlich -->
|
||||
|
|
@ -338,6 +384,7 @@ function scrollToInfo() {
|
|||
<!-- Normal rows: + / x toggle for uploader -->
|
||||
<button
|
||||
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"
|
||||
data-testid="agenda-item-add-slides"
|
||||
:title="showUploader ? 'Schließen' : 'Folien hinzufügen'"
|
||||
|
|
@ -355,7 +402,7 @@ function scrollToInfo() {
|
|||
</tr>
|
||||
|
||||
<!-- 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">
|
||||
<SlideUploader
|
||||
type="agenda_item"
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
document.addEventListener('click', onBodyClick)
|
||||
|
||||
// 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) {
|
||||
dropdownOpen.value = true
|
||||
} else {
|
||||
// For matched songs: auto-create/select worship-leader arrangement if un-changed.
|
||||
ensureLeaderArrangement()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -131,18 +131,15 @@ function startEditExpire(slide) {
|
|||
}
|
||||
|
||||
function saveExpireDate(slide) {
|
||||
router.patch(
|
||||
axios.patch(
|
||||
route('slides.update-expire-date', slide.id),
|
||||
{ expire_date: editingExpireValue.value || null },
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => {
|
||||
)
|
||||
.then(() => {
|
||||
editingExpireId.value = null
|
||||
emit('updated')
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
function cancelEditExpire() {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ const serviceSong = computed(() => props.agendaItem.serviceSong ?? props.agendaI
|
|||
|
||||
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>).
|
||||
const matchedSongId = computed(() => serviceSong.value?.song?.id ?? serviceSong.value?.song_id ?? null)
|
||||
|
||||
|
|
@ -94,15 +96,15 @@ const formattedDuration = computed(() => {
|
|||
})
|
||||
|
||||
const rowBgClass = computed(() => {
|
||||
if (!isMatched.value) return 'bg-amber-50'
|
||||
if (serviceSong.value?.song_arrangement_id) return 'bg-emerald-50'
|
||||
return 'bg-yellow-50'
|
||||
if (!isMatched.value) return 'bg-red-50'
|
||||
if (isConfirmed.value) return 'bg-emerald-50'
|
||||
return 'bg-amber-50'
|
||||
})
|
||||
|
||||
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'
|
||||
if (!isMatched.value) return 'border-l-4 border-l-red-400'
|
||||
if (isConfirmed.value) return 'border-l-4 border-l-emerald-500'
|
||||
return 'border-l-4 border-l-amber-400'
|
||||
})
|
||||
|
||||
async function requestCreation() {
|
||||
|
|
@ -177,6 +179,26 @@ async function downloadBundle() {
|
|||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -264,22 +286,51 @@ async function downloadBundle() {
|
|||
<!-- Aktionen -->
|
||||
<td class="py-2.5 align-top">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<!-- Assign status -->
|
||||
<!-- Additional red overlay: no content slides -->
|
||||
<span
|
||||
v-if="missingContentSlides"
|
||||
class="inline-flex h-2 w-2 rounded-full bg-red-500"
|
||||
title="Keine Inhaltsfolien"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isMatched"
|
||||
class="inline-flex h-2 w-2 rounded-full bg-emerald-500"
|
||||
title="Zugeordnet"
|
||||
></span>
|
||||
<span
|
||||
|
||||
<!-- 3-state status icon -->
|
||||
<!-- not matched -> link-slash, red -->
|
||||
<svg
|
||||
v-if="!isMatched"
|
||||
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
|
||||
class="inline-flex h-2 w-2 rounded-full bg-amber-400"
|
||||
title="Nicht zugeordnet"
|
||||
></span>
|
||||
class="h-4 w-4 text-emerald-600"
|
||||
data-testid="song-status-icon"
|
||||
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 -->
|
||||
<span
|
||||
|
|
@ -327,6 +378,36 @@ async function downloadBundle() {
|
|||
</svg>
|
||||
</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 -->
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ function triggerSync() {
|
|||
<!-- Logo / App Name -->
|
||||
<Link
|
||||
data-testid="auth-layout-logo"
|
||||
:href="route('dashboard')"
|
||||
href="/"
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -113,7 +113,88 @@ function scrollToInfoBlock() {
|
|||
const arrangementDialogItem = ref(null)
|
||||
|
||||
function openArrangementDialog(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) {
|
||||
|
|
@ -515,8 +596,28 @@ async function downloadPreview() {
|
|||
Keine Ablauf-Elemente vorhanden. Bitte synchronisiere die Daten zuerst.
|
||||
</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 -->
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@
|
|||
Route::post('/service-songs/{serviceSongId}/unassign', [ServiceSongController::class, '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'])
|
||||
->name('api.service-songs.update');
|
||||
|
||||
|
|
|
|||
140
tests/Feature/ServiceSongControllerTest.php
Normal file
140
tests/Feature/ServiceSongControllerTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue