presseportale/resources/views/livewire/customer/press-kits/show.blade.php
Kevin Adametz 72432fdb82 web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben

F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope
auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und
Medienpfade sowie die PressRelease-Helfer schreiben jetzt
withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht
kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit
Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter
und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst
verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein.
Der Vorschau-Test prüfte bisher forceDelete statt delete.

F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen
Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht
auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER.
Neuer Index contacts_email_idx.

F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei
Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die
Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt-
Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache
vorher nicht und traf den Fehler nie.

F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst
(ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung
gewinnt die Erstquelle.

F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss
aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren
Vertrag.

F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann
Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht
als verlorenes Logo.

Block 4 — KI-Prüfung und Scheduler

F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei
Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen
(ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler
veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum
aktuellen Text passt, sondern plant eine Prüfung mit Routing ein.

F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period).
Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der
Reset ist idempotent und lässt den laufenden Monat in Ruhe.

F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen
Fehler-Exit-Code, damit der Failure-Hook greift.

F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt.

F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:40:48 +00:00

895 lines
46 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Rules\NoPromotionalText;
use App\Services\Company\CompanyProfileCheck;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\Image\ImageService;
use App\Services\PressRelease\VisibilityStats;
use App\Support\WebCaches;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new #[Layout('components.layouts.app'), Title('Firma')] class extends Component {
use WithFileUploads;
#[Locked]
public int $id;
public bool $showCompanyForm = false;
public string $companyName = '';
public string $companyAddress = '';
public string $companyEmail = '';
public string $companyPhone = '';
public string $companyWebsite = '';
public string $companyCountryCode = 'DE';
public string $companyBoilerplate = '';
public bool $companyDisableFooterCode = false;
public $companyLogo = null;
public bool $removeCompanyLogo = false;
public bool $showContactForm = false;
public ?int $editingContactId = null;
public string $contactFirstName = '';
public string $contactLastName = '';
public string $contactResponsibility = '';
public string $contactEmail = '';
public string $contactPhone = '';
public function mount(int $id): void
{
$this->id = $id;
$context = app(CustomerCompanyContext::class);
$company = $context->findFor(auth()->user(), $id);
abort_unless($company !== null, 404);
$context->select(auth()->user(), $id);
}
public function startEditCompany(): void
{
$company = $this->company();
$this->authorize('update', $company);
$this->companyName = (string) $company->name;
$this->companyAddress = (string) ($company->address ?? '');
$this->companyEmail = (string) ($company->email ?? '');
$this->companyPhone = (string) ($company->phone ?? '');
$this->companyWebsite = (string) ($company->website ?? '');
$this->companyCountryCode = (string) ($company->country_code ?? 'DE');
$this->companyBoilerplate = (string) ($company->boilerplate ?? '');
$this->companyDisableFooterCode = (bool) $company->disable_footer_code;
$this->companyLogo = null;
$this->removeCompanyLogo = false;
$this->showCompanyForm = true;
}
public function cancelCompanyForm(): void
{
$this->resetCompanyForm();
}
public function saveCompany(ImageService $imageService, CompanyProfileCheck $profileCheck, ConversionRecorder $conversions): void
{
$company = $this->company();
$this->authorize('update', $company);
$validated = $this->validate([
// Öffentliche Kurzfelder sind keine Werbefläche: keine Links,
// keine Adressen, kein Blacklist-Treffer (NoPromotionalText).
'companyName' => ['required', 'string', 'max:255', app(NoPromotionalText::class)],
'companyAddress' => ['nullable', 'string', 'max:1000', app(NoPromotionalText::class)],
'companyEmail' => ['nullable', 'email', 'max:190'],
'companyPhone' => ['nullable', 'string', 'max:40'],
'companyWebsite' => ['nullable', 'url', 'max:190'],
'companyCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
// Reiner Text (Roadmap 1b.3): Links im Profiltext sind nicht
// vorgesehen — sonst wird das Profil ein Backlink-Automat. Die
// Website ist das eine, systemseitig ausgezeichnete Link-Feld.
'companyBoilerplate' => ['nullable', 'string', 'max:2000'],
'companyLogo' => ['nullable', 'image', 'max:' . (int) (ImageService::MAX_LOGO_BYTES / 1024)],
]);
$boilerplate = trim(strip_tags((string) $validated['companyBoilerplate']));
$address = trim((string) $validated['companyAddress']);
// Rot-Ebene der bestehenden Prüfung — dieselbe wie für eine Meldung.
// Gilt für beide Fließtextfelder, jeweils nur bei Änderung.
if ($boilerplate !== '' && $boilerplate !== trim((string) $company->boilerplate) && $profileCheck->isRed($company, $boilerplate)) {
throw ValidationException::withMessages([
'companyBoilerplate' => __('Die Beschreibung enthält unzulässige Inhalte und kann so nicht gespeichert werden.'),
]);
}
if ($address !== '' && $address !== trim((string) $company->address) && $profileCheck->isRed($company, $address)) {
throw ValidationException::withMessages([
'companyAddress' => __('Die Adresse enthält unzulässige Inhalte und kann so nicht gespeichert werden.'),
]);
}
$company->fill([
'name' => $validated['companyName'],
'address' => $validated['companyAddress'] ?: null,
'email' => $validated['companyEmail'] ?: null,
'phone' => $validated['companyPhone'] ?: null,
'website' => $validated['companyWebsite'] ?: null,
'country_code' => $validated['companyCountryCode'] ?: null,
'boilerplate' => $boilerplate !== '' ? $boilerplate : null,
'disable_footer_code' => $this->companyDisableFooterCode,
]);
$previousLogoPath = $company->logo_path;
$previousLogoVariants = $company->logo_variants;
$discardPreviousLogo = false;
$stored = null;
if ($this->removeCompanyLogo) {
$company->logo_path = null;
$company->logo_variants = null;
$discardPreviousLogo = true;
}
if ($this->companyLogo) {
// 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->companyLogo, $company->portal?->value ?? 'presseecho', $company->id);
} catch (\RuntimeException $exception) {
report($exception);
throw ValidationException::withMessages([
'companyLogo' => __('Das Logo konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.'),
]);
}
$company->logo_path = $stored['path'];
$company->logo_variants = $stored['variants'];
$discardPreviousLogo = true;
}
try {
$company->save();
} 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 !== $company->logo_path) {
$imageService->deleteCompanyLogo($previousLogoPath, $previousLogoVariants);
}
$this->resetCompanyForm();
// Stufe 3 der Konversionsleiter: Newsroom gepflegt (einmal je Firma).
$conversions->recordNewsroomEdited($company, auth()->user());
// Der öffentliche Newsroom zeigt Name, Logo und Beschreibung — die
// Änderung soll nicht erst nach Ablauf des Seiten-Caches sichtbar sein.
WebCaches::forgetPublicPages();
session()->flash('company-status', __('Stammdaten wurden gespeichert.'));
}
public function startCreateContact(): void
{
$this->authorize('update', $this->company());
$this->resetContactForm();
$this->showContactForm = true;
}
public function editContact(int $contactId): void
{
$this->authorize('update', $this->company());
$contact = $this->contact($contactId);
$this->editingContactId = $contact->id;
$this->contactFirstName = (string) ($contact->first_name ?? '');
$this->contactLastName = (string) ($contact->last_name ?? '');
$this->contactResponsibility = (string) ($contact->responsibility ?? '');
$this->contactEmail = (string) ($contact->email ?? '');
$this->contactPhone = (string) ($contact->phone ?? '');
$this->showContactForm = true;
}
public function cancelContactForm(): void
{
$this->resetContactForm();
}
public function saveContact(): void
{
$company = $this->company();
$this->authorize('update', $company);
$validated = $this->validate([
'contactFirstName' => ['nullable', 'string', 'max:80', app(NoPromotionalText::class)],
'contactLastName' => ['nullable', 'string', 'max:80', app(NoPromotionalText::class)],
'contactResponsibility' => ['nullable', 'string', 'max:255', app(NoPromotionalText::class)],
'contactEmail' => ['required', 'email', 'max:255'],
'contactPhone' => ['nullable', 'string', 'max:40'],
]);
if (blank($validated['contactFirstName']) && blank($validated['contactLastName'])) {
throw ValidationException::withMessages([
'contactLastName' => __('Bitte geben Sie mindestens einen Namen an.'),
]);
}
$payload = [
'company_id' => $company->id,
'portal' => $company->portal?->value,
'first_name' => $validated['contactFirstName'] ?: null,
'last_name' => $validated['contactLastName'] ?: null,
'responsibility' => $validated['contactResponsibility'] ?: null,
'email' => $validated['contactEmail'],
'phone' => $validated['contactPhone'] ?: null,
];
if ($this->editingContactId) {
$this->contact($this->editingContactId)->update($payload);
session()->flash('contact-status', __('Pressekontakt wurde aktualisiert.'));
} else {
Contact::query()->create($payload);
session()->flash('contact-status', __('Pressekontakt wurde angelegt.'));
}
// Die Kontakte liegen mit im gecachten Newsroom-Seitenpaket — die
// Änderung soll nicht erst nach Ablauf des Seiten-Caches greifen.
WebCaches::forgetPublicPages();
$this->resetContactForm();
}
public function deleteContact(int $contactId): void
{
$this->authorize('update', $this->company());
$contact = $this->contact($contactId);
$contact->delete();
WebCaches::forgetPublicPages();
if ($this->editingContactId === $contactId) {
$this->resetContactForm();
}
session()->flash('contact-status', __('Pressekontakt wurde gelöscht.'));
}
public function with(): array
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$stats = app(VisibilityStats::class);
$company = $context
->accessibleCompanyQuery($user)
->withCount(['contacts', 'pressReleases'])
->findOrFail($this->id);
return [
'company' => $company,
'roleLabel' => $context->roleLabelFor($company, $user),
'canManageCompany' => $user->can('update', $company),
'canManageContacts' => $user->can('update', $company),
'countries' => (array) config('countries.items', []),
'contacts' => Contact::withoutGlobalScope(PortalScope::class)
->where('company_id', $company->id)
->withCount('pressReleases')
->orderBy('last_name')
->orderBy('first_name')
->limit(10)
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'email', 'phone']),
// Alle Meldungen der Firma, nicht nur die eigenen (Roadmap 1c.3):
// Wer den Newsroom beansprucht hat, soll sehen, was connektar
// für ihn eingeliefert hat — die Policy erlaubt das Firmen-
// mitgliedern ohnehin. Sortiert nach Veröffentlichung, Entwürfe
// nach Anlage.
'pressReleases' => $pressReleases = PressRelease::withoutGlobalScope(PortalScope::class)
->where('company_id', $company->id)
->orderByRaw('coalesce(published_at, created_at) desc')
->limit(15)
->get(['id', 'title', 'status', 'created_at', 'published_at', 'hits', 'user_id']),
'recentViews' => $stats->recentViewsFor($pressReleases->modelKeys(), 30),
'summary' => $stats->companySummary($company, 30),
];
}
private function company(): Company
{
$company = app(CustomerCompanyContext::class)->findFor(auth()->user(), $this->id);
abort_unless($company !== null, 404);
return $company;
}
private function contact(int $contactId): Contact
{
return Contact::withoutGlobalScope(PortalScope::class)->where('company_id', $this->id)->findOrFail($contactId);
}
private function resetCompanyForm(): void
{
$this->showCompanyForm = false;
$this->companyName = '';
$this->companyAddress = '';
$this->companyEmail = '';
$this->companyPhone = '';
$this->companyWebsite = '';
$this->companyCountryCode = 'DE';
$this->companyBoilerplate = '';
$this->companyDisableFooterCode = false;
$this->companyLogo = null;
$this->removeCompanyLogo = false;
$this->resetValidation();
}
private function resetContactForm(): void
{
$this->showContactForm = false;
$this->editingContactId = null;
$this->contactFirstName = '';
$this->contactLastName = '';
$this->contactResponsibility = '';
$this->contactEmail = '';
$this->contactPhone = '';
$this->resetValidation();
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<header class="page-header">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Firma') }}</span>
@if ($company->is_active)
<span class="badge ok">{{ __('Aktiv') }}</span>
@else
<span class="badge err">{{ __('Inaktiv') }}</span>
@endif
<span class="badge hub">{{ $company->portal?->label() ?? __('Portal unbekannt') }}</span>
<span class="badge hub">{{ $roleLabel }}</span>
@if ($company->disable_footer_code)
<span class="badge warn">{{ __('Footer-Code deaktiviert') }}</span>
@endif
</div>
<div class="flex items-start gap-4">
@if ($company->logoUrl())
<img src="{{ $company->logoUrl() }}" alt="{{ $company->name }}" width="64" height="64"
class="h-16 max-h-16 w-16 max-w-16 rounded-[6px] border border-[color:var(--color-bg-rule)] object-contain bg-[color:var(--color-bg-elev)] flex-shrink-0" />
@else
<div class="flex h-16 w-16 items-center justify-center rounded-[6px]
border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] flex-shrink-0">
<flux:icon.building-office class="size-7 text-[color:var(--color-ink-3)]" />
</div>
@endif
<div class="min-w-0">
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)] break-words">
{{ $company->name }}
</h1>
<div class="text-[12px] text-[color:var(--color-ink-3)] mt-1">{{ $company->slug }}</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 flex-shrink-0">
<flux:button icon="arrow-left" variant="filled" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
@if ($canManageCompany)
<flux:button icon="pencil" variant="filled" wire:click="startEditCompany">
{{ __('Stammdaten bearbeiten') }}
</flux:button>
@endif
<flux:button icon="plus" variant="primary" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
</div>
</header>
{{-- ============== QUICK-NAV ============== --}}
<nav class="flex flex-wrap items-center gap-2 border-b border-[color:var(--color-bg-rule)] pb-3">
<a href="#stammdaten" class="text-[12px] font-semibold text-[color:var(--color-ink-3)] hover:text-[color:var(--color-ink)] px-3 py-1.5 rounded-[4px] hover:bg-[color:var(--color-bg-elev)] transition-colors">{{ __('Stammdaten') }}</a>
<a href="#pressekontakte" class="text-[12px] font-semibold text-[color:var(--color-ink-3)] hover:text-[color:var(--color-ink)] px-3 py-1.5 rounded-[4px] hover:bg-[color:var(--color-bg-elev)] transition-colors">{{ __('Pressekontakte') }}</a>
<a href="#pressemitteilungen" class="text-[12px] font-semibold text-[color:var(--color-ink-3)] hover:text-[color:var(--color-ink)] px-3 py-1.5 rounded-[4px] hover:bg-[color:var(--color-bg-elev)] transition-colors">{{ __('Pressemitteilungen') }}</a>
<a href="#abrechnung" class="text-[12px] font-semibold text-[color:var(--color-ink-3)] hover:text-[color:var(--color-ink)] px-3 py-1.5 rounded-[4px] hover:bg-[color:var(--color-bg-elev)] transition-colors">{{ __('Abrechnung') }}</a>
<a href="#statistik" class="text-[12px] font-semibold text-[color:var(--color-ink-3)] hover:text-[color:var(--color-ink)] px-3 py-1.5 rounded-[4px] hover:bg-[color:var(--color-bg-elev)] transition-colors">{{ __('Statistik') }}</a>
</nav>
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<x-portal.stat-card variant="primary" :label="__('Pressemitteilungen')" :value="number_format($company->press_releases_count)">
<x-slot:meta>{{ __('zugeordnet') }}</x-slot:meta>
<x-slot:trend>{{ __('Content-Output') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('Pressekontakte')" :value="number_format($company->contacts_count)">
<x-slot:meta>{{ __('Ansprechpartner') }}</x-slot:meta>
<x-slot:trend>{{ __('für PMs verfügbar') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Portal')" :value="$company->portal?->label() ?? '–'">
<x-slot:meta>{{ __('Sichtbarkeit') }}</x-slot:meta>
<x-slot:trend>{{ __('für diese Firma') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Deine Rolle')" :value="$roleLabel">
<x-slot:meta>{{ __('Berechtigungen') }}</x-slot:meta>
<x-slot:trend>{{ $canManageCompany ? __('Voller Schreibzugriff') : __('Lesezugriff') }}</x-slot:trend>
</x-portal.stat-card>
</section>
<div class="grid gap-6 xl:grid-cols-2">
{{-- ============== STAMMDATEN ============== --}}
<article class="panel" id="stammdaten">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Stammdaten') }}</span>
@if ($canManageCompany)
<flux:button size="sm" variant="filled" icon="pencil" wire:click="startEditCompany">
{{ __('Bearbeiten') }}
</flux:button>
@endif
</div>
@if (session('company-status'))
<div class="mx-5 mt-4 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
{{ session('company-status') }}
</div>
@endif
@if ($showCompanyForm)
<div class="mx-5 my-4 rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-5">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-4">
{{ __('Stammdaten bearbeiten') }}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:input wire:model="companyName" :label="__('Firmenname')" required />
<flux:error name="companyName" />
</flux:field>
<flux:field>
<flux:input wire:model="companyEmail" :label="__('E-Mail')" type="email" />
<flux:error name="companyEmail" />
</flux:field>
<flux:field>
<flux:input wire:model="companyPhone" :label="__('Telefon')" />
<flux:error name="companyPhone" />
</flux:field>
<flux:field>
<flux:input wire:model="companyWebsite" :label="__('Website')" placeholder="https://..." />
<flux:error name="companyWebsite" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:textarea wire:model="companyAddress" :label="__('Adresse')" rows="3" />
<flux:error name="companyAddress" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:textarea wire:model="companyBoilerplate" :label="__('Beschreibung')" rows="5" maxlength="2000"
:description="__('Kurzporträt des Unternehmens — erscheint im Newsroom und unter jeder Meldung als „Über das Unternehmen“. Reiner Text, keine Links; die Website steht im eigenen Feld.')" />
<flux:error name="companyBoilerplate" />
</flux:field>
<flux:field>
<flux:select wire:model="companyCountryCode" :label="__('Land')">
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
<flux:error name="companyCountryCode" />
</flux:field>
<flux:field>
<flux:switch wire:model="companyDisableFooterCode" :label="__('Footer-Code deaktivieren')" />
</flux:field>
</div>
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] space-y-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">
{{ __('Firmenlogo') }}
</div>
@php($logoUrl = $company->logoUrl())
@if ($logoUrl && ! $removeCompanyLogo)
<div class="flex items-center gap-3">
<img src="{{ $logoUrl }}" alt="{{ $company->name }}" width="64" height="64"
class="h-16 max-h-16 w-16 max-w-16 rounded-[6px] border border-[color:var(--color-bg-rule)] object-contain bg-white" />
<flux:button type="button" size="sm" variant="filled" wire:click="$set('removeCompanyLogo', true)">
{{ __('Logo entfernen') }}
</flux:button>
</div>
@endif
<flux:field>
<flux:file-upload wire:model="companyLogo"
:label="__('Neues Logo hochladen')"
accept="image/jpeg,image/png,image/webp,image/gif"
:description="__('JPG/PNG/WebP/GIF, max. 4 MB. Varianten werden automatisch generiert.')">
<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:error name="companyLogo" />
@if ($companyLogo)
<flux:file-item class="mt-2"
:heading="$companyLogo->getClientOriginalName()"
:image="$companyLogo->temporaryUrl()"
:size="$companyLogo->getSize()"
>
<x-slot name="actions">
<flux:file-item.remove wire:click="$set('companyLogo', null)" :aria-label="__('Logo entfernen')" />
</x-slot>
</flux:file-item>
@endif
</flux:field>
</div>
{{-- Bilder im Newsroom: zurückgestellt, bis die Bild-Kennzeichnung
angepasst ist (Entscheidung 15.09.2026). Sichtbar als BALD,
nicht weggelassen — Leitplanke der Roadmap. --}}
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] space-y-2">
<div class="flex items-center gap-2">
<span class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Bilder im Newsroom') }}</span>
<flux:badge size="sm" color="zinc">{{ __('BALD') }}</flux:badge>
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Funktion folgt. Bilder im Newsroom unterliegen derselben KI-Kennzeichnung wie Bilder in Meldungen; die wird gerade überarbeitet.') }}
</p>
</div>
<div class="mt-4 pt-4 border-t border-[color:var(--color-bg-rule)] flex justify-end gap-2">
<flux:button type="button" variant="filled" wire:click="cancelCompanyForm">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="button" variant="primary" wire:click="saveCompany">
{{ __('Speichern') }}
</flux:button>
</div>
</div>
@endif
<dl class="p-5 grid gap-3 sm:grid-cols-2 text-[12.5px]">
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('E-Mail') }}</dt>
<dd class="text-[color:var(--color-ink)] break-all">{{ $company->email ?: '–' }}</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Telefon') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $company->phone ?: '–' }}</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Website') }}</dt>
<dd class="break-all">
@if ($company->website)
<a href="{{ $company->website }}" target="_blank" rel="noopener"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">{{ $company->website }}</a>
@else
<span class="text-[color:var(--color-ink)]">–</span>
@endif
</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Land') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $company->country_code ?: '–' }}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Adresse') }}</dt>
<dd class="text-[color:var(--color-ink)] whitespace-pre-line">{{ $company->address ?: '–' }}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Beschreibung') }}</dt>
<dd class="text-[color:var(--color-ink)] whitespace-pre-line">{{ $company->boilerplate ?: '–' }}</dd>
</div>
</dl>
</article>
{{-- ============== PRESSEKONTAKTE ============== --}}
<article class="panel" id="pressekontakte">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Pressekontakte') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ trans_choice(':count Kontakt|:count Kontakte', $company->contacts_count, ['count' => $company->contacts_count]) }}
</span>
</div>
@if ($canManageContacts)
<div class="px-5 pt-4 flex justify-end">
<flux:button size="sm" variant="primary" icon="plus" wire:click="startCreateContact">
{{ __('Kontakt hinzufügen') }}
</flux:button>
</div>
@endif
@if (session('contact-status'))
<div class="mx-5 mt-4 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
{{ session('contact-status') }}
</div>
@endif
@if ($showContactForm)
<div class="mx-5 my-4 rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-5">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-4">
{{ $editingContactId ? __('Pressekontakt bearbeiten') : __('Neuen Pressekontakt anlegen') }}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:input wire:model="contactFirstName" :label="__('Vorname')" />
<flux:error name="contactFirstName" />
</flux:field>
<flux:field>
<flux:input wire:model="contactLastName" :label="__('Nachname')" />
<flux:error name="contactLastName" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:input wire:model="contactResponsibility" :label="__('Position / Rolle')" />
<flux:error name="contactResponsibility" />
</flux:field>
<flux:field>
<flux:input wire:model="contactEmail" :label="__('E-Mail')" type="email" required />
<flux:error name="contactEmail" />
</flux:field>
<flux:field>
<flux:input wire:model="contactPhone" :label="__('Telefon')" />
<flux:error name="contactPhone" />
</flux:field>
</div>
<div class="mt-4 pt-4 border-t border-[color:var(--color-bg-rule)] flex justify-end gap-2">
<flux:button type="button" variant="filled" wire:click="cancelContactForm">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="button" variant="primary" wire:click="saveContact">
{{ __('Speichern') }}
</flux:button>
</div>
</div>
@endif
<div class="p-5 space-y-2">
@forelse ($contacts as $contact)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $contact->displayName() }}
</div>
<div class="text-[12px] text-[color:var(--color-ink-3)] mt-0.5">
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-[color:var(--color-ink-3)]">
@if ($contact->email)
<a href="mailto:{{ $contact->email }}"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">{{ $contact->email }}</a>
@endif
@if ($contact->phone)
<span>{{ $contact->phone }}</span>
@endif
@if ($contact->press_releases_count > 0)
<span>{{ trans_choice('in :count Pressemitteilung hinterlegt|in :count Pressemitteilungen hinterlegt', $contact->press_releases_count, ['count' => $contact->press_releases_count]) }}</span>
@endif
</div>
</div>
@if ($canManageContacts)
<div class="flex gap-1 flex-shrink-0">
<flux:button size="sm" variant="filled" icon="pencil" wire:click="editContact({{ $contact->id }})">
{{ __('Bearbeiten') }}
</flux:button>
<flux:button size="sm" variant="filled" icon="trash"
wire:click="deleteContact({{ $contact->id }})"
wire:confirm="{{ __('Diesen Pressekontakt löschen?') }}">
{{ __('Löschen') }}
</flux:button>
</div>
@endif
</div>
</div>
@empty
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ __('Keine Pressekontakte hinterlegt') }}</div>
<p class="mt-1 text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Pressekontakte helfen, Pressemitteilungen eindeutig einer Ansprechperson zuzuordnen.') }}
</p>
@if ($canManageContacts)
<flux:button class="mt-3" size="sm" variant="primary" icon="plus" wire:click="startCreateContact">
{{ __('Kontakt hinzufügen') }}
</flux:button>
@endif
</div>
@endforelse
</div>
</article>
</div>
{{-- ============== PRESSEMITTEILUNGEN ============== --}}
<article class="panel overflow-hidden" id="pressemitteilungen">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Pressemitteilungen dieser Firma') }}</span>
<flux:button size="sm" variant="filled" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Alle anzeigen') }}
</flux:button>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Titel') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Datum') }}</flux:table.column>
<flux:table.column align="end">{{ __('Aufrufe') }}</flux:table.column>
<flux:table.column align="end">{{ __('30 Tage') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse ($pressReleases as $pressRelease)
<flux:table.row wire:key="company-pr-{{ $pressRelease->id }}">
<flux:table.cell>
<a href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate class="text-[13px] font-semibold text-[color:var(--color-ink)] hover:underline">{{ $pressRelease->title }}</a>
@if ($pressRelease->user_id !== auth()->id())
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ __('eingereicht über Partner') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
@php($statusValue = $pressRelease->status->value)
<span @class([
'badge dot',
'ok' => $statusValue === 'published',
'warn' => $statusValue === 'review',
'err' => $statusValue === 'rejected',
'hub' => ! in_array($statusValue, ['published', 'review', 'rejected'], true),
])>
{{ $pressRelease->status->label() }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $pressRelease->published_at?->format('d.m.Y') ?? ($pressRelease->created_at?->format('d.m.Y') ?? '–') }}
</span>
</flux:table.cell>
<flux:table.cell align="end">
<span class="text-[12.5px] tabular-nums font-semibold text-[color:var(--color-ink)]">{{ number_format((int) $pressRelease->hits, 0, ',', '.') }}</span>
</flux:table.cell>
<flux:table.cell align="end">
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($recentViews[$pressRelease->id] ?? 0, 0, ',', '.') }}</span>
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="filled" icon="eye"
href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate>
{{ __('Öffnen') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.newspaper class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Pressemitteilungen für diese Firma') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
{{ __('Erstellen Sie die erste Pressemitteilung direkt mit dieser Firma als Kontext.') }}
</p>
<flux:button class="mt-4" size="sm" variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</article>
<div class="grid gap-6 xl:grid-cols-2">
{{-- ============== ABRECHNUNG ============== --}}
<article class="panel" id="abrechnung">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Abrechnung') }}</span>
<span class="badge warn">{{ __('In Vorbereitung') }}</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Firmenspezifische Zahlungsarten und Add-ons werden hier später zusammengeführt.') }}
</p>
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ __('Noch keine firmenspezifische Abrechnung') }}
</div>
<p class="mt-1 text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Rechnungen finden Sie aktuell gesammelt im Finanzbereich. Firmenscharfe Zahlungsarten folgen mit dem Preismodell.') }}
</p>
<flux:button class="mt-3" size="sm" variant="filled" href="{{ route('me.invoices.index') }}" wire:navigate>
{{ __('Rechnungen öffnen') }}
</flux:button>
</div>
</div>
</article>
{{-- ============== STATISTIK (Roadmap 1c.3) ============== --}}
<article class="panel" id="statistik">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Sichtbarkeit') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('alle Meldungen dieser Firma') }}</span>
</div>
<div class="p-5 space-y-4">
<div class="grid grid-cols-2 gap-3">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Aufrufe gesamt') }}
</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">
{{ number_format($summary['views_total'], 0, ',', '.') }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Aufrufe 30 Tage') }}
</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">
{{ number_format($summary['views_recent'], 0, ',', '.') }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Veröffentlicht') }}
</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">
{{ number_format($summary['published'], 0, ',', '.') }}
<span class="text-[11.5px] font-medium text-[color:var(--color-ink-3)]">/ {{ number_format($summary['releases'], 0, ',', '.') }}</span>
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Zuletzt veröffentlicht') }}
</div>
<div class="text-[14px] font-bold text-[color:var(--color-ink)] mt-1.5">
{{ $summary['last_published_at']?->format('d.m.Y') ?? '–' }}
</div>
</div>
</div>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0 leading-relaxed">
{{ __('Aufrufe sind Seitenaufrufe der Meldungen auf dem Portal. Der Verlauf je Meldung steht auf ihrer Detailseite.') }}
</p>
</div>
</article>
</div>
</div>