diff --git a/resources/views/components/press-release-submit-modal.blade.php b/resources/views/components/press-release-submit-modal.blade.php index 77cf5ef..eba4d78 100644 --- a/resources/views/components/press-release-submit-modal.blade.php +++ b/resources/views/components/press-release-submit-modal.blade.php @@ -1,6 +1,7 @@ @props([ 'name' => 'confirm-submit-review', 'action', + 'bookingAction' => null, 'confirmLabel' => null, 'quotaTotal' => null, 'quotaRemaining' => null, @@ -18,6 +19,11 @@ Submit-Gate (Decision-Update §5.1): Ohne aktive Buchung zeigt das Modal statt des Prüf-Flows einen Buchungs-Hinweis — der Button konvertiert, er verschwindet nicht. Serverseitig sichert submitForReview() das Gate ab. + + `booking-action` (optional): Livewire-Methode, die den Entwurf zuerst + speichert und dann zur Buchungsseite leitet. ZWINGEND in Erstellen/Bearbeiten + setzen — sonst ginge der erfasste Inhalt beim Sprung verloren. Fehlt sie + (z. B. Detailansicht, dort ist bereits gespeichert), wird direkt verlinkt. --}} @php($bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true)) @@ -31,7 +37,7 @@

- {{ __('Zum Einreichen einer Pressemitteilung wird eine aktive Buchung benötigt. Ihre Entwürfe bleiben gespeichert und können jederzeit weiter bearbeitet werden.') }} + {{ __('Zum Einreichen einer Pressemitteilung wird eine aktive Buchung benötigt. Ihr Entwurf wird dabei gespeichert und bleibt jederzeit bearbeitbar.') }}

{{ __('Nach der Buchung reichen Sie die Pressemitteilung mit einem Klick zur Prüfung ein.') }} @@ -42,9 +48,17 @@ {{ __('Später') }} - - {{ __('Buchung auswählen') }} - + @if ($bookingAction) + {{-- Entwurf zuerst speichern, dann zur Buchung — kein Datenverlust. --}} + + {{ __('Speichern & Buchung auswählen') }} + + @else + + {{ __('Buchung auswählen') }} + + @endif

