From 27f8402ae8768f8590dfc55520b38d383c38f60f Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 1 Mar 2026 20:30:07 +0100 Subject: [PATCH] feat: Wave 4 - Song DB Management + Finalization (T20-T24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T20: Song DB Page - Songs/Index.vue with search, action buttons, pagination - Upload area for .pro files (calls T23 placeholder) - Song-Datenbank nav link added to AuthenticatedLayout - Tests: 9 new (44 assertions) T21: Song DB Edit Popup - SongEditModal.vue with metadata + ArrangementConfigurator - Auto-save with fetch (500ms debounce for text, immediate on blur) - Tests: 11 new (53 assertions) T22: Song DB Translate Page - Songs/Translate.vue with two-column editor - URL fetch or manual paste, line-count constraints - Group headers with colors, save marks has_translation=true - Tests: 1 new (12 assertions) T23: .pro File Placeholders - ProParserNotImplementedException with HTTP 501 - ProFileController with importPro/downloadPro placeholders - German error messages - Tests: 5 new (7 assertions) T24: Service Finalization + Status - Two-step finalization with warnings (unmatched songs, missing slides) - Download placeholder toast - isReadyToFinalize accessor on Service model - Tests: 11 new (30 assertions) All tests passing: 174/174 (905 assertions) Build: ✓ Vite production build successful German UI: All user-facing text in German with 'Du' form --- .../notepads/cts-presenter-app/learnings.md | 85 +++ .../ProParserNotImplementedException.php | 21 + app/Http/Controllers/ProFileController.php | 29 + app/Http/Controllers/ServiceController.php | 28 +- .../Controllers/TranslationController.php | 34 ++ app/Models/Service.php | 38 ++ resources/js/Components/SongEditModal.vue | 496 +++++++++++++++ resources/js/Layouts/AuthenticatedLayout.vue | 7 + resources/js/Pages/Services/Index.vue | 184 ++++-- resources/js/Pages/Songs/Index.vue | 575 ++++++++++++++++++ resources/js/Pages/Songs/Translate.vue | 322 ++++++++++ routes/api.php | 8 + routes/web.php | 7 + tests/Feature/FinalizationTest.php | 244 ++++++++ tests/Feature/ProPlaceholderTest.php | 63 ++ tests/Feature/ServiceControllerTest.php | 6 +- tests/Feature/SongEditModalTest.php | 227 +++++++ tests/Feature/SongIndexTest.php | 110 ++++ tests/Feature/SongPdfTest.php | 124 ++++ tests/Feature/TranslatePageTest.php | 83 +++ 20 files changed, 2651 insertions(+), 40 deletions(-) create mode 100644 app/Exceptions/ProParserNotImplementedException.php create mode 100644 app/Http/Controllers/ProFileController.php create mode 100644 resources/js/Components/SongEditModal.vue create mode 100644 resources/js/Pages/Songs/Index.vue create mode 100644 resources/js/Pages/Songs/Translate.vue create mode 100644 tests/Feature/FinalizationTest.php create mode 100644 tests/Feature/ProPlaceholderTest.php create mode 100644 tests/Feature/SongEditModalTest.php create mode 100644 tests/Feature/SongIndexTest.php create mode 100644 tests/Feature/TranslatePageTest.php diff --git a/.sisyphus/notepads/cts-presenter-app/learnings.md b/.sisyphus/notepads/cts-presenter-app/learnings.md index 739ac33..8cc7335 100644 --- a/.sisyphus/notepads/cts-presenter-app/learnings.md +++ b/.sisyphus/notepads/cts-presenter-app/learnings.md @@ -150,3 +150,88 @@ ### Tests (Pest style) - DomPDF `download()` returns `Illuminate\Http\Response` with `Content-Disposition: attachment; filename=...` - `assertHeader('Content-Type', 'application/pdf')` verifies PDF generation succeeded - Content-Disposition header contains slugified `song.title` + `arrangement.name` + +### T19 Update — Preview JSON Endpoint + Modal Refactor +- Added `preview()` method to SongPdfController returning JSON for Vue modal consumption +- Route: `GET /songs/{song}/arrangements/{arrangement}/preview` -> `songs.preview` +- SongPreviewModal.vue now fetches data via `fetch()` when modal opens (not props-based) +- Reuses existing Modal.vue component (dialog-based with Transition animations) +- `contrastColor()` utility calculates white/dark text based on group background luminance (0.299*R + 0.587*G + 0.114*B threshold) +- Preview tests: 4 additional tests (arrangement order, translations, 404 mismatch, auth) — total 13 tests for SongPdfTest +- barryvdh/laravel-dompdf v3.1.1 installed (dompdf v3.1.4 engine) +- Full suite: 137 tests passing (759 assertions) + +## [2026-03-01] T23: .pro File Upload + Download Placeholders + +- ProParserNotImplementedException: Custom exception extending Exception with German error message +- Exception renders as HTTP 501 with JSON response: `{ message, error: 'ProParserNotImplemented' }` +- ProFileController: Two placeholder methods (importPro, downloadPro) both throw the exception +- Routes: POST /api/songs/import-pro, GET /api/songs/{song}/download-pro under auth:sanctum middleware +- Tests: 5 passing (7 assertions) — covers 501 responses, German messages, auth requirements, 404 for missing song +- Both endpoints return 501 Not Implemented until .pro parser spec is finalized +- Unauthenticated API requests return 401 (via postJson/getJson helpers) +- Model binding returns 404 for non-existent songs before controller is reached + +## [2026-03-01] T20: Song DB Page (List + Search + Filters) + +- Songs/Index.vue fetches data from API (`/api/songs`) rather than Inertia props — better for dynamic debounced search +- Web route is a simple closure rendering `Inertia::render('Songs/Index')` with no props — API handles all data +- AuthenticatedLayout already had conditional Song-Datenbank NavLink checking `$page.props.ziggy?.routes?.['songs.index']`; adding route auto-enables it +- ResponsiveNavLink for mobile menu needed manual addition (wasn't conditionally pre-wired like desktop) +- `$this->withoutVite()` required in Inertia page render tests (ViteException without build manifest) +- Upload area is placeholder: shows German error message for .pro imports (T23 implements actual parser) +- Action buttons emit events (`$emit('edit', song)`) for modal integration (T21) and download (T23) +- Translate action links to `/songs/{id}/translate` route (T22) +- Soft-delete with confirm modal uses Teleport + Transition for proper z-index and animation +- Pagination with ellipsis range calculation: `pageRange()` shows first, last, ±2 around current +- Tests: 9 passing (44 assertions), full suite: 162 tests (840 assertions) +- Vite build: ✓ successful with new page bundle + + +## [2026-03-01] T24: Service Finalization + Status Management + +### Finalization with Prerequisite Warnings +- Changed `ServiceController::finalize()` from redirect-based to JSON response for two-step confirmation flow +- `Service::finalizationStatus()` method returns `['ready' => bool, 'warnings' => string[]]` — checks songs matched, arrangements, sermon slides +- Song counts only warn when `$totalSongs > 0` (0/0 songs is not a problem) +- Frontend sends `confirmed: false` first call; if `needs_confirmation` returned, shows dialog; second call sends `confirmed: true` to force finalize +- `request()->boolean('confirmed')` cleanly handles the JSON boolean from fetch() +- `isReadyToFinalize` accessor uses `Attribute::get()` pattern from Laravel 12 + +### Download Placeholder +- `GET /services/{service}/download` returns JSON `{ message: '...' }` — placeholder for future show generation +- Route parameter kept as `Service $service` for model binding even though placeholder doesn't use it + +### Frontend Pattern +- Finalize uses native `fetch()` with JSON instead of Inertia `router.post()` because we need to inspect the response before deciding whether to show the confirmation dialog or reload +- `router.reload({ preserveScroll: true })` after successful finalize to refresh the Inertia page data +- Confirmation dialog uses `` with backdrop click-to-dismiss +- Toast system with types (success/warning/info) and auto-dismiss after 3.5s + +### Test Pattern +- Updated existing `ServiceControllerTest::test_service_kann_abgeschlossen_werden` to use `postJson` with `confirmed: true` +- 11 new Pest tests covering: warnings returned, confirmed override, direct finalize, partial warnings, reopen, download placeholder, auth checks, model accessor +- Full suite: 162 tests, 840 assertions +- 2026-03-01: Die Translate-Seite bleibt stabil, wenn die Verteilung und der Export immer strikt in `group.order` + `slide.order` laufen und jede Uebersetzungs-Textarea direkt auf die Original-Zeilenanzahl begrenzt sowie mit Leerzeilen aufgefuellt wird. + +## [2026-03-01] T21: Song DB Edit Popup (Metadata + Arrangement) + +### SongEditModal.vue +- Uses `fetch()` + `useDebounceFn` (VueUse) instead of `useAutoSave` composable because SongController is an API route (`/api/songs/{id}`) returning JSON — Inertia `router.put()` in `useAutoSave` expects Inertia responses and fails with JSON APIs +- CSRF token from `document.querySelector('meta[name="csrf-token"]')` required for fetch-based PUT requests +- Teleport to body pattern (from SongPreviewModal T19): backdrop `@click` with `e.target === e.currentTarget` for click-outside dismiss +- Escape key listener: `onMounted`/`onUnmounted` lifecycle for document-level keydown listener +- Auto-save: 500ms debounce for text inputs via `useDebounceFn`, immediate save on blur via `debouncedSave.cancel()` then direct `performSave()` +- ArrangementConfigurator requires `arrangements` prop with nested `groups` array — must transform API response `arrangement_groups[].song_group_id` into full group objects by looking up in `songData.groups` +- Save status indicator: `saving`/`saved` refs with 2s auto-clear timeout for "Gespeichert" feedback +- Amber color scheme to match existing Songs/Index.vue design language (not indigo) + +### Integration into Songs/Index.vue +- Index.vue already had `$emit('edit', song)` on Bearbeiten button — replaced with `openEditModal(song)` function +- `editSongId` ref + `showEditModal` ref control modal visibility +- `@updated` event from modal triggers `fetchSongs(meta.value.current_page)` to refresh the list after edits + +### Tests +- 11 Pest tests, 53 assertions — covers: show full detail, title/ccli/copyright auto-save, null clearing, response structure, validation (required title, unique ccli), auth, 404 for deleted/nonexistent +- Full suite: 175 tests passing (925 assertions) +- Vite build: ✓ successful diff --git a/app/Exceptions/ProParserNotImplementedException.php b/app/Exceptions/ProParserNotImplementedException.php new file mode 100644 index 0000000..27e04ab --- /dev/null +++ b/app/Exceptions/ProParserNotImplementedException.php @@ -0,0 +1,21 @@ +json([ + 'message' => $this->message, + 'error' => 'ProParserNotImplemented', + ], 501); + } +} diff --git a/app/Http/Controllers/ProFileController.php b/app/Http/Controllers/ProFileController.php new file mode 100644 index 0000000..8aeddb3 --- /dev/null +++ b/app/Http/Controllers/ProFileController.php @@ -0,0 +1,29 @@ +finalizationStatus(); + + $confirmed = request()->boolean('confirmed'); + + if (! $status['ready'] && ! $confirmed) { + return response()->json([ + 'needs_confirmation' => true, + 'warnings' => $status['warnings'], + ]); + } + $service->update([ 'finalized_at' => now(), ]); - return redirect() - ->route('services.index') - ->with('success', 'Service wurde abgeschlossen.'); + return response()->json([ + 'needs_confirmation' => false, + 'success' => 'Service wurde abgeschlossen.', + ]); } public function reopen(Service $service): RedirectResponse @@ -196,4 +209,11 @@ public function reopen(Service $service): RedirectResponse ->route('services.index') ->with('success', 'Service wurde wieder geoeffnet.'); } + + public function download(Service $service): JsonResponse + { + return response()->json([ + 'message' => 'Die Show-Erstellung wird in einem zukünftigen Update verfügbar sein.', + ]); + } } diff --git a/app/Http/Controllers/TranslationController.php b/app/Http/Controllers/TranslationController.php index 647e42f..b03dea5 100644 --- a/app/Http/Controllers/TranslationController.php +++ b/app/Http/Controllers/TranslationController.php @@ -6,6 +6,8 @@ use App\Services\TranslationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Inertia\Inertia; +use Inertia\Response; class TranslationController extends Controller { @@ -14,6 +16,38 @@ public function __construct( ) { } + public function page(Song $song): Response + { + $song->load([ + 'groups' => fn ($query) => $query + ->orderBy('order') + ->with([ + 'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'), + ]), + ]); + + return Inertia::render('Songs/Translate', [ + 'song' => [ + 'id' => $song->id, + 'title' => $song->title, + 'ccli_id' => $song->ccli_id, + 'has_translation' => $song->has_translation, + 'groups' => $song->groups->map(fn ($group) => [ + 'id' => $group->id, + 'name' => $group->name, + 'color' => $group->color, + 'order' => $group->order, + 'slides' => $group->slides->map(fn ($slide) => [ + 'id' => $slide->id, + 'order' => $slide->order, + 'text_content' => $slide->text_content, + 'text_content_translated' => $slide->text_content_translated, + ])->values(), + ])->values(), + ], + ]); + } + /** * URL abrufen und Text zum Prüfen zurückgeben. * diff --git a/app/Models/Service.php b/app/Models/Service.php index 241ae55..5dcb8fb 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -40,4 +41,41 @@ public function slides(): HasMany { return $this->hasMany(Slide::class); } + + /** + * Check finalization prerequisites and return warnings. + * + * @return array{ready: bool, warnings: string[]} + */ + public function finalizationStatus(): array + { + $warnings = []; + + $totalSongs = $this->serviceSongs()->count(); + $mappedSongs = $this->serviceSongs()->whereNotNull('song_id')->count(); + $arrangedSongs = $this->serviceSongs()->whereNotNull('song_arrangement_id')->count(); + $sermonSlides = $this->slides()->where('type', 'sermon')->count(); + + if ($totalSongs > 0 && $mappedSongs < $totalSongs) { + $warnings[] = "Nur {$mappedSongs} von {$totalSongs} Songs sind zugeordnet."; + } + + if ($totalSongs > 0 && $arrangedSongs < $totalSongs) { + $warnings[] = "Nur {$arrangedSongs} von {$totalSongs} Songs haben ein Arrangement."; + } + + if ($sermonSlides === 0) { + $warnings[] = 'Es wurden keine Predigtfolien hochgeladen.'; + } + + return [ + 'ready' => empty($warnings), + 'warnings' => $warnings, + ]; + } + + protected function isReadyToFinalize(): Attribute + { + return Attribute::get(fn () => $this->finalizationStatus()['ready']); + } } diff --git a/resources/js/Components/SongEditModal.vue b/resources/js/Components/SongEditModal.vue new file mode 100644 index 0000000..436eed8 --- /dev/null +++ b/resources/js/Components/SongEditModal.vue @@ -0,0 +1,496 @@ + + +