boolean('archived'); $query = Service::query(); if ($archived) { $query->whereDate('date', '<', Carbon::today()) ->orderByDesc('date'); } else { $query->whereDate('date', '>=', Carbon::today()) ->orderBy('date'); } $sermonPatterns = Setting::get('agenda_sermon_matching'); $matcher = $sermonPatterns ? app(AgendaMatcherService::class) : null; $services = $query ->withCount([ 'serviceSongs as songs_total_count', 'serviceSongs as songs_mapped_count' => fn ($query) => $query->whereNotNull('song_id'), 'serviceSongs as songs_arranged_count' => fn ($query) => $query->whereNotNull('song_arrangement_id'), ]) ->addSelect([ 'has_sermon_slides' => Slide::query() ->selectRaw('CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END') ->whereColumn('slides.service_id', 'services.id') ->where('slides.type', 'sermon'), 'info_slides_count' => Slide::query() ->selectRaw('COUNT(*)') ->where('slides.type', 'information') ->whereNull('slides.deleted_at') ->where(function ($query) { $query ->whereNull('slides.service_id') ->orWhereColumn('slides.service_id', 'services.id'); }) ->where(function ($query) { $query->whereNull('slides.expire_date') ->orWhereColumn('slides.expire_date', '>=', 'services.date'); }) ->when( ! $archived, 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(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, 'has_agenda' => (bool) $service->has_agenda, ]; }) ->values(); return Inertia::render('Services/Index', [ 'services' => $services, 'archived' => $archived, ]); } public function edit(Service $service): Response { $service->load([ 'serviceSongs' => fn ($query) => $query->orderBy('order'), 'serviceSongs.song', 'serviceSongs.song.groups', 'serviceSongs.song.arrangements.arrangementGroups.group', 'serviceSongs.arrangement', 'slides', 'agendaItems' => fn ($q) => $q->orderBy('sort_order'), 'agendaItems.slides', 'agendaItems.serviceSong.song.groups.slides', 'agendaItems.serviceSong.song.arrangements.arrangementGroups.group', 'agendaItems.serviceSong.arrangement.arrangementGroups.group', ]); $songsCatalog = Song::query() ->orderBy('title') ->get(['id', 'title', 'ccli_id', 'has_translation']) ->map(fn (Song $song) => [ 'id' => $song->id, 'title' => $song->title, 'ccli_id' => $song->ccli_id, 'has_translation' => $song->has_translation, ]) ->values(); $informationSlides = Slide::query() ->where('type', 'information') ->whereNull('deleted_at') ->where(function ($query) use ($service) { $query ->whereNull('service_id') ->orWhere('service_id', $service->id); }) ->when( $service->date, fn ($query) => $query ->where(function ($q) use ($service) { $q->whereNull('expire_date') ->orWhereDate('expire_date', '>=', $service->date); }) ->when( $service->date->isFuture() || $service->date->isToday(), fn ($q) => $q->whereDate('uploaded_at', '<=', $service->date) ) ) ->orderByDesc('uploaded_at') ->get(); $moderationSlides = $service->slides ->where('type', 'moderation') ->sortByDesc('uploaded_at') ->values(); $sermonSlides = $service->slides ->where('type', 'sermon') ->sortByDesc('uploaded_at') ->values(); $prevService = Service::where('date', '<', $service->date->toDateString()) ->orderByDesc('date') ->first(['id', 'title', 'date']); $nextService = Service::where('date', '>', $service->date->toDateString()) ->orderBy('date') ->first(['id', 'title', 'date']); // Load agenda settings $agendaSettings = [ 'start_title' => Setting::get('agenda_start_title'), 'end_title' => Setting::get('agenda_end_title'), 'announcement_position' => Setting::get('agenda_announcement_position'), 'sermon_matching' => Setting::get('agenda_sermon_matching'), ]; // Filter agenda items (exclude before-event, apply boundary filtering) $matcher = app(AgendaMatcherService::class); $agendaItemsArray = $service->agendaItems ->filter(fn ($i) => ! $i->is_before_event) ->values() ->all(); $filteredItems = $matcher->filterBetween( $agendaItemsArray, $agendaSettings['start_title'], $agendaSettings['end_title'] ); // Add computed flags to each agenda item $announcementPatterns = $agendaSettings['announcement_position']; $sermonPatterns = $agendaSettings['sermon_matching']; $agendaItemsMapped = array_map(function ($item) use ($matcher, $announcementPatterns, $sermonPatterns) { $arr = $item->toArray(); $arr['is_announcement_position'] = $announcementPatterns ? $matcher->matchesAny($item->title, array_map('trim', explode(',', $announcementPatterns))) : false; $arr['is_sermon'] = $sermonPatterns ? $matcher->matchesAny($item->title, array_map('trim', explode(',', $sermonPatterns))) : false; return $arr; }, $filteredItems); return Inertia::render('Services/Edit', [ 'service' => [ 'id' => $service->id, 'title' => $service->title, 'date' => $service->date?->toDateString(), 'preacher_name' => $service->preacher_name, 'beamer_tech_name' => $service->beamer_tech_name, 'finalized_at' => $service->finalized_at?->toJSON(), 'last_synced_at' => $service->last_synced_at?->toJSON(), 'has_agenda' => $service->has_agenda, ], 'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [ 'id' => $ss->id, 'order' => $ss->order, 'cts_song_name' => $ss->cts_song_name, 'cts_ccli_id' => $ss->cts_ccli_id, 'use_translation' => $ss->use_translation, 'song_id' => $ss->song_id, 'song_arrangement_id' => $ss->song_arrangement_id, 'matched_at' => $ss->matched_at?->toJSON(), 'request_sent_at' => $ss->request_sent_at?->toJSON(), 'song' => $ss->song ? [ 'id' => $ss->song->id, 'title' => $ss->song->title, 'ccli_id' => $ss->song->ccli_id, 'has_translation' => $ss->song->has_translation, 'groups' => $ss->song->groups ->sortBy('order') ->values() ->map(fn ($group) => [ 'id' => $group->id, 'name' => $group->name, 'color' => $group->color, 'order' => $group->order, ]), 'arrangements' => $ss->song->arrangements ->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1) ->values() ->map(fn ($arrangement) => [ 'id' => $arrangement->id, 'name' => $arrangement->name, 'is_default' => $arrangement->is_default, 'groups' => $arrangement->arrangementGroups ->sortBy('order') ->values() ->map(fn ($arrangementGroup) => [ 'id' => $arrangementGroup->group?->id, 'name' => $arrangementGroup->group?->name, 'color' => $arrangementGroup->group?->color, ]) ->filter(fn ($group) => $group['id'] !== null) ->values(), ]), ] : null, 'arrangement' => $ss->arrangement ? [ 'id' => $ss->arrangement->id, 'name' => $ss->arrangement->name, ] : null, ]), 'informationSlides' => $informationSlides, 'moderationSlides' => $moderationSlides, 'sermonSlides' => $sermonSlides, 'songsCatalog' => $songsCatalog, 'agendaItems' => $agendaItemsMapped, 'agendaSettings' => $agendaSettings, 'prevService' => $prevService ? [ 'id' => $prevService->id, 'title' => $prevService->title, 'date' => $prevService->date?->toDateString(), ] : null, 'nextService' => $nextService ? [ 'id' => $nextService->id, 'title' => $nextService->title, 'date' => $nextService->date?->toDateString(), ] : null, ]); } public function finalize(Service $service): JsonResponse { $status = $service->finalizationStatus(); $confirmed = request()->boolean('confirmed'); if (! $status['ready'] && ! $confirmed) { return response()->json([ 'needs_confirmation' => true, 'warnings' => $status['warnings'], ]); } $service->update([ 'finalized_at' => now(), ]); return response()->json([ 'needs_confirmation' => false, 'success' => 'Service wurde abgeschlossen.', ]); } public function reopen(Service $service): RedirectResponse { $service->update([ 'finalized_at' => null, ]); return redirect() ->route('services.index') ->with('success', 'Service wurde wieder geoeffnet.'); } public function destroy(Service $service): RedirectResponse { $service->serviceSongs()->delete(); $service->slides()->where('type', '!=', 'information')->delete(); $service->delete(); return redirect() ->route('services.index') ->with('success', 'Service wurde gelöscht und wird beim nächsten Sync neu erstellt.'); } public function download(Service $service): JsonResponse|BinaryFileResponse { if (! $service->finalized_at) { abort(403, 'Nur abgeschlossene Services können heruntergeladen werden.'); } try { $playlistService = new \App\Services\PlaylistExportService; $result = $playlistService->generatePlaylist($service); $response = response()->download($result['path'], $result['filename']); if ($result['skipped'] > 0) { $response->headers->set('X-Skipped-Songs', (string) $result['skipped']); } return $response->deleteFileAfterSend(false); } catch (\RuntimeException $e) { return response()->json(['message' => $e->getMessage()], 422); } } public function downloadBundle(Request $request, Service $service, string $blockType): BinaryFileResponse { $request->merge(['blockType' => $blockType]); $request->validate([ 'blockType' => 'required|in:information,moderation,sermon', ]); $bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType); return response() ->download( $bundlePath, "{$service->id}_{$blockType}.probundle", ['Content-Type' => 'application/zip'] ) ->deleteFileAfterSend(true); } }