@else diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php index 31d7d46..a200eed 100644 --- a/resources/views/livewire/customer/press-releases/create.blade.php +++ b/resources/views/livewire/customer/press-releases/create.blade.php @@ -400,7 +400,16 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex $this->redirect(route('me.press-releases.edit', $pr->id), navigate: true); } - public function save(string $submitStatus = 'draft'): void + /** + * Validiert das Formular und legt die Pressemitteilung als Entwurf an. + * Gibt den persistierten Entwurf zurück oder null, wenn Firma/Kontakt nicht + * auflösbar sind (Validierungsfehler wirft ValidationException). + * + * Wichtig: Dies ist der einzige Persistenz-Pfad. Jede Aktion (Speichern, + * Einreichen, „Buchung auswählen") MUSS zuerst hierüber speichern, bevor + * navigiert wird — sonst geht der erfasste Inhalt verloren. + */ + private function persistDraft(): ?PressRelease { $this->syncScheduledAt(); $this->useEmbargo = false; @@ -421,7 +430,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex $this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.')); $this->notifyValidationError(); - return; + return null; } $contact = null; @@ -433,7 +442,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex $this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.')); $this->notifyValidationError(); - return; + return null; } } @@ -457,6 +466,39 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex $pr->contacts()->sync([$contact->id]); } + return $pr; + } + + /** + * Speichert den Entwurf und leitet anschließend zur Buchungsseite — wird + * vom Submit-Modal genutzt, wenn keine Buchung vorliegt. So bleibt der + * erfasste Inhalt erhalten (kein Sprung ohne Speichern). + */ + public function saveDraftAndChooseBooking(): void + { + $pr = $this->persistDraft(); + + if (! $pr) { + return; + } + + Flux::toast( + heading: __('Entwurf gespeichert'), + text: __('Ihr Entwurf ist gesichert. Wählen Sie eine Buchung, um ihn anschließend einzureichen.'), + variant: 'success', + ); + + $this->redirect(route('me.bookings.index'), navigate: true); + } + + public function save(string $submitStatus = 'draft'): void + { + $pr = $this->persistDraft(); + + if (! $pr) { + return; + } + if ($submitStatus === 'review') { $this->authorize('submitForReview', $pr); @@ -1374,6 +1416,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 71baee1..bec6511 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -368,7 +368,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl } } - public function save(bool $submitAfterSave = false): void + /** + * Validiert und speichert die Bearbeitung. Gibt [PressRelease, contentChanged] + * zurück oder null bei Firma-/Kontakt-Fehlern (Validierung wirft). + * + * Wichtig: einziger Persistenz-Pfad. Jede Aktion (Speichern, Einreichen, + * „Buchung auswählen") MUSS zuerst hierüber speichern, bevor navigiert wird. + * + * @return array{0: \App\Models\PressRelease, 1: bool}|null + */ + private function persistEdit(): ?array { $this->syncScheduledAt(); $this->useEmbargo = false; @@ -391,7 +400,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.')); $this->notifyValidationError(); - return; + return null; } $contact = null; @@ -403,7 +412,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.')); $this->notifyValidationError(); - return; + return null; } } @@ -435,6 +444,50 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $pr->contacts()->sync($contact ? [$contact->id] : []); + return [$pr, $contentChanged]; + } + + /** + * Speichert die Bearbeitung und leitet zur Buchungsseite — wird vom + * Submit-Modal genutzt, wenn keine Buchung vorliegt. So gehen die + * erfassten Änderungen nicht verloren (kein Sprung ohne Speichern). + */ + public function saveDraftAndChooseBooking(): void + { + $result = $this->persistEdit(); + + if (! $result) { + return; + } + + [$pr, $contentChanged] = $result; + + if ($contentChanged) { + $service = app(PressReleaseService::class); + $fresh = $pr->fresh(); + $service->reclassifyIfClassified($fresh); + $service->rescoreIfScored($fresh); + } + + Flux::toast( + heading: __('Gespeichert'), + text: __('Ihre Änderungen sind gesichert. Wählen Sie eine Buchung, um die PM anschließend einzureichen.'), + variant: 'success', + ); + + $this->redirect(route('me.bookings.index'), navigate: true); + } + + public function save(bool $submitAfterSave = false): void + { + $result = $this->persistEdit(); + + if (! $result) { + return; + } + + [$pr, $contentChanged] = $result; + if ($submitAfterSave) { $this->authorize('submitForReview', $pr); @@ -1332,6 +1385,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl diff --git a/tests/Feature/PressReleaseDraftNotLostTest.php b/tests/Feature/PressReleaseDraftNotLostTest.php new file mode 100644 index 0000000..324b1ae --- /dev/null +++ b/tests/Feature/PressReleaseDraftNotLostTest.php @@ -0,0 +1,87 @@ +seed(RolesAndPermissionsSeeder::class); + // Gate aktiv, damit der „Buchung auswählen"-Pfad im Submit-Modal greift. + config()->set('billing.enforce_booking', true); +}); + +test('create: choosing a booking from the submit modal saves the draft first', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + $company = Company::factory()->presseecho()->create(); + $customer->companies()->attach($company->id, ['role' => 'owner']); + $contact = Contact::factory()->for($company)->create(['portal' => $company->portal->value]); + $category = Category::factory()->create(); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.create') + ->set('title', 'Entwurf der auf keinen Fall verloren gehen darf') + ->set('text', str_repeat('Inhaltlich relevanter Fließtext. ', 5)) + ->set('categoryId', $category->id) + ->set('contactId', $contact->id) + ->call('saveDraftAndChooseBooking') + ->assertHasNoErrors() + ->assertRedirect(route('me.bookings.index')); + + $pr = PressRelease::query()->where('user_id', $customer->id)->latest('id')->first(); + + expect($pr)->not->toBeNull(); + expect($pr->title)->toBe('Entwurf der auf keinen Fall verloren gehen darf'); + expect($pr->status)->toBe(PressReleaseStatus::Draft); +}); + +test('create: the submit modal offers the saving booking button when no booking exists', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + $company = Company::factory()->presseecho()->create(); + $customer->companies()->attach($company->id, ['role' => 'owner']); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.create') + ->assertSee('Buchung erforderlich') + ->assertSee('Speichern & Buchung auswählen'); +}); + +test('edit: choosing a booking from the submit modal persists the edits first', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + $company = Company::factory()->presseecho()->create(); + $customer->companies()->attach($company->id, ['role' => 'owner']); + $pr = PressRelease::factory()->create([ + 'user_id' => $customer->id, + 'company_id' => $company->id, + 'category_id' => Category::factory()->create()->id, + 'portal' => $company->portal->value, + 'status' => 'draft', + 'title' => 'Originaltitel', + ]); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]) + ->set('title', 'Bearbeiteter Titel der erhalten bleiben muss') + ->set('text', str_repeat('Neuer Fließtext mit Inhalt. ', 5)) + ->call('saveDraftAndChooseBooking') + ->assertHasNoErrors() + ->assertRedirect(route('me.bookings.index')); + + expect($pr->fresh()->title)->toBe('Bearbeiteter Titel der erhalten bleiben muss'); + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); +});