presseportale/resources/views/livewire/admin/companies/edit.blade.php
Kevin Adametz 31da9ea8b6
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

601 lines
25 KiB
PHP

<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Services\Company\CompanyRemovalService;
use App\Services\Image\ImageService;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends Component
{
use WithFileUploads;
/**
* Die bearbeitete Firma. #[Locked], weil save(), deleteCompany(),
* markGone() und restoreGone() alle auf diesem Wert arbeiten — ohne das
* Attribut könnte der Client ihn im Livewire-Payload austauschen und die
* Aktion träfe eine andere Firma als die im Formular angezeigte.
* Gegenstück zum #[Locked] $id der PM-Bearbeitung.
*/
#[Locked]
public int $companyId;
public string $portal = 'both';
/**
* Bestandsschutz: „Beide Portale" wird nur angeboten, wenn die Firma
* den Wert bereits trägt — neu zuweisen lässt er sich nicht mehr.
*/
public bool $allowBothPortal = false;
#[Validate('required|min:3|max:255')]
public string $company_name = '';
#[Validate('nullable|max:500')]
public string $description = '';
#[Validate('required|email')]
public string $email = '';
#[Validate('nullable|max:50')]
public string $phone = '';
#[Validate('nullable|url')]
public string $website = '';
#[Validate('nullable|max:255')]
public string $street = '';
#[Validate('nullable|max:20')]
public string $zip = '';
#[Validate('nullable|max:255')]
public string $city = '';
#[Validate('nullable|max:255')]
public string $state = '';
#[Validate('required|max:2')]
public string $country = 'DE';
#[Validate('nullable|image|max:4096')]
public $logo;
public bool $remove_logo = false;
public ?string $current_logo_url = null;
#[Validate('nullable|max:255')]
public ?string $tax_id = null;
#[Validate('nullable|max:255')]
public ?string $registration_number = null;
public bool $is_verified = false;
public bool $is_active = true;
/** Zeitpunkt der 410-Entfernung des Newsrooms (formatiert) oder null. */
public ?string $goneAt = null;
/** Beim Entfernen mit 410 zusätzlich alle PMs der Firma entfernen. */
public bool $goneIncludesPressReleases = false;
public function mount(int $id): void
{
$this->companyId = $id;
$company = Company::query()->find($id);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
$this->redirect(route('admin.companies.index'), navigate: true);
return;
}
$this->portal = $company->portal?->value ?? Portal::Both->value;
$this->allowBothPortal = $company->portal === Portal::Both || $company->portal === null;
$this->company_name = $company->name;
$this->description = '';
$this->email = $company->email ?? '';
$this->phone = $company->phone ?? '';
$this->website = $company->website ?? '';
$this->street = $company->address ?? '';
$this->zip = '';
$this->city = '';
$this->state = '';
$this->country = $company->country_code ?? 'DE';
$this->is_verified = false;
$this->is_active = (bool) $company->is_active;
$this->goneAt = $company->gone_at?->format('d.m.Y H:i');
$this->current_logo_url = $company->logoUrl();
}
public function update(ImageService $imageService): void
{
$this->authorize('companies:write');
$this->validate();
$company = Company::query()->find($this->companyId);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
$this->redirect(route('admin.companies.index'), navigate: true);
return;
}
$allowedPortals = [Portal::Presseecho->value, Portal::Businessportal24->value];
if ($company->portal === Portal::Both || $company->portal === null) {
$allowedPortals[] = Portal::Both->value;
}
if (! in_array($this->portal, $allowedPortals, true)) {
throw ValidationException::withMessages([
'portal' => __('„Beide Portale" kann nicht mehr neu zugewiesen werden.'),
]);
}
$slug = $company->generateUniqueSlug($this->company_name, ['portal' => $this->portal]);
$previousLogoPath = $company->logo_path;
$previousLogoVariants = $company->logo_variants;
$logoPath = $previousLogoPath;
$logoVariants = $previousLogoVariants;
$discardPreviousLogo = false;
$stored = null;
if ($this->remove_logo) {
$logoPath = null;
$logoVariants = null;
$discardPreviousLogo = true;
}
if ($this->logo) {
// Erst die neue Datei speichern, dann die DB-Referenz wechseln, das
// alte Logo erst danach löschen: Scheitert der Storage, bleiben
// bisheriges Logo und Datensatz unangetastet (F23).
try {
$stored = $imageService->storeCompanyLogo(
$this->logo,
$this->portal === Portal::Both->value ? Portal::Presseecho->value : $this->portal,
$company->id,
);
} catch (\RuntimeException $exception) {
report($exception);
throw ValidationException::withMessages([
'logo' => __('Das Logo konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.'),
]);
}
$logoPath = $stored['path'];
$logoVariants = $stored['variants'];
$discardPreviousLogo = true;
}
try {
$company->update([
'portal' => $this->portal,
'name' => $this->company_name,
'slug' => $slug,
'address' => $this->composeAddress(),
'country_code' => strtoupper($this->country),
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'website' => $this->website ?: null,
'logo_path' => $logoPath,
'logo_variants' => $logoVariants,
'is_active' => $this->is_active,
]);
} catch (\Throwable $exception) {
// Ohne DB-Referenz wäre die neue Datei verwaist.
if ($stored !== null) {
$imageService->deleteCompanyLogo($stored['path'], $stored['variants']);
}
throw $exception;
}
if ($discardPreviousLogo && $previousLogoPath !== null && $previousLogoPath !== $logoPath) {
$imageService->deleteCompanyLogo($previousLogoPath, $previousLogoVariants);
}
session()->flash('success', 'Firma erfolgreich aktualisiert.');
$this->redirect(route('admin.companies.index'), navigate: true);
}
public function with(): array
{
return [
'countries' => collect([
['code' => 'DE', 'name' => 'Deutschland'],
['code' => 'AT', 'name' => 'Österreich'],
['code' => 'CH', 'name' => 'Schweiz'],
['code' => 'FR', 'name' => 'Frankreich'],
['code' => 'GB', 'name' => 'Großbritannien'],
['code' => 'US', 'name' => 'USA'],
]),
'portalOptions' => array_filter(
Portal::cases(),
fn (Portal $portalCase): bool => $portalCase !== Portal::Both || $this->allowBothPortal,
),
];
}
/**
* Entfernung mit 410 statt Löschung: die Newsroom-URL liefert danach einen
* Tombstone (410 Gone) statt eines 404. Umkehrbar über restoreGone().
*/
public function markGone(CompanyRemovalService $removal): void
{
$this->authorize('companies:write');
$company = Company::query()->find($this->companyId);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
$this->redirect(route('admin.companies.index'), navigate: true);
return;
}
$affected = $removal->markGone($company, $this->goneIncludesPressReleases);
session()->flash('success', $affected > 0
? __('Newsroom mit 410 entfernt — inklusive :count Pressemitteilung(en).', ['count' => $affected])
: __('Newsroom mit 410 entfernt. Die URL liefert jetzt 410 Gone.'));
$this->redirect(route('admin.companies.index'), navigate: true);
}
public function restoreGone(CompanyRemovalService $removal): void
{
$this->authorize('companies:write');
$company = Company::query()->find($this->companyId);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
$this->redirect(route('admin.companies.index'), navigate: true);
return;
}
$removal->restoreFromGone($company);
session()->flash('success', __('410-Entfernung aufgehoben. Bereits entfernte Pressemitteilungen bleiben entfernt.'));
$this->redirect(route('admin.companies.edit', $this->companyId), navigate: true);
}
public function deleteCompany(): void
{
$this->authorize('companies:write');
$company = Company::query()->find($this->companyId);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
$this->redirect(route('admin.companies.index'), navigate: true);
return;
}
$company->delete();
session()->flash('success', __('Firma wurde erfolgreich gelöscht.'));
$this->redirect(route('admin.companies.index'), navigate: true);
}
protected function composeAddress(): ?string
{
$lineOne = trim($this->street);
$lineTwo = trim(trim($this->zip).' '.trim($this->city));
$lineThree = trim($this->state);
$parts = array_filter([$lineOne, $lineTwo, $lineThree], fn ($value) => $value !== '');
return $parts !== [] ? implode(', ', $parts) : null;
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Stammdaten · Firma bearbeiten')"
:title="__('Firma bearbeiten')"
:description="__('Stammdaten, Adresse, Logo und Rechtsangaben der Firma aktualisieren.')"
>
<x-slot:meta>
<span class="badge hub">ID {{ $companyId }}</span>
</x-slot:meta>
<x-slot:actions>
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.companies.show', $companyId) }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
<form wire:submit="update" class="space-y-6">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Basisinformationen') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach ($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Firmenname') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input wire:model="company_name" placeholder="{{ __('Vollständiger Firmenname...') }}" />
<flux:error name="company_name" />
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:textarea wire:model="description" rows="4" placeholder="{{ __('Kurze Beschreibung der Firma (optional)...') }}" />
<flux:error name="description" />
</flux:field>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('E-Mail') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input wire:model="email" type="email" placeholder="{{ __('kontakt@firma.de') }}" icon="envelope" />
<flux:error name="email" />
</flux:field>
<flux:field>
<flux:label>{{ __('Telefon') }}</flux:label>
<flux:input wire:model="phone" type="tel" placeholder="{{ __('+49 123 456789') }}" icon="phone" />
<flux:error name="phone" />
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Website') }}</flux:label>
<flux:input wire:model="website" type="url" placeholder="{{ __('https://www.firma.de') }}" icon="globe-alt" />
<flux:error name="website" />
</flux:field>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Adresse') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Straße & Hausnummer') }}</flux:label>
<flux:input wire:model="street" placeholder="{{ __('Musterstraße 123') }}" />
<flux:error name="street" />
</flux:field>
<div class="grid gap-4 sm:grid-cols-3">
<flux:field>
<flux:label>{{ __('PLZ') }}</flux:label>
<flux:input wire:model="zip" placeholder="{{ __('12345') }}" />
<flux:error name="zip" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Stadt') }}</flux:label>
<flux:input wire:model="city" placeholder="{{ __('Berlin') }}" />
<flux:error name="city" />
</flux:field>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Bundesland / Region') }}</flux:label>
<flux:input wire:model="state" placeholder="{{ __('Bayern') }}" />
<flux:error name="state" />
</flux:field>
<flux:field>
<flux:label>{{ __('Land') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model="country">
@foreach ($countries as $country)
<option value="{{ $country['code'] }}">{{ $country['name'] }}</option>
@endforeach
</flux:select>
<flux:error name="country" />
</flux:field>
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Rechtliche Daten') }}</span>
</div>
<div class="p-5 grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Steuernummer / USt-IdNr.') }}</flux:label>
<flux:input wire:model="tax_id" placeholder="{{ __('DE123456789') }}" />
<flux:error name="tax_id" />
</flux:field>
<flux:field>
<flux:label>{{ __('Handelsregisternummer') }}</flux:label>
<flux:input wire:model="registration_number" placeholder="{{ __('HRB 12345') }}" />
<flux:error name="registration_number" />
</flux:field>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Logo & Status') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Firmenlogo') }}</flux:label>
<flux:file-upload wire:model="logo" accept="image/jpeg,image/png,image/webp,image/gif">
<flux:file-upload.dropzone
:heading="__('Logo hierher ziehen oder klicken')"
:text="__('JPG, PNG, WebP oder GIF · max. 4 MB')"
with-progress
inline
/>
</flux:file-upload>
<flux:description>{{ __('Maximal 4 MB. Varianten (sq/wide) werden automatisch generiert.') }}</flux:description>
<flux:error name="logo" />
@if ($logo)
<div class="mt-4">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-2">
{{ __('Neues Logo (Vorschau):') }}
</div>
<img src="{{ $logo->temporaryUrl() }}" width="128" height="128"
class="h-32 max-h-32 w-32 max-w-32 rounded-[6px] border border-[color:var(--color-bg-rule)] object-contain bg-[color:var(--color-bg-elev)]">
</div>
@elseif ($current_logo_url && ! $remove_logo)
<div class="mt-4 flex items-center gap-4">
<div>
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-2">
{{ __('Aktuelles Logo:') }}
</div>
<img src="{{ $current_logo_url }}" width="128" height="128"
class="h-32 max-h-32 w-32 max-w-32 rounded-[6px] border border-[color:var(--color-bg-rule)] object-contain bg-[color:var(--color-bg-elev)]">
</div>
<flux:button type="button" size="sm" variant="filled" wire:click="$set('remove_logo', true)">
{{ __('Logo entfernen') }}
</flux:button>
</div>
@elseif ($remove_logo)
<div class="mt-4 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5" />
<div class="flex-1">
{{ __('Logo wird beim Speichern entfernt.') }}
</div>
<flux:button type="button" size="sm" variant="filled" wire:click="$set('remove_logo', false)">
{{ __('Rückgängig') }}
</flux:button>
</div>
@endif
</flux:field>
<div class="flex gap-6 pt-2 border-t border-[color:var(--color-bg-rule)]">
<flux:checkbox wire:model="is_verified" label="{{ __('Firma ist verifiziert') }}" />
<flux:checkbox wire:model="is_active" label="{{ __('Firma ist aktiv') }}" />
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
</div>
@if ($goneAt)
<div class="mx-5 mt-5 rounded-[5px] border border-red-300 bg-red-50 p-3 text-[12.5px] text-red-900">
{{ __('Dieser Newsroom ist mit 410 entfernt (seit :date). Die öffentliche Newsroom-URL liefert einen Tombstone statt Inhalten.', ['date' => $goneAt]) }}
</div>
@endif
<div class="p-5 flex justify-between items-center gap-3 flex-wrap">
<div class="flex gap-3 flex-wrap">
@if ($goneAt)
<flux:button
variant="filled"
icon="arrow-uturn-left"
type="button"
wire:click.prevent="restoreGone"
wire:confirm="{{ __('410-Entfernung aufheben? Der Newsroom wird wieder aktiv geschaltet. Bereits entfernte Pressemitteilungen bleiben entfernt.') }}"
>
{{ __('410-Entfernung aufheben') }}
</flux:button>
@else
<flux:button
variant="danger"
icon="no-symbol"
type="button"
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-company-gone')"
>
{{ __('Entfernen mit 410') }}
</flux:button>
@endif
<flux:modal.trigger name="confirm-company-deletion">
<flux:button
variant="danger"
icon="trash"
type="button"
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-company-deletion')"
>
{{ __('Vollständig löschen') }}
</flux:button>
</flux:modal.trigger>
</div>
<div class="flex gap-3">
<flux:button variant="filled" href="{{ route('admin.companies.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Änderungen speichern') }}
</flux:button>
</div>
</div>
</article>
</form>
<flux:modal name="confirm-company-gone" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Newsroom mit 410 entfernen?') }}</flux:heading>
<flux:subheading>
{{ __('Der Newsroom wird deaktiviert und verschwindet aus Newsroom-Übersicht und Sitemap. Seine bestehende URL antwortet danach mit „410 Gone“ und zeigt einen Hinweis auf die Entfernung — statt eines 404. Die Firma bleibt in der Verwaltung erhalten, die Entfernung ist umkehrbar.') }}
</flux:subheading>
</div>
<flux:checkbox
wire:model="goneIncludesPressReleases"
label="{{ __('Auch alle Pressemitteilungen dieses Newsrooms mit 410 entfernen') }}"
description="{{ __('Ohne diesen Haken bleiben veröffentlichte Pressemitteilungen der Firma weiterhin einzeln abrufbar. Das Aufheben der Entfernung gibt Pressemitteilungen nicht automatisch wieder frei.') }}"
/>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="markGone">
{{ __('Mit 410 entfernen') }}
</flux:button>
</div>
</div>
</flux:modal>
<flux:modal name="confirm-company-deletion" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Firma wirklich löschen?') }}</flux:heading>
<flux:subheading>
{{ __('Diese Aktion kann nicht direkt rückgängig gemacht werden. Die Firma wird archiviert (Soft Delete) und aus den Standardlisten entfernt. Die öffentliche Newsroom-URL antwortet danach mit 404. Soll die Adresse stattdessen einen Entfernungs-Hinweis mit „410 Gone“ ausliefern, brechen Sie hier ab und wählen „Entfernen mit 410“.') }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="deleteCompany">
{{ __('Löschung bestätigen') }}
</flux:button>
</div>
</div>
</flux:modal>
</div>