Backend-WIP: Kontakt-Queue, Page-Cache, Validierungs-Feedback, i18n

Gebuendelt, da ueber gemeinsame lang-/Service-Dateien verschraenkt:
- Kontakt-Nachrichten: contact_messages-Tabelle + Model/Enums/Factory,
  Admin-Queue (livewire/admin/contact-messages), ContactController
  persistiert das Anliegen zusaetzlich zur Brand-Mail
- Page-Data-Cache (Services/Web/*PageData) + web-archive-Indizes auf
  press_releases
- Validierungs-Feedback + Company-Portal-Restriction (Feature-Tests)
- i18n-Auslagerung lang/de|en (auth, passwords, validation, static)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-09 15:38:11 +00:00
parent c6b5ec8479
commit fd8e050cb7
60 changed files with 3963 additions and 327 deletions

View file

@ -15,7 +15,8 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
{
use WithFileUploads;
public string $portal = 'both';
#[Validate('required|in:'.Portal::Presseecho->value.','.Portal::Businessportal24->value)]
public string $portal = Portal::Presseecho->value;
#[Validate('required|min:3|max:255')]
public string $company_name = '';
@ -86,7 +87,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
if ($this->logo) {
$stored = $imageService->storeCompanyLogo(
$this->logo,
$this->portal === Portal::Both->value ? Portal::Presseecho->value : $this->portal,
$this->portal,
$company->id,
);
@ -111,7 +112,13 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
['code' => 'GB', 'name' => 'Großbritannien'],
['code' => 'US', 'name' => 'USA'],
]),
'portalOptions' => Portal::cases(),
// „Beide Portale" ist bei der Anlage nicht wählbar, damit neue
// Firmen ihre PMs nicht automatisch auf beiden Portalen
// veröffentlichen.
'portalOptions' => array_filter(
Portal::cases(),
fn (Portal $portalCase): bool => $portalCase !== Portal::Both,
),
];
}

View file

@ -4,6 +4,7 @@ use App\Enums\Portal;
use App\Models\Company;
use App\Services\Image\ImageService;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
@ -18,6 +19,12 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
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 = '';
@ -78,6 +85,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
}
$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 ?? '';
@ -105,6 +113,18 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
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]);
$logoPath = $company->logo_path;
$logoVariants = $company->logo_variants;
@ -157,7 +177,10 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
['code' => 'GB', 'name' => 'Großbritannien'],
['code' => 'US', 'name' => 'USA'],
]),
'portalOptions' => Portal::cases(),
'portalOptions' => array_filter(
Portal::cases(),
fn (Portal $portalCase): bool => $portalCase !== Portal::Both || $this->allowBothPortal,
),
];
}

View file

@ -0,0 +1,185 @@
<?php
use App\Enums\ContactMessageStatus;
use App\Enums\ContactMessageTopic;
use App\Models\ContactMessage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Component
{
use WithPagination;
#[Url(as: 'status', except: 'open')]
public string $statusFilter = 'open';
#[Url(as: 'topic', except: 'all')]
public string $topicFilter = 'all';
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function updatedTopicFilter(): void
{
$this->resetPage();
}
public function with(): array
{
$messages = ContactMessage::query()
->when($this->statusFilter === 'open', fn ($query) => $query->open())
->when(! in_array($this->statusFilter, ['open', 'all'], true), fn ($query) => $query->where('status', $this->statusFilter))
->when($this->topicFilter !== 'all', fn ($query) => $query->where('topic', $this->topicFilter))
->latest()
->paginate(50);
return [
'messages' => $messages,
'topicOptions' => ContactMessageTopic::cases(),
'openCount' => ContactMessage::query()->open()->count(),
'totalCount' => ContactMessage::query()->count(),
];
}
public function statusBadgeClass(ContactMessageStatus $status): string
{
return match ($status) {
ContactMessageStatus::Open => 'warn',
ContactMessageStatus::InProgress => 'hub',
ContactMessageStatus::Resolved => 'ok',
ContactMessageStatus::Rejected => 'err',
};
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Kontakt')"
:title="__('Kontakt')"
:description="__('Anliegen aus dem Kontakt-Widget und dem Kontaktformular: Kontaktaufnahmen, Fehlermeldungen, PM-Meldungen und Feedback – manuelle Bearbeitung.')"
/>
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-portal.stat-card variant="primary" :label="__('Offen')" :value="number_format($openCount)">
<x-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta>
<x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)">
<x-slot:meta>{{ __('alle Anliegen') }}</x-slot:meta>
<x-slot:trend>{{ __('inkl. erledigt') }}</x-slot:trend>
</x-portal.stat-card>
</section>
{{-- ============== FILTER ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter') }}</span>
</div>
<div class="p-5 flex flex-col gap-4 sm:flex-row">
<flux:select wire:model.live="statusFilter" class="w-full sm:w-56">
<option value="open">{{ __('Offen & in Bearbeitung') }}</option>
<option value="all">{{ __('Alle Status') }}</option>
<option value="resolved">{{ __('Erledigt') }}</option>
<option value="rejected">{{ __('Abgelehnt') }}</option>
</flux:select>
<flux:select wire:model.live="topicFilter" class="w-full sm:w-56">
<option value="all">{{ __('Alle Anliegen') }}</option>
@foreach ($topicOptions as $topicOption)
<option value="{{ $topicOption->value }}">{{ $topicOption->label() }}</option>
@endforeach
</flux:select>
</div>
</article>
{{-- ============== TABELLE ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $messages->total()]) }}
</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Anliegen') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Absender') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('Eingegangen') }}</flux:table.column>
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($messages as $message)
<flux:table.row :key="$message->id">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $message->topic->label() }}
</span>
<span class="block text-[11px] font-mono text-[color:var(--color-ink-4)]">
{{ $message->reference() }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="badge dot {{ $this->statusBadgeClass($message->status) }}">
{{ $message->status->label() }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink)]">
{{ $message->name ?: __('anonym') }}
</span>
<span class="block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $message->email }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">
{{ $message->portal ?: '—' }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $message->created_at?->format('d.m.Y H:i') ?? '-' }}
</span>
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="filled" icon="eye"
href="{{ route('admin.contact-messages.show', $message->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.chat-bubble-left-right class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Anliegen in dieser Ansicht') }}
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0 max-w-[420px]">
{{ __('Anliegen aus dem Kontakt-Widget (Kontaktaufnahme, Fehlermeldung, PM-Meldung, Feedback) laufen hier auf.') }}
</p>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<x-portal.table-pagination :paginator="$messages" />
</article>
</div>

View file

@ -0,0 +1,217 @@
<?php
use App\Enums\ContactMessageStatus;
use App\Models\ContactMessage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class extends Component
{
#[Locked]
public int $id;
public string $adminNote = '';
public string $notification = '';
public function mount(int $id): void
{
$this->id = $id;
$this->adminNote = (string) ($this->message()->admin_note ?? '');
}
public function markInProgress(): void
{
$this->message()->update(['status' => ContactMessageStatus::InProgress->value]);
$this->notification = __('Status auf „In Bearbeitung" gesetzt.');
}
public function resolve(): void
{
$this->finish(ContactMessageStatus::Resolved, __('Anliegen als erledigt markiert.'));
}
public function reject(): void
{
$this->finish(ContactMessageStatus::Rejected, __('Anliegen abgelehnt.'));
}
public function reopen(): void
{
$this->message()->update([
'status' => ContactMessageStatus::Open->value,
'resolved_by_user_id' => null,
'resolved_at' => null,
]);
$this->notification = __('Anliegen wieder geöffnet.');
}
private function finish(ContactMessageStatus $status, string $notification): void
{
$this->message()->update([
'status' => $status->value,
'admin_note' => $this->adminNote !== '' ? $this->adminNote : null,
'resolved_by_user_id' => auth()->id(),
'resolved_at' => now(),
]);
$this->notification = $notification;
}
private function message(): ContactMessage
{
return ContactMessage::query()->with(['user', 'resolver'])->findOrFail($this->id);
}
public function with(): array
{
return ['message' => $this->message()];
}
public function statusBadgeClass(ContactMessageStatus $status): string
{
return match ($status) {
ContactMessageStatus::Open => 'warn',
ContactMessageStatus::InProgress => 'hub',
ContactMessageStatus::Resolved => 'ok',
ContactMessageStatus::Rejected => 'err',
};
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
size="sm"
:badge="__('Admin Backend')"
:eyebrow="__('Kontakt · Anliegen')"
:title="$message->topic->label()"
>
<x-slot:meta>
<span class="badge dot {{ $this->statusBadgeClass($message->status) }}">{{ $message->status->label() }}</span>
</x-slot:meta>
<x-slot:actions>
<flux:button icon="arrow-left" variant="filled" href="{{ route('admin.contact-messages.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
@if ($notification)
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 3000)" x-show="show" x-transition
class="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" />
{{ $notification }}
</div>
@endif
<div class="grid gap-6 xl:grid-cols-3">
{{-- ============== ANLIEGEN ============== --}}
<article class="panel xl:col-span-2">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
</div>
<div class="p-5 space-y-4">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] text-[color:var(--color-ink)] whitespace-pre-line">
{{ $message->message ?: __('Keine Nachricht hinterlegt.') }}
</div>
<dl class="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">{{ __('Vorgangsnummer') }}</dt>
<dd class="text-[color:var(--color-ink)] font-mono">{{ $message->reference() }}</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Name') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $message->name ?: __('anonym') }}</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Absender') }}</dt>
<dd class="text-[color:var(--color-ink)] break-all">
@if ($message->email)
<a href="mailto:{{ $message->email }}"
class="text-[color:var(--color-hub)] underline underline-offset-2">{{ $message->email }}</a>
@else
{{ __('anonym') }}
@endif
</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Eingegangen') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $message->created_at?->format('d.m.Y H:i') ?? '-' }}</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Portal') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $message->portal ?: '—' }}</dd>
</div>
@if ($message->user)
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Benutzerkonto') }}</dt>
<dd class="text-[color:var(--color-ink)]">
{{ $message->user->name }}
@if (\Illuminate\Support\Facades\Route::has('admin.users.show'))
<a href="{{ route('admin.users.show', $message->user->id) }}" wire:navigate
class="ml-2 text-[12px] text-[color:var(--color-hub)] underline underline-offset-2">{{ __('öffnen') }}</a>
@endif
</dd>
</div>
@endif
@if ($message->page_url)
<div class="sm:col-span-2">
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Abgesendet von Seite') }}</dt>
<dd class="text-[color:var(--color-ink)]">
<a href="{{ $message->page_url }}" target="_blank" rel="noopener"
class="text-[color:var(--color-hub)] underline underline-offset-2 break-all">{{ $message->page_url }}</a>
</dd>
</div>
@endif
</dl>
</div>
</article>
{{-- ============== BEARBEITUNG ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bearbeitung') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:textarea wire:model="adminNote" :label="__('Interne Notiz')" rows="4"
:placeholder="__('Wie wurde das Anliegen bearbeitet?')" />
</flux:field>
@if ($message->resolver)
<p class="text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Zuletzt bearbeitet von :name am :date', [
'name' => $message->resolver->name,
'date' => $message->resolved_at?->format('d.m.Y H:i') ?? '-',
]) }}
</p>
@endif
<div class="flex flex-col gap-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
@if ($message->status->isOpen())
@if ($message->status !== \App\Enums\ContactMessageStatus::InProgress)
<flux:button variant="filled" icon="clock" wire:click="markInProgress">
{{ __('In Bearbeitung nehmen') }}
</flux:button>
@endif
<flux:button variant="primary" icon="check" wire:click="resolve">
{{ __('Als erledigt markieren') }}
</flux:button>
<flux:button variant="danger" icon="x-mark" wire:click="reject">
{{ __('Ablehnen') }}
</flux:button>
@else
<flux:button variant="filled" icon="arrow-path" wire:click="reopen">
{{ __('Wieder öffnen') }}
</flux:button>
@endif
</div>
</div>
</article>
</div>
</div>

View file

@ -14,20 +14,17 @@ use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends Component
{
new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends Component {
public function with(): array
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$selectedCompanyId = $context->selectedCompanyId($user);
$selectedCompany = $selectedCompanyId === null
? null
: $context->findFor($user, $selectedCompanyId);
$selectedCompany = $selectedCompanyId === null ? null : $context->findFor($user, $selectedCompanyId);
$pressReleaseQuery = PressRelease::withoutGlobalScopes()
->where('user_id', $user->id)
->when($selectedCompanyId !== null, fn ($query) => $query->where('company_id', $selectedCompanyId));
->when($selectedCompanyId !== null, fn($query) => $query->where('company_id', $selectedCompanyId));
$now = Carbon::now();
$currentStart = $now->copy()->startOfMonth();
@ -59,7 +56,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
// Launch-Gate (WS-5): Solange billing.enforce_booking aus ist, ist
// dies immer false und der Hinweis bleibt unsichtbar. Bei scharfem
// Gate sehen Bestandskunden (grandfathered) den Banner nicht.
'bookingRequired' => config('billing.enforce_booking') && ! $user->hasActiveBooking(),
'bookingRequired' => config('billing.enforce_booking') && !$user->hasActiveBooking(),
'stats' => [
'total' => (int) ($stats->total ?? 0),
'published' => (int) ($stats->published ?? 0),
@ -69,13 +66,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
],
'profileCompleteness' => $this->profileCompleteness($profile),
'billingCompleteness' => $this->billingCompleteness($billingAddress),
'qualityHints' => $this->qualityHints(
$user,
$profile,
$billingAddress,
$selectedCompany,
$pressReleaseQuery,
),
'qualityHints' => $this->qualityHints($user, $profile, $billingAddress, $selectedCompany, $pressReleaseQuery),
'recent' => $recent,
'companies' => $context->latestCompaniesFor($user),
'companiesTotal' => $context->companyCountFor($user),
@ -93,20 +84,13 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
*/
private function profileCompleteness(?Profile $profile): int
{
if (! $profile) {
if (!$profile) {
return 0;
}
$fields = [
'salutation_key',
'first_name',
'last_name',
'phone',
'address',
'country_code',
];
$fields = ['salutation_key', 'first_name', 'last_name', 'phone', 'address', 'country_code'];
$filled = collect($fields)->filter(fn (string $field) => filled($profile->{$field}))->count();
$filled = collect($fields)->filter(fn(string $field) => filled($profile->{$field}))->count();
return (int) round(($filled / count($fields)) * 100);
}
@ -123,13 +107,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
/**
* @return list<array{icon: string, title: string, description: string, href: string, action: string, percent?: int}>
*/
private function qualityHints(
User $user,
?Profile $profile,
?BillingAddress $billingAddress,
?Company $selectedCompany,
Builder $pressReleaseQuery,
): array {
private function qualityHints(User $user, ?Profile $profile, ?BillingAddress $billingAddress, ?Company $selectedCompany, Builder $pressReleaseQuery): array
{
$hints = [];
$profilePercent = $this->profileCompleteness($profile);
@ -138,7 +117,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
'icon' => 'user',
'title' => __('Profil unvollständig'),
'description' => __('Ergänzen Sie Salutation, Telefon und Adresse für eine saubere Kundenakte.'),
'href' => route('me.profile').'#profil',
'href' => route('me.profile') . '#profil',
'action' => __('Profil öffnen'),
'percent' => $profilePercent,
];
@ -148,11 +127,9 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
if ($billingPercent < 100) {
$hints[] = [
'icon' => 'archive-box',
'title' => $billingAddress
? __('Rechnungsadresse unvollständig')
: __('Rechnungsadresse fehlt'),
'title' => $billingAddress ? __('Rechnungsadresse unvollständig') : __('Rechnungsadresse fehlt'),
'description' => __('Hinterlegen Sie eine Rechnungsadresse, damit spätere Buchungen sauber abgerechnet werden können.'),
'href' => route('me.profile').'#rechnungsadresse',
'href' => route('me.profile') . '#rechnungsadresse',
'action' => __('Rechnungsadresse ergänzen'),
'percent' => $billingPercent,
];
@ -171,18 +148,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
];
}
} else {
$unassignedPressReleasesCount = (clone $pressReleaseQuery)
->whereNull('company_id')
->count();
$unassignedPressReleasesCount = (clone $pressReleaseQuery)->whereNull('company_id')->count();
if ($unassignedPressReleasesCount > 0) {
$hints[] = [
'icon' => 'newspaper',
'title' => trans_choice(
':count Pressemitteilung ohne Firma|:count Pressemitteilungen ohne Firma',
$unassignedPressReleasesCount,
['count' => $unassignedPressReleasesCount],
),
'title' => trans_choice(':count Pressemitteilung ohne Firma|:count Pressemitteilungen ohne Firma', $unassignedPressReleasesCount, ['count' => $unassignedPressReleasesCount]),
'description' => __('Ordnen Sie Legacy-Pressemitteilungen einer Firma zu, damit Portal und Pressekontakte eindeutig sind.'),
'href' => route('me.press-releases.index', ['company' => 'unassigned']),
'action' => __('Pressemitteilungen prüfen'),
@ -207,7 +178,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
{{ __('Mein Dashboard') }}
</h1>
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Willkommen zurück, ') }}<strong class="font-semibold text-[color:var(--color-ink)]">{{ $user->name }}</strong>.
{{ __('Willkommen zurück, ') }}<strong
class="font-semibold text-[color:var(--color-ink)]">{{ $user->name }}</strong>.
@if ($selectedCompany)
{{ __('Übersicht für :company', ['company' => $selectedCompany->name]) }} —
@endif
@ -222,10 +194,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
</span>
@else
<a href="{{ route('me.press-kits.index') }}" wire:navigate
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-[4px] text-[12px] font-semibold whitespace-nowrap bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)] hover:bg-[color:var(--color-warn-soft)]/80 transition">
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-[4px] text-[12px] font-semibold whitespace-nowrap bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)] hover:bg-[color:var(--color-warn-soft)]/80 transition">
<flux:icon.exclamation-triangle class="size-[13px] flex-shrink-0" />
{{ __('Keine Firma zugeordnet') }}
<span class="underline underline-offset-[3px] decoration-[color:var(--color-accent-deep)]/40">{{ __('zuordnen') }} →</span>
<span
class="underline underline-offset-[3px] decoration-[color:var(--color-accent-deep)]/40">{{ __('zuordnen') }}
→</span>
</a>
@endif
</div>
@ -233,10 +207,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
{{-- ============== BUCHUNGS-GATE (WS-5) ============== --}}
@if ($bookingRequired)
<section class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-5 py-4">
<section
class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-5 py-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-3 min-w-0">
<span class="flex-shrink-0 mt-0.5 w-9 h-9 rounded-[5px] flex items-center justify-center
<span
class="flex-shrink-0 mt-0.5 w-9 h-9 rounded-[5px] flex items-center justify-center
bg-white/60 border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
<flux:icon.lock-closed class="size-[17px]" />
</span>
@ -250,7 +226,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
</div>
</div>
<div class="flex-shrink-0">
<flux:button variant="primary" icon="credit-card" href="{{ route('me.bookings.index') }}" wire:navigate>
<flux:button variant="primary" icon="credit-card" href="{{ route('me.bookings.index') }}"
wire:navigate>
{{ __('Buchung auswählen') }}
</flux:button>
</div>
@ -265,12 +242,13 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
<x-slot:trend>
@php
$delta = $stats['deltaMonth'];
$deltaLabel = $delta === 0
? __('keine Veränderung ggü. Vormonat')
: trans_choice(':sign:abs ggü. Vormonat', abs($delta), [
'sign' => $delta > 0 ? '+' : '−',
'abs' => abs($delta),
]);
$deltaLabel =
$delta === 0
? __('keine Veränderung ggü. Vormonat')
: trans_choice(':sign:abs ggü. Vormonat', abs($delta), [
'sign' => $delta > 0 ? '+' : '−',
'abs' => abs($delta),
]);
@endphp
<span class="flex items-center gap-1">
@if ($delta > 0)
@ -315,7 +293,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
{{ $recent->count() }} {{ __('von') }} {{ $stats['total'] }}
</span>
<a href="{{ route('me.press-releases.index') }}" wire:navigate
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
{{ __('Alle anzeigen') }} →
</a>
</div>
@ -327,14 +305,16 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
$portal = $pr->portal?->value ?? 'both';
$showPe = in_array($portal, ['presseecho', 'both'], true);
$showBp = in_array($portal, ['businessportal24', 'both'], true);
$primaryDate = $pr->status === PressReleaseStatus::Published && $pr->published_at
? $pr->published_at
: $pr->created_at;
$primaryDate =
$pr->status === PressReleaseStatus::Published && $pr->published_at
? $pr->published_at
: $pr->created_at;
@endphp
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
class="flex items-center justify-between gap-4 px-5 py-3 border-b border-[color:var(--color-bg-rule)] last:border-b-0 hover:bg-[color:var(--color-bg)] transition-colors">
<div class="min-w-0 flex-1">
<p class="truncate text-[13px] font-medium text-[color:var(--color-ink)] m-0">{{ $pr->title }}</p>
<p class="truncate text-[13px] font-medium text-[color:var(--color-ink)] m-0">
{{ $pr->title }}</p>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5 m-0 truncate">
PM-{{ $pr->id }}
<span class="text-[color:var(--color-ink-4)] mx-1">·</span>
@ -355,10 +335,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
</a>
@empty
<div class="px-10 py-14 flex flex-col items-center text-center">
<div class="w-16 h-16 rounded-[6px] flex items-center justify-center mb-5 relative
<div
class="w-16 h-16 rounded-[6px] flex items-center justify-center mb-5 relative
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.newspaper class="size-7" />
<span class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full text-[10px] font-bold flex items-center justify-center font-mono
<span
class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full text-[10px] font-bold flex items-center justify-center font-mono
bg-[color:var(--color-accent)] text-white">0</span>
</div>
<div class="text-[16px] font-semibold m-0 text-[color:var(--color-ink)]">
@ -368,20 +350,19 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
{{ __('Starten Sie mit einer ersten Mitteilung für die aktive Firma oder Ihr Kundenkonto. Veröffentlichung erfolgt nach bestandener Inhaltsprüfung auf beiden Portalen.') }}
</p>
<div class="mt-6 flex items-center gap-2.5">
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}"
wire:navigate>
{{ __('Erste Pressemitteilung erstellen') }}
</flux:button>
</div>
<div class="mt-9 grid gap-3 w-full max-w-[560px] grid-cols-1 sm:grid-cols-3">
@foreach ([
['num' => '01', 'label' => __('Firma zuordnen')],
['num' => '02', 'label' => __('Mitteilung verfassen')],
['num' => '03', 'label' => __('Zur Prüfung senden')],
] as $step)
<div class="text-left px-3 py-2.5 rounded-[3px]
@foreach ([['num' => '01', 'label' => __('Firma zuordnen')], ['num' => '02', 'label' => __('Mitteilung verfassen')], ['num' => '03', 'label' => __('Zur Prüfung senden')]] as $step)
<div
class="text-left px-3 py-2.5 rounded-[3px]
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]">
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">
<div
class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">
{{ $step['num'] }}
</div>
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
@ -393,12 +374,13 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
</div>
@endforelse
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] flex items-center gap-2.5 text-[11.5px]
<div
class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] flex items-center gap-2.5 text-[11.5px]
bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink-3)]">
<flux:icon.shield-check class="size-[12px] text-[color:var(--color-hub)] flex-shrink-0" />
{{ __('Tipp: Geprüfte Mitteilungen erscheinen i. d. R. binnen') }}
<strong class="font-semibold text-[color:var(--color-ink-2)]">{{ __('4 Stunden') }}</strong>
{{ __('werktags auf beiden Portalen.') }}
<strong class="font-semibold text-[color:var(--color-ink-2)]">{{ __('wenigen Minuten') }}</strong>
{{ __('auf den Portalen.') }}
</div>
</article>
@ -421,13 +403,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
@if (count($qualityHints) > 0)
<div class="space-y-3">
@foreach ($qualityHints as $hint)
<x-portal.hint-card
:icon="$hint['icon']"
:title="$hint['title']"
:percent="$hint['percent'] ?? null"
:href="$hint['href']"
:action="$hint['action']"
>
<x-portal.hint-card :icon="$hint['icon']" :title="$hint['title']" :percent="$hint['percent'] ?? null"
:href="$hint['href']" :action="$hint['action']">
{{ $hint['description'] }}
</x-portal.hint-card>
@endforeach
@ -459,7 +436,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
{{ $companiesTotal }} {{ __('zugeordnet') }}
</span>
<a href="{{ route('me.press-kits.index') }}" wire:navigate
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
{{ __('Alle Firmen anzeigen') }} →
</a>
</div>
@ -470,17 +447,19 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
<div class="grid gap-3 grid-cols-1 md:grid-cols-2">
@foreach ($companies as $company)
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate
class="block relative rounded-[5px] p-5 transition-colors
class="block relative rounded-[5px] p-5 transition-colors
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]
hover:border-[color:var(--color-hub)]/50">
<div class="flex items-center gap-3 mb-2">
<span class="w-10 h-10 rounded-[4px] flex items-center justify-center
<span
class="w-10 h-10 rounded-[4px] flex items-center justify-center
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)]
text-[color:var(--color-hub)] text-[12px] font-bold">
{{ \Illuminate\Support\Str::of($company->name)->take(2)->upper() }}
</span>
<div class="min-w-0">
<div class="text-[13.5px] font-semibold truncate text-[color:var(--color-ink)]">
<div
class="text-[13.5px] font-semibold truncate text-[color:var(--color-ink)]">
{{ $company->name }}
</div>
<div class="text-[11px] mt-0.5 text-[color:var(--color-ink-3)]">
@ -495,17 +474,19 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
<div class="mt-4 text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
{{ __('Die zehn neuesten Firmen werden hier als Vorschau angezeigt.') }}
<a href="{{ route('me.press-kits.index') }}" wire:navigate
class="font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
class="font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
{{ __('Zur vollständigen Firmenliste') }} →
</a>
</div>
@endif
@else
<div class="grid gap-3 grid-cols-1 md:grid-cols-2">
<div class="relative rounded-[5px] p-5 transition-colors
<div
class="relative rounded-[5px] p-5 transition-colors
border border-dashed border-[color:var(--color-bg-rule)] hover:bg-[color:var(--color-bg-elev)]">
<div class="flex items-center gap-3 mb-3">
<span class="w-10 h-10 rounded-[4px] flex items-center justify-center
<span
class="w-10 h-10 rounded-[4px] flex items-center justify-center
border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]
text-[color:var(--color-ink-4)]">
<flux:icon.building-office class="size-[18px]" />
@ -524,14 +505,16 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
</p>
</div>
<div class="p-5 rounded-[5px]
<div
class="p-5 rounded-[5px]
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]">
<div class="eyebrow muted mb-2">{{ __('Hinweis') }}</div>
<div class="text-[13px] leading-[1.55] m-0 text-[color:var(--color-ink-2)]">
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte die Firmenverwaltung oder wenden Sie sich an den Support.') }}
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte die Firmenverwaltung oder wenden Sie sich an den Support.') }}
</div>
<div class="mt-3">
<flux:button size="sm" variant="filled" href="{{ route('me.press-kits.index') }}" wire:navigate>
<flux:button size="sm" variant="filled"
href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Firmen öffnen') }}
</flux:button>
</div>
@ -545,7 +528,9 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
<article class="panel-dark">
<div class="panel-head">
<span class="eyebrow on-dark">{{ __('Brand-Bridge') }}</span>
<span class="font-mono text-[10px] tracking-[0.14em] uppercase text-[color:var(--color-ink-on-dark-3)]">A · B</span>
<span
class="font-mono text-[10px] tracking-[0.14em] uppercase text-[color:var(--color-ink-on-dark-3)]">A
· B</span>
</div>
<div class="px-5 py-5">
@ -558,7 +543,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
<div class="rounded-[4px] px-3.5 py-3 border bg-[color:var(--color-panel-dark-2)] border-white/5">
<div class="flex items-center gap-2 mb-1.5">
<span class="dot-pe"></span>
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">presseecho</span>
<span
class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">presseecho</span>
</div>
<div class="font-mono text-[15px] font-semibold text-white tabular-nums">
{{ __($bridgeStatus['presseecho']['state']) }}
@ -570,7 +556,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
<div class="rounded-[4px] px-3.5 py-3 border bg-[color:var(--color-panel-dark-2)] border-white/5">
<div class="flex items-center gap-2 mb-1.5">
<span class="dot-bp"></span>
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">businessportal24</span>
<span
class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">businessportal24</span>
</div>
<div class="font-mono text-[15px] font-semibold text-white tabular-nums">
{{ __($bridgeStatus['businessportal24']['state']) }}
@ -601,13 +588,17 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
</section>
{{-- ============== FOOTER ============== --}}
<footer class="flex items-center justify-between pt-4 pb-2 text-[11px]
<footer
class="flex items-center justify-between pt-4 pb-2 text-[11px]
border-t border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
<span>© {{ now()->format('Y') }} pressekonto.de · Publisher-Hub</span>
<span>© {{ now()->format('Y') }} pressekonto.com · Publisher-Hub</span>
<span class="flex items-center gap-5">
<a href="{{ route('me.security') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Sicherheit') }}</a>
<a href="{{ route('me.tokens.index') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('API & Tokens') }}</a>
<a href="{{ route('me.profile') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Profil') }}</a>
<a href="{{ route('me.security') }}" wire:navigate
class="hover:text-[color:var(--color-hub)]">{{ __('Sicherheit') }}</a>
<a href="{{ route('me.tokens.index') }}" wire:navigate
class="hover:text-[color:var(--color-hub)]">{{ __('API & Tokens') }}</a>
<a href="{{ route('me.profile') }}" wire:navigate
class="hover:text-[color:var(--color-hub)]">{{ __('Profil') }}</a>
</span>
</footer>
</div>

