diff --git a/.sisyphus/notepads/edit-page-restructure/learnings.md b/.sisyphus/notepads/edit-page-restructure/learnings.md index 593aad0..ee38c52 100644 --- a/.sisyphus/notepads/edit-page-restructure/learnings.md +++ b/.sisyphus/notepads/edit-page-restructure/learnings.md @@ -297,3 +297,22 @@ ### Vue Field Definition Pattern - Optional `helpText` property displayed below input (agenda fields only, not macro fields) - All fields use same `form[key]`, `saving[key]`, `saved[key]`, `errors[key]` reactive objects - On blur: `saveField(field.key)` triggers PATCH to `route('settings.update')` endpoint + +## 2026-03-29 — Service list status columns update + +### Implementation +- Updated `ServiceController::index()` to compute and pass `agenda_slides_count` +- `agenda_slides_count` uses correlated subquery on `service_agenda_items`: counts non-song items (`service_song_id IS NULL`) that have at least 1 non-deleted slide via `whereExists()` +- Updated `has_sermon_slides` logic: if `Setting::get('agenda_sermon_matching')` is configured, checks agenda items matching the pattern for slides (using `AgendaMatcherService`); otherwise falls back to legacy `slides.type = 'sermon'` subquery +- Eager-loads `agendaItems` (non-song only) with slides for the sermon check in the map callback +- Updated `Index.vue` to show `{count} weitere Folien` when `agenda_slides_count > 0`, with `data-testid="agenda-slides-count"` + +### Test Coverage (3 new tests) +1. `services_index_zaehlt_agenda_slides_fuer_nicht_song_items` — verifies non-song agenda items with slides count correctly (song items excluded) +2. `services_index_sermon_check_nutzt_agenda_matching_wenn_konfiguriert` — sermon slides via agenda item with setting +3. `services_index_sermon_ohne_agenda_slides_zeigt_false` — no slides on sermon agenda item → false + +### Key Patterns +- `addSelect` with correlated subquery using `ServiceAgendaItem::query()` + `whereExists(Slide::query()...)` +- Sermon check done in PHP (map callback) because `fnmatch()` pattern matching can't be done in SQL +- `->with(['agendaItems' => fn ($q) => $q->whereNull('service_song_id')])` for efficient eager loading of only non-song agenda items diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index 4d489a9..95d2d77 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Service; +use App\Models\ServiceAgendaItem; use App\Models\Setting; use App\Models\Slide; use App\Models\Song; @@ -32,6 +33,9 @@ public function index(): Response ->orderBy('date'); } + $sermonPatterns = Setting::get('agenda_sermon_matching'); + $matcher = $sermonPatterns ? app(AgendaMatcherService::class) : null; + $services = $query ->withCount([ 'serviceSongs as songs_total_count', @@ -61,24 +65,55 @@ public function index(): Response fn ($q) => $q ->whereColumn(DB::raw('DATE(slides.uploaded_at)'), '<=', 'services.date') ), + 'agenda_slides_count' => ServiceAgendaItem::query() + ->selectRaw('COUNT(*)') + ->whereColumn('service_agenda_items.service_id', 'services.id') + ->whereNull('service_agenda_items.service_song_id') + ->whereExists( + Slide::query() + ->selectRaw('1') + ->whereColumn('slides.service_agenda_item_id', 'service_agenda_items.id') + ->whereNull('slides.deleted_at') + ), ]) + ->with(['agendaItems' => fn ($q) => $q->whereNull('service_song_id')]) + ->with(['agendaItems.slides' => fn ($q) => $q->whereNull('deleted_at')]) ->get() - ->map(fn (Service $service) => [ - 'id' => $service->id, - 'cts_event_id' => $service->cts_event_id, - 'title' => $service->title, - 'date' => $service->date?->toDateString(), - 'preacher_name' => $service->preacher_name, - 'beamer_tech_name' => $service->beamer_tech_name, - 'last_synced_at' => $service->last_synced_at?->toJSON(), - 'updated_at' => $service->updated_at?->toJSON(), - 'finalized_at' => $service->finalized_at?->toJSON(), - 'songs_total_count' => (int) $service->songs_total_count, - 'songs_mapped_count' => (int) $service->songs_mapped_count, - 'songs_arranged_count' => (int) $service->songs_arranged_count, - 'has_sermon_slides' => (bool) $service->has_sermon_slides, - 'info_slides_count' => (int) $service->info_slides_count, - ]) + ->map(function (Service $service) use ($matcher, $sermonPatterns) { + // Determine sermon slides status + $hasSermonSlides = (bool) $service->has_sermon_slides; + if ($matcher && $sermonPatterns) { + $sermonItems = $service->agendaItems->filter( + fn (ServiceAgendaItem $item) => $matcher->matchesAny( + $item->title, + array_map('trim', explode(',', $sermonPatterns)) + ) + ); + if ($sermonItems->isNotEmpty()) { + $hasSermonSlides = $sermonItems->contains( + fn (ServiceAgendaItem $item) => $item->slides->isNotEmpty() + ); + } + } + + return [ + 'id' => $service->id, + 'cts_event_id' => $service->cts_event_id, + 'title' => $service->title, + 'date' => $service->date?->toDateString(), + 'preacher_name' => $service->preacher_name, + 'beamer_tech_name' => $service->beamer_tech_name, + 'last_synced_at' => $service->last_synced_at?->toJSON(), + 'updated_at' => $service->updated_at?->toJSON(), + 'finalized_at' => $service->finalized_at?->toJSON(), + 'songs_total_count' => (int) $service->songs_total_count, + 'songs_mapped_count' => (int) $service->songs_mapped_count, + 'songs_arranged_count' => (int) $service->songs_arranged_count, + 'has_sermon_slides' => $hasSermonSlides, + 'info_slides_count' => (int) $service->info_slides_count, + 'agenda_slides_count' => (int) $service->agenda_slides_count, + ]; + }) ->values(); return Inertia::render('Services/Index', [ diff --git a/resources/js/Pages/Services/Index.vue b/resources/js/Pages/Services/Index.vue index 87b6f03..7922c77 100644 --- a/resources/js/Pages/Services/Index.vue +++ b/resources/js/Pages/Services/Index.vue @@ -338,6 +338,15 @@ function stateIconClass(isDone) { {{ service.info_slides_count }} Infofolien +
+ + {{ service.agenda_slides_count }} weitere Folien +
+
diff --git a/tests/Feature/ServiceControllerTest.php b/tests/Feature/ServiceControllerTest.php index f26c0f9..34ab5dc 100644 --- a/tests/Feature/ServiceControllerTest.php +++ b/tests/Feature/ServiceControllerTest.php @@ -147,6 +147,7 @@ public function test_services_index_zeigt_nur_heutige_und_kuenftige_services_mit ->where('services.0.songs_arranged_count', 1) ->where('services.0.has_sermon_slides', true) ->where('services.0.info_slides_count', 3) + ->where('services.0.agenda_slides_count', 0) ->where('services.1.id', $futureService->id) ->where('services.1.title', 'Gottesdienst Zukunft') ->where('services.1.songs_total_count', 2) @@ -154,6 +155,7 @@ public function test_services_index_zeigt_nur_heutige_und_kuenftige_services_mit ->where('services.1.songs_arranged_count', 1) ->where('services.1.has_sermon_slides', false) ->where('services.1.info_slides_count', 1) + ->where('services.1.agenda_slides_count', 0) ); } @@ -354,6 +356,172 @@ public function test_services_index_zeigt_vergangene_services_mit_archived_param ); } + public function test_services_index_zaehlt_agenda_slides_fuer_nicht_song_items(): void + { + Carbon::setTestNow('2026-03-01 10:00:00'); + $this->withoutVite(); + + $user = User::factory()->create(); + + $service = Service::factory()->create([ + 'title' => 'Service mit Agenda', + 'date' => Carbon::today()->addDays(2), + 'finalized_at' => null, + ]); + + // Non-song agenda item WITH slides → counts + $agendaItem1 = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'sort_order' => 1, + 'service_song_id' => null, + 'is_before_event' => false, + ]); + Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $agendaItem1->id, + 'type' => 'sermon', + 'uploaded_at' => Carbon::today()->subDay(), + ]); + + // Non-song agenda item WITH slides → counts + $agendaItem2 = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Hinweise', + 'sort_order' => 2, + 'service_song_id' => null, + 'is_before_event' => false, + ]); + Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $agendaItem2->id, + 'type' => 'moderation', + 'uploaded_at' => Carbon::today()->subDay(), + ]); + + // Non-song agenda item WITHOUT slides → does NOT count + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'sort_order' => 3, + 'service_song_id' => null, + 'is_before_event' => false, + ]); + + // Song agenda item WITH slides → does NOT count (has service_song_id) + $song = Song::factory()->create(); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'song_arrangement_id' => null, + 'use_translation' => false, + 'order' => 1, + 'cts_song_name' => 'Lobpreis', + 'cts_ccli_id' => '55555', + ]); + $agendaItemSong = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Lobpreis', + 'sort_order' => 4, + 'service_song_id' => $serviceSong->id, + 'is_before_event' => false, + ]); + Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $agendaItemSong->id, + 'type' => 'moderation', + 'uploaded_at' => Carbon::today()->subDay(), + ]); + + $response = $this->actingAs($user)->get(route('services.index')); + + $response->assertOk(); + $response->assertInertia( + fn ($page) => $page + ->component('Services/Index') + ->has('services', 1) + ->where('services.0.agenda_slides_count', 2) + ); + } + + public function test_services_index_sermon_check_nutzt_agenda_matching_wenn_konfiguriert(): void + { + Carbon::setTestNow('2026-03-01 10:00:00'); + $this->withoutVite(); + + $user = User::factory()->create(); + + $service = Service::factory()->create([ + 'title' => 'Service mit Sermon Setting', + 'date' => Carbon::today()->addDays(2), + 'finalized_at' => null, + ]); + + Setting::set('agenda_sermon_matching', 'Predigt*'); + + // Sermon agenda item with slides + $sermonItem = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt: Hoffnung', + 'sort_order' => 1, + 'service_song_id' => null, + 'is_before_event' => false, + ]); + Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $sermonItem->id, + 'type' => 'sermon', + 'uploaded_at' => Carbon::today()->subDay(), + ]); + + // No old-style sermon slides directly on service (without agenda item) + + $response = $this->actingAs($user)->get(route('services.index')); + + $response->assertOk(); + $response->assertInertia( + fn ($page) => $page + ->component('Services/Index') + ->has('services', 1) + ->where('services.0.has_sermon_slides', true) + ); + } + + public function test_services_index_sermon_ohne_agenda_slides_zeigt_false(): void + { + Carbon::setTestNow('2026-03-01 10:00:00'); + $this->withoutVite(); + + $user = User::factory()->create(); + + $service = Service::factory()->create([ + 'title' => 'Service ohne Predigtfolien', + 'date' => Carbon::today()->addDays(2), + 'finalized_at' => null, + ]); + + Setting::set('agenda_sermon_matching', 'Predigt*'); + + // Sermon agenda item WITHOUT slides + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt: Thema', + 'sort_order' => 1, + 'service_song_id' => null, + 'is_before_event' => false, + ]); + + $response = $this->actingAs($user)->get(route('services.index')); + + $response->assertOk(); + $response->assertInertia( + fn ($page) => $page + ->component('Services/Index') + ->has('services', 1) + ->where('services.0.has_sermon_slides', false) + ); + } + public function test_edit_seite_liefert_leere_agenda_items_und_settings(): void { Carbon::setTestNow('2026-03-01 10:00:00');