feat(ui): update service list status columns for agenda model

This commit is contained in:
Thorsten Bus 2026-03-29 12:18:50 +02:00
parent 18d0d6f965
commit e88079e211
4 changed files with 247 additions and 16 deletions

View file

@ -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

View file

@ -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', [

View file

@ -338,6 +338,15 @@ function stateIconClass(isDone) {
<span>{{ service.info_slides_count }} Infofolien</span>
</div>
<div
v-if="service.agenda_slides_count > 0"
data-testid="agenda-slides-count"
class="flex items-center gap-1.5 text-emerald-700"
>
<span aria-hidden="true"></span>
<span>{{ service.agenda_slides_count }} weitere Folien</span>
</div>
<div :class="stateIconClass(Boolean(service.finalized_at))" class="flex items-center gap-1.5">
<span aria-hidden="true">{{ service.finalized_at ? '✓' : '•' }}</span>
<span>

View file

@ -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');