View file

@ -38,10 +38,12 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
try {
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
// „Beide Portale" ist bei der Firmen-Anlage bewusst nicht
// wählbar: PMs übernehmen das Portal der Firma und würden
// sonst automatisch auf beiden Portalen veröffentlicht.
'portal' => ['required', Rule::in([
Portal::Presseecho->value,
Portal::Businessportal24->value,
Portal::Both->value,
])],
'address' => ['nullable', 'string', 'max:1000'],
'email' => ['nullable', 'email', 'max:190'],
@ -106,7 +108,6 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
'portals' => [
Portal::Presseecho->value => Portal::Presseecho->label(),
Portal::Businessportal24->value => Portal::Businessportal24->label(),
Portal::Both->value => Portal::Both->label(),
],
'countries' => (array) config('countries.items', []),
];

View file

@ -46,7 +46,7 @@ new class extends Component
$reference = $this->referenceNow();
return $this->baseQuery()
->when($this->timeframe === 'today', fn (Builder $query) => $query->whereDate('published_at', $reference->toDateString()))
->when($this->timeframe === 'today', fn (Builder $query) => $query->whereBetween('published_at', [$reference->copy()->startOfDay(), $reference->copy()->endOfDay()]))
->when($this->timeframe === 'week', fn (Builder $query) => $query->where('published_at', '>=', $reference->copy()->subDays(7)))
->orderByDesc('published_at')
->paginate(8);