diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index b2755b8..1c444f7 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -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 ? [ diff --git a/app/Http/Controllers/ServiceSongController.php b/app/Http/Controllers/ServiceSongController.php index db09f83..7a6dfe1 100644 --- a/app/Http/Controllers/ServiceSongController.php +++ b/app/Http/Controllers/ServiceSongController.php @@ -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.', + ]); + } } diff --git a/app/Models/ServiceSong.php b/app/Models/ServiceSong.php index a60b441..76f80fc 100644 --- a/app/Models/ServiceSong.php +++ b/app/Models/ServiceSong.php @@ -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', ]; } diff --git a/app/Services/SongMatchingService.php b/app/Services/SongMatchingService.php index 62f2de6..d245b20 100644 --- a/app/Services/SongMatchingService.php +++ b/app/Services/SongMatchingService.php @@ -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, ]); } } diff --git a/database/migrations/2026_06_21_100000_add_confirmed_at_to_service_songs_table.php b/database/migrations/2026_06_21_100000_add_confirmed_at_to_service_songs_table.php new file mode 100644 index 0000000..a96220d --- /dev/null +++ b/database/migrations/2026_06_21_100000_add_confirmed_at_to_service_songs_table.php @@ -0,0 +1,22 @@ +timestamp('confirmed_at')->nullable()->after('matched_at'); + }); + } + + public function down(): void + { + Schema::table('service_songs', function (Blueprint $table) { + $table->dropColumn('confirmed_at'); + }); + } +}; diff --git a/resources/js/Components/AgendaItemRow.vue b/resources/js/Components/AgendaItemRow.vue index de4f499..518bb49 100644 --- a/resources/js/Components/AgendaItemRow.vue +++ b/resources/js/Components/AgendaItemRow.vue @@ -1,7 +1,8 @@ @@ -264,22 +286,51 @@ async function downloadBundle() { - + - - + + + + + + + + + + + class="h-4 w-4 text-emerald-600" + data-testid="song-status-icon" + fill="currentColor" + viewBox="0 0 24 24" + title="Bestätigt" + > + + + + + + + + Bestätigen + + + + + + + + Bestätigt + + diff --git a/resources/js/Pages/Services/Edit.vue b/resources/js/Pages/Services/Edit.vue index 1433c6b..69de520 100644 --- a/resources/js/Pages/Services/Edit.vue +++ b/resources/js/Pages/Services/Edit.vue @@ -113,7 +113,88 @@ function scrollToInfoBlock() { const arrangementDialogItem = ref(null) 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) { @@ -515,8 +596,28 @@ async function downloadPreview() { Keine Ablauf-Elemente vorhanden. Bitte synchronisiere die Daten zuerst. + + + + + Nicht zugeordnet + + + + Zu bestätigen + + + + Bestätigt + + + - + Nr diff --git a/routes/api.php b/routes/api.php index 2721338..179e56d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/ServiceSongControllerTest.php b/tests/Feature/ServiceSongControllerTest.php new file mode 100644 index 0000000..14b0440 --- /dev/null +++ b/tests/Feature/ServiceSongControllerTest.php @@ -0,0 +1,140 @@ +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(); +});