feat: Wave 3 (partial) - Service Edit page + 4 blocks (T14-T18)
T14: Service Edit Page Layout + Routing
- ServiceController::edit() with eager-loaded relationships
- Services/Edit.vue with 4 collapsible accordion blocks
- Route: GET /services/{service}/edit
- Information slides query: global + service-specific with expire_date filtering
- Tests: 2 new (edit page render + auth guard)
T15: Information Block (Slides + Expire Dates)
- InformationBlock.vue with dynamic expire_date filtering
- Shows slides where type='information' AND expire_date >= service.date
- Global visibility across services (not service-specific)
- SlideUploader with showExpireDate=true
- SlideGrid with prominent expire date + inline editing
- Badge showing slide count + 'expiring soon' warning (within 3 days)
- Tests: 7 new (105 assertions)
T16: Moderation Block (Service-Specific)
- ModerationBlock.vue (service-specific slides)
- Filters: type='moderation' AND service_id = current_service
- No expire date field (unlike Information block)
- Service isolation (slides from Service A don't appear in Service B)
- Tests: 5 new (14 assertions)
T17: Sermon Block (Service-Specific)
- SermonBlock.vue (identical to Moderation but type='sermon')
- Service-specific slides, no expire date
- Tests: 5 new (14 assertions)
T18: Songs Block (Matching + Arrangement + Translation)
- SongsBlock.vue with conditional UI (unmatched vs matched states)
- Unmatched: 'Erstellung anfragen' button + searchable select for manual assign
- Matched: ArrangementConfigurator + translation checkbox + preview/download buttons
- ServiceSongController::update() for use_translation and song_arrangement_id
- ArrangementConfigurator emits 'arrangement-selected' for auto-save
- ServiceController::edit() provides songsCatalog for matching search
- Tests: 2 new (45 assertions)
T19: Song PDF (INCOMPLETE - timeout)
- SongPdfController.php created (partial)
- resources/views/pdf/song.blade.php created (partial)
- SongPreviewModal.vue MISSING
- Tests MISSING
- Will be completed in next commit
All tests passing: 124/124 (703 assertions)
Build: ✓ Vite production build successful
German UI: All user-facing text in German with 'Du' form
Dependencies: barryvdh/laravel-dompdf added for PDF generation
This commit is contained in:
parent
d915f8cfc2
commit
b2d230e991
|
|
@ -14,3 +14,109 @@
|
||||||
- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware.
|
- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware.
|
||||||
- 2026-03-01: `removeTranslation` uses a two-step approach: collect slide IDs via `SongSlide::whereIn('song_group_id', $song->groups()->pluck('id'))` then bulk-update `text_content_translated = null`, avoiding N+1 queries.
|
- 2026-03-01: `removeTranslation` uses a two-step approach: collect slide IDs via `SongSlide::whereIn('song_group_id', $song->groups()->pluck('id'))` then bulk-update `text_content_translated = null`, avoiding N+1 queries.
|
||||||
- 2026-03-01: Der Arrangement-Konfigurator bleibt stabil bei mehrfachen Gruppeninstanzen, wenn die Sequenz mit Vue-Keys im Muster `${group.id}-${index}` gerendert und die Reihenfolge nach jedem Drag-End sofort per `router.put(..., { preserveScroll: true })` gespeichert wird.
|
- 2026-03-01: Der Arrangement-Konfigurator bleibt stabil bei mehrfachen Gruppeninstanzen, wenn die Sequenz mit Vue-Keys im Muster `${group.id}-${index}` gerendert und die Reihenfolge nach jedem Drag-End sofort per `router.put(..., { preserveScroll: true })` gespeichert wird.
|
||||||
|
|
||||||
|
## [2026-03-01] Wave 2 Complete — T8-T13
|
||||||
|
|
||||||
|
### T11: Arrangement Configurator
|
||||||
|
- ArrangementController: store (clone from default), clone (duplicate existing), update (reorder groups), destroy (prevent last)
|
||||||
|
- ArrangementConfigurator.vue: vue-draggable-plus with clone mode for group pills
|
||||||
|
- **CRITICAL**: Vue key for repeating groups MUST be `${group.id}-${index}` NOT just `group.id` (groups repeat in arrangements)
|
||||||
|
- Color picker integration: group_colors array in update payload, applied to SongGroup records
|
||||||
|
- Default arrangement protection: if deleting is_default=true, promote next arrangement to default
|
||||||
|
- Tests: 4 passing (17 assertions)
|
||||||
|
|
||||||
|
### T12: Song Matching Service
|
||||||
|
- SongMatchingService: autoMatch (CCLI), manualAssign, requestCreation (email), unassign
|
||||||
|
- ServiceSongController: API endpoints for /api/service-songs/{id}/assign, /request, /unassign
|
||||||
|
- Auto-match runs during CTS sync (ChurchToolsService updated to call autoMatch)
|
||||||
|
- MissingSongRequest mailable already existed from T7, reused here
|
||||||
|
- matched_at timestamp tracks when assignment occurred
|
||||||
|
- Tests: 14 passing (33 assertions)
|
||||||
|
|
||||||
|
### T13: Translation Service
|
||||||
|
- TranslationService: fetchFromUrl (HTTP + strip_tags), importTranslation (line-count distribution), removeTranslation
|
||||||
|
- TranslationController: POST /translation/fetch-url, POST /songs/{song}/translation/import, DELETE /songs/{song}/translation
|
||||||
|
- Line-count algorithm: for each slide (ordered by group.order, slide.order), take N lines from translated text where N = original slide line count
|
||||||
|
- Best-effort URL scraping: Http::timeout(10), catch all exceptions, return null on failure
|
||||||
|
- has_translation flag on Song model tracks translation state
|
||||||
|
- Tests: 18 passing (18 assertions)
|
||||||
|
|
||||||
|
### Wave 2 Summary
|
||||||
|
- All 6 tasks completed in parallel delegation
|
||||||
|
- T11 timed out during polling but work completed successfully
|
||||||
|
- Total: 103 tests passing (488 assertions)
|
||||||
|
- Vite build: ✓ successful
|
||||||
|
- Commit: d915f8c (27 files, +3951/-25 lines)
|
||||||
|
|
||||||
|
### Next: Wave 3 (T14-T19)
|
||||||
|
- Service Edit page layout + 4 blocks (Information, Moderation, Sermon, Songs)
|
||||||
|
- Song preview modal + PDF download
|
||||||
|
- All blocks integrate SlideUploader/SlideGrid from T10
|
||||||
|
- ArrangementConfigurator from T11 embedded in Songs block
|
||||||
|
|
||||||
|
## [2026-03-01] T16: Moderation Block (Service-Specific Slides)
|
||||||
|
|
||||||
|
- ModerationBlock.vue: Simple wrapper around SlideUploader + SlideGrid with `showExpireDate=false`
|
||||||
|
- Filtering: `type='moderation' AND service_id = current_service` via computed property
|
||||||
|
- SlideUploader/SlideGrid already support `showExpireDate` prop (from T10)
|
||||||
|
- Service-specific filtering ensures moderation slides from Service A don't appear in Service B
|
||||||
|
- No expire_date field anywhere in UI (unlike Information block)
|
||||||
|
- Tests: 5 passing (14 assertions) — verifies service-specific isolation
|
||||||
|
- Vite build: ✓ successful
|
||||||
|
|
||||||
|
### T17: Sermon Block (Service-Specific Slides)
|
||||||
|
- SermonBlock.vue: Identical to ModerationBlock but with `type='sermon'`
|
||||||
|
- Filters slides: `type='sermon' AND service_id = current_service`
|
||||||
|
- Uses SlideUploader with `type="sermon"`, `serviceId={current}`, `showExpireDate={false}`
|
||||||
|
- Uses SlideGrid with `showExpireDate={false}`
|
||||||
|
- Tests: 5 passing (14 assertions) — verifies service-specific filtering, type isolation, no expire_date
|
||||||
|
- Build: ✓ successful
|
||||||
|
- 2026-03-01: Songs-Block nutzt fuer unmatched CTS-Songs eine lokale Such-Eingabe plus gefiltertes Select (Titel/CCLI), danach `POST /api/service-songs/{id}/assign` und soft reload nur von `serviceSongs`.
|
||||||
|
- 2026-03-01: Arrangement-Auswahl wird ueber ein neues `arrangement-selected` Event aus dem ArrangementConfigurator nach oben gemeldet und per `PATCH /api/service-songs/{id}` als `song_arrangement_id` sofort gespeichert.
|
||||||
|
- 2026-03-01: Translation-Toggle im Songs-Block speichert direkt per `PATCH /api/service-songs/{id}` (`use_translation`) und bleibt so ohne separaten Save-Button konsistent mit dem Auto-Save-Prinzip.
|
||||||
|
|
||||||
|
## [2026-03-01] T15: Information Block (Slides + Expire Dates)
|
||||||
|
|
||||||
|
- InformationBlock.vue: Wraps SlideUploader + SlideGrid with `showExpireDate=true` and `serviceId=null` (global slides)
|
||||||
|
- Server-side filtering in ServiceController.edit(): `type='information' AND expire_date >= service.date AND (service_id IS NULL OR service_id = current)`
|
||||||
|
- Information slides are GLOBAL — not tied to a specific service, appear in all services where `expire_date >= service.date`
|
||||||
|
- The `whereNull('deleted_at')` in edit() query is redundant with SoftDeletes trait but harmless
|
||||||
|
- Slides without expire_date are excluded from information block (require scheduling)
|
||||||
|
- SlideUploader passes `serviceId=null` for information slides (they're global, not service-specific)
|
||||||
|
- ExpiringSoonCount computed: badges warn when slides expire within 3 days of service date
|
||||||
|
- Edit.vue updated: replaced placeholder for 'information' block key with actual InformationBlock component
|
||||||
|
- `router.reload({ preserveScroll: true })` used for refreshing page after slide upload/delete/update
|
||||||
|
- Tests: 7 passing (105 assertions) — covers expire date filtering, global visibility, soft-delete exclusion, type isolation, null expire_date, ordering
|
||||||
|
- Full suite: 122 tests passing (658 assertions)
|
||||||
|
- Vite build: ✓ successful
|
||||||
|
|
||||||
|
|
||||||
|
## [2026-03-01] T14: Service Edit Page Layout + Routing
|
||||||
|
|
||||||
|
### ServiceController::edit()
|
||||||
|
- Eager-loads `serviceSongs` (ordered), `serviceSongs.song`, `serviceSongs.arrangement`, `slides`
|
||||||
|
- Information slides query is complex: global (service_id=null) + service-specific, filtered by expire_date >= service.date
|
||||||
|
- Moderation/sermon slides filtered from loaded `$service->slides` collection via `->where('type', ...)`
|
||||||
|
- Service data returned as explicit array (not full model) to control frontend shape
|
||||||
|
- serviceSongs mapped to include nested song/arrangement data as null-safe arrays
|
||||||
|
|
||||||
|
### Edit.vue Page Pattern
|
||||||
|
- Uses collapsible accordion blocks (expandedBlocks ref with boolean per key)
|
||||||
|
- 4 blocks: Information, Moderation, Predigt, Songs — each with colored gradient icon, badge count, chevron toggle
|
||||||
|
- Block content area is placeholder div (T15-T18 will replace with actual components)
|
||||||
|
- Vue Transition with max-h trick for collapse animation
|
||||||
|
- `router.get(route('services.index'))` for back navigation
|
||||||
|
- German date format: `toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' })`
|
||||||
|
|
||||||
|
### Index.vue Integration
|
||||||
|
- "Bearbeiten" button now uses `router.get(route('services.edit', service.id))` instead of showComingSoon
|
||||||
|
|
||||||
|
### Route
|
||||||
|
- `GET /services/{service}/edit` → `services.edit` named route, uses implicit model binding
|
||||||
|
- Placed after finalize/reopen POST routes in web.php
|
||||||
|
|
||||||
|
### Test Pattern
|
||||||
|
- PHPUnit style (not Pest) in this project's ServiceControllerTest
|
||||||
|
- `$this->withoutVite()` required for Inertia page assertion tests
|
||||||
|
- `assertInertia(fn ($page) => $page->component(...)->has(...)->where(...))` for deep prop assertions
|
||||||
|
- Auth test: unauthenticated GET redirects to login route
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
|
use App\Models\Song;
|
||||||
use App\Models\Slide;
|
use App\Models\Slide;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
@ -60,6 +61,120 @@ public function index(): Response
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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
|
||||||
|
->whereNotNull('expire_date')
|
||||||
|
->whereDate('expire_date', '>=', $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();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
],
|
||||||
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function finalize(Service $service): RedirectResponse
|
public function finalize(Service $service): RedirectResponse
|
||||||
{
|
{
|
||||||
$service->update([
|
$service->update([
|
||||||
|
|
|
||||||
|
|
@ -64,4 +64,40 @@ public function unassign(int $serviceSongId): JsonResponse
|
||||||
'service_song' => $serviceSong->fresh(),
|
'service_song' => $serviceSong->fresh(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function update(int $serviceSongId, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'song_arrangement_id' => ['nullable', 'integer', 'exists:song_arrangements,id'],
|
||||||
|
'use_translation' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$serviceSong = ServiceSong::with('song')->findOrFail($serviceSongId);
|
||||||
|
|
||||||
|
if (array_key_exists('song_arrangement_id', $validated) && $validated['song_arrangement_id'] !== null) {
|
||||||
|
if ($serviceSong->song_id === null || $serviceSong->song === null) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Arrangement kann ohne zugeordneten Song nicht gespeichert werden.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isValidArrangement = $serviceSong->song
|
||||||
|
->arrangements()
|
||||||
|
->whereKey($validated['song_arrangement_id'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $isValidArrangement) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Das Arrangement gehoert nicht zu diesem Song.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceSong->update($validated);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Service-Song wurde aktualisiert.',
|
||||||
|
'service_song' => $serviceSong->fresh(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
app/Http/Controllers/SongPdfController.php
Normal file
44
app/Http/Controllers/SongPdfController.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\SongArrangement;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class SongPdfController extends Controller
|
||||||
|
{
|
||||||
|
public function download(Song $song, SongArrangement $arrangement): Response
|
||||||
|
{
|
||||||
|
abort_unless($arrangement->song_id === $song->id, 404);
|
||||||
|
|
||||||
|
$arrangement->load([
|
||||||
|
'arrangementGroups' => fn ($query) => $query->orderBy('order'),
|
||||||
|
'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$groupsInOrder = $arrangement->arrangementGroups->map(function ($arrangementGroup) {
|
||||||
|
$group = $arrangementGroup->group;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $group->name,
|
||||||
|
'color' => $group->color ?? '#6b7280',
|
||||||
|
'slides' => $group->slides->map(fn ($slide) => [
|
||||||
|
'text_content' => $slide->text_content,
|
||||||
|
'text_content_translated' => $slide->text_content_translated,
|
||||||
|
])->values()->all(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$pdf = Pdf::loadView('pdf.song', [
|
||||||
|
'song' => $song,
|
||||||
|
'arrangement' => $arrangement,
|
||||||
|
'groupsInOrder' => $groupsInOrder,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$filename = str($song->title)->slug() . '-' . str($arrangement->name)->slug() . '.pdf';
|
||||||
|
|
||||||
|
return $pdf->download($filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"5pm-hdh/churchtools-api": "^2.1",
|
"5pm-hdh/churchtools-api": "^2.1",
|
||||||
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"intervention/image": "^3.11",
|
"intervention/image": "^3.11",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
|
|
||||||
524
composer.lock
generated
524
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f9b57143fc4f1ac36c3d47c263788518",
|
"content-hash": "60fa6caea53a668cea289b6864429e5f",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "5pm-hdh/churchtools-api",
|
"name": "5pm-hdh/churchtools-api",
|
||||||
|
|
@ -54,6 +54,83 @@
|
||||||
},
|
},
|
||||||
"time": "2024-08-01T06:09:22+00:00"
|
"time": "2024-08-01T06:09:22+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "barryvdh/laravel-dompdf",
|
||||||
|
"version": "v3.1.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/barryvdh/laravel-dompdf.git",
|
||||||
|
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
|
||||||
|
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"dompdf/dompdf": "^3.0",
|
||||||
|
"illuminate/support": "^9|^10|^11|^12",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"larastan/larastan": "^2.7|^3.0",
|
||||||
|
"orchestra/testbench": "^7|^8|^9|^10",
|
||||||
|
"phpro/grumphp": "^2.5",
|
||||||
|
"squizlabs/php_codesniffer": "^3.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
|
||||||
|
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Barryvdh\\DomPDF\\ServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Barryvdh\\DomPDF\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Barry vd. Heuvel",
|
||||||
|
"email": "barryvdh@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A DOMPDF Wrapper for Laravel",
|
||||||
|
"keywords": [
|
||||||
|
"dompdf",
|
||||||
|
"laravel",
|
||||||
|
"pdf"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
|
||||||
|
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://fruitcake.nl",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/barryvdh",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-02-13T15:07:54+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
"version": "0.14.8",
|
"version": "0.14.8",
|
||||||
|
|
@ -525,6 +602,161 @@
|
||||||
],
|
],
|
||||||
"time": "2024-02-05T11:56:58+00:00"
|
"time": "2024-02-05T11:56:58+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "dompdf/dompdf",
|
||||||
|
"version": "v3.1.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dompdf/dompdf.git",
|
||||||
|
"reference": "db712c90c5b9868df3600e64e68da62e78a34623"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
|
||||||
|
"reference": "db712c90c5b9868df3600e64e68da62e78a34623",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"dompdf/php-font-lib": "^1.0.0",
|
||||||
|
"dompdf/php-svg-lib": "^1.0.0",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"masterminds/html5": "^2.0",
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"mockery/mockery": "^1.3",
|
||||||
|
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||||
|
"squizlabs/php_codesniffer": "^3.5",
|
||||||
|
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-gd": "Needed to process images",
|
||||||
|
"ext-gmagick": "Improves image processing performance",
|
||||||
|
"ext-imagick": "Improves image processing performance",
|
||||||
|
"ext-zlib": "Needed for pdf stream compression"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Dompdf\\": "src/"
|
||||||
|
},
|
||||||
|
"classmap": [
|
||||||
|
"lib/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"LGPL-2.1"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "The Dompdf Community",
|
||||||
|
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||||
|
"homepage": "https://github.com/dompdf/dompdf",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||||
|
"source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
|
||||||
|
},
|
||||||
|
"time": "2025-10-29T12:43:30+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dompdf/php-font-lib",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||||
|
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||||
|
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"FontLib\\": "src/FontLib"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"LGPL-2.1-or-later"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "The FontLib Community",
|
||||||
|
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||||
|
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||||
|
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
|
||||||
|
},
|
||||||
|
"time": "2026-01-20T14:10:26+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dompdf/php-svg-lib",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||||
|
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||||
|
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"php": "^7.1 || ^8.0",
|
||||||
|
"sabberworm/php-css-parser": "^8.4 || ^9.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Svg\\": "src/Svg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"LGPL-3.0-or-later"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "The SvgLib Community",
|
||||||
|
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A library to read, parse and export to PDF SVG files.",
|
||||||
|
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||||
|
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
|
||||||
|
},
|
||||||
|
"time": "2026-01-02T16:01:13+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "dragonmantank/cron-expression",
|
"name": "dragonmantank/cron-expression",
|
||||||
"version": "v3.6.0",
|
"version": "v3.6.0",
|
||||||
|
|
@ -2655,6 +2887,73 @@
|
||||||
],
|
],
|
||||||
"time": "2026-01-15T06:54:53+00:00"
|
"time": "2026-01-15T06:54:53+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "masterminds/html5",
|
||||||
|
"version": "2.10.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Masterminds/html5-php.git",
|
||||||
|
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
|
||||||
|
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"php": ">=5.3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.7-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Masterminds\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Matt Butcher",
|
||||||
|
"email": "technosophos@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Matt Farina",
|
||||||
|
"email": "matt@mattfarina.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Asmir Mustafic",
|
||||||
|
"email": "goetas@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "An HTML5 parser and serializer.",
|
||||||
|
"homepage": "http://masterminds.github.io/html5-php",
|
||||||
|
"keywords": [
|
||||||
|
"HTML5",
|
||||||
|
"dom",
|
||||||
|
"html",
|
||||||
|
"parser",
|
||||||
|
"querypath",
|
||||||
|
"serializer",
|
||||||
|
"xml"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||||
|
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
|
||||||
|
},
|
||||||
|
"time": "2025-07-25T09:04:22+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
|
|
@ -4159,6 +4458,86 @@
|
||||||
},
|
},
|
||||||
"time": "2025-12-14T04:43:48+00:00"
|
"time": "2025-12-14T04:43:48+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sabberworm/php-css-parser",
|
||||||
|
"version": "v9.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||||
|
"reference": "59373045e11ad47b5c18fc615feee0219e42f6d3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/59373045e11ad47b5c18fc615feee0219e42f6d3",
|
||||||
|
"reference": "59373045e11ad47b5c18fc615feee0219e42f6d3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||||
|
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||||
|
"phpstan/extension-installer": "1.4.3",
|
||||||
|
"phpstan/phpstan": "1.12.32 || 2.1.32",
|
||||||
|
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
|
||||||
|
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
|
||||||
|
"phpunit/phpunit": "8.5.52",
|
||||||
|
"rawr/phpunit-data-provider": "3.3.1",
|
||||||
|
"rector/rector": "1.2.10 || 2.2.8",
|
||||||
|
"rector/type-perfect": "1.0.0 || 2.1.0",
|
||||||
|
"squizlabs/php_codesniffer": "4.0.1",
|
||||||
|
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "9.3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/Rule/Rule.php",
|
||||||
|
"src/RuleSet/RuleContainer.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Sabberworm\\CSS\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Raphael Schweikert"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Oliver Klee",
|
||||||
|
"email": "github@oliverklee.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jake Hotson",
|
||||||
|
"email": "jake.github@qzdesign.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Parser for CSS Files written in PHP",
|
||||||
|
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||||
|
"keywords": [
|
||||||
|
"css",
|
||||||
|
"parser",
|
||||||
|
"stylesheet"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||||
|
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.2.0"
|
||||||
|
},
|
||||||
|
"time": "2026-02-21T17:12:03+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "spatie/pdf-to-image",
|
"name": "spatie/pdf-to-image",
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
|
|
@ -6711,6 +7090,149 @@
|
||||||
],
|
],
|
||||||
"time": "2026-02-15T10:53:20+00:00"
|
"time": "2026-02-15T10:53:20+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "thecodingmachine/safe",
|
||||||
|
"version": "v3.4.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thecodingmachine/safe.git",
|
||||||
|
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||||
|
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"php-parallel-lint/php-parallel-lint": "^1.4",
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpunit/phpunit": "^10",
|
||||||
|
"squizlabs/php_codesniffer": "^3.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"lib/special_cases.php",
|
||||||
|
"generated/apache.php",
|
||||||
|
"generated/apcu.php",
|
||||||
|
"generated/array.php",
|
||||||
|
"generated/bzip2.php",
|
||||||
|
"generated/calendar.php",
|
||||||
|
"generated/classobj.php",
|
||||||
|
"generated/com.php",
|
||||||
|
"generated/cubrid.php",
|
||||||
|
"generated/curl.php",
|
||||||
|
"generated/datetime.php",
|
||||||
|
"generated/dir.php",
|
||||||
|
"generated/eio.php",
|
||||||
|
"generated/errorfunc.php",
|
||||||
|
"generated/exec.php",
|
||||||
|
"generated/fileinfo.php",
|
||||||
|
"generated/filesystem.php",
|
||||||
|
"generated/filter.php",
|
||||||
|
"generated/fpm.php",
|
||||||
|
"generated/ftp.php",
|
||||||
|
"generated/funchand.php",
|
||||||
|
"generated/gettext.php",
|
||||||
|
"generated/gmp.php",
|
||||||
|
"generated/gnupg.php",
|
||||||
|
"generated/hash.php",
|
||||||
|
"generated/ibase.php",
|
||||||
|
"generated/ibmDb2.php",
|
||||||
|
"generated/iconv.php",
|
||||||
|
"generated/image.php",
|
||||||
|
"generated/imap.php",
|
||||||
|
"generated/info.php",
|
||||||
|
"generated/inotify.php",
|
||||||
|
"generated/json.php",
|
||||||
|
"generated/ldap.php",
|
||||||
|
"generated/libxml.php",
|
||||||
|
"generated/lzf.php",
|
||||||
|
"generated/mailparse.php",
|
||||||
|
"generated/mbstring.php",
|
||||||
|
"generated/misc.php",
|
||||||
|
"generated/mysql.php",
|
||||||
|
"generated/mysqli.php",
|
||||||
|
"generated/network.php",
|
||||||
|
"generated/oci8.php",
|
||||||
|
"generated/opcache.php",
|
||||||
|
"generated/openssl.php",
|
||||||
|
"generated/outcontrol.php",
|
||||||
|
"generated/pcntl.php",
|
||||||
|
"generated/pcre.php",
|
||||||
|
"generated/pgsql.php",
|
||||||
|
"generated/posix.php",
|
||||||
|
"generated/ps.php",
|
||||||
|
"generated/pspell.php",
|
||||||
|
"generated/readline.php",
|
||||||
|
"generated/rnp.php",
|
||||||
|
"generated/rpminfo.php",
|
||||||
|
"generated/rrd.php",
|
||||||
|
"generated/sem.php",
|
||||||
|
"generated/session.php",
|
||||||
|
"generated/shmop.php",
|
||||||
|
"generated/sockets.php",
|
||||||
|
"generated/sodium.php",
|
||||||
|
"generated/solr.php",
|
||||||
|
"generated/spl.php",
|
||||||
|
"generated/sqlsrv.php",
|
||||||
|
"generated/ssdeep.php",
|
||||||
|
"generated/ssh2.php",
|
||||||
|
"generated/stream.php",
|
||||||
|
"generated/strings.php",
|
||||||
|
"generated/swoole.php",
|
||||||
|
"generated/uodbc.php",
|
||||||
|
"generated/uopz.php",
|
||||||
|
"generated/url.php",
|
||||||
|
"generated/var.php",
|
||||||
|
"generated/xdiff.php",
|
||||||
|
"generated/xml.php",
|
||||||
|
"generated/xmlrpc.php",
|
||||||
|
"generated/yaml.php",
|
||||||
|
"generated/yaz.php",
|
||||||
|
"generated/zip.php",
|
||||||
|
"generated/zlib.php"
|
||||||
|
],
|
||||||
|
"classmap": [
|
||||||
|
"lib/DateTime.php",
|
||||||
|
"lib/DateTimeImmutable.php",
|
||||||
|
"lib/Exceptions/",
|
||||||
|
"generated/Exceptions/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/thecodingmachine/safe/issues",
|
||||||
|
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/OskarStark",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/shish",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/silasjoisten",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/staabm",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-02-04T18:08:13+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "tightenco/ziggy",
|
"name": "tightenco/ziggy",
|
||||||
"version": "v2.6.1",
|
"version": "v2.6.1",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['arrangement-selected'])
|
||||||
|
|
||||||
const selectedId = ref(
|
const selectedId = ref(
|
||||||
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? null,
|
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? null,
|
||||||
)
|
)
|
||||||
|
|
@ -49,6 +51,13 @@ watch(
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedId,
|
||||||
|
(arrangementId) => {
|
||||||
|
emit('arrangement-selected', Number(arrangementId))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const colorMap = computed(() => {
|
const colorMap = computed(() => {
|
||||||
const map = {}
|
const map = {}
|
||||||
|
|
||||||
|
|
|
||||||
127
resources/js/Components/Blocks/InformationBlock.vue
Normal file
127
resources/js/Components/Blocks/InformationBlock.vue
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import SlideUploader from '@/Components/SlideUploader.vue'
|
||||||
|
import SlideGrid from '@/Components/SlideGrid.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serviceId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
serviceDate: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
slides: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['slides-updated'])
|
||||||
|
|
||||||
|
// Information slides are already filtered server-side (expire_date >= service.date)
|
||||||
|
// but we keep a computed for any additional client-side needs
|
||||||
|
const informationSlides = computed(() => {
|
||||||
|
return props.slides.filter((slide) => slide.type === 'information')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Count slides expiring within 3 days of service date for the warning badge
|
||||||
|
const expiringSoonCount = computed(() => {
|
||||||
|
const serviceDate = new Date(props.serviceDate)
|
||||||
|
const threeDaysAfter = new Date(serviceDate)
|
||||||
|
threeDaysAfter.setDate(threeDaysAfter.getDate() + 3)
|
||||||
|
|
||||||
|
return informationSlides.value.filter((slide) => {
|
||||||
|
if (!slide.expire_date) return false
|
||||||
|
const expDate = new Date(slide.expire_date)
|
||||||
|
return expDate >= serviceDate && expDate <= threeDaysAfter
|
||||||
|
}).length
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSlideUploaded() {
|
||||||
|
emit('slides-updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlideDeleted() {
|
||||||
|
emit('slides-updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlideUpdated() {
|
||||||
|
emit('slides-updated')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="information-block space-y-6">
|
||||||
|
<!-- Block header -->
|
||||||
|
<div class="border-b border-amber-200/60 pb-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Info icon with warm glow -->
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 ring-1 ring-amber-200/60">
|
||||||
|
<svg class="h-5 w-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
Informationsfolien
|
||||||
|
</h3>
|
||||||
|
<p class="mt-0.5 text-sm text-gray-500">
|
||||||
|
Globale Folien — sichtbar in allen Gottesdiensten bis zum Ablaufdatum
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide count badges -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
v-if="informationSlides.length > 0"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 ring-1 ring-amber-200/80"
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||||
|
</svg>
|
||||||
|
{{ informationSlides.length }} {{ informationSlides.length === 1 ? 'Folie' : 'Folien' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="expiringSoonCount > 0"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full bg-orange-50 px-2.5 py-1 text-xs font-semibold text-orange-700 ring-1 ring-orange-300/80"
|
||||||
|
:title="`${expiringSoonCount} Folie(n) laufen bald ab`"
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
{{ expiringSoonCount }} läuft bald ab
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide uploader — information slides are GLOBAL (service_id = null) -->
|
||||||
|
<SlideUploader
|
||||||
|
type="information"
|
||||||
|
:service-id="null"
|
||||||
|
:show-expire-date="true"
|
||||||
|
@uploaded="handleSlideUploaded"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Slide grid with prominent expire dates -->
|
||||||
|
<SlideGrid
|
||||||
|
:slides="informationSlides"
|
||||||
|
type="information"
|
||||||
|
:show-expire-date="true"
|
||||||
|
@deleted="handleSlideDeleted"
|
||||||
|
@updated="handleSlideUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.information-block {
|
||||||
|
/* Subtle warm background hint for visual distinction */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
resources/js/Components/Blocks/ModerationBlock.vue
Normal file
74
resources/js/Components/Blocks/ModerationBlock.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import SlideUploader from '@/Components/SlideUploader.vue'
|
||||||
|
import SlideGrid from '@/Components/SlideGrid.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serviceId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
slides: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['slides-updated'])
|
||||||
|
|
||||||
|
// Filter slides to only show moderation slides for this service
|
||||||
|
const moderationSlides = computed(() => {
|
||||||
|
return props.slides.filter(
|
||||||
|
(slide) => slide.type === 'moderation' && slide.service_id === props.serviceId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSlideUploaded() {
|
||||||
|
emit('slides-updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlideDeleted() {
|
||||||
|
emit('slides-updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlideUpdated() {
|
||||||
|
emit('slides-updated')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="moderation-block space-y-6">
|
||||||
|
<!-- Block header -->
|
||||||
|
<div class="border-b border-gray-200 pb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
Moderationsfolien
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Folien für diesen Gottesdienst
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide uploader -->
|
||||||
|
<SlideUploader
|
||||||
|
type="moderation"
|
||||||
|
:service-id="serviceId"
|
||||||
|
:show-expire-date="false"
|
||||||
|
@uploaded="handleSlideUploaded"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Slide grid -->
|
||||||
|
<SlideGrid
|
||||||
|
:slides="moderationSlides"
|
||||||
|
type="moderation"
|
||||||
|
:show-expire-date="false"
|
||||||
|
@deleted="handleSlideDeleted"
|
||||||
|
@updated="handleSlideUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.moderation-block {
|
||||||
|
/* Component-specific styles if needed */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
resources/js/Components/Blocks/SermonBlock.vue
Normal file
74
resources/js/Components/Blocks/SermonBlock.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import SlideUploader from '@/Components/SlideUploader.vue'
|
||||||
|
import SlideGrid from '@/Components/SlideGrid.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serviceId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
slides: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['slides-updated'])
|
||||||
|
|
||||||
|
// Filter slides to only show sermon slides for this service
|
||||||
|
const sermonSlides = computed(() => {
|
||||||
|
return props.slides.filter(
|
||||||
|
(slide) => slide.type === 'sermon' && slide.service_id === props.serviceId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSlideUploaded() {
|
||||||
|
emit('slides-updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlideDeleted() {
|
||||||
|
emit('slides-updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlideUpdated() {
|
||||||
|
emit('slides-updated')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sermon-block space-y-6">
|
||||||
|
<!-- Block header -->
|
||||||
|
<div class="border-b border-gray-200 pb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
Predigtfolien
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Folien für diesen Gottesdienst
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide uploader -->
|
||||||
|
<SlideUploader
|
||||||
|
type="sermon"
|
||||||
|
:service-id="serviceId"
|
||||||
|
:show-expire-date="false"
|
||||||
|
@uploaded="handleSlideUploaded"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Slide grid -->
|
||||||
|
<SlideGrid
|
||||||
|
:slides="sermonSlides"
|
||||||
|
type="sermon"
|
||||||
|
:show-expire-date="false"
|
||||||
|
@deleted="handleSlideDeleted"
|
||||||
|
@updated="handleSlideUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sermon-block {
|
||||||
|
/* Component-specific styles if needed */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
356
resources/js/Components/Blocks/SongsBlock.vue
Normal file
356
resources/js/Components/Blocks/SongsBlock.vue
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
<script setup>
|
||||||
|
import ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue'
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serviceSongs: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
songsCatalog: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const toastMessage = ref('')
|
||||||
|
const toastVariant = ref('info')
|
||||||
|
|
||||||
|
const selectedSongIds = reactive({})
|
||||||
|
const searchTerms = reactive({})
|
||||||
|
const translationValues = reactive({})
|
||||||
|
|
||||||
|
const showToast = (message, variant = 'info') => {
|
||||||
|
toastMessage.value = message
|
||||||
|
toastVariant.value = variant
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toastMessage.value = ''
|
||||||
|
}, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (value) => (value ?? '').toString().toLowerCase().trim()
|
||||||
|
|
||||||
|
const initLocalState = () => {
|
||||||
|
props.serviceSongs.forEach((serviceSong) => {
|
||||||
|
selectedSongIds[serviceSong.id] = selectedSongIds[serviceSong.id] ?? ''
|
||||||
|
searchTerms[serviceSong.id] = searchTerms[serviceSong.id] ?? ''
|
||||||
|
translationValues[serviceSong.id] = serviceSong.use_translation
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.serviceSongs,
|
||||||
|
() => {
|
||||||
|
initLocalState()
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedSongs = () => {
|
||||||
|
return [...props.serviceSongs].sort((a, b) => a.order - b.order)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCatalog = (serviceSongId) => {
|
||||||
|
const term = normalize(searchTerms[serviceSongId])
|
||||||
|
|
||||||
|
if (term === '') {
|
||||||
|
return props.songsCatalog.slice(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.songsCatalog
|
||||||
|
.filter((song) => normalize(song.title).includes(term) || normalize(song.ccli_id).includes(term))
|
||||||
|
.slice(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignSong = async (serviceSongId) => {
|
||||||
|
const selectedSongId = Number(selectedSongIds[serviceSongId])
|
||||||
|
|
||||||
|
if (!selectedSongId) {
|
||||||
|
showToast('Bitte waehle zuerst einen Song aus.', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.axios.post(`/api/service-songs/${serviceSongId}/assign`, {
|
||||||
|
song_id: selectedSongId,
|
||||||
|
})
|
||||||
|
|
||||||
|
selectedSongIds[serviceSongId] = ''
|
||||||
|
searchTerms[serviceSongId] = ''
|
||||||
|
showToast('Song wurde zugeordnet.', 'success')
|
||||||
|
|
||||||
|
router.reload({
|
||||||
|
only: ['serviceSongs'],
|
||||||
|
preserveScroll: true,
|
||||||
|
preserveState: true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
showToast('Zuordnung fehlgeschlagen.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestCreation = async (serviceSongId) => {
|
||||||
|
try {
|
||||||
|
await window.axios.post(`/api/service-songs/${serviceSongId}/request`)
|
||||||
|
|
||||||
|
showToast('Anfrage wurde gesendet.', 'success')
|
||||||
|
|
||||||
|
router.reload({
|
||||||
|
only: ['serviceSongs'],
|
||||||
|
preserveScroll: true,
|
||||||
|
preserveState: true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
showToast('Anfrage konnte nicht gesendet werden.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateUseTranslation = async (serviceSongId) => {
|
||||||
|
try {
|
||||||
|
await window.axios.patch(`/api/service-songs/${serviceSongId}`, {
|
||||||
|
use_translation: Boolean(translationValues[serviceSongId]),
|
||||||
|
})
|
||||||
|
|
||||||
|
showToast('Uebersetzung wurde gespeichert.', 'success')
|
||||||
|
} catch {
|
||||||
|
showToast('Speichern fehlgeschlagen.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateArrangementSelection = async (serviceSongId, arrangementId) => {
|
||||||
|
try {
|
||||||
|
await window.axios.patch(`/api/service-songs/${serviceSongId}`, {
|
||||||
|
song_arrangement_id: arrangementId,
|
||||||
|
})
|
||||||
|
|
||||||
|
showToast('Arrangement wurde gespeichert.', 'success')
|
||||||
|
} catch {
|
||||||
|
showToast('Arrangement konnte nicht gespeichert werden.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPlaceholder = () => {
|
||||||
|
showToast('Demnaechst verfuegbar')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(value).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastClasses = () => {
|
||||||
|
if (toastVariant.value === 'success') {
|
||||||
|
return 'border-emerald-300 bg-emerald-50 text-emerald-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toastVariant.value === 'warning') {
|
||||||
|
return 'border-amber-300 bg-amber-50 text-amber-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toastVariant.value === 'error') {
|
||||||
|
return 'border-red-300 bg-red-50 text-red-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'border-slate-300 bg-slate-50 text-slate-700'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="translate-y-1 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="toastMessage"
|
||||||
|
class="rounded-lg border px-4 py-3 text-sm font-medium"
|
||||||
|
:class="toastClasses()"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toastMessage }}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="serviceSong in sortedSongs()"
|
||||||
|
:key="serviceSong.id"
|
||||||
|
class="space-y-4 rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Song {{ serviceSong.order }}
|
||||||
|
</p>
|
||||||
|
<h4 class="mt-1 text-lg font-semibold text-gray-900">
|
||||||
|
{{ serviceSong.cts_song_name || '-' }}
|
||||||
|
</h4>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-600">
|
||||||
|
<span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium">
|
||||||
|
CCLI: {{ serviceSong.cts_ccli_id || '-' }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium">
|
||||||
|
Hat Uebersetzung:
|
||||||
|
{{ serviceSong.song?.has_translation ? 'Ja' : 'Nein' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="serviceSong.song_id"
|
||||||
|
class="inline-flex items-center rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700"
|
||||||
|
>
|
||||||
|
Zugeordnet
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||||
|
>
|
||||||
|
Nicht zugeordnet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!serviceSong.song_id"
|
||||||
|
class="space-y-4 rounded-lg border border-amber-200 bg-amber-50/40 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-amber-900">
|
||||||
|
Dieser CTS-Song ist noch nicht in der Song-DB verknuepft.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="serviceSong.request_sent_at"
|
||||||
|
class="mt-1 text-xs text-amber-700"
|
||||||
|
>
|
||||||
|
Anfrage gesendet am {{ formatDateTime(serviceSong.request_sent_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-md border border-amber-300 bg-white px-3 py-2 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
|
||||||
|
@click="requestCreation(serviceSong.id)"
|
||||||
|
>
|
||||||
|
Erstellung anfragen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-[1fr,1fr,auto] md:items-end">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
|
Song suchen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="searchTerms[serviceSong.id]"
|
||||||
|
type="text"
|
||||||
|
placeholder="Titel oder CCLI eingeben"
|
||||||
|
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
|
Song aus DB auswaehlen
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedSongIds[serviceSong.id]"
|
||||||
|
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Bitte auswaehlen
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="song in filteredCatalog(serviceSong.id)"
|
||||||
|
:key="song.id"
|
||||||
|
:value="song.id"
|
||||||
|
>
|
||||||
|
{{ song.title }} (CCLI: {{ song.ccli_id || '-' }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700"
|
||||||
|
@click="assignSong(serviceSong.id)"
|
||||||
|
>
|
||||||
|
Zuordnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Zugeordnet zu:
|
||||||
|
<span class="font-semibold text-gray-900">
|
||||||
|
{{ serviceSong.song?.title }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="serviceSong.song?.has_translation"
|
||||||
|
class="inline-flex items-center gap-2 text-sm font-medium text-gray-800"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="translationValues[serviceSong.id]"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
|
||||||
|
@change="updateUseTranslation(serviceSong.id)"
|
||||||
|
>
|
||||||
|
Uebersetzung verwenden
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArrangementConfigurator
|
||||||
|
:song-id="serviceSong.song.id"
|
||||||
|
:arrangements="serviceSong.song.arrangements || []"
|
||||||
|
:available-groups="serviceSong.song.groups || []"
|
||||||
|
:selected-arrangement-id="serviceSong.song_arrangement_id"
|
||||||
|
@arrangement-selected="(arrangementId) => updateArrangementSelection(serviceSong.id, arrangementId)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||||
|
@click="showPlaceholder"
|
||||||
|
>
|
||||||
|
Vorschau
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||||
|
@click="showPlaceholder"
|
||||||
|
>
|
||||||
|
PDF herunterladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="serviceSongs.length === 0"
|
||||||
|
class="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-10 text-center"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-gray-600">
|
||||||
|
Fuer diesen Service sind aktuell keine Songs vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
296
resources/js/Pages/Services/Edit.vue
Normal file
296
resources/js/Pages/Services/Edit.vue
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
<script setup>
|
||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||||
|
import { Head, router } from '@inertiajs/vue3'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import InformationBlock from '@/Components/Blocks/InformationBlock.vue'
|
||||||
|
import ModerationBlock from '@/Components/Blocks/ModerationBlock.vue'
|
||||||
|
import SongsBlock from '@/Components/Blocks/SongsBlock.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
service: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
serviceSongs: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
informationSlides: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
moderationSlides: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
sermonSlides: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
songsCatalog: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!props.service.date) return ''
|
||||||
|
return new Date(props.service.date).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ── Collapsible block state ─────────────────────────────── */
|
||||||
|
const expandedBlocks = ref({
|
||||||
|
information: true,
|
||||||
|
moderation: true,
|
||||||
|
sermon: true,
|
||||||
|
songs: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleBlock(key) {
|
||||||
|
expandedBlocks.value[key] = !expandedBlocks.value[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.get(route('services.index'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Block definitions ───────────────────────────────────── */
|
||||||
|
const blocks = [
|
||||||
|
{
|
||||||
|
key: 'information',
|
||||||
|
label: 'Information',
|
||||||
|
description: 'Info-Folien fuer alle kommenden Services',
|
||||||
|
icon: 'info',
|
||||||
|
accentFrom: 'from-sky-400',
|
||||||
|
accentTo: 'to-blue-500',
|
||||||
|
badgeColor: 'bg-sky-100 text-sky-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'moderation',
|
||||||
|
label: 'Moderation',
|
||||||
|
description: 'Moderationsfolien fuer diesen Service',
|
||||||
|
icon: 'moderation',
|
||||||
|
accentFrom: 'from-violet-400',
|
||||||
|
accentTo: 'to-purple-500',
|
||||||
|
badgeColor: 'bg-violet-100 text-violet-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sermon',
|
||||||
|
label: 'Predigt',
|
||||||
|
description: 'Predigtfolien fuer diesen Service',
|
||||||
|
icon: 'sermon',
|
||||||
|
accentFrom: 'from-amber-400',
|
||||||
|
accentTo: 'to-orange-500',
|
||||||
|
badgeColor: 'bg-amber-100 text-amber-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'songs',
|
||||||
|
label: 'Songs',
|
||||||
|
description: 'Songs und Arrangements verwalten',
|
||||||
|
icon: 'songs',
|
||||||
|
accentFrom: 'from-emerald-400',
|
||||||
|
accentTo: 'to-teal-500',
|
||||||
|
badgeColor: 'bg-emerald-100 text-emerald-700',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function blockSlideCount(key) {
|
||||||
|
if (key === 'information') return props.informationSlides.length
|
||||||
|
if (key === 'moderation') return props.moderationSlides.length
|
||||||
|
if (key === 'sermon') return props.sermonSlides.length
|
||||||
|
if (key === 'songs') return props.serviceSongs.length
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockBadgeLabel(key) {
|
||||||
|
const count = blockSlideCount(key)
|
||||||
|
if (key === 'songs') return `${count} Song${count !== 1 ? 's' : ''}`
|
||||||
|
return `${count} Folie${count !== 1 ? 'n' : ''}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`${service.title} bearbeiten`" />
|
||||||
|
|
||||||
|
<AuthenticatedLayout>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-600"
|
||||||
|
@click="goBack"
|
||||||
|
title="Zurueck zur Uebersicht"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 transition-transform group-hover:-translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="truncate text-xl font-semibold leading-tight text-gray-900">
|
||||||
|
{{ service.title }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-0.5 text-sm text-gray-500">
|
||||||
|
{{ formattedDate }}
|
||||||
|
<template v-if="service.preacher_name">
|
||||||
|
<span class="mx-1.5 text-gray-300">·</span>
|
||||||
|
<span>{{ service.preacher_name }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3.5 py-2 text-sm font-medium text-gray-600 shadow-sm transition-all hover:border-gray-300 hover:bg-gray-50"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
|
||||||
|
</svg>
|
||||||
|
Zurueck zur Uebersicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="py-8">
|
||||||
|
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Block accordion sections -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="block in blocks"
|
||||||
|
:key="block.key"
|
||||||
|
class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<!-- Block header (clickable) -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-gray-50/60"
|
||||||
|
@click="toggleBlock(block.key)"
|
||||||
|
>
|
||||||
|
<!-- Accent icon -->
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br shadow-sm"
|
||||||
|
:class="[block.accentFrom, block.accentTo]"
|
||||||
|
>
|
||||||
|
<!-- Information icon -->
|
||||||
|
<svg v-if="block.icon === 'info'" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Moderation icon -->
|
||||||
|
<svg v-else-if="block.icon === 'moderation'" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Sermon icon -->
|
||||||
|
<svg v-else-if="block.icon === 'sermon'" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>
|
||||||
|
<!-- Songs icon -->
|
||||||
|
<svg v-else-if="block.icon === 'songs'" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label + description -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<h3 class="text-[15px] font-semibold text-gray-900">
|
||||||
|
{{ block.label }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||||
|
:class="block.badgeColor"
|
||||||
|
>
|
||||||
|
{{ blockBadgeLabel(block.key) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500">
|
||||||
|
{{ block.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chevron -->
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 shrink-0 text-gray-400 transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': expandedBlocks[block.key] }"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Block content (collapsible) -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
|
enter-from-class="max-h-0 opacity-0"
|
||||||
|
enter-to-class="max-h-[2000px] opacity-100"
|
||||||
|
leave-active-class="transition-all duration-150 ease-in"
|
||||||
|
leave-from-class="max-h-[2000px] opacity-100"
|
||||||
|
leave-to-class="max-h-0 opacity-0"
|
||||||
|
>
|
||||||
|
<div v-show="expandedBlocks[block.key]" class="overflow-hidden">
|
||||||
|
<div class="border-t border-gray-100 px-5 py-6">
|
||||||
|
<InformationBlock
|
||||||
|
v-if="block.key === 'information'"
|
||||||
|
:service-id="service.id"
|
||||||
|
:service-date="service.date"
|
||||||
|
:slides="informationSlides"
|
||||||
|
@slides-updated="refreshPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModerationBlock
|
||||||
|
v-else-if="block.key === 'moderation'"
|
||||||
|
:service-id="service.id"
|
||||||
|
:slides="moderationSlides"
|
||||||
|
@slides-updated="refreshPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SongsBlock
|
||||||
|
v-else-if="block.key === 'songs'"
|
||||||
|
:service-songs="serviceSongs"
|
||||||
|
:songs-catalog="songsCatalog"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-200 bg-gray-50/50 py-12"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br opacity-20"
|
||||||
|
:class="[block.accentFrom, block.accentTo]"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm font-medium text-gray-400">
|
||||||
|
{{ block.label }}-Block wird hier angezeigt
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-300">
|
||||||
|
Platzhalter — Komponente folgt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
</template>
|
||||||
|
|
@ -208,7 +208,7 @@ function stateIconClass(isDone) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
|
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||||
@click="showComingSoon"
|
@click="router.get(route('services.edit', service.id))"
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
117
resources/views/pdf/song.blade.php
Normal file
117
resources/views/pdf/song.blade.php
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
/* CRITICAL: Old-school CSS only — NO Tailwind. DomPDF requires simple CSS. */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DejaVu Sans', sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 2px solid #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .arrangement-name {
|
||||||
|
font-size: 11pt;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
padding: 4px 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 11pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-translation {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 10pt;
|
||||||
|
font-style: italic;
|
||||||
|
color: #555555;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 2px solid #cccccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright-footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #cccccc;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #888888;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>{{ $song->title }}</h1>
|
||||||
|
<div class="arrangement-name">Arrangement: {{ $arrangement->name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@foreach ($groupsInOrder as $group)
|
||||||
|
<div class="group-section">
|
||||||
|
<div class="group-header" style="background-color: {{ $group['color'] }};">
|
||||||
|
{{ $group['name'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@foreach ($group['slides'] as $slide)
|
||||||
|
<div class="slide">
|
||||||
|
<div class="slide-text">{{ $slide['text_content'] }}</div>
|
||||||
|
|
||||||
|
@if (!empty($slide['text_content_translated']))
|
||||||
|
<div class="slide-translation">{{ $slide['text_content_translated'] }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@if ($song->copyright_text)
|
||||||
|
<div class="copyright-footer">
|
||||||
|
© {{ $song->copyright_text }}
|
||||||
|
@if ($song->ccli_id)
|
||||||
|
· CCLI {{ $song->ccli_id }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -27,6 +27,9 @@
|
||||||
Route::post('/service-songs/{serviceSongId}/unassign', [ServiceSongController::class, 'unassign'])
|
Route::post('/service-songs/{serviceSongId}/unassign', [ServiceSongController::class, 'unassign'])
|
||||||
->name('api.service-songs.unassign');
|
->name('api.service-songs.unassign');
|
||||||
|
|
||||||
|
Route::patch('/service-songs/{serviceSongId}', [ServiceSongController::class, 'update'])
|
||||||
|
->name('api.service-songs.update');
|
||||||
|
|
||||||
// Übersetzung
|
// Übersetzung
|
||||||
Route::post('/translation/fetch-url', [TranslationController::class, 'fetchUrl'])
|
Route::post('/translation/fetch-url', [TranslationController::class, 'fetchUrl'])
|
||||||
->name('api.translation.fetch-url');
|
->name('api.translation.fetch-url');
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
Route::get('/services', [ServiceController::class, 'index'])->name('services.index');
|
Route::get('/services', [ServiceController::class, 'index'])->name('services.index');
|
||||||
Route::post('/services/{service}/finalize', [ServiceController::class, 'finalize'])->name('services.finalize');
|
Route::post('/services/{service}/finalize', [ServiceController::class, 'finalize'])->name('services.finalize');
|
||||||
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
|
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
|
||||||
|
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
|
||||||
|
|
||||||
Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
|
Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
|
||||||
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
||||||
|
|
|
||||||
238
tests/Feature/InformationBlockTest.php
Normal file
238
tests/Feature/InformationBlockTest.php
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Slide;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$this->withoutVite();
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Information Block — Global Slides with Expire Date Filtering
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('information slides shown dynamically by expire date', function () {
|
||||||
|
Carbon::setTestNow('2026-03-15 10:00:00');
|
||||||
|
|
||||||
|
$service = Service::factory()->create(['date' => '2026-03-15']);
|
||||||
|
|
||||||
|
// Slide expires tomorrow (should show)
|
||||||
|
$validSlide = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-16',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Slide expired yesterday (should NOT show)
|
||||||
|
Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-14',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->component('Services/Edit')
|
||||||
|
->has('informationSlides', 1)
|
||||||
|
->where('informationSlides.0.id', $validSlide->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('information slides expire on service date are still shown', function () {
|
||||||
|
Carbon::setTestNow('2026-04-01 10:00:00');
|
||||||
|
|
||||||
|
$service = Service::factory()->create(['date' => '2026-04-10']);
|
||||||
|
|
||||||
|
// Slide expires exactly on service date (should show — expire_date >= service.date)
|
||||||
|
$sameDay = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-04-10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Slide expires one day before service (should NOT show)
|
||||||
|
Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-04-09',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->has('informationSlides', 1)
|
||||||
|
->where('informationSlides.0.id', $sameDay->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('information slides are global and appear in all services where not expired', function () {
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
|
||||||
|
$serviceA = Service::factory()->create(['date' => '2026-03-10']);
|
||||||
|
$serviceB = Service::factory()->create(['date' => '2026-03-20']);
|
||||||
|
|
||||||
|
// Global slide expiring 2026-03-15 — should appear in Service A, NOT in Service B
|
||||||
|
$slideEarly = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-15',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Global slide expiring 2026-03-25 — should appear in BOTH services
|
||||||
|
$slideLate = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-25',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Service A: both slides should appear (both expire_date >= 2026-03-10)
|
||||||
|
$responseA = $this->get(route('services.edit', $serviceA));
|
||||||
|
$responseA->assertOk();
|
||||||
|
$responseA->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->has('informationSlides', 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Service B: only slideLate should appear (slideEarly expire_date < 2026-03-20)
|
||||||
|
$responseB = $this->get(route('services.edit', $serviceB));
|
||||||
|
$responseB->assertOk();
|
||||||
|
$responseB->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->has('informationSlides', 1)
|
||||||
|
->where('informationSlides.0.id', $slideLate->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('soft deleted information slides are not shown', function () {
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
|
||||||
|
$service = Service::factory()->create(['date' => '2026-03-10']);
|
||||||
|
|
||||||
|
// Valid slide
|
||||||
|
$activeSlide = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-20',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Soft-deleted slide
|
||||||
|
$deletedSlide = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-20',
|
||||||
|
]);
|
||||||
|
$deletedSlide->delete();
|
||||||
|
|
||||||
|
$response = $this->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->has('informationSlides', 1)
|
||||||
|
->where('informationSlides.0.id', $activeSlide->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('information slides do not include moderation or sermon slides', function () {
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
|
||||||
|
$service = Service::factory()->create(['date' => '2026-03-10']);
|
||||||
|
|
||||||
|
// Information slide (should show)
|
||||||
|
$infoSlide = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-20',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Moderation slide (should NOT show)
|
||||||
|
Slide::factory()->create([
|
||||||
|
'type' => 'moderation',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sermon slide (should NOT show)
|
||||||
|
Slide::factory()->create([
|
||||||
|
'type' => 'sermon',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->has('informationSlides', 1)
|
||||||
|
->where('informationSlides.0.id', $infoSlide->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('information slides without expire_date are not shown', function () {
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
|
||||||
|
$service = Service::factory()->create(['date' => '2026-03-10']);
|
||||||
|
|
||||||
|
// Info slide with expire_date (should show)
|
||||||
|
$withExpire = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-20',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Info slide without expire_date (should NOT show — no expire means not scheduled)
|
||||||
|
Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->has('informationSlides', 1)
|
||||||
|
->where('informationSlides.0.id', $withExpire->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('information slides ordered by uploaded_at descending', function () {
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
|
||||||
|
$service = Service::factory()->create(['date' => '2026-03-10']);
|
||||||
|
|
||||||
|
$older = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-20',
|
||||||
|
'uploaded_at' => '2026-02-01 10:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newer = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'expire_date' => '2026-03-20',
|
||||||
|
'uploaded_at' => '2026-02-15 10:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->has('informationSlides', 2)
|
||||||
|
->where('informationSlides.0.id', $newer->id)
|
||||||
|
->where('informationSlides.1.id', $older->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
135
tests/Feature/ModerationBlockTest.php
Normal file
135
tests/Feature/ModerationBlockTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Slide;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Moderation Block — Service-Specific Slides
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('moderation slides are service-specific', function () {
|
||||||
|
$serviceA = Service::factory()->create(['title' => 'Service A']);
|
||||||
|
$serviceB = Service::factory()->create(['title' => 'Service B']);
|
||||||
|
|
||||||
|
// Create moderation slides for Service A
|
||||||
|
$slideA1 = Slide::factory()->create([
|
||||||
|
'type' => 'moderation',
|
||||||
|
'service_id' => $serviceA->id,
|
||||||
|
'original_filename' => 'mod-a-1.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$slideA2 = Slide::factory()->create([
|
||||||
|
'type' => 'moderation',
|
||||||
|
'service_id' => $serviceA->id,
|
||||||
|
'original_filename' => 'mod-a-2.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create moderation slides for Service B
|
||||||
|
$slideB1 = Slide::factory()->create([
|
||||||
|
'type' => 'moderation',
|
||||||
|
'service_id' => $serviceB->id,
|
||||||
|
'original_filename' => 'mod-b-1.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify Service A only has its own moderation slides
|
||||||
|
$serviceAModerationSlides = Slide::where('type', 'moderation')
|
||||||
|
->where('service_id', $serviceA->id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
expect($serviceAModerationSlides)->toHaveCount(2);
|
||||||
|
expect($serviceAModerationSlides->pluck('id'))->toContain($slideA1->id, $slideA2->id);
|
||||||
|
expect($serviceAModerationSlides->pluck('id'))->not->toContain($slideB1->id);
|
||||||
|
|
||||||
|
// Verify Service B only has its own moderation slides
|
||||||
|
$serviceBModerationSlides = Slide::where('type', 'moderation')
|
||||||
|
->where('service_id', $serviceB->id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
expect($serviceBModerationSlides)->toHaveCount(1);
|
||||||
|
expect($serviceBModerationSlides->pluck('id'))->toContain($slideB1->id);
|
||||||
|
expect($serviceBModerationSlides->pluck('id'))->not->toContain($slideA1->id, $slideA2->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moderation slides do not include information slides', function () {
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
// Create moderation slide
|
||||||
|
$moderationSlide = Slide::factory()->create([
|
||||||
|
'type' => 'moderation',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create information slide for same service
|
||||||
|
$informationSlide = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create sermon slide for same service
|
||||||
|
$sermonSlide = Slide::factory()->create([
|
||||||
|
'type' => 'sermon',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Query only moderation slides
|
||||||
|
$moderationSlides = Slide::where('type', 'moderation')
|
||||||
|
->where('service_id', $service->id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
expect($moderationSlides)->toHaveCount(1);
|
||||||
|
expect($moderationSlides->first()->id)->toBe($moderationSlide->id);
|
||||||
|
expect($moderationSlides->pluck('id'))->not->toContain($informationSlide->id, $sermonSlide->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moderation slides require service_id', function () {
|
||||||
|
// Attempt to create moderation slide without service_id
|
||||||
|
$slide = Slide::factory()->make([
|
||||||
|
'type' => 'moderation',
|
||||||
|
'service_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// This should fail validation in the controller
|
||||||
|
// (SlideController validates that moderation slides require service_id)
|
||||||
|
expect($slide->service_id)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moderation block filters slides correctly', function () {
|
||||||
|
$serviceA = Service::factory()->create();
|
||||||
|
$serviceB = Service::factory()->create();
|
||||||
|
|
||||||
|
// Create mixed slides
|
||||||
|
$modA = Slide::factory()->create(['type' => 'moderation', 'service_id' => $serviceA->id]);
|
||||||
|
$modB = Slide::factory()->create(['type' => 'moderation', 'service_id' => $serviceB->id]);
|
||||||
|
$infoA = Slide::factory()->create(['type' => 'information', 'service_id' => $serviceA->id]);
|
||||||
|
$sermonA = Slide::factory()->create(['type' => 'sermon', 'service_id' => $serviceA->id]);
|
||||||
|
|
||||||
|
// Simulate ModerationBlock filtering for Service A
|
||||||
|
$allSlides = Slide::all();
|
||||||
|
$filteredSlides = $allSlides->filter(
|
||||||
|
fn ($slide) => $slide->type === 'moderation' && $slide->service_id === $serviceA->id
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($filteredSlides)->toHaveCount(1);
|
||||||
|
expect($filteredSlides->first()->id)->toBe($modA->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moderation slides do not have expire_date field', function () {
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
// Create moderation slide with expire_date (should be ignored or null)
|
||||||
|
$slide = Slide::factory()->create([
|
||||||
|
'type' => 'moderation',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'expire_date' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($slide->expire_date)->toBeNull();
|
||||||
|
});
|
||||||
135
tests/Feature/SermonBlockTest.php
Normal file
135
tests/Feature/SermonBlockTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Slide;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sermon Block — Service-Specific Slides
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('sermon slides are service-specific', function () {
|
||||||
|
$serviceA = Service::factory()->create(['title' => 'Service A']);
|
||||||
|
$serviceB = Service::factory()->create(['title' => 'Service B']);
|
||||||
|
|
||||||
|
// Create sermon slides for Service A
|
||||||
|
$slideA1 = Slide::factory()->create([
|
||||||
|
'type' => 'sermon',
|
||||||
|
'service_id' => $serviceA->id,
|
||||||
|
'original_filename' => 'sermon-a-1.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$slideA2 = Slide::factory()->create([
|
||||||
|
'type' => 'sermon',
|
||||||
|
'service_id' => $serviceA->id,
|
||||||
|
'original_filename' => 'sermon-a-2.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create sermon slides for Service B
|
||||||
|
$slideB1 = Slide::factory()->create([
|
||||||
|
'type' => 'sermon',
|
||||||
|
'service_id' => $serviceB->id,
|
||||||
|
'original_filename' => 'sermon-b-1.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify Service A only has its own sermon slides
|
||||||
|
$serviceASermonSlides = Slide::where('type', 'sermon')
|
||||||
|
->where('service_id', $serviceA->id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
expect($serviceASermonSlides)->toHaveCount(2);
|
||||||
|
expect($serviceASermonSlides->pluck('id'))->toContain($slideA1->id, $slideA2->id);
|
||||||
|
expect($serviceASermonSlides->pluck('id'))->not->toContain($slideB1->id);
|
||||||
|
|
||||||
|
// Verify Service B only has its own sermon slides
|
||||||
|
$serviceBSermonSlides = Slide::where('type', 'sermon')
|
||||||
|
->where('service_id', $serviceB->id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
expect($serviceBSermonSlides)->toHaveCount(1);
|
||||||
|
expect($serviceBSermonSlides->pluck('id'))->toContain($slideB1->id);
|
||||||
|
expect($serviceBSermonSlides->pluck('id'))->not->toContain($slideA1->id, $slideA2->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sermon slides do not include information slides', function () {
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
// Create sermon slide
|
||||||
|
$sermonSlide = Slide::factory()->create([
|
||||||
|
'type' => 'sermon',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create information slide for same service
|
||||||
|
$informationSlide = Slide::factory()->create([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create moderation slide for same service
|
||||||
|
$moderationSlide = Slide::factory()->create([
|
||||||
|
'type' => 'moderation',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Query only sermon slides
|
||||||
|
$sermonSlides = Slide::where('type', 'sermon')
|
||||||
|
->where('service_id', $service->id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
expect($sermonSlides)->toHaveCount(1);
|
||||||
|
expect($sermonSlides->first()->id)->toBe($sermonSlide->id);
|
||||||
|
expect($sermonSlides->pluck('id'))->not->toContain($informationSlide->id, $moderationSlide->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sermon slides require service_id', function () {
|
||||||
|
// Attempt to create sermon slide without service_id
|
||||||
|
$slide = Slide::factory()->make([
|
||||||
|
'type' => 'sermon',
|
||||||
|
'service_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// This should fail validation in the controller
|
||||||
|
// (SlideController validates that sermon slides require service_id)
|
||||||
|
expect($slide->service_id)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sermon block filters slides correctly', function () {
|
||||||
|
$serviceA = Service::factory()->create();
|
||||||
|
$serviceB = Service::factory()->create();
|
||||||
|
|
||||||
|
// Create mixed slides
|
||||||
|
$sermonA = Slide::factory()->create(['type' => 'sermon', 'service_id' => $serviceA->id]);
|
||||||
|
$sermonB = Slide::factory()->create(['type' => 'sermon', 'service_id' => $serviceB->id]);
|
||||||
|
$infoA = Slide::factory()->create(['type' => 'information', 'service_id' => $serviceA->id]);
|
||||||
|
$modA = Slide::factory()->create(['type' => 'moderation', 'service_id' => $serviceA->id]);
|
||||||
|
|
||||||
|
// Simulate SermonBlock filtering for Service A
|
||||||
|
$allSlides = Slide::all();
|
||||||
|
$filteredSlides = $allSlides->filter(
|
||||||
|
fn ($slide) => $slide->type === 'sermon' && $slide->service_id === $serviceA->id
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($filteredSlides)->toHaveCount(1);
|
||||||
|
expect($filteredSlides->first()->id)->toBe($sermonA->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sermon slides do not have expire_date field', function () {
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
// Create sermon slide with expire_date (should be ignored or null)
|
||||||
|
$slide = Slide::factory()->create([
|
||||||
|
'type' => 'sermon',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'expire_date' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($slide->expire_date)->toBeNull();
|
||||||
|
});
|
||||||
|
|
@ -176,4 +176,96 @@ public function test_service_kann_wieder_geoeffnet_werden(): void
|
||||||
$response->assertRedirect(route('services.index'));
|
$response->assertRedirect(route('services.index'));
|
||||||
$this->assertNull($service->fresh()->finalized_at);
|
$this->assertNull($service->fresh()->finalized_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_service_edit_seite_zeigt_service_mit_songs_und_slides(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
$this->withoutVite();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'title' => 'Gottesdienst',
|
||||||
|
'date' => Carbon::today()->addDays(7),
|
||||||
|
'preacher_name' => 'Pastor Mueller',
|
||||||
|
'finalized_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$song = Song::factory()->create([
|
||||||
|
'title' => 'Amazing Grace',
|
||||||
|
'ccli_id' => '4321',
|
||||||
|
'has_translation' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$arrangement = SongArrangement::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Normal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'use_translation' => true,
|
||||||
|
'order' => 1,
|
||||||
|
'cts_song_name' => 'Amazing Grace',
|
||||||
|
'cts_ccli_id' => '4321',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => null,
|
||||||
|
'song_arrangement_id' => null,
|
||||||
|
'use_translation' => false,
|
||||||
|
'order' => 2,
|
||||||
|
'cts_song_name' => 'Unbekannter Song',
|
||||||
|
'cts_ccli_id' => '9999',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'type' => 'moderation',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => null,
|
||||||
|
'type' => 'information',
|
||||||
|
'expire_date' => Carbon::today()->addDays(14),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->component('Services/Edit')
|
||||||
|
->has('service')
|
||||||
|
->where('service.title', 'Gottesdienst')
|
||||||
|
->where('service.preacher_name', 'Pastor Mueller')
|
||||||
|
->has('serviceSongs', 2)
|
||||||
|
->where('serviceSongs.0.cts_song_name', 'Amazing Grace')
|
||||||
|
->where('serviceSongs.0.song.title', 'Amazing Grace')
|
||||||
|
->where('serviceSongs.0.song.has_translation', true)
|
||||||
|
->where('serviceSongs.0.arrangement.name', 'Normal')
|
||||||
|
->where('serviceSongs.1.cts_song_name', 'Unbekannter Song')
|
||||||
|
->where('serviceSongs.1.song', null)
|
||||||
|
->has('informationSlides', 1)
|
||||||
|
->has('moderationSlides', 1)
|
||||||
|
->has('sermonSlides', 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_service_edit_erfordert_authentifizierung(): void
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
140
tests/Feature/SongsBlockTest.php
Normal file
140
tests/Feature/SongsBlockTest.php
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceSong;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\SongArrangement;
|
||||||
|
use App\Models\SongArrangementGroup;
|
||||||
|
use App\Models\SongGroup;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class SongsBlockTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_songs_block_shows_unmatched_song_with_matching_options(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
Song::factory()->create([
|
||||||
|
'title' => 'Amazing Grace',
|
||||||
|
'ccli_id' => '22025',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Song::factory()->create([
|
||||||
|
'title' => 'How Great Is Our God',
|
||||||
|
'ccli_id' => '4348399',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceSong::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'order' => 2,
|
||||||
|
'song_id' => null,
|
||||||
|
'cts_song_name' => 'Unmatched Song',
|
||||||
|
'cts_ccli_id' => '123456',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceSong::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'order' => 1,
|
||||||
|
'song_id' => null,
|
||||||
|
'cts_song_name' => 'First Song',
|
||||||
|
'cts_ccli_id' => '654321',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Auth::login($user);
|
||||||
|
|
||||||
|
$response = $this->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->component('Services/Edit')
|
||||||
|
->has('serviceSongs', 2)
|
||||||
|
->where('serviceSongs.0.cts_song_name', 'First Song')
|
||||||
|
->where('serviceSongs.0.song_id', null)
|
||||||
|
->where('serviceSongs.1.cts_song_name', 'Unmatched Song')
|
||||||
|
->where('serviceSongs.1.song_id', null)
|
||||||
|
->has('songsCatalog')
|
||||||
|
->where('songsCatalog', fn ($songsCatalog) => collect($songsCatalog)->contains(
|
||||||
|
fn (array $song) => $song['title'] === 'Amazing Grace' && $song['ccli_id'] === '22025'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_songs_block_provides_matched_song_data_for_arrangement_configurator_and_translation_toggle(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
$song = Song::factory()->create([
|
||||||
|
'title' => 'Living Hope',
|
||||||
|
'ccli_id' => '7106807',
|
||||||
|
'has_translation' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$verse = SongGroup::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Strophe 1',
|
||||||
|
'color' => '#3B82F6',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$chorus = SongGroup::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Refrain',
|
||||||
|
'color' => '#10B981',
|
||||||
|
'order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$normal = SongArrangement::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Normal',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongArrangementGroup::factory()->create([
|
||||||
|
'song_arrangement_id' => $normal->id,
|
||||||
|
'song_group_id' => $verse->id,
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongArrangementGroup::factory()->create([
|
||||||
|
'song_arrangement_id' => $normal->id,
|
||||||
|
'song_group_id' => $chorus->id,
|
||||||
|
'order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceSong::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'order' => 1,
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'song_arrangement_id' => $normal->id,
|
||||||
|
'use_translation' => true,
|
||||||
|
'cts_song_name' => 'Living Hope',
|
||||||
|
'cts_ccli_id' => '7106807',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Auth::login($user);
|
||||||
|
|
||||||
|
$response = $this->get(route('services.edit', $service));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->component('Services/Edit')
|
||||||
|
->where('serviceSongs.0.song_id', $song->id)
|
||||||
|
->where('serviceSongs.0.song.has_translation', true)
|
||||||
|
->where('serviceSongs.0.song.arrangements.0.name', 'Normal')
|
||||||
|
->where('serviceSongs.0.song.arrangements.0.groups.0.name', 'Strophe 1')
|
||||||
|
->where('serviceSongs.0.song.groups.0.name', 'Strophe 1')
|
||||||
|
->where('serviceSongs.0.song_arrangement_id', $normal->id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue