feat(ui): update service list status columns for agenda model
This commit is contained in:
parent
18d0d6f965
commit
e88079e211
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue