22-05-2026 Optimierung der User und Admin Panels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

This commit is contained in:
Kevin Adametz 2026-05-22 11:18:59 +02:00
parent d2ba22c0cf
commit e8c47b7553
73 changed files with 10282 additions and 1546 deletions

View file

@ -665,6 +665,450 @@
max-width: 440px;
}
/* ============================================================
* SEG-TOGGLE (Karten- vs. Listenansicht)
* ============================================================ */
.seg-toggle {
display: inline-flex;
background: var(--color-bg-card);
border: 1px solid var(--color-hub-soft-2);
border-radius: 4px;
padding: 2px;
gap: 0;
}
.seg-toggle button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
font-size: 12px;
font-weight: 600;
color: var(--color-ink-3);
border-radius: 3px;
transition: background 0.12s, color 0.12s;
cursor: pointer;
}
.seg-toggle button:hover {
color: var(--color-hub);
}
.seg-toggle button.is-active {
background: var(--color-hub);
color: #fff;
}
.seg-toggle button svg {
opacity: 0.7;
}
.seg-toggle button.is-active svg {
opacity: 1;
}
/* ============================================================
* FIRM-CARD Firmen-Karte im Card-Grid
* ============================================================ */
.firm-card {
background: var(--color-bg-card);
border: 1px solid var(--color-bg-rule);
border-radius: 6px;
padding: 18px;
transition: border-color 0.15s;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 266px;
}
.firm-card:hover {
border-color: var(--color-hub-line);
}
.firm-card.is-self {
border-color: var(--color-bg-rule);
box-shadow: none;
}
.firm-card .logo {
width: 56px;
height: 56px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 18px;
letter-spacing: -0.5px;
flex-shrink: 0;
overflow: hidden;
}
.firm-card .logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.firm-card .name {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.3px;
color: var(--color-ink);
line-height: 1.2;
margin: 0;
}
.firm-card .meta-line {
font-size: 11.5px;
color: var(--color-ink-3);
margin-top: 3px;
line-height: 1.4;
}
.firm-card .kpis {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
border-top: 1px solid var(--color-bg-rule-2);
padding-top: 11px;
margin-top: auto;
}
.firm-card .kpi {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 4px;
border-right: 1px solid var(--color-bg-rule-2);
}
.firm-card .kpi:last-child {
border-right: 0;
}
.firm-card .kpi .k {
font-family: var(--font-mono);
font-size: 15.5px;
font-weight: 600;
color: var(--color-ink);
font-variant-numeric: tabular-nums;
line-height: 1.1;
letter-spacing: -0.3px;
}
.firm-card .kpi .l {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-ink-4);
}
/* ============================================================
* ROLE-PILL Rolle innerhalb einer Firma
* ============================================================ */
.role-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px 2px 7px;
background: var(--color-bg-elev);
border: 1px dashed var(--color-hub-soft-2);
border-radius: 99px;
font-size: 10.5px;
color: var(--color-ink-3);
font-weight: 600;
letter-spacing: 0.04em;
}
.role-pill::before {
content: "";
width: 5px;
height: 5px;
border-radius: 99px;
background: var(--color-accent-warm);
}
.role-pill.admin {
color: var(--color-hub);
}
.role-pill.admin::before {
background: var(--color-hub);
}
/* ============================================================
* MINI-LOGO Kleines Logo in Listen-Zeilen
* ============================================================ */
.mini-logo {
width: 30px;
height: 30px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: -0.3px;
flex-shrink: 0;
overflow: hidden;
}
.mini-logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ============================================================
* LOGO-COLOR-TOKENS Deterministische Avatar-Varianten
* ============================================================ */
.lg-brew {
background: linear-gradient(135deg, #3a4d2f 0%, #1f2e1a 100%);
color: var(--color-accent-soft);
}
.lg-mv {
background: linear-gradient(
135deg,
var(--color-hub) 0%,
var(--color-hub-2) 100%
);
color: #fff;
}
.lg-soft {
background: var(--color-accent-soft);
color: var(--color-accent-deep);
border: 1px solid
color-mix(in oklab, var(--color-accent-warm), transparent 50%);
}
.lg-warm {
background: linear-gradient(
135deg,
var(--color-accent-warm) 0%,
var(--color-accent-deep) 100%
);
color: #fff;
}
.lg-blank {
background: repeating-linear-gradient(
135deg,
var(--color-bg-elev) 0 6px,
var(--color-bg-rule-2) 6px 12px
);
color: var(--color-ink-4);
border: 1px dashed var(--color-hub-soft-2);
}
/* ============================================================
* CARD-ACTION Aktion-Button auf einer Karte
* ============================================================ */
.card-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--color-hub-soft-2);
background: var(--color-bg-card);
color: var(--color-hub);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
transition: border-color 0.12s, background 0.12s;
white-space: nowrap;
}
.card-action:hover {
border-color: var(--color-hub);
background: var(--color-bg);
}
.card-action.primary {
background: var(--color-hub);
color: #fff;
border-color: var(--color-hub);
}
.card-action.primary:hover {
background: var(--color-hub-2);
}
/* ============================================================
* MENU-TRIGGER 3-Dots Menu-Knopf
* ============================================================ */
.menu-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 4px;
border: 1px solid transparent;
color: var(--color-ink-3);
background: transparent;
transition: background 0.12s, border-color 0.12s, color 0.12s;
cursor: pointer;
}
.menu-trigger:hover {
background: var(--color-bg);
border-color: var(--color-hub-soft-2);
color: var(--color-hub);
}
/* ============================================================
* PAGE-BTN Pagination-Buttons
* ============================================================ */
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 0 9px;
border-radius: 4px;
border: 1px solid var(--color-hub-soft-2);
background: var(--color-bg-card);
font-size: 12px;
font-weight: 600;
color: var(--color-ink-2);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.page-btn:hover {
border-color: var(--color-hub);
color: var(--color-hub);
}
.page-btn.is-current {
background: var(--color-hub);
border-color: var(--color-hub);
color: #fff;
}
.page-btn.is-disabled {
color: var(--color-ink-4);
border-color: var(--color-bg-rule-2);
background: var(--color-bg-elev);
cursor: default;
}
.page-btn.is-disabled:hover {
color: var(--color-ink-4);
border-color: var(--color-bg-rule-2);
}
/* ============================================================
* TABLE.LIST Hub-styled Datentabelle (für reine HTML-Tabellen)
* ============================================================ */
table.list {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
}
table.list thead th {
text-align: left;
font-weight: 700;
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-ink-3);
padding: 11px 14px;
background: var(--color-bg-elev);
border-bottom: 1px solid var(--color-bg-rule);
white-space: nowrap;
}
table.list thead th:first-child {
padding-left: 18px;
}
table.list thead th:last-child {
padding-right: 18px;
}
table.list tbody td {
padding: 14px;
border-bottom: 1px solid var(--color-bg-rule-2);
vertical-align: middle;
}
table.list tbody td:first-child {
padding-left: 18px;
}
table.list tbody td:last-child {
padding-right: 18px;
}
table.list tbody tr:last-child td {
border-bottom: 0;
}
table.list tbody tr {
transition: background 0.1s;
}
table.list tbody tr:hover {
background: var(--color-bg-elev);
}
.row-title {
font-weight: 600;
color: var(--color-ink);
font-size: 13.5px;
line-height: 1.35;
letter-spacing: -0.1px;
display: inline-flex;
align-items: center;
gap: 9px;
}
.row-title:hover {
color: var(--color-hub);
}
.row-sub {
font-size: 11.5px;
color: var(--color-ink-3);
margin-top: 3px;
line-height: 1.4;
}
.row-num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 13px;
color: var(--color-ink);
font-weight: 600;
}
.row-num .sub {
font-family: var(--font-sans, "Inter Tight", sans-serif);
font-weight: 400;
font-size: 11px;
color: var(--color-ink-4);
margin-left: 4px;
letter-spacing: 0.02em;
}
/* ============================================================
* ADD-TILE "Neue Firma anlegen" Karte im Card-Grid
* ============================================================ */
.add-tile {
border: 1.5px dashed var(--color-hub-soft-2);
background: var(--color-bg-elev);
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
min-height: 266px;
transition: border-color 0.15s, background 0.15s, color 0.15s;
cursor: pointer;
color: var(--color-ink-2);
}
.add-tile:hover {
border-color: var(--color-hub);
border-style: solid;
background: var(--color-bg-card);
color: var(--color-hub);
}
.add-tile .ico {
width: 48px;
height: 48px;
border-radius: 6px;
background: var(--color-hub-soft);
color: var(--color-hub);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
}
.add-tile:hover .ico {
background: var(--color-hub);
color: #fff;
}
.add-tile .lbl {
font-size: 14px;
font-weight: 600;
}
.add-tile .sub {
font-size: 11.5px;
color: var(--color-ink-3);
margin-top: 6px;
line-height: 1.5;
max-width: 200px;
}
}
/* ============================================================

View file

@ -0,0 +1,81 @@
/**
* Portal Form Hooks
*
* Globale UX-Helper für FluxUI-Forms im Hub/Portal-Bereich.
*
* Aktuell:
* 1) Smooth-Scroll zum ersten Validation-Error nach Submit-Klick,
* damit der User in langen Forms (z.B. PR-Edit) nicht nach Errors
* suchen muss.
*
* Wird im Portal-Layout NACH @fluxScripts eingebunden Livewire ist
* dann garantiert verfügbar. Bewusst KEIN Alpine.start() o.ä.; FluxUI
* bringt seine eigene Alpine-Instanz mit, doppelter Bootstrap würde
* Komponenten brechen (siehe partials/head.blade.php Kommentar).
*/
(function () {
if (typeof document === 'undefined') {
return;
}
// Pending-Flag: wird nur gesetzt, wenn der User explizit auf einen
// Submit-/Save-Button klickt. Andernfalls würde JEDES wire:model-Update
// einen Scroll triggern, was bei Live-Validation extrem nervig wäre.
let scrollPending = false;
// Selektoren für Buttons, die wir als "Submit-Intent" interpretieren.
const SUBMIT_SELECTORS = [
'[wire\\:click*="save"]',
'[wire\\:click*="submit"]',
'[wire\\:click*="update"]',
'[wire\\:submit]',
'button[type="submit"]',
].join(',');
document.addEventListener('click', (event) => {
const trigger = event.target.closest(SUBMIT_SELECTORS);
if (trigger) {
scrollPending = true;
}
}, true);
document.addEventListener('livewire:init', () => {
if (!window.Livewire || typeof window.Livewire.hook !== 'function') {
return;
}
window.Livewire.hook('commit', ({ succeed }) => {
succeed(() => {
if (!scrollPending) {
return;
}
scrollPending = false;
requestAnimationFrame(() => {
const invalid = document.querySelector('[data-flux-control][aria-invalid="true"]')
|| document.querySelector('[aria-invalid="true"]')
|| document.querySelector('[data-flux-error]:not(:empty)');
if (!invalid) {
return;
}
const field = invalid.closest('[data-flux-field]')
|| invalid.closest('[data-flux-control]')
|| invalid;
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
const focusable = field.querySelector('input, textarea, select, [contenteditable="true"]');
if (focusable && typeof focusable.focus === 'function') {
// Kleine Verzögerung, damit der Scroll erst sichtbar startet,
// bevor wir den Cursor reinpacken — sonst springt der Browser
// direkt zum Element und das smooth-Scroll wirkt unruhig.
setTimeout(() => focusable.focus({ preventScroll: true }), 320);
}
});
});
});
});
})();

View file

@ -10,340 +10,364 @@
Flash beim allerersten Aufruf ist akzeptiert.
--}}
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => request()->cookie('flux_appearance') === 'dark'])>
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-bg text-ink antialiased">
<flux:sidebar sticky stashable class="border-e border-bg-rule">
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
{{-- Brand-Block: Wortmarke + Hub-Eyebrow --}}
<a href="{{ config('domains.domain_main_url') }}" class="block px-2 pt-1 pb-3 no-underline">
<span class="text-[19px] font-bold tracking-[-0.4px] leading-none">
<x-web.brand-mark brand="pressekonto" :serif="false" />
</span>
<div class="mt-1.5 text-[10px] font-semibold tracking-[0.18em] uppercase text-ink-3">
Publisher · Hub
</div>
</a>
<head>
@include('partials.head')
</head>
@php
$user = auth()->user();
$impersonation = app(\App\Actions\Admin\UserImpersonation::class);
$impersonator = $impersonation->impersonator();
$isImpersonating = $impersonation->isActive();
$canAdmin = ($user?->canAccessAdmin() ?? false) && ! $isImpersonating;
$canCustomer = $user?->canAccessCustomer() ?? false;
$reviewCount = $canAdmin
? app(\App\Services\Admin\AdminPerformanceCache::class)->pressReleaseReviewCount()
: 0;
@endphp
<body class="min-h-screen bg-bg text-ink antialiased">
<flux:sidebar sticky stashable class="border-e border-bg-rule">
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
<flux:navlist variant="outline">
{{-- Dashboard (Admin/Editor) --}}
@if($canAdmin)
<flux:navlist.item icon="chart-bar" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate class="mb-4">
{{ __('Dashboard') }}
</flux:navlist.item>
@endif
{{-- Brand-Block: Wortmarke + Hub-Eyebrow --}}
<a href="{{ config('domains.domain_main_url') }}" class="block px-2 pt-1 pb-3 no-underline">
<span class="text-[19px] font-bold tracking-[-0.4px] leading-none">
<x-web.brand-mark brand="pressekonto" :serif="false" />
</span>
<div class="mt-1.5 text-[10px] font-semibold tracking-[0.18em] uppercase text-ink-3">
Publisher · Hub
</div>
</a>
{{-- Mein Bereich sichtbar für alle Panel-User --}}
@if($canCustomer)
<flux:navlist.group :heading="__('Mein Bereich')" class="grid mb-4">
<flux:navlist.item icon="home" :href="route('me.dashboard')" :current="request()->routeIs('me.dashboard')" wire:navigate>
{{ __('Übersicht') }}
</flux:navlist.item>
<flux:navlist.item icon="newspaper" :href="route('me.press-releases.index')" :current="request()->routeIs('me.press-releases.*')" wire:navigate>
{{ __('Meine Pressemitteilungen') }}
</flux:navlist.item>
<flux:navlist.item icon="building-office" :href="route('me.press-kits.index')" :current="request()->routeIs('me.press-kits.*')" wire:navigate>
{{ __('Firmen') }}
</flux:navlist.item>
<flux:navlist.item icon="shopping-bag" :href="route('me.bookings.index')" :current="request()->routeIs('me.bookings.*')" wire:navigate>
{{ __('Buchungen & Add-ons') }}
</flux:navlist.item>
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
{{ __('Statistiken') }} <span class="text-xs">{{ __('später') }}</span>
</div>
</flux:navlist.group>
@php
$user = auth()->user();
$impersonation = app(\App\Actions\Admin\UserImpersonation::class);
$impersonator = $impersonation->impersonator();
$isImpersonating = $impersonation->isActive();
$canAdmin = ($user?->canAccessAdmin() ?? false) && !$isImpersonating;
$canCustomer = $user?->canAccessCustomer() ?? false;
$reviewCount = $canAdmin
? app(\App\Services\Admin\AdminPerformanceCache::class)->pressReleaseReviewCount()
: 0;
@endphp
<flux:navlist.group :heading="__('Finanzen')" class="grid mb-4">
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
{{ __('Credits & Tarif') }} <span class="text-xs">{{ __('später') }}</span>
</div>
<flux:navlist.item icon="document-text" :href="route('me.invoices.index')" :current="request()->routeIs('me.invoices.*')" wire:navigate>
{{ __('Rechnungen') }}
</flux:navlist.item>
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
{{ __('Zahlungsarten') }} <span class="text-xs">{{ __('später') }}</span>
</div>
</flux:navlist.group>
<flux:navlist.group :heading="__('Konto')" class="grid mb-4">
<flux:navlist.item icon="user" :href="route('me.profile')" :current="request()->routeIs('me.profile')" wire:navigate>
{{ __('Profil') }}
</flux:navlist.item>
<flux:navlist.item icon="shield-check" :href="route('me.security')" :current="request()->routeIs('me.security')" wire:navigate>
{{ __('Sicherheit') }}
</flux:navlist.item>
<flux:navlist.item icon="key" :href="route('me.tokens.index')" :current="request()->routeIs('me.tokens.*')" wire:navigate>
{{ __('API & Integrationen') }}
</flux:navlist.item>
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
{{ __('Benachrichtigungen') }} <span class="text-xs">{{ __('später') }}</span>
</div>
</flux:navlist.group>
@endif
{{-- Content Management (Admin/Editor) --}}
@if($canAdmin)
<flux:navlist.group :heading="__('Content')" class="grid mb-4">
<flux:navlist.item
icon="newspaper"
:href="route('admin.press-releases.index', $reviewCount > 0 ? ['status' => 'review'] : [])"
:current="request()->routeIs('admin.press-releases.*')"
:badge="$reviewCount > 0 ? $reviewCount : null"
badge-color="yellow"
wire:navigate
>
{{ __('Pressemitteilungen') }}
</flux:navlist.item>
<flux:navlist.item icon="folder" :href="route('admin.categories.index')" :current="request()->routeIs('admin.categories.*')" wire:navigate>
{{ __('Kategorien') }}
</flux:navlist.item>
<flux:navlist.item icon="code-bracket-square" :href="route('admin.footer-codes.index')" :current="request()->routeIs('admin.footer-codes.*')" wire:navigate>
{{ __('Footer-Codes') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- CRM --}}
<flux:navlist.group :heading="__('CRM')" class="grid mb-4">
<flux:navlist.item icon="building-office" :href="route('admin.companies.index')" :current="request()->routeIs('admin.companies.*')" wire:navigate>
{{ __('Firmen') }}
</flux:navlist.item>
<flux:navlist.item icon="user-group" :href="route('admin.contacts.index')" :current="request()->routeIs('admin.contacts.*')" wire:navigate>
{{ __('Kontakte') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- Billing --}}
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')" :current="request()->routeIs('admin.invoices.*')" wire:navigate>
{{ __('Legacy Rechnungen') }}
</flux:navlist.item>
<flux:navlist.item icon="credit-card" :href="route('admin.payments.index')" :current="request()->routeIs('admin.payments.*')" wire:navigate>
{{ __('Zahlungen') }}
</flux:navlist.item>
<flux:navlist.item icon="ticket" :href="route('admin.coupons.index')" :current="request()->routeIs('admin.coupons.*')" wire:navigate>
{{ __('Gutscheine') }}
</flux:navlist.item>
<flux:navlist.item icon="envelope" :href="route('admin.newsletter.sync')" :current="request()->routeIs('admin.newsletter.sync')" wire:navigate>
{{ __('Newsletter Sync') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- Administration --}}
<flux:navlist.group :heading="__('Administration')" class="grid mb-4">
<flux:navlist.item icon="cog" :href="route('admin.presets.index')" :current="request()->routeIs('admin.presets.*')" wire:navigate>
{{ __('Voreinstellungen') }}
</flux:navlist.item>
<flux:navlist.item icon="users" :href="route('admin.users.index')" :current="request()->routeIs('admin.users.*')" wire:navigate>
{{ __('Benutzer') }}
</flux:navlist.item>
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')" :current="request()->routeIs('admin.roles.*')" wire:navigate>
{{ __('Rollen & Rechte') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- Reports --}}
<flux:navlist.group :heading="__('Reports')" class="grid mb-4">
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')" :current="request()->routeIs('admin.reports.*')" wire:navigate>
{{ __('Performance') }}
</flux:navlist.item>
</flux:navlist.group>
@endif
</flux:navlist>
{{-- Portal-Filter für Admin-Benutzer (P2.6) --}}
@auth
@if($canAdmin)
<div class="border-t border-zinc-200 dark:border-zinc-700 mt-2 pt-2">
<livewire:admin.portal-switcher />
</div>
@endif
@endauth
@if($impersonator)
{{-- Testmodus-Block im Hub-Stil (statt Amber-Warnfarbe).
Dunkles Hub-Blau-Panel mit Bernstein-Eyebrow, klare
CTA „Zurück zum Admin" als helle Pille. --}}
<div class="mt-3 relative overflow-hidden rounded-[5px] bg-hub p-4 text-ink-on-dark">
<div class="absolute -top-6 -right-6 w-16 h-16 rounded-full bg-hub-3 opacity-50"></div>
<div class="absolute -bottom-8 -left-8 w-20 h-20 rounded-full bg-hub-3 opacity-30"></div>
<div class="relative">
<div class="flex items-center gap-2 mb-2">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
<span class="text-[10.5px] font-bold tracking-[0.20em] uppercase text-accent-soft">
{{ __('Testmodus aktiv') }}
</span>
</div>
<p class="text-[12px] leading-[1.5] text-ink-on-dark-2 m-0">
{{ __('Angemeldet als') }}
<strong class="text-white font-semibold">{{ $user?->name }}</strong>.<br/>
{{ __('Admin:') }}
<strong class="text-white font-semibold">{{ $impersonator->name }}</strong>
</p>
<form method="POST" action="{{ route('admin.impersonate.leave') }}" class="mt-3">
@csrf
<button
type="submit"
class="w-full px-3 py-2 bg-white text-hub text-[12px] font-semibold rounded-[3px] hover:bg-bg transition-colors flex items-center justify-center gap-1.5"
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M9 3L3 9M3 9H8M3 9V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ __('Zurück zum Admin') }}
</button>
</form>
</div>
</div>
<flux:navlist variant="outline">
{{-- Dashboard (Admin/Editor) --}}
@if ($canAdmin)
<flux:navlist.item icon="chart-bar" :href="route('dashboard')"
:current="request()->routeIs('dashboard')" wire:navigate class="mb-4">
{{ __('Dashboard') }}
</flux:navlist.item>
@endif
<flux:spacer />
<!-- Desktop User Menu -->
<flux:dropdown position="bottom" align="start">
<flux:profile
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
icon-trailing="chevrons-up-down"
/>
{{-- Mein Bereich sichtbar für alle Panel-User --}}
@if ($canCustomer)
<flux:navlist.group :heading="__('Mein Bereich')" class="grid mb-4">
<flux:navlist.item icon="home" :href="route('me.dashboard')"
:current="request()->routeIs('me.dashboard')" wire:navigate>
{{ __('Übersicht') }}
</flux:navlist.item>
<flux:navlist.item icon="newspaper" :href="route('me.press-releases.index')"
:current="request()->routeIs('me.press-releases.*')" wire:navigate>
{{ __('Pressemitteilungen') }}
</flux:navlist.item>
<flux:navlist.item icon="building-office" :href="route('me.press-kits.index')"
:current="request()->routeIs('me.press-kits.*')" wire:navigate>
{{ __('Firmen') }}
</flux:navlist.item>
<flux:navlist.item icon="shopping-bag" :href="route('me.bookings.index')"
:current="request()->routeIs('me.bookings.*')" wire:navigate>
{{ __('Buchungen & Add-ons') }}
</flux:navlist.item>
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
{{ __('Statistiken') }} <span class="text-xs">{{ __('später') }}</span>
</div>
</flux:navlist.group>
<flux:menu class="w-[220px]">
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
<span
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
>
{{ auth()->user()->initials() }}
</span>
<flux:navlist.group :heading="__('Finanzen')" class="grid mb-4">
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
{{ __('Credits & Tarif') }} <span class="text-xs">{{ __('später') }}</span>
</div>
<flux:navlist.item icon="document-text" :href="route('me.invoices.index')"
:current="request()->routeIs('me.invoices.*')" wire:navigate>
{{ __('Rechnungen') }}
</flux:navlist.item>
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
{{ __('Zahlungsarten') }} <span class="text-xs">{{ __('später') }}</span>
</div>
</flux:navlist.group>
<flux:navlist.group :heading="__('Konto')" class="grid mb-4">
<flux:navlist.item icon="user" :href="route('me.profile')"
:current="request()->routeIs('me.profile')" wire:navigate>
{{ __('Profil') }}
</flux:navlist.item>
<flux:navlist.item icon="shield-check" :href="route('me.security')"
:current="request()->routeIs('me.security')" wire:navigate>
{{ __('Sicherheit') }}
</flux:navlist.item>
<flux:navlist.item icon="key" :href="route('me.tokens.index')"
:current="request()->routeIs('me.tokens.*')" wire:navigate>
{{ __('API & Integrationen') }}
</flux:navlist.item>
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
{{ __('Benachrichtigungen') }} <span class="text-xs">{{ __('später') }}</span>
</div>
</flux:navlist.group>
@endif
{{-- Content Management (Admin/Editor) --}}
@if ($canAdmin)
<flux:navlist.group :heading="__('Content')" class="grid mb-4">
<flux:navlist.item icon="newspaper"
:href="route('admin.press-releases.index', $reviewCount > 0 ? ['status' => 'review'] : [])"
:current="request()->routeIs('admin.press-releases.*')"
:badge="$reviewCount > 0 ? $reviewCount : null" badge-color="yellow" wire:navigate>
{{ __('Pressemitteilungen') }}
</flux:navlist.item>
<flux:navlist.item icon="folder" :href="route('admin.categories.index')"
:current="request()->routeIs('admin.categories.*')" wire:navigate>
{{ __('Kategorien') }}
</flux:navlist.item>
<flux:navlist.item icon="code-bracket-square" :href="route('admin.footer-codes.index')"
:current="request()->routeIs('admin.footer-codes.*')" wire:navigate>
{{ __('Footer-Codes') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- CRM --}}
<flux:navlist.group :heading="__('CRM')" class="grid mb-4">
<flux:navlist.item icon="building-office" :href="route('admin.companies.index')"
:current="request()->routeIs('admin.companies.*')" wire:navigate>
{{ __('Firmen') }}
</flux:navlist.item>
<flux:navlist.item icon="user-group" :href="route('admin.contacts.index')"
:current="request()->routeIs('admin.contacts.*')" wire:navigate>
{{ __('Kontakte') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- Billing --}}
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')"
:current="request()->routeIs('admin.invoices.*')" wire:navigate>
{{ __('Legacy Rechnungen') }}
</flux:navlist.item>
<flux:navlist.item icon="credit-card" :href="route('admin.payments.index')"
:current="request()->routeIs('admin.payments.*')" wire:navigate>
{{ __('Zahlungen') }}
</flux:navlist.item>
<flux:navlist.item icon="ticket" :href="route('admin.coupons.index')"
:current="request()->routeIs('admin.coupons.*')" wire:navigate>
{{ __('Gutscheine') }}
</flux:navlist.item>
<flux:navlist.item icon="envelope" :href="route('admin.newsletter.sync')"
:current="request()->routeIs('admin.newsletter.sync')" wire:navigate>
{{ __('Newsletter Sync') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- Administration --}}
<flux:navlist.group :heading="__('Administration')" class="grid mb-4">
<flux:navlist.item icon="cog" :href="route('admin.presets.index')"
:current="request()->routeIs('admin.presets.*')" wire:navigate>
{{ __('Voreinstellungen') }}
</flux:navlist.item>
<flux:navlist.item icon="users" :href="route('admin.users.index')"
:current="request()->routeIs('admin.users.*')" wire:navigate>
{{ __('Benutzer') }}
</flux:navlist.item>
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')"
:current="request()->routeIs('admin.roles.*')" wire:navigate>
{{ __('Rollen & Rechte') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- Reports --}}
<flux:navlist.group :heading="__('Reports')" class="grid mb-4">
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')"
:current="request()->routeIs('admin.reports.*')" wire:navigate>
{{ __('Performance') }}
</flux:navlist.item>
</flux:navlist.group>
@endif
</flux:navlist>
{{-- Portal-Filter für Admin-Benutzer (P2.6) --}}
@auth
@if ($canAdmin)
<div class="border-t border-zinc-200 dark:border-zinc-700 mt-2 pt-2">
<livewire:admin.portal-switcher />
</div>
@endif
@endauth
@if ($impersonator)
{{-- Testmodus-Block im Hub-Stil (statt Amber-Warnfarbe).
Dunkles Hub-Blau-Panel mit Bernstein-Eyebrow, klare
CTA „Zurück zum Admin" als helle Pille. --}}
<div class="mt-3 relative overflow-hidden rounded-[5px] bg-hub p-4 text-ink-on-dark">
<div class="absolute -top-6 -right-6 w-16 h-16 rounded-full bg-hub-3 opacity-50"></div>
<div class="absolute -bottom-8 -left-8 w-20 h-20 rounded-full bg-hub-3 opacity-30"></div>
<div class="relative">
<div class="flex items-center gap-2 mb-2">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
<span class="text-[10.5px] font-bold tracking-[0.20em] uppercase text-accent-soft">
{{ __('Testmodus aktiv') }}
</span>
</div>
<p class="text-[12px] leading-[1.5] text-ink-on-dark-2 m-0">
{{ __('Angemeldet als') }}
<strong class="text-white font-semibold">{{ $user?->name }}</strong>.<br />
{{ __('Admin:') }}
<strong class="text-white font-semibold">{{ $impersonator->name }}</strong>
</p>
<form method="POST" action="{{ route('admin.impersonate.leave') }}" class="mt-3">
@csrf
<button type="submit"
class="w-full px-3 py-2 bg-white text-hub text-[12px] font-semibold rounded-[3px] hover:bg-bg transition-colors flex items-center justify-center gap-1.5">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M9 3L3 9M3 9H8M3 9V4" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ __('Zurück zum Admin') }}
</button>
</form>
</div>
</div>
@endif
<flux:spacer />
<!-- Desktop User Menu -->
<flux:dropdown position="bottom" align="start">
<flux:profile :name="auth()->user()->name" :initials="auth()->user()->initials()"
icon-trailing="chevrons-up-down" />
<flux:menu class="w-[220px]">
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
<span
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
{{ auth()->user()->initials() }}
</span>
</span>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
</div>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
</div>
</div>
</flux:menu.radio.group>
</div>
</flux:menu.radio.group>
<flux:menu.separator />
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>{{ __('Settings') }}</flux:menu.item>
</flux:menu.radio.group>
<flux:menu.radio.group>
<flux:menu.item :href="route('me.profile')" icon="user" wire:navigate>
{{ __('Profil') }}</flux:menu.item>
</flux:menu.radio.group>
<flux:menu.separator />
<flux:menu.separator />
{{-- Phase 5: Appearance-Switcher direkt im User-Menü.
{{-- Phase 5: Appearance-Switcher direkt im User-Menü.
`$flux.appearance` ist FluxUIs Magic-Object, persistent
über LocalStorage. Werte: 'light' | 'dark' | 'system'. --}}
<div class="px-3 py-2">
<div class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
{{ __('Erscheinung') }}
</div>
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance" class="w-full">
<flux:radio value="light" icon="sun" :title="__('Hell')" />
<flux:radio value="dark" icon="moon" :title="__('Dunkel')" />
<flux:radio value="system" icon="computer-desktop" :title="__('System')" />
</flux:radio.group>
<div class="px-3 py-2">
<div
class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
{{ __('Erscheinung') }}
</div>
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance"
class="w-full">
<flux:radio value="light" icon="sun" :title="__('Hell')" />
<flux:radio value="dark" icon="moon" :title="__('Dunkel')" />
<flux:radio value="system" icon="computer-desktop" :title="__('System')" />
</flux:radio.group>
</div>
<flux:menu.separator />
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
</flux:sidebar>
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle"
class="w-full">
{{ __('Abmelden') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
</flux:sidebar>
<!-- Mobile User Menu -->
<flux:header class="lg:hidden">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<!-- Mobile User Menu -->
<flux:header class="lg:hidden">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<a href="{{ config('domains.domain_main_url') }}" class="me-5 ml-2 flex items-baseline no-underline">
<span class="text-[16px] font-bold tracking-[-0.3px] leading-none">
<x-web.brand-mark brand="pressekonto" :serif="false" />
</span>
</a>
<flux:spacer />
<a href="{{ config('domains.domain_main_url') }}" class="me-5 ml-2 flex items-baseline no-underline">
<span class="text-[16px] font-bold tracking-[-0.3px] leading-none">
<x-web.brand-mark brand="pressekonto" :serif="false" />
</span>
</a>
<flux:spacer />
<flux:dropdown position="top" align="end">
<flux:profile
:initials="auth()->user()->initials()"
icon-trailing="chevron-down"
/>
<flux:dropdown position="top" align="end">
<flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" />
<flux:menu>
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
<span
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
>
{{ auth()->user()->initials() }}
</span>
<flux:menu>
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
<span
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
{{ auth()->user()->initials() }}
</span>
</span>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
</div>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
</div>
</div>
</flux:menu.radio.group>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>{{ __('Settings') }}</flux:menu.item>
</flux:menu.radio.group>
<flux:menu.separator />
{{-- Phase 5: Appearance-Switcher (Mobile-Dropdown). --}}
<div class="px-3 py-2">
<div class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
{{ __('Erscheinung') }}
</div>
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance" class="w-full">
<flux:radio value="light" icon="sun" :title="__('Hell')" />
<flux:radio value="dark" icon="moon" :title="__('Dunkel')" />
<flux:radio value="system" icon="computer-desktop" :title="__('System')" />
</flux:radio.group>
</div>
</flux:menu.radio.group>
<flux:menu.separator />
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
</flux:header>
<flux:menu.radio.group>
<flux:menu.item :href="route('me.profile')" icon="user" wire:navigate>
{{ __('Profil') }}</flux:menu.item>
</flux:menu.radio.group>
{{ $slot }}
<flux:menu.separator />
{{-- Phase 5: Appearance-Switcher (Mobile-Dropdown). --}}
<div class="px-3 py-2">
<div
class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
{{ __('Erscheinung') }}
</div>
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance"
class="w-full">
<flux:radio value="light" icon="sun" :title="__('Hell')" />
<flux:radio value="dark" icon="moon" :title="__('Dunkel')" />
<flux:radio value="system" icon="computer-desktop" :title="__('System')" />
</flux:radio.group>
</div>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle"
class="w-full">
{{ __('Abmelden') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
</flux:header>
{{ $slot }}
@persist('toast')
<flux:toast position="top end" class="pt-24" />
@endpersist
@vite(['resources/js/portal-form-hooks.js'], 'build/portal')
@fluxScripts
</body>
@fluxScripts
</body>
</html>

View file

@ -40,9 +40,7 @@
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}" />
<link rel="preconnect" href="https://fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
<link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|source-serif-4:400,500,600,700|jetbrains-mono:400,500,600" rel="stylesheet" />
@include('partials.local-fonts')
{{-- Nur CSS aus dem Web-Build laden. Alpine bringt @livewireScripts mit;
würden wir hier zusätzlich resources/js/app.js mit Alpine.start()

View file

@ -0,0 +1,112 @@
@php
if (! isset($scrollTo)) {
$scrollTo = 'body';
}
$scrollIntoViewJsSnippet = ($scrollTo !== false)
? <<<JS
(\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView()
JS
: '';
$pageName = $paginator->getPageName();
$isLengthAware = method_exists($paginator, 'total');
@endphp
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="portal-pagination flex flex-col gap-3 rounded-md border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-card)] px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p class="m-0 text-[12px] leading-5 text-[color:var(--color-ink-3)]">
@if ($isLengthAware)
{{ __('Zeige') }}
<span class="font-mono font-semibold text-[color:var(--color-ink)]">{{ $paginator->firstItem() }}</span>
{{ __('bis') }}
<span class="font-mono font-semibold text-[color:var(--color-ink)]">{{ $paginator->lastItem() }}</span>
{{ __('von') }}
<span class="font-mono font-semibold text-[color:var(--color-ink)]">{{ $paginator->total() }}</span>
@else
{{ __('Seite') }}
<span class="font-mono font-semibold text-[color:var(--color-ink)]">{{ $paginator->currentPage() }}</span>
<span class="text-[color:var(--color-ink-4)]">·</span>
{{ __('einfache Navigation') }}
@endif
</p>
<div class="flex flex-wrap items-center gap-1.5">
@if ($paginator->onFirstPage())
<span aria-disabled="true" aria-label="{{ __('pagination.previous') }}" class="inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-ink-4)]">
{{ __('Zurück') }}
</span>
@else
<button
type="button"
wire:click="previousPage('{{ $pageName }}')"
x-on:click="{{ $scrollIntoViewJsSnippet }}"
wire:loading.attr="disabled"
aria-label="{{ __('pagination.previous') }}"
class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-hub)] transition hover:border-[color:var(--color-hub)] hover:bg-[color:var(--color-hub-soft)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-hub)]/25 disabled:cursor-wait disabled:opacity-60"
>
{{ __('Zurück') }}
</button>
@endif
@if ($isLengthAware)
@foreach ($elements as $element)
@if (is_string($element))
<span aria-disabled="true" class="inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] px-2 text-[12px] font-semibold text-[color:var(--color-ink-4)]">
{{ $element }}
</span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page === $paginator->currentPage())
<span
aria-current="page"
class="is-active inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] border border-[color:var(--color-hub)] bg-[color:var(--color-hub)] px-3 text-[12px] font-bold text-[color:var(--color-ink-on-dark)] shadow-[0_0_0_2px_var(--color-hub-soft)]"
>
{{ $page }}
</span>
@else
<button
type="button"
wire:key="paginator-{{ $pageName }}-page-{{ $page }}"
wire:click="gotoPage({{ $page }}, '{{ $pageName }}')"
x-on:click="{{ $scrollIntoViewJsSnippet }}"
wire:loading.attr="disabled"
aria-label="{{ __('Go to page :page', ['page' => $page]) }}"
class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-ink-2)] transition hover:border-[color:var(--color-hub)] hover:bg-[color:var(--color-hub-soft)] hover:text-[color:var(--color-hub)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-hub)]/25 disabled:cursor-wait disabled:opacity-60"
>
{{ $page }}
</button>
@endif
@endforeach
@endif
@endforeach
@else
<span
aria-current="page"
class="is-active inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] border border-[color:var(--color-hub)] bg-[color:var(--color-hub)] px-3 text-[12px] font-bold text-[color:var(--color-ink-on-dark)] shadow-[0_0_0_2px_var(--color-hub-soft)]"
>
{{ $paginator->currentPage() }}
</span>
@endif
@if ($paginator->hasMorePages())
<button
type="button"
wire:click="nextPage('{{ $pageName }}')"
x-on:click="{{ $scrollIntoViewJsSnippet }}"
wire:loading.attr="disabled"
aria-label="{{ __('pagination.next') }}"
class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-hub)] transition hover:border-[color:var(--color-hub)] hover:bg-[color:var(--color-hub-soft)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-hub)]/25 disabled:cursor-wait disabled:opacity-60"
>
{{ __('Weiter') }}
</button>
@else
<span aria-disabled="true" aria-label="{{ __('pagination.next') }}" class="inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-ink-4)]">
{{ __('Weiter') }}
</span>
@endif
</div>
</nav>
@endif

View file

@ -15,8 +15,7 @@
<link rel="icon" href="{{ asset('img/favicons/admin-favicon.ico') }}">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600,700" rel="stylesheet" />
@include('partials.local-fonts')
<!-- Styles -->
@vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal')

View file

@ -12,15 +12,7 @@
<link rel="icon" href="{{ asset('img/favicons/' . $theme . '-favicon.ico') }}">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
@if ($theme === 'landing1')
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700" rel="stylesheet" />
@elseif($theme === 'landing2')
<link href="https://fonts.bunny.net/css?family=poppins:400,500,600,700" rel="stylesheet" />
@else
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600,700" rel="stylesheet" />
@endif
@include('partials.local-fonts')
<!-- Dynamisches CSS basierend auf Domain-Konfiguration -->
@if ($theme === 'landing1')

View file

@ -66,7 +66,7 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
})
->orderBy($sort, $this->sortDir);
$categories = $categoriesQuery->simplePaginate(50);
$categories = $categoriesQuery->paginate(50);
if (! $sortsByCount) {
$this->hydrateCounts($categories);
@ -306,5 +306,5 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
@endforelse
</section>
{{ $categories->links() }}
{{ $categories->links('components.portal.pagination') }}
</div>

View file

@ -101,7 +101,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
})
->orderBy($sort, $this->sortDir);
$companies = $companiesQuery->simplePaginate(50);
$companies = $companiesQuery->paginate(50);
if (! $sortsByCount) {
$this->hydrateCounts($companies);
@ -586,7 +586,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
</flux:table.rows>
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $companies->links() }}
{{ $companies->links('components.portal.pagination') }}
</div>
</article>
</div>

View file

@ -176,7 +176,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
$query->where('portal', $this->portalFilter);
})
->orderBy(in_array($this->sortBy, ['last_name', 'email', 'company_id', 'press_releases_count', 'created_at'], true) ? $this->sortBy : 'created_at', $this->sortDir)
->simplePaginate(50);
->paginate(50);
// Firmen-Filter: nur Live-Suche, nie alle laden
$term = trim($this->companySearch);
@ -745,7 +745,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $contacts->links() }}
{{ $contacts->links('components.portal.pagination') }}
</div>
</article>
</div>

View file

@ -250,7 +250,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Codes')] class extends Com
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $codes->links() }}
{{ $codes->links('components.portal.pagination') }}
</div>
</article>
</div>

View file

@ -328,7 +328,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $invoices->links() }}
{{ $invoices->links('components.portal.pagination') }}
</div>
</article>
</div>

View file

@ -208,7 +208,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellungen')] class extends
</flux:table.rows>
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $presets->links() }}
{{ $presets->links('components.portal.pagination') }}
</div>
</article>
</div>

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@ -157,16 +158,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
try {
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
return;
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
Flux::toast(text: $e->getMessage(), variant: 'danger');
return;
}
session()->flash('success', __('Pressemitteilung veröffentlicht.'));
Flux::toast(text: __('Pressemitteilung veröffentlicht.'), variant: 'success');
}
public function reject(int $id): void
@ -176,12 +182,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
try {
app(PressReleaseService::class)->reject($pr, __('Bitte überarbeiten Sie die Pressemitteilung.'));
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
Flux::toast(text: $e->getMessage(), variant: 'danger');
return;
}
session()->flash('success', __('Pressemitteilung abgelehnt.'));
Flux::toast(text: __('Pressemitteilung abgelehnt.'), variant: 'warning');
}
public function archive(int $id): void
@ -191,12 +197,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
try {
app(PressReleaseService::class)->archive($pr);
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
Flux::toast(text: $e->getMessage(), variant: 'danger');
return;
}
session()->flash('success', __('Pressemitteilung archiviert.'));
Flux::toast(text: __('Pressemitteilung archiviert.'), variant: 'success');
}
public function with(): array
@ -226,7 +232,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
->when($this->companyFilter !== 'all', fn ($q) => $q->where('company_id', (int) $this->companyFilter))
->when($this->contactFilter !== 'all', fn ($q) => $q->whereHas('contacts', fn ($contactQuery) => $contactQuery->where('contacts.id', (int) $this->contactFilter)))
->orderBy(in_array($this->sortBy, ['title', 'status', 'portal', 'hits', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir)
->simplePaginate(50);
->paginate(50);
return [
'pressReleases' => $query,
@ -373,18 +379,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
}; ?>
<div class="space-y-8">
@if (session('success'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
@endif
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
@ -905,6 +900,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
</div>
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.calendar variant="micro" class="size-3" />
<span>{{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }}</span>
</div>
@endif
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.lock-closed variant="micro" class="size-3" />
<span>{{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }}</span>
</div>
@endif
</flux:table.cell>
<flux:table.cell>
@ -1029,5 +1036,5 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
</flux:table>
</article>
{{ $pressReleases->links() }}
{{ $pressReleases->links('components.portal.pagination') }}
</div>

View file

@ -28,13 +28,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
try {
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
Flux::modal('confirm-show-publish')->close();
return;
}
session()->flash('success', __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'));
Flux::toast(text: __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'), variant: 'success');
Flux::modal('confirm-show-publish')->close();
}
@ -49,7 +54,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$this->rejectReason = '';
session()->flash('success', __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'));
Flux::toast(text: __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'), variant: 'warning');
Flux::modal('confirm-show-reject')->close();
}
@ -57,7 +62,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
app(PressReleaseService::class)->archive($pr);
session()->flash('success', __('Archiviert.'));
Flux::toast(text: __('Archiviert.'), variant: 'success');
Flux::modal('confirm-show-archive')->close();
}
@ -65,17 +70,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{
$pr = PressRelease::withoutGlobalScopes()
->with([
'company:id,name,slug',
'company:id,name,email,phone,slug',
'category.translations',
'user:id,name',
'user:id,name,email',
'images',
'attachments',
'contacts' => fn ($query) => $query
->withoutGlobalScopes()
->orderBy('last_name')
->orderBy('first_name')
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
'statusLogs.changedBy:id,name',
])
->findOrFail($this->id);
$latestRejection = null;
if ($pr->status->value === 'rejected') {
$latestRejection = $pr->statusLogs
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
return [
'pr' => $pr,
'statusLogs' => $pr->statusLogs,
'contacts' => $pr->contacts,
'latestRejection' => $latestRejection,
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
?? $pr->category?->translations->first()?->name
?? '',
@ -100,18 +119,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
};
@endphp
@if (session('success'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
@endif
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
@ -126,6 +134,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ $pr->title }}
</h1>
@if ($pr->subtitle)
<p class="text-[18px] font-medium tracking-[-0.2px] leading-[1.35] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->subtitle }}
</p>
@endif
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
<strong class="font-semibold text-[color:var(--color-ink)]">{{ __('Firma') }}:</strong>
{{ $pr->company?->name ?? '' }}
@ -148,39 +161,98 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
</header>
{{-- ============== REJECTION-HINWEIS ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Diese Pressemitteilung wurde abgelehnt') }}</span>
<span class="badge err dot">{{ __('Handlung erforderlich') }}</span>
</div>
<div class="p-5 flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-err-soft)] border border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
<flux:icon.exclamation-triangle class="size-[18px]" />
</div>
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
@if ($latestRejection->reason)
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Begründung') }}:</strong>
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
@else
{{ __('Der Autor sollte den Inhalt überarbeiten und erneut einreichen.') }}
@endif
<span class="mt-2 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
@if ($latestRejection->changedBy)
· {{ __('durch :name', ['name' => $latestRejection->changedBy->name]) }}
@endif
</span>
</div>
</div>
</article>
@endif
{{-- ============== STATUS-WORKFLOW ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<article class="panel">
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-3">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[200px]">
{{ __('Diese PM wartet auf Prüfung.') }}
</p>
<flux:modal.trigger name="confirm-show-publish">
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
</flux:modal.trigger>
<flux:modal.trigger name="confirm-show-reject">
<flux:button type="button" variant="danger">{{ __('Ablehnen') }}</flux:button>
</flux:modal.trigger>
<div class="p-5 flex flex-wrap items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
<flux:icon.clock class="size-[18px]" />
</div>
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
<p class="m-0">{{ __('Diese PM wartet auf eine redaktionelle Entscheidung.') }}</p>
@if ($pr->scheduled_at)
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.calendar variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
{{ __('Geplante Veröffentlichung: :date', ['date' => $pr->scheduled_at->format('d.m.Y H:i')]) }}
</p>
@endif
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:modal.trigger name="confirm-show-publish">
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
</flux:modal.trigger>
<flux:modal.trigger name="confirm-show-reject">
<flux:button type="button" variant="danger">{{ __('Ablehnen') }}</flux:button>
</flux:modal.trigger>
</div>
</div>
</article>
@endif
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
<article class="panel">
<article class="panel" style="border-color:var(--color-ok); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge ok dot">{{ __('Live') }}</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-3">
@if ($pr->hits > 0)
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[200px]">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits) }}</strong>
{{ __('Aufrufe seit Veröffentlichung') }}
<div class="p-5 flex flex-wrap items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-ok-soft)] border border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[18px]" />
</div>
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
<p class="m-0">
{{ __('Veröffentlicht am') }}
<strong class="text-[color:var(--color-ink)] font-semibold">{{ $pr->published_at?->format('d.m.Y H:i') ?? '' }}</strong>
</p>
@endif
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.lock-closed variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
{{ __('Sperrfrist bis: :date', ['date' => $pr->embargo_at->format('d.m.Y H:i')]) }}
</p>
@endif
@if ($pr->hits > 0)
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits, 0, ',', '.') }}</strong>
{{ __('Aufrufe seit Veröffentlichung') }}
</p>
@endif
</div>
<flux:modal.trigger name="confirm-show-archive">
<flux:button type="button" variant="ghost">{{ __('Archivieren') }}</flux:button>
</flux:modal.trigger>
@ -188,142 +260,256 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</article>
@endif
{{-- ============== TEXT + SIDEBAR ============== --}}
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
{{-- ============== KONTAKTE + STATUS/VERLAUF ============== --}}
<div class="grid gap-6 xl:grid-cols-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
<span class="section-eyebrow">{{ __('Zugeordnete Pressekontakte') }}</span>
@if ($pr->company)
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('admin.companies.show', $pr->company->id) }}" wire:navigate>
{{ __('Firma') }}
</flux:button>
@endif
</div>
<div class="p-5">
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
{!! $pr->renderedText() !!}
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-4">
{{ __('Kontakte, die dieser Pressemitteilung zugeordnet sind.') }}
</p>
<div class="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="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
</div>
<div class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $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
</div>
</div>
@empty
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Dieser Pressemitteilung ist kein Pressekontakt zugeordnet.') }}
</div>
@endforelse
</div>
</div>
</article>
<aside class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Details') }}</span>
</div>
<dl class="p-5 space-y-2.5 text-[12.5px]">
<div class="flex justify-between gap-2">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Status') }}</dt>
<dd class="font-semibold text-[color:var(--color-ink)]">{{ $pr->status->label() }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Erstellt') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $pr->created_at->format('d.m.Y H:i') }}</dd>
</div>
@if ($pr->published_at)
<div class="flex justify-between gap-2">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Veröffentlicht') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $pr->published_at->format('d.m.Y H:i') }}</dd>
</div>
@endif
<div class="flex justify-between gap-2">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Aufrufe') }}</dt>
<dd class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits) }}</dd>
</div>
@if ($pr->keywords)
<div class="pt-2 border-t border-[color:var(--color-bg-rule)]">
<dt class="text-[color:var(--color-ink-3)] mb-1">{{ __('Stichwörter') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $pr->keywords }}</dd>
</div>
@endif
@if ($pr->backlink_url)
<div class="pt-2 border-t border-[color:var(--color-bg-rule)]">
<dt class="text-[color:var(--color-ink-3)] mb-1">{{ __('Backlink') }}</dt>
<dd class="break-all">
<a href="{{ $pr->backlink_url }}" target="_blank"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->backlink_url }}
</a>
</dd>
</div>
@endif
@if ($pr->no_export)
<div class="mt-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
<span class="badge hub">{{ __('Kein Export') }}</span>
</div>
@endif
</dl>
</article>
@if ($pr->images->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bilder') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $pr->images->count() }}
</span>
</div>
<div class="p-5 space-y-2">
@foreach ($pr->images as $image)
<div class="flex items-center gap-2 text-[12.5px]">
<flux:icon.photo class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
<span class="truncate text-[color:var(--color-ink-2)]">{{ basename($image->path) }}</span>
@if ($image->is_preview)
<span class="badge hub">{{ __('Preview') }}</span>
@endif
</div>
@endforeach
</div>
</article>
@endif
</aside>
</div>
{{-- ============== STATUS-VERLAUF ============== --}}
@if ($statusLogs->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Verlauf') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $statusLogs->count() }} {{ __('Einträge') }}
</span>
<span class="section-eyebrow">{{ __('Status & Verlauf') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
</div>
<div class="p-5">
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
@foreach ($statusLogs as $log)
<li class="text-[12.5px]">
<div class="flex flex-wrap items-center gap-2">
@php
$logClass = match ($log->to_status?->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
<span @class(['badge', $logClass])>{{ $log->to_status?->label() ?? $log->to_status }}</span>
@if ($log->from_status)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('von') }} {{ $log->from_status->label() }}
</span>
@endif
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if ($log->changedBy)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $log->changedBy->name }}</span>
@endif
@if ($log->source !== 'admin')
<span class="badge hub">{{ $log->source }}</span>
@endif
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Autor') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1 truncate">
{{ $pr->user?->name ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Erstellt') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->created_at?->format('d.m.Y H:i') ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Veröffentlicht') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->published_at?->format('d.m.Y H:i') ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ number_format($pr->hits, 0, ',', '.') }}
</div>
</div>
@if ($pr->scheduled_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplant') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->scheduled_at->format('d.m.Y H:i') }}
</div>
@if ($log->reason)
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
</div>
@endif
@if ($pr->embargo_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->embargo_at->format('d.m.Y H:i') }}
</div>
</div>
@endif
</div>
@if ($pr->no_export)
<div class="mt-3 flex items-center gap-2 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.no-symbol variant="micro" class="size-3.5" />
<span>{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}</span>
</div>
@endif
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
@if ($statusLogs->isNotEmpty())
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
@foreach ($statusLogs as $log)
<li class="text-[12.5px]">
<div class="flex flex-wrap items-center gap-2">
@php
$logClass = match ($log->to_status?->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
<span @class(['badge', $logClass])>{{ $log->to_status?->label() ?? $log->to_status }}</span>
@if ($log->from_status)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('von') }} {{ $log->from_status->label() }}
</span>
@endif
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if ($log->changedBy)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $log->changedBy->name }}</span>
@endif
@if ($log->source && $log->source !== 'admin')
<span class="badge hub">{{ $log->source }}</span>
@endif
</div>
@if ($log->reason)
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0 whitespace-pre-line">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
@else
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch keine Statusänderungen protokolliert.') }}
</p>
@endif
</div>
</article>
</div>
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5">
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
{!! $pr->renderedText() !!}
</div>
@if ($pr->keywords || $pr->backlink_url)
<div class="mt-6 space-y-2 border-t border-[color:var(--color-bg-rule)] pt-4 text-[12.5px] text-[color:var(--color-ink-2)]">
@if ($pr->keywords)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Stichwörter') }}:</strong>
{{ $pr->keywords }}
</p>
@endif
@if ($pr->backlink_url)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Backlink') }}:</strong>
<a href="{{ $pr->backlink_url }}" target="_blank"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->backlink_url }}
</a>
</p>
@endif
</div>
@endif
</div>
</article>
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
@if ($pr->boilerplate_override)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Eigener Abbinder (Boilerplate)') }}</span>
<span class="badge hub">{{ __('Override') }}</span>
</div>
<div class="p-5">
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-3">
{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}
</p>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] leading-[1.6] text-[color:var(--color-ink-2)] whitespace-pre-line">
{{ $pr->boilerplate_override }}
</div>
</div>
</article>
@endif
{{-- ============== MEDIEN ============== --}}
@if ($pr->images->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bilder') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $pr->images->count() }}
</span>
</div>
<div class="p-5 space-y-2">
@foreach ($pr->images as $image)
<div class="flex items-center gap-2 text-[12.5px]">
<flux:icon.photo class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
<span class="truncate text-[color:var(--color-ink-2)]">{{ basename($image->path) }}</span>
@if ($image->is_preview)
<span class="badge hub">{{ __('Preview') }}</span>
@endif
</div>
@endforeach
</div>
</article>
@endif
{{-- ANHÄNGE-ANZEIGE TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung.
Wird mit dem Anhang-Manager in einer späteren Phase wieder aktiviert.
@if ($pr->attachments->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anhänge') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $pr->attachments->count() }}
</span>
</div>
<div class="p-5 space-y-2">
@foreach ($pr->attachments as $attachment)
<div class="flex items-center gap-2 text-[12.5px]">
<flux:icon.paper-clip class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
<span class="truncate text-[color:var(--color-ink-2)] flex-1">
{{ $attachment->title ?: $attachment->original_name }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] flex-shrink-0">
{{ number_format($attachment->size / 1024, 0, ',', '.') }} KB
</span>
</div>
@endforeach
</div>
</article>
@endif
--}}
@if($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:modal name="confirm-show-publish" class="max-w-lg">
<div class="space-y-6">

View file

@ -6,7 +6,7 @@ use App\Models\Company;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use Flux\Flux;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@ -66,18 +66,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
'pressReleases as published_press_releases_count' => fn ($query) => $query->where('status', PressReleaseStatus::Published->value),
])
->withExists(['profile', 'billingAddress'])
->when($this->search, function ($query): void {
$term = trim($this->search);
if ($this->supportsFullTextSearch($term)) {
$query->whereFullText(['name', 'email'], $term);
return;
}
$query->where(function ($searchQuery): void {
$searchQuery->where('name', 'like', '%'.$this->search.'%')->orWhere('email', 'like', '%'.$this->search.'%');
});
->when(filled(trim($this->search)), function (Builder $query): void {
$this->applySearch($query, $this->search);
})
->when($this->activeFilter !== 'all', function ($query): void {
$query->where('is_active', $this->activeFilter === 'active');
@ -115,7 +105,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
};
})
->orderBy($sort, $this->sortDir)
->simplePaginate(50);
->paginate(50);
$this->hydrateCompanyCounts($users);
@ -266,9 +256,30 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
->find($this->viewingUserId);
}
private function supportsFullTextSearch(string $term): bool
private function applySearch(Builder $query, string $search): void
{
return mb_strlen($term) >= 3 && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
$terms = preg_split('/\s+/', trim($search), -1, PREG_SPLIT_NO_EMPTY);
if ($terms === false || $terms === []) {
return;
}
$query->where(function (Builder $searchQuery) use ($terms): void {
foreach ($terms as $term) {
$pattern = '%'.$this->escapeLikeTerm($term).'%';
$searchQuery->where(function (Builder $termQuery) use ($pattern): void {
$termQuery
->whereLike('name', $pattern)
->orWhereLike('email', $pattern);
});
}
});
}
private function escapeLikeTerm(string $term): string
{
return addcslashes($term, '\%_');
}
public function updatedSearch(): void
@ -586,7 +597,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $users->links() }}
{{ $users->links('components.portal.pagination') }}
</div>
</article>

View file

@ -0,0 +1,382 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Services\PressRelease\PressReleaseAttachmentStorage;
use Flux\Flux;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
/**
* Reusable attachments manager for a single press release. Mirrors the API
* of the press-release-images-manager: upload, edit metadata, reorder,
* delete. Authorisation is delegated to the `update` ability on the
* PressReleasePolicy so the component is safe for admins and customers.
*
* Storage uses the `public` disk with path obscurity (UUID prefix). Embargo
* / unpublished-state guards live at the PressRelease level.
*/
new class extends Component
{
use WithFileUploads;
#[Locked]
public int $pressReleaseId;
public $newFile = null;
public string $newTitle = '';
public string $newDescription = '';
public ?int $editingId = null;
public string $editTitle = '';
public string $editDescription = '';
public function mount(int $pressReleaseId): void
{
$this->pressReleaseId = $pressReleaseId;
}
public function upload(PressReleaseAttachmentStorage $storage): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease)) {
$this->addError('newFile', __('Anhänge können nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
return;
}
$maxKb = (int) (PressReleaseAttachmentStorage::MAX_BYTES / 1024);
$allowedExtensions = implode(',', PressReleaseAttachmentStorage::ALLOWED_EXTENSIONS);
$this->validate([
'newFile' => ['required', 'file', 'mimes:'.$allowedExtensions, 'max:'.$maxKb],
'newTitle' => ['nullable', 'string', 'max:120'],
'newDescription' => ['nullable', 'string', 'max:500'],
]);
$stored = $storage->store($this->newFile, $pressRelease->id);
$pressRelease->attachments()->create([
'disk' => $stored['disk'],
'path' => $stored['path'],
'original_name' => $stored['original_name'],
'mime' => $stored['mime'],
'size' => $stored['size'],
'title' => $this->newTitle ?: null,
'description' => $this->newDescription ?: null,
'sort_order' => ((int) $pressRelease->attachments()->max('sort_order')) + 1,
]);
$this->reset(['newFile', 'newTitle', 'newDescription']);
Flux::toast(text: __('Anhang hochgeladen.'), variant: 'success');
}
public function startEdit(int $attachmentId): void
{
$attachment = $this->getAttachment($attachmentId);
if (! $attachment) {
return;
}
$this->editingId = $attachment->id;
$this->editTitle = $attachment->title ?? '';
$this->editDescription = $attachment->description ?? '';
}
public function cancelEdit(): void
{
$this->reset(['editingId', 'editTitle', 'editDescription']);
}
public function updateAttachment(): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease) || $this->editingId === null) {
return;
}
$this->validate([
'editTitle' => ['nullable', 'string', 'max:120'],
'editDescription' => ['nullable', 'string', 'max:500'],
]);
$attachment = $this->getAttachment($this->editingId);
if (! $attachment) {
return;
}
$attachment->update([
'title' => trim($this->editTitle) ?: null,
'description' => trim($this->editDescription) ?: null,
]);
$this->cancelEdit();
Flux::toast(text: __('Anhang aktualisiert.'), variant: 'success');
}
public function moveUp(int $attachmentId): void
{
$this->swapSortOrder($attachmentId, -1);
}
public function moveDown(int $attachmentId): void
{
$this->swapSortOrder($attachmentId, 1);
}
public function remove(int $attachmentId, PressReleaseAttachmentStorage $storage): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease)) {
return;
}
$attachment = $this->getAttachment($attachmentId);
if (! $attachment) {
return;
}
$storage->delete($attachment->disk, $attachment->path);
$attachment->delete();
Flux::toast(text: __('Anhang entfernt.'), variant: 'success');
}
public function with(): array
{
$pressRelease = $this->getPressRelease();
return [
'attachments' => $pressRelease->attachments()
->orderBy('sort_order')
->orderBy('id')
->get(),
'canEdit' => auth()->user()?->can('update', $pressRelease) === true
&& $this->canChangeAttachments($pressRelease),
'maxMb' => round(PressReleaseAttachmentStorage::MAX_BYTES / 1024 / 1024),
'allowedExtensions' => PressReleaseAttachmentStorage::ALLOWED_EXTENSIONS,
];
}
private function swapSortOrder(int $attachmentId, int $direction): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease)) {
return;
}
$attachments = $pressRelease->attachments()->orderBy('sort_order')->orderBy('id')->get();
$currentIndex = $attachments->search(fn (PressReleaseAttachment $att) => $att->id === $attachmentId);
if ($currentIndex === false) {
return;
}
$targetIndex = $currentIndex + $direction;
if ($targetIndex < 0 || $targetIndex >= $attachments->count()) {
return;
}
$current = $attachments[$currentIndex];
$target = $attachments[$targetIndex];
$currentSort = $current->sort_order;
$current->update(['sort_order' => $target->sort_order]);
$target->update(['sort_order' => $currentSort]);
}
private function getPressRelease(): PressRelease
{
return PressRelease::withoutGlobalScopes()
->findOrFail($this->pressReleaseId);
}
private function getAttachment(int $attachmentId): ?PressReleaseAttachment
{
return PressReleaseAttachment::query()
->where('press_release_id', $this->pressReleaseId)
->whereKey($attachmentId)
->first();
}
private function canChangeAttachments(PressRelease $pressRelease): bool
{
if (auth()->user()?->canAccessAdmin()) {
return ! in_array($pressRelease->status, [PressReleaseStatus::Archived], true);
}
return in_array(
$pressRelease->status,
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
true,
);
}
}; ?>
<section class="panel">
<div class="p-5">
<div class="flex items-center justify-between mb-3 gap-4 flex-wrap">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Anhänge / Downloads') }}
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
{{ count($attachments) }}/10
</span>
</span>
<div class="flex items-center gap-2">
<span class="text-[10.5px] text-[color:var(--color-ink-4)]">
{{ strtoupper(implode(' · ', $allowedExtensions)) }} · max. {{ $maxMb }} MB
</span>
</div>
</div>
@if ($canEdit)
<form wire:submit="upload"
class="rounded-[5px] border border-dashed border-[color:var(--color-hub-soft-2)] bg-[color:var(--color-bg-elev)] p-4 space-y-3">
<div class="grid gap-3 sm:grid-cols-[1fr_auto] items-start">
<flux:field>
<flux:label>{{ __('Datei') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input
type="file"
wire:model="newFile"
accept=".{{ implode(',.', $allowedExtensions) }}"
/>
<flux:error name="newFile" />
</flux:field>
<flux:button type="submit" variant="primary" icon="arrow-up-tray" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="upload,newFile">{{ __('Hochladen') }}</span>
<span wire:loading wire:target="upload,newFile">{{ __('Lädt…') }}</span>
</flux:button>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Titel (optional)') }}</flux:label>
<flux:input wire:model="newTitle" placeholder="{{ __('z.B. Pressemappe Frühjahr 2026') }}" />
<flux:error name="newTitle" />
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung (optional)') }}</flux:label>
<flux:input wire:model="newDescription" placeholder="{{ __('Kurze Beschreibung des Dokuments') }}" />
<flux:error name="newDescription" />
</flux:field>
</div>
</form>
@endif
@if ($attachments->isEmpty())
<div class="mt-4 rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-6 text-center">
<flux:icon name="document" class="mx-auto mb-2 size-8 text-[color:var(--color-ink-4)]" />
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch keine Anhänge — Pressemappen, Factsheets oder Bildmaterial-Pakete passen hier rein.') }}
</p>
</div>
@else
<div class="mt-4 grid gap-3 sm:grid-cols-2">
@foreach ($attachments as $attachment)
<article class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-card)] p-3"
wire:key="att-{{ $attachment->id }}">
@if ($editingId === $attachment->id && $canEdit)
{{-- Inline-Edit-Form --}}
<div class="space-y-2">
<flux:field>
<flux:label>{{ __('Titel') }}</flux:label>
<flux:input wire:model="editTitle" />
<flux:error name="editTitle" />
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:input wire:model="editDescription" />
<flux:error name="editDescription" />
</flux:field>
<div class="flex justify-end gap-1 pt-1">
<flux:button size="xs" variant="ghost" wire:click="cancelEdit">{{ __('Abbrechen') }}</flux:button>
<flux:button size="xs" variant="primary" icon="check" wire:click="updateAttachment">{{ __('Speichern') }}</flux:button>
</div>
</div>
@else
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-11 h-11 rounded-[4px] bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)] flex items-center justify-center">
@php
$ext = strtolower(pathinfo($attachment->original_name ?? '', PATHINFO_EXTENSION));
$iconName = match (true) {
$ext === 'pdf' => 'document-text',
in_array($ext, ['doc', 'docx'], true) => 'document-text',
in_array($ext, ['xls', 'xlsx'], true) => 'table-cells',
in_array($ext, ['ppt', 'pptx'], true) => 'presentation-chart-bar',
$ext === 'zip' => 'archive-box',
default => 'document',
};
@endphp
<flux:icon :name="$iconName" variant="mini" class="size-5" />
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-semibold text-[color:var(--color-ink)] m-0 truncate"
title="{{ $attachment->title ?? $attachment->original_name }}">
{{ $attachment->title ?? $attachment->original_name }}
</p>
@if ($attachment->title)
<p class="text-[11px] text-[color:var(--color-ink-4)] m-0 truncate"
title="{{ $attachment->original_name }}">
{{ $attachment->original_name }}
</p>
@endif
@if ($attachment->description)
<p class="text-[11.5px] text-[color:var(--color-ink-3)] mt-1 mb-0 line-clamp-2">
{{ $attachment->description }}
</p>
@endif
<p class="text-[10.5px] text-[color:var(--color-ink-4)] font-mono mt-1 mb-0">
{{ strtoupper($ext ?: '?') }} ·
@php
$bytes = (int) $attachment->size;
$sizeLabel = $bytes >= 1048576
? number_format($bytes / 1048576, 1, ',', '.').' MB'
: number_format(max(1, (int) round($bytes / 1024)), 0, ',', '.').' KB';
@endphp
{{ $sizeLabel }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-1 mt-3 pt-3 border-t border-[color:var(--color-bg-rule)]">
@if ($attachment->url())
<flux:button size="xs" variant="ghost" icon="arrow-down-tray"
href="{{ $attachment->url() }}" target="_blank" rel="noopener">
{{ __('Download') }}
</flux:button>
@endif
@if ($canEdit)
<flux:button size="xs" variant="ghost" icon="pencil-square" wire:click="startEdit({{ $attachment->id }})" :title="__('Bearbeiten')" />
<flux:button size="xs" variant="ghost" icon="arrow-up" wire:click="moveUp({{ $attachment->id }})" :title="__('Hoch')" />
<flux:button size="xs" variant="ghost" icon="arrow-down" wire:click="moveDown({{ $attachment->id }})" :title="__('Runter')" />
<span class="flex-1"></span>
<flux:button size="xs" variant="ghost" icon="trash" wire:click="remove({{ $attachment->id }})"
wire:confirm="{{ __('Anhang wirklich entfernen?') }}" :title="__('Entfernen')" />
@endif
</div>
@endif
</article>
@endforeach
</div>
@endif
</div>
</section>

View file

@ -4,6 +4,7 @@ use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
@ -73,7 +74,7 @@ new class extends Component
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newIsPreview']);
session()->flash('image-status', __('Bild hochgeladen.'));
Flux::toast(text: __('Bild hochgeladen.'), variant: 'success');
}
public function setPreview(int $imageId): void
@ -90,7 +91,7 @@ new class extends Component
$pressRelease->images()->where('id', '!=', $image->id)->update(['is_preview' => false]);
$image->update(['is_preview' => true]);
session()->flash('image-status', __('Vorschaubild gesetzt.'));
Flux::toast(text: __('Vorschaubild gesetzt.'), variant: 'success');
}
public function moveUp(int $imageId): void
@ -121,7 +122,7 @@ new class extends Component
$imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();
session()->flash('image-status', __('Bild entfernt.'));
Flux::toast(text: __('Bild entfernt.'), variant: 'success');
}
public function with(): array
@ -198,10 +199,6 @@ new class extends Component
<flux:badge color="zinc" size="sm">{{ count($images) }}</flux:badge>
</div>
@if(session('image-status'))
<flux:callout color="green" icon="check-circle" class="mt-3">{{ session('image-status') }}</flux:callout>
@endif
@if($canEdit)
<form wire:submit="upload" class="mt-4 space-y-3 rounded-md border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="xs">{{ __('Neues Bild hinzufügen') }}</flux:heading>

View file

@ -6,6 +6,96 @@ use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component
{
public function with(): array
{
return [
'creditSummary' => [
'total' => 17,
'bonus' => 12,
'paid' => 5,
'auto_refill' => __('ab 10 Credits empfohlen'),
'validity' => __('Bonus-Credits verfallen monatlich, gekaufte Credits bleiben 24 Monate gültig.'),
],
'currentPlan' => [
'name' => 'Starter',
'price' => '19 €/Mo.',
'press_releases' => '3 PMs/Monat',
'bonus_credits' => 12,
],
'creditPackages' => [
['name' => 'Test', 'credits' => 10, 'price' => '10 €', 'rate' => '1,00 €', 'saving' => null],
['name' => 'Standard', 'credits' => 50, 'price' => '45 €', 'rate' => '0,90 €', 'saving' => '10 %'],
['name' => 'Plus', 'credits' => 150, 'price' => '120 €', 'rate' => '0,80 €', 'saving' => '20 %'],
['name' => 'Pro', 'credits' => 500, 'price' => '375 €', 'rate' => '0,75 €', 'saving' => '25 %'],
['name' => 'Business', 'credits' => 1500, 'price' => '1.050 €', 'rate' => '0,70 €', 'saving' => '30 %'],
],
'serviceGroups' => [
[
'title' => __('Veröffentlichung'),
'description' => __('Basisleistungen rund um Veröffentlichung, Korrektur und Aktualisierung.'),
'services' => [
['name' => __('Standard-PM (Pay-as-you-go)'), 'credits' => '19', 'meta' => __('1 Veröffentlichung')],
['name' => __('PM-Korrektur'), 'credits' => '8', 'meta' => __('Pfad C')],
['name' => __('PM-Update'), 'credits' => '4', 'meta' => __('im ersten Jahr ggf. kostenlos')],
['name' => __('Depublizierung'), 'credits' => '1925', 'meta' => __('abhängig vom Aufwand')],
],
],
[
'title' => __('Bilder'),
'description' => __('Stock- und KI-Bilder für mehr Sichtbarkeit in Listen und Detailseiten.'),
'services' => [
['name' => __('Free-Stock'), 'credits' => '0', 'meta' => __('Unsplash, Pexels')],
['name' => __('Premium-Stock'), 'credits' => '8', 'meta' => __('Adobe, Shutterstock')],
['name' => __('KI-Bild generieren'), 'credits' => '4', 'meta' => __('neues Motiv')],
['name' => __('KI-Bild Re-Generation'), 'credits' => '2', 'meta' => __('Variante erzeugen')],
],
],
[
'title' => __('KI-Textservices'),
'description' => __('Qualität verbessern, Score-Stufe erreichen und bessere Headlines testen.'),
'services' => [
['name' => __('Quality-Check'), 'credits' => '3', 'meta' => __('Stil und Pressestil')],
['name' => __('Lektorat'), 'credits' => '8', 'meta' => __('sprachliche Prüfung')],
['name' => __('Pressetext-Optimierung'), 'credits' => '15', 'meta' => __('Headlines und SEO')],
['name' => __('Headline-Booster'), 'credits' => '5', 'meta' => __('nur Headlines')],
['name' => __('PM aus Stichworten generieren'), 'credits' => '25', 'meta' => __('Entwurf aus Briefing')],
['name' => __('Übersetzung DE/EN'), 'credits' => '12', 'meta' => __('pro Sprachrichtung')],
],
],
[
'title' => __('Distribution'),
'description' => __('Zusätzliche Formate und externe Reichweite für passende Meldungen.'),
'services' => [
['name' => __('PDF-Export mit Branding'), 'credits' => '2', 'meta' => __('für Weitergabe')],
['name' => __('Social-Snippet-Generierung'), 'credits' => '3', 'meta' => __('Kurztexte')],
['name' => __('Verteiler-Versand klein'), 'credits' => '39', 'meta' => __('branchenspezifisch')],
['name' => __('Verteiler-Versand mittel'), 'credits' => '99', 'meta' => __('mehr Empfänger')],
['name' => __('Verteiler-Versand groß'), 'credits' => '199', 'meta' => __('branchenübergreifend')],
],
],
[
'title' => __('Account & Profil'),
'description' => __('Vertrauen, Wiedererkennung und zusätzliche Profilfunktionen.'),
'services' => [
['name' => __('Verifiziertes Firmenprofil'), 'credits' => '79', 'meta' => __('einmalig')],
['name' => __('Custom Subdomain'), 'credits' => '49', 'meta' => __('pro Jahr')],
['name' => __('Erweiterte Statistiken'), 'credits' => '15', 'meta' => __('pro Monat')],
],
],
],
'placements' => [
['name' => __('Highlight Kategorie'), 'credits' => '15', 'duration' => __('3 Tage'), 'tier' => __('Standard'), 'score' => '30+'],
['name' => __('Highlight Kategorie'), 'credits' => '30', 'duration' => __('7 Tage'), 'tier' => __('Standard'), 'score' => '30+'],
['name' => __('Startseite-Highlight'), 'credits' => '39', 'duration' => __('24 h'), 'tier' => __('Geprüft'), 'score' => '60+'],
['name' => __('Startseite-Highlight'), 'credits' => '89', 'duration' => __('3 Tage'), 'tier' => __('Geprüft'), 'score' => '60+'],
['name' => __('Top-Slot Startseite'), 'credits' => '119', 'duration' => __('24 h'), 'tier' => __('Hochwertig'), 'score' => '80+'],
['name' => __('Newsletter-Erwähnung'), 'credits' => '59', 'duration' => __('nächster Versand'), 'tier' => __('Geprüft'), 'score' => '60+'],
['name' => __('Social-Share'), 'credits' => '25', 'duration' => __('offizieller Kanal'), 'tier' => __('Geprüft'), 'score' => '60+'],
],
'activeBookings' => [],
'bookingHistory' => [],
];
}
}; ?>
<div class="space-y-8">
@ -15,56 +105,285 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
<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 · Finanzen') }}</span>
<span class="badge warn">{{ __('In Vorbereitung') }}</span>
<span class="badge hub">{{ __('Konzeptstand Mai 2026') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Buchungen & Add-ons') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Hier werden künftig gebuchte Leistungen, Add-ons und Erweiterungen für Ihre Firmen gebündelt.') }}
{{ __('Der Marktplatz für Credit-Pakete, KI-Services, Platzierungen und Firmen-Add-ons. Die Preise folgen dem neuen Credit-Modell: 1 Credit entspricht dem Listenwert von 1 €.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button size="sm" variant="ghost" icon="document-text" href="{{ route('me.invoices.index') }}" wire:navigate>
{{ __('Rechnungen') }}
</flux:button>
<flux:button size="sm" variant="primary" icon="plus" disabled>
{{ __('Credits kaufen') }}
</flux:button>
</div>
</header>
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
<div class="flex-1">
{{ __('Der Bereich ist bereits in der Navigation vorbereitet. Buchbare Add-ons werden aktiviert, sobald das Preismodell und die Zahlungslogik final sind.') }}
{{-- ============== CREDIT-ÜBERSICHT ============== --}}
<section class="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Credit-Stand') }}</span>
<span class="badge ok dot">{{ __('Auto-Refill vorbereitet') }}</span>
</div>
<div class="p-5 grid gap-5 md:grid-cols-[0.8fr_1.2fr]">
<div>
<div class="text-[42px] font-bold tracking-[-1.2px] leading-none text-[color:var(--color-ink)]">
{{ $creditSummary['total'] }}
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-2 mb-0">
{{ __('verfügbare Credits') }}
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Bonus-Credits') }}</div>
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">{{ $creditSummary['bonus'] }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('monatlich verfallend') }}</div>
</div>
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Gekaufte Credits') }}</div>
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">{{ $creditSummary['paid'] }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('24 Monate gültig') }}</div>
</div>
</div>
<div class="md:col-span-2 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
<div class="flex-1">
{{ $creditSummary['validity'] }}
{{ __('Für spätere Checkouts ist Auto-Refill :threshold vorgesehen.', ['threshold' => $creditSummary['auto_refill']]) }}
</div>
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktueller Tarif') }}</span>
<span class="badge hub">{{ $currentPlan['name'] }}</span>
</div>
<div class="p-5 space-y-4">
<div>
<div class="text-[28px] font-bold tracking-[-0.7px] text-[color:var(--color-ink)]">
{{ $currentPlan['price'] }}
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-1 mb-0">
{{ __('inkl. :credits Bonus-Credits und :pms', [
'credits' => $currentPlan['bonus_credits'],
'pms' => $currentPlan['press_releases'],
]) }}
</p>
</div>
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4">
<div class="text-[12px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Nächster sinnvoller Schritt') }}
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Bei mehreren PMs mit KI-Optimierung oder Platzierungen ergänzt das Standard-Paket die monatlichen Bonus-Credits am saubersten.') }}
</p>
</div>
</div>
</article>
</section>
{{-- ============== CREDIT-PAKETE ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Credit-Pakete') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Volumenrabatt nach Paketgröße') }}</span>
</div>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Paket') }}</flux:table.column>
<flux:table.column>{{ __('Credits') }}</flux:table.column>
<flux:table.column>{{ __('Preis') }}</flux:table.column>
<flux:table.column>{{ __('Effektiv/Credit') }}</flux:table.column>
<flux:table.column>{{ __('Ersparnis') }}</flux:table.column>
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach ($creditPackages as $package)
<flux:table.row wire:key="credit-package-{{ $package['name'] }}">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $package['name'] }}</span>
</flux:table.cell>
<flux:table.cell>{{ number_format($package['credits'], 0, ',', '.') }}</flux:table.cell>
<flux:table.cell>
<span class="font-semibold text-[color:var(--color-ink)]">{{ $package['price'] }}</span>
</flux:table.cell>
<flux:table.cell>{{ $package['rate'] }}</flux:table.cell>
<flux:table.cell>
@if ($package['saving'])
<span class="badge ok">{{ $package['saving'] }}</span>
@else
<span class="text-[12px] text-[color:var(--color-ink-3)]"></span>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="ghost" disabled>
{{ __('Kaufen') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table>
</article>
{{-- ============== PLATZIERUNGEN ============== --}}
<section class="space-y-4">
<div>
<span class="section-eyebrow">{{ __('Boost & Platzierungen') }}</span>
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
{{ __('Sichtbarkeit buchen, wenn die Score-Stufe passt') }}
</h2>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[760px] m-0">
{{ __('Platzierungen bleiben an Qualitätsstufen gekoppelt: Standard reicht für Kategorie-Highlights, Geprüft für Startseite/Newsletter/Social und Hochwertig für den Top-Slot.') }}
</p>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach ($placements as $placement)
<article class="panel">
<div class="p-5 space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.megaphone class="size-5" />
</div>
<div>
<h3 class="text-[14px] font-semibold text-[color:var(--color-ink)] m-0">
{{ $placement['name'] }}
</h3>
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-1 mb-0">
{{ $placement['duration'] }}
</p>
</div>
</div>
<div class="text-right">
<div class="text-[22px] font-bold text-[color:var(--color-ink)]">{{ $placement['credits'] }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 rounded-[6px] border border-[color:var(--color-bg-rule)] p-3">
<div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Mindeststufe') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $placement['tier'] }}</div>
</div>
<flux:tooltip content="{{ __('Interner Score-Schwellenwert: :score', ['score' => $placement['score']]) }}">
<span class="badge hub">{{ __('Score :score', ['score' => $placement['score']]) }}</span>
</flux:tooltip>
</div>
<flux:button size="sm" variant="primary" class="w-full" disabled>
{{ __('Buchung vorbereiten') }}
</flux:button>
</div>
</article>
@endforeach
</div>
</section>
{{-- ============== SERVICE-MARKTPLATZ ============== --}}
<section class="space-y-4">
<div>
<span class="section-eyebrow">{{ __('Add-on-Marktplatz') }}</span>
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
{{ __('Buchbare Services nach Kategorie') }}
</h2>
</div>
<div class="grid gap-4 xl:grid-cols-2">
@foreach ($serviceGroups as $group)
<article class="panel">
<div class="panel-head">
<div class="flex items-center gap-2">
<flux:icon.sparkles class="size-4 text-[color:var(--color-hub)]" />
<span class="section-eyebrow">{{ $group['title'] }}</span>
</div>
</div>
<div class="p-5">
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-0 mb-4">
{{ $group['description'] }}
</p>
<div class="divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($group['services'] as $service)
<div class="py-3 first:pt-0 last:pb-0 flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $service['name'] }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $service['meta'] }}</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-[15px] font-bold text-[color:var(--color-ink)]">{{ $service['credits'] }}</div>
<div class="text-[10.5px] uppercase tracking-[0.08em] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</div>
</div>
</div>
@endforeach
</div>
</div>
</article>
@endforeach
</div>
</section>
{{-- ============== AKTIVE BUCHUNGEN / VERLAUF ============== --}}
<section class="grid gap-4 lg:grid-cols-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Firmenbezogene Add-ons') }}</span>
<span class="section-eyebrow">{{ __('Aktive Buchungen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('läuft aktuell') }}</span>
</div>
<div class="p-5">
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Zum Beispiel zusätzliche Sichtbarkeit, Verifizierung oder besondere Platzierungen.') }}
</p>
@forelse ($activeBookings as $booking)
<div>{{ $booking }}</div>
@empty
<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.calendar-days class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Noch keine aktiven Buchungen') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
{{ __('Gebuchte Highlights, Newsletter-Platzierungen oder Add-ons erscheinen hier mit Laufzeit und zugehöriger Firma.') }}
</p>
</div>
@endforelse
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Credits & Tarif') }}</span>
<span class="section-eyebrow">{{ __('Verlauf') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('verbrauchte Credits') }}</span>
</div>
<div class="p-5">
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Tarif- und Credit-Informationen folgen, sobald das neue Preismodell live ist.') }}
</p>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Zahlungsarten') }}</span>
</div>
<div class="p-5">
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Zahlungsarten werden später unter Finanzen eingebunden.') }}
</p>
@forelse ($bookingHistory as $booking)
<div>{{ $booking }}</div>
@empty
<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-bg-subtle)] border border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
<flux:icon.clock class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Noch kein Buchungsverlauf') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
{{ __('Nach dem ersten Checkout werden Verbrauch, Rechnungsbezug und betroffene Pressemitteilung hier nachvollziehbar.') }}
</p>
</div>
@endforelse
</div>
</article>
</section>

View file

@ -28,10 +28,16 @@ new class extends Component
public function with(CustomerCompanyContext $context): array
{
$user = auth()->user();
$selectedCompanyId = $context->selectedCompanyId($user);
$companies = $context->switcherCompaniesFor($user, $selectedCompanyId, 51);
$visibleCompanies = $companies->take(50)->values();
return [
'companies' => $context->companiesFor($user),
'selectedCompany' => $context->selectedCompany($user),
'companies' => $visibleCompanies,
'hasMoreCompanies' => $companies->count() > 50,
'selectedCompany' => $selectedCompanyId === null
? null
: $visibleCompanies->firstWhere('id', $selectedCompanyId),
'context' => $context,
'user' => $user,
];
@ -55,6 +61,9 @@ new class extends Component
{{ $company->name }} · {{ $context->roleLabelFor($company, $user) }}
</option>
@endforeach
@if ($hasMoreCompanies)
<option value="all" disabled>{{ __('Weitere Firmen über „Firmen" öffnen') }}</option>
@endif
</flux:select>
</div>

View file

@ -21,16 +21,28 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$selectedCompanyId = $context->selectedCompanyId($user);
$selectedCompany = $context->selectedCompany($user);
$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));
$myPRs = (clone $pressReleaseQuery)
->selectRaw('status, count(*) as count')
->groupBy('status')
->pluck('count', 'status');
$now = Carbon::now();
$currentStart = $now->copy()->startOfMonth();
$previousStart = $now->copy()->subMonthNoOverflow()->startOfMonth();
$previousEnd = $now->copy()->subMonthNoOverflow()->endOfMonth();
$stats = (clone $pressReleaseQuery)
->toBase()
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as published', [PressReleaseStatus::Published->value])
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as review', [PressReleaseStatus::Review->value])
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
->selectRaw('SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as current_month', [$currentStart])
->selectRaw('SUM(CASE WHEN created_at BETWEEN ? AND ? THEN 1 ELSE 0 END) as previous_month', [$previousStart, $previousEnd])
->first();
$recent = (clone $pressReleaseQuery)
->with('company:id,name')
@ -45,11 +57,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
'user' => $user,
'selectedCompany' => $selectedCompany,
'stats' => [
'total' => (clone $pressReleaseQuery)->count(),
'published' => $myPRs->get('published', 0),
'review' => $myPRs->get('review', 0),
'draft' => $myPRs->get('draft', 0),
'deltaMonth' => $this->totalDeltaToPreviousMonth(clone $pressReleaseQuery),
'total' => (int) ($stats->total ?? 0),
'published' => (int) ($stats->published ?? 0),
'review' => (int) ($stats->review ?? 0),
'draft' => (int) ($stats->draft ?? 0),
'deltaMonth' => (int) ($stats->current_month ?? 0) - (int) ($stats->previous_month ?? 0),
],
'profileCompleteness' => $this->profileCompleteness($profile),
'billingCompleteness' => $this->billingCompleteness($billingAddress),
@ -61,7 +73,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
$pressReleaseQuery,
),
'recent' => $recent,
'companies' => $context->companiesFor($user),
'companies' => $context->latestCompaniesFor($user),
'companiesTotal' => $context->companyCountFor($user),
'bridgeStatus' => [
/* Heute hardcoded — perspektivisch aus echtem Sync-Service. */
'presseecho' => ['state' => 'connected', 'subline' => __('Archiv · Branchen-Tiefe')],
@ -110,27 +123,6 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
return (int) round(($filled / count($fields)) * 100);
}
/**
* Vergleicht PRs im aktuellen Monat mit dem Vormonat (Differenz, Vorzeichen mit Pfeil im View).
*/
private function totalDeltaToPreviousMonth(Builder $pressReleaseQuery): int
{
$now = Carbon::now();
$currentStart = $now->copy()->startOfMonth();
$previousStart = $now->copy()->subMonthNoOverflow()->startOfMonth();
$previousEnd = $now->copy()->subMonthNoOverflow()->endOfMonth();
$currentCount = (clone $pressReleaseQuery)
->where('created_at', '>=', $currentStart)
->count();
$previousCount = (clone $pressReleaseQuery)
->whereBetween('created_at', [$previousStart, $previousEnd])
->count();
return $currentCount - $previousCount;
}
/**
* @return list<array{icon: string, title: string, description: string, href: string, action: string, percent?: int}>
*/
@ -234,7 +226,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
{{ __('Aktive Firma:') }} <strong class="font-semibold">{{ $selectedCompany->name }}</strong>
</span>
@else
<a href="{{ route('me.profile') }}#firmen" wire:navigate
<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">
<flux:icon.exclamation-triangle class="size-[13px] flex-shrink-0" />
{{ __('Keine Firma zugeordnet') }}
@ -448,11 +440,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
<span class="section-eyebrow">{{ __('Meine Firmen') }}</span>
<div class="flex items-center gap-3">
<span class="badge hub" style="font-size:9.5px;padding:1px 6px;">
{{ $companies->count() }} {{ __('zugeordnet') }}
{{ $companiesTotal }} {{ __('zugeordnet') }}
</span>
<a href="{{ route('me.profile') }}" wire:navigate
<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">
{{ __('Profil & Firma verwalten') }}
{{ __('Alle Firmen anzeigen') }}
</a>
</div>
</div>
@ -483,6 +475,15 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
</a>
@endforeach
</div>
@if ($companiesTotal > $companies->count())
<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">
{{ __('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
@ -511,11 +512,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
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 Ihr Profil 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="ghost" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Profil prüfen') }}
<flux:button size="sm" variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Firmen öffnen') }}
</flux:button>
</div>
</div>

View file

@ -224,7 +224,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
@endforelse
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $invoices->links() }}
{{ $invoices->links('components.portal.pagination') }}
</div>
</article>
</div>

View file

@ -0,0 +1,218 @@
<?php
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Models\Company;
use Flux\Flux;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class extends Component
{
public string $name = '';
public string $portal = '';
public string $type = '';
public string $address = '';
public string $email = '';
public string $phone = '';
public string $website = '';
public string $countryCode = 'DE';
public bool $disableFooterCode = false;
public function mount(): void
{
$this->type = CompanyType::Company->value;
$this->countryCode = (string) config('countries.default', 'DE');
}
public function save(): void
{
try {
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'portal' => ['required', Rule::in([
Portal::Presseecho->value,
Portal::Businessportal24->value,
Portal::Both->value,
])],
'type' => ['required', Rule::in([CompanyType::Company->value, CompanyType::Agency->value])],
'address' => ['nullable', 'string', 'max:1000'],
'email' => ['nullable', 'email', 'max:190'],
'phone' => ['nullable', 'string', 'max:40'],
'website' => ['nullable', 'url', 'max:190'],
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
]);
} catch (ValidationException $e) {
$count = array_sum(array_map('count', $e->errors()));
Flux::toast(
heading: __('Bitte Eingaben prüfen'),
text: $count > 1
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
variant: 'danger',
duration: 6000,
);
throw $e;
}
$user = auth()->user();
$company = new Company([
'portal' => $validated['portal'],
'owner_user_id' => $user->id,
'type' => $validated['type'],
'name' => $validated['name'],
'address' => $validated['address'] ?: null,
'country_code' => $validated['countryCode'] ?: null,
'email' => $validated['email'] ?: null,
'phone' => $validated['phone'] ?: null,
'website' => $validated['website'] ?: null,
'is_active' => true,
'disable_footer_code' => $this->disableFooterCode,
]);
$company->slug = $company->generateUniqueSlug($validated['name'], [
'portal' => $validated['portal'],
]);
$company->save();
$user->companies()->syncWithoutDetaching([
$company->id => ['role' => 'owner'],
]);
Flux::toast(
heading: __('Firma angelegt'),
text: __('„:name" wurde angelegt und steht sofort zur Verfügung.', ['name' => $company->name]),
variant: 'success',
);
$this->redirect(route('me.press-kits.show', $company->id), navigate: true);
}
public function with(): array
{
return [
'portals' => [
Portal::Presseecho->value => Portal::Presseecho->label(),
Portal::Businessportal24->value => Portal::Businessportal24->label(),
Portal::Both->value => Portal::Both->label(),
],
'types' => [
CompanyType::Company->value => CompanyType::Company->label(),
CompanyType::Agency->value => CompanyType::Agency->label(),
],
'countries' => (array) config('countries.items', []),
];
}
}; ?>
<div class="space-y-6">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Firmen · Anlegen') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Neue Firma anlegen') }}
</h1>
<p class="text-[12.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-3)]">
{{ __('Lege Stammdaten und Portal-Zuordnung an. Die Firma steht sofort zur Verfügung — die redaktionelle Prüfung erfolgt erst bei der ersten Pressemitteilung.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Zurück zur Liste') }}
</flux:button>
</div>
</header>
<form wire:submit.prevent="save" class="space-y-6">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Stammdaten') }}</span>
</div>
<div class="p-5 grid gap-4 sm:grid-cols-2">
<flux:field class="sm:col-span-2">
<flux:input wire:model="name" :label="__('Firmenname')" required autofocus />
<flux:error name="name" />
</flux:field>
<flux:field>
<flux:select wire:model="portal" :label="__('Portal')" :placeholder="__('Bitte wählen…')" required>
@foreach ($portals as $value => $label)
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
<flux:error name="portal" />
</flux:field>
<flux:field>
<flux:select wire:model="type" :label="__('Typ')" required>
@foreach ($types as $value => $label)
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
<flux:error name="type" />
</flux:field>
<flux:field>
<flux:input wire:model="email" :label="__('E-Mail')" type="email" />
<flux:error name="email" />
</flux:field>
<flux:field>
<flux:input wire:model="phone" :label="__('Telefon')" />
<flux:error name="phone" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:input wire:model="website" :label="__('Website')" placeholder="https://..." />
<flux:error name="website" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:textarea wire:model="address" :label="__('Adresse')" rows="3" />
<flux:error name="address" />
</flux:field>
<flux:field>
<flux:select wire:model="countryCode" :label="__('Land')">
@foreach ($countries as $code => $countryName)
<flux:select.option value="{{ $code }}">{{ $countryName }}</flux:select.option>
@endforeach
</flux:select>
<flux:error name="countryCode" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code deaktivieren (z. B. wenn die Firma keine Quellenangabe haben möchte)')" />
</flux:field>
</div>
</article>
<div class="flex items-center justify-end gap-2">
<flux:button variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary" icon="check">
{{ __('Firma anlegen') }}
</flux:button>
</div>
</form>
</div>

View file

@ -1,8 +1,15 @@
<?php
use App\Models\Company;
use App\Models\User;
use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
@ -12,39 +19,402 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
public string $search = '';
#[Url(as: 'view', except: 'all')]
public string $savedView = 'all';
#[Url(as: 'portal', except: '')]
public string $portalFilter = '';
#[Url(as: 'role', except: 'all')]
public string $roleFilter = 'all';
#[Url(as: 'mode', except: 'cards')]
public string $viewMode = 'cards';
public function updatedSearch(): void
{
$this->resetPage();
}
public function setSavedView(string $view): void
{
$allowed = ['all', 'active', 'drafts', 'inactive', 'shared'];
$this->savedView = in_array($view, $allowed, true) ? $view : 'all';
$this->resetPage();
}
public function setPortalFilter(string $portal): void
{
$allowed = ['', 'presseecho', 'businessportal24'];
$this->portalFilter = in_array($portal, $allowed, true) ? $portal : '';
$this->resetPage();
}
public function setRoleFilter(string $role): void
{
$allowed = ['all', 'owner', 'responsible', 'member'];
$this->roleFilter = in_array($role, $allowed, true) ? $role : 'all';
$this->resetPage();
}
public function setViewMode(string $mode): void
{
$this->viewMode = $mode === 'list' ? 'list' : 'cards';
}
public function resetFilters(): void
{
$this->search = '';
$this->savedView = 'all';
$this->portalFilter = '';
$this->roleFilter = 'all';
$this->resetPage();
}
/**
* @return Builder<Company>
*/
private function baseQuery(User $user): Builder
{
return app(CustomerCompanyContext::class)
->accessibleCompanyQuery($user);
}
/**
* Wendet die "Saved View"-Logik auf eine Query an.
*
* @param Builder<Company> $query
*/
private function applySavedView(Builder $query, User $user, string $view): void
{
match ($view) {
'active' => $query->where('is_active', true),
'inactive' => $query->where('is_active', false),
'drafts' => $query->whereRaw('1 = 0'),
'shared' => $query->where('owner_user_id', '!=', $user->id),
default => null,
};
}
/**
* @param Builder<Company> $query
*/
private function applySharedFilters(Builder $query): void
{
if (filled($this->portalFilter)) {
$query->where(function ($query) {
$query->where('portal', $this->portalFilter)
->orWhere('portal', 'both');
});
}
if (filled($this->search)) {
$search = trim($this->search);
$query->where(function ($query) use ($search): void {
$query->where('name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%')
->orWhere('address', 'like', '%'.$search.'%')
->orWhere('slug', 'like', '%'.$search.'%');
});
}
}
/**
* @param Builder<Company> $query
*/
private function applyRoleFilter(Builder $query, User $user, string $role): void
{
if ($role === 'all') {
return;
}
if ($role === 'owner') {
$query->where('owner_user_id', $user->id);
return;
}
$query->where('owner_user_id', '!=', $user->id)
->whereHas('users', function ($query) use ($user, $role): void {
$query->where('users.id', $user->id)
->where('company_user.role', $role);
});
}
/**
* Sammelt alle Counter-Werte in genau drei Queries:
* 1) aggregiertes COUNT/SUM CASE auf companies
* 2) COUNT auf press_releases
* 3) COUNT auf contacts
*
* @return array{
* counters: array{companies: int, active: int, press_releases: int, contacts: int},
* saved_views: array{all: int, active: int, drafts: int, inactive: int, shared: int},
* }
*/
private function buildAggregateCounts(User $user): array
{
$row = $this->baseQuery($user)
->selectRaw(
'COUNT(*) as total_companies,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_companies,
SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) as inactive_companies,
SUM(CASE WHEN owner_user_id <> ? THEN 1 ELSE 0 END) as shared_companies',
[$user->id]
)
->first();
$totalCompanies = (int) ($row->total_companies ?? 0);
$activeCompanies = (int) ($row->active_companies ?? 0);
$inactiveCompanies = (int) ($row->inactive_companies ?? 0);
$sharedCompanies = (int) ($row->shared_companies ?? 0);
if ($totalCompanies === 0) {
return [
'counters' => [
'companies' => 0,
'active' => 0,
'press_releases' => 0,
'contacts' => 0,
],
'saved_views' => [
'all' => 0,
'active' => 0,
'drafts' => 0,
'inactive' => 0,
'shared' => 0,
],
];
}
$companyIdsQuery = $this->baseQuery($user)->select('companies.id');
$pressReleaseCount = (int) \App\Models\PressRelease::query()
->withoutGlobalScopes()
->whereIn('company_id', $companyIdsQuery)
->count();
$contactsCount = (int) \App\Models\Contact::query()
->withoutGlobalScopes()
->whereIn('company_id', $companyIdsQuery)
->count();
return [
'counters' => [
'companies' => $totalCompanies,
'active' => $activeCompanies,
'press_releases' => $pressReleaseCount,
'contacts' => $contactsCount,
],
'saved_views' => [
'all' => $totalCompanies,
'active' => $activeCompanies,
'drafts' => 0,
'inactive' => $inactiveCompanies,
'shared' => $sharedCompanies,
],
];
}
/**
* Bestimmt deterministisch einen Logo-Token (lg-*) anhand der Company-Id.
*/
public function logoVariant(Company $company): string
{
$variants = ['lg-brew', 'lg-mv', 'lg-soft', 'lg-warm'];
if (blank($company->name)) {
return 'lg-blank';
}
return $variants[$company->id % count($variants)];
}
/**
* Initialen aus dem Firmennamen (max. 2 Zeichen, Großbuchstaben).
*/
public function logoInitials(Company $company): string
{
$name = trim((string) $company->name);
if (blank($name)) {
return '';
}
$words = preg_split('/\s+/u', $name) ?: [];
$letters = '';
foreach ($words as $word) {
$first = mb_substr($word, 0, 1);
if ($first !== '') {
$letters .= $first;
}
if (mb_strlen($letters) >= 2) {
break;
}
}
if ($letters === '') {
$letters = mb_substr($name, 0, 2);
}
return mb_strtoupper($letters);
}
/**
* Liefert eine kompakte Meta-Line: Stadt · Typ.
*/
public function metaLine(Company $company): string
{
$parts = [];
$address = trim((string) ($company->address ?? ''));
if (filled($address)) {
$lastLine = collect(preg_split('/\r?\n/', $address))
->map(fn ($line) => trim((string) $line))
->filter()
->last();
if (is_string($lastLine) && filled($lastLine)) {
$parts[] = $lastLine;
}
}
$type = $company->type?->label();
if (is_string($type) && filled($type)) {
$parts[] = $type;
}
return implode(' · ', $parts);
}
/**
* Rolle des aktuellen Users für die Karte (admin|member).
*/
public function userRoleKey(Company $company, User $user): string
{
if ($company->owner_user_id === $user->id) {
return 'owner';
}
return (string) ($company->getAttribute('current_user_role') ?? $company->pivot?->role ?? 'member');
}
public function isAdminRole(string $roleKey): bool
{
return in_array($roleKey, ['owner', 'responsible'], true);
}
public function roleLabel(string $roleKey): string
{
return match ($roleKey) {
'owner' => __('Owner'),
'responsible' => __('Verantwortlich'),
default => __('Mitglied'),
};
}
public function fastLogoUrl(Company $company): ?string
{
if (blank($company->logo_path)) {
return null;
}
$logoPath = trim((string) $company->logo_path);
if (Str::startsWith($logoPath, ['http://', 'https://']) && blank($company->legacy_portal)) {
return $logoPath;
}
if (Str::startsWith($logoPath, '/storage/')) {
return asset($logoPath);
}
if (filled($company->legacy_portal)) {
return null;
}
if (! Str::startsWith($logoPath, ['http://', 'https://'])) {
return asset('storage/'.ltrim($logoPath, '/'));
}
return null;
}
public function with(): array
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$pressKits = $context->accessibleCompanyQuery($user)
->withCount(['contacts', 'pressReleases'])
->when(filled($this->search), function ($query): void {
$search = trim($this->search);
$query = $this->baseQuery($user)
->select([
'companies.id',
'companies.owner_user_id',
'companies.portal',
'companies.type',
'companies.name',
'companies.address',
'companies.logo_path',
'companies.legacy_portal',
'companies.is_active',
])
->addSelect([
'current_user_role' => DB::table('company_user')
->select('role')
->whereColumn('company_user.company_id', 'companies.id')
->where('company_user.user_id', $user->id)
->limit(1),
])
->withCount([
'contacts' => fn ($q) => $q->withoutGlobalScopes(),
'pressReleases' => fn ($q) => $q->withoutGlobalScopes(),
])
->withMax(['pressReleases' => fn ($q) => $q->withoutGlobalScopes()], 'published_at');
$query->where(function ($query) use ($search): void {
$query->where('name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%')
->orWhere('slug', 'like', '%'.$search.'%');
});
})
$this->applySavedView($query, $user, $this->savedView);
$this->applySharedFilters($query);
$this->applyRoleFilter($query, $user, $this->roleFilter);
$pressKits = $query
->orderBy('name')
->simplePaginate(24);
->paginate(50)
->through(function (Company $company) use ($user): Company {
$roleKey = $this->userRoleKey($company, $user);
$lastPublishedAt = $company->press_releases_max_published_at
? Carbon::parse($company->press_releases_max_published_at)
: null;
$company->setAttribute('panel_role_key', $roleKey);
$company->setAttribute('panel_is_admin', $this->isAdminRole($roleKey));
$company->setAttribute('panel_role_label', $this->roleLabel($roleKey));
$company->setAttribute('panel_logo_url', $this->fastLogoUrl($company));
$company->setAttribute('panel_logo_variant', $this->logoVariant($company));
$company->setAttribute('panel_logo_initials', $this->logoInitials($company));
$company->setAttribute('panel_meta_line', $this->metaLine($company));
$company->setAttribute(
'panel_last_press_release_short',
$lastPublishedAt?->format('d.m.') ?? '—'
);
$company->setAttribute(
'panel_last_press_release_date',
$lastPublishedAt?->format('d.m.Y') ?? '—'
);
return $company;
});
$aggregates = $this->buildAggregateCounts($user);
return [
'pressKits' => $pressKits,
'context' => $context,
'user' => $user,
'hasActiveFilters' => filled($this->search)
|| $this->savedView !== 'all'
|| filled($this->portalFilter)
|| $this->roleFilter !== 'all',
'counters' => $aggregates['counters'],
'savedViewCounts' => $aggregates['saved_views'],
];
}
}; ?>
<div class="space-y-8">
<div class="space-y-6">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
@ -55,102 +425,486 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Meine Firmen') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Verwalten Sie Firmen, Pressekontakte und zugeordnete Pressemitteilungen.') }}
<div class="counter-strip mt-3">
<span class="seg">
<b>{{ $counters['companies'] }}</b> {{ __('Firmen') }}
</span>
<span class="sep"></span>
<span class="seg is-ok">
<b>{{ $counters['active'] }}</b> {{ __('aktiv') }}
</span>
<span class="sep"></span>
<span class="seg">
<b>{{ $counters['press_releases'] }}</b>
{{ __('Pressemitteilungen gesamt') }}
</span>
<span class="sep"></span>
<span class="seg">
<b>{{ $counters['contacts'] }}</b>
{{ __('Pressekontakte hinterlegt') }}
</span>
</div>
<p class="mt-3 text-[12.5px] leading-[1.55] max-w-[640px] m-0 text-[color:var(--color-ink-3)]">
{{ __('Eine Firma ist der Container für Pressemitteilungen: Stammdaten, Boilerplate, Pressekontakte. Anlage ohne separate Freigabe — die redaktionelle Prüfung erfolgt erst bei der Pressemitteilung.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="primary" icon="plus" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Firma anlegen anfragen') }}
<flux:button variant="ghost" icon="document-arrow-down" disabled>
{{ __('Export') }}
<span class="badge muted ml-2" style="font-size:9px;padding:0 5px;letter-spacing:0.06em;">
{{ __('bald') }}
</span>
</flux:button>
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
{{ __('Firma anlegen') }}
</flux:button>
</div>
</header>
{{-- ============== FILTER-PANEL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
</div>
<div class="p-5">
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="{{ __('Firma suchen...') }}" />
</div>
</article>
{{-- ============== SAVED VIEW TABS ============== --}}
<nav class="view-tabs" aria-label="{{ __('Gespeicherte Ansichten') }}">
@php
$savedViewMeta = [
'all' => __('Alle'),
'active' => __('Aktiv'),
'drafts' => __('In Anlage'),
'inactive' => __('Inaktiv'),
'shared' => __('Mit mir geteilt'),
];
@endphp
{{-- ============== FIRMEN-CARDS ============== --}}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@forelse ($pressKits as $company)
<article class="panel flex flex-col">
<div class="panel-head">
<div class="min-w-0">
<span class="section-eyebrow truncate">{{ $company->name }}</span>
</div>
@if ($company->is_active)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@foreach ($savedViewMeta as $key => $label)
<button
type="button"
wire:click="setSavedView('{{ $key }}')"
class="view-tab {{ $savedView === $key ? 'is-active' : '' }}"
data-view="{{ $key }}"
@if ($key === 'drafts') disabled aria-disabled="true" @endif
>
{{ $label }}
<span class="cnt">{{ $savedViewCounts[$key] }}</span>
</button>
@endforeach
</nav>
{{-- ============== FILTER + SUCHE ============== --}}
<section class="space-y-3">
<div class="flex items-center gap-2 flex-wrap">
<flux:dropdown align="start">
<button type="button" class="filter-chip {{ filled($portalFilter) ? 'is-active' : '' }}">
@if ($portalFilter === 'presseecho')
<span class="dot-pe inline-block"></span>
@elseif ($portalFilter === 'businessportal24')
<span class="dot-bp inline-block"></span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
<span class="dot-pe inline-block" style="margin-right:1px;"></span>
<span class="dot-bp inline-block" style="margin-left:-2px;"></span>
@endif
</div>
{{ __('Portal') }}:
<strong class="font-semibold">
@switch($portalFilter)
@case('presseecho') presseecho @break
@case('businessportal24') businessportal24 @break
@default {{ __('Alle') }}
@endswitch
</strong>
<flux:icon.chevron-down class="size-3 caret" />
</button>
<flux:menu>
<flux:menu.item wire:click="setPortalFilter('')">{{ __('Alle Portale') }}</flux:menu.item>
<flux:menu.item wire:click="setPortalFilter('presseecho')">presseecho</flux:menu.item>
<flux:menu.item wire:click="setPortalFilter('businessportal24')">businessportal24</flux:menu.item>
</flux:menu>
</flux:dropdown>
<div class="p-5 space-y-4 flex-1">
<div class="text-[11.5px] text-[color:var(--color-ink-3)] truncate">
{{ $company->slug }}
</div>
<flux:dropdown align="start">
<button type="button" class="filter-chip {{ $roleFilter !== 'all' ? 'is-active' : '' }}">
<flux:icon.user class="size-3 opacity-70" />
{{ __('Rolle') }}:
<strong class="font-semibold">
@switch($roleFilter)
@case('owner') {{ __('Owner') }} @break
@case('responsible') {{ __('Verantwortlich') }} @break
@case('member') {{ __('Mitglied') }} @break
@default {{ __('Alle') }}
@endswitch
</strong>
<flux:icon.chevron-down class="size-3 caret" />
</button>
<flux:menu>
<flux:menu.item wire:click="setRoleFilter('all')">{{ __('Alle Rollen') }}</flux:menu.item>
<flux:menu.item wire:click="setRoleFilter('owner')">{{ __('Owner') }}</flux:menu.item>
<flux:menu.item wire:click="setRoleFilter('responsible')">{{ __('Verantwortlich') }}</flux:menu.item>
<flux:menu.item wire:click="setRoleFilter('member')">{{ __('Mitglied') }}</flux:menu.item>
</flux:menu>
</flux:dropdown>
<div class="flex flex-wrap gap-2">
<span class="badge hub">{{ $company->portal?->label() ?? __('Portal unbekannt') }}</span>
<span class="badge hub">{{ $context->roleLabelFor($company, $user) }}</span>
@if ($company->disable_footer_code)
<span class="badge warn">{{ __('Footer-Code aus') }}</span>
@endif
</div>
<button type="button" class="filter-chip" disabled aria-disabled="true" title="{{ __('Branche-Filter folgt') }}">
<flux:icon.tag class="size-3 opacity-70" />
{{ __('Branche') }}: <strong class="font-semibold">{{ __('bald') }}</strong>
</button>
<div class="grid grid-cols-2 gap-3 pt-1">
<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)]">
{{ __('Pressemitteilungen') }}
</div>
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
{{ $company->press_releases_count }}
</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)]">
{{ __('Pressekontakte') }}
</div>
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
{{ $company->contacts_count }}
</div>
</div>
</div>
</div>
<span class="w-px h-6 bg-[color:var(--color-bg-rule)] mx-1"></span>
<div class="px-5 pb-4 pt-3 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
{{ __('Firma öffnen') }}
</flux:button>
</div>
</article>
@empty
<article class="panel md:col-span-2 xl:col-span-3">
<div class="p-10 flex flex-col items-center justify-center 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.building-office class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Firmen gefunden') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
{{ __('Prüfen Sie die Suche oder wenden Sie sich an den Support, wenn eine Firma fehlen sollte.') }}
</p>
<flux:button class="mt-4" variant="primary" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Profil prüfen') }}
</flux:button>
</div>
</article>
@endforelse
<div class="search-wrap" style="max-width:340px;">
<flux:icon.magnifying-glass class="ico size-3" />
<input
type="search"
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Firmenname, Stadt oder E-Mail…') }}"
/>
</div>
<span class="flex-1"></span>
{{-- View-Toggle Karten/Liste --}}
<div class="seg-toggle" role="tablist" aria-label="{{ __('Ansicht umschalten') }}">
<button
type="button"
wire:click="setViewMode('cards')"
class="cursor-pointer {{ $viewMode === 'cards' ? 'is-active' : '' }}"
aria-label="{{ __('Kartenansicht') }}"
data-viewmode="cards"
>
<flux:icon.squares-2x2 class="size-3" />
{{ __('Karten') }}
</button>
<button
type="button"
wire:click="setViewMode('list')"
class="cursor-pointer {{ $viewMode === 'list' ? 'is-active' : '' }}"
aria-label="{{ __('Listenansicht') }}"
data-viewmode="list"
>
<flux:icon.list-bullet class="size-3" />
{{ __('Liste') }}
</button>
</div>
</div>
</section>
{{ $pressKits->links() }}
{{-- ============== CONTENT-HOST ============== --}}
<article data-state-host>
@if ($pressKits->isEmpty())
{{-- Empty States --}}
@if ($hasActiveFilters)
{{-- Empty: Filter ohne Treffer --}}
<div class="panel" data-state="empty-filter">
<div class="empty-stage">
<div class="empty-ico warm">
<flux:icon.funnel class="size-6" />
</div>
<h3 class="empty-title">{{ __('Keine Firmen mit diesen Filtern') }}</h3>
<p class="empty-sub">
{{ __('Aktive Filter passen auf keine Einträge. Filter zurücksetzen oder weiter fassen.') }}
</p>
<div class="flex items-center gap-2.5 mt-6">
<flux:button variant="primary" wire:click="resetFilters">
{{ __('Alle Filter zurücksetzen') }}
</flux:button>
</div>
</div>
</div>
@else
{{-- Empty: noch keine Firma --}}
<div class="panel" data-state="empty-none">
<div class="empty-stage">
<div class="empty-ico">
<flux:icon.building-office class="size-6" />
</div>
<h3 class="empty-title">{{ __('Noch keine Firma angelegt') }}</h3>
<p class="empty-sub">
{{ __('Lege deine erste Firma an. Du kannst direkt im Anschluss eine Pressemitteilung darauf veröffentlichen — eine separate Freigabe der Firma ist nicht erforderlich.') }}
</p>
<div class="flex items-center gap-2.5 mt-6">
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
{{ __('Erste Firma anlegen') }}
</flux:button>
</div>
<div class="mt-9 grid gap-3 w-full max-w-[560px]" style="grid-template-columns:repeat(3,1fr);">
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">01</div>
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
{{ __('Stammdaten erfassen') }}
</div>
</div>
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">02</div>
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
{{ __('Boilerplate schreiben') }}
</div>
</div>
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">03</div>
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
{{ __('Pressekontakte zuordnen') }}
</div>
</div>
</div>
</div>
</div>
@endif
@elseif ($viewMode === 'cards')
{{-- Karten-Ansicht --}}
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3" data-state="cards">
@foreach ($pressKits as $company)
<div
class="firm-card {{ $company->panel_is_admin ? 'is-self' : '' }}"
wire:key="firm-card-{{ $company->id }}"
data-testid="firm-card-{{ $company->id }}"
>
<div class="flex items-start justify-between gap-3">
<div class="logo {{ $company->panel_logo_url ? '' : $company->panel_logo_variant }}">
@if ($company->panel_logo_url)
<img src="{{ $company->panel_logo_url }}" alt="{{ $company->name }}" loading="lazy" />
@else
{{ $company->panel_logo_initials }}
@endif
</div>
<div class="flex items-center gap-1">
@if ($company->is_active)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
@endif
</div>
</div>
<div class="min-w-0">
<h3 class="name">{{ $company->name }}</h3>
@if (filled($company->panel_meta_line))
<div class="meta-line">{{ $company->panel_meta_line }}</div>
@endif
</div>
<div class="flex items-center gap-2 flex-wrap">
@if ($company->portal === \App\Enums\Portal::Both)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@elseif ($company->portal === \App\Enums\Portal::Presseecho)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
@elseif ($company->portal === \App\Enums\Portal::Businessportal24)
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@endif
<span class="role-pill {{ $company->panel_is_admin ? 'admin' : '' }}">
{{ $company->panel_role_label }}
</span>
</div>
<div class="kpis">
<div class="kpi">
<span class="k">{{ $company->press_releases_count }}</span>
<span class="l">{{ __('PMs') }}</span>
</div>
<div class="kpi">
<span class="k">{{ $company->contacts_count }}</span>
<span class="l">{{ __('Kontakte') }}</span>
</div>
<div class="kpi">
<span class="k">
{{ $company->panel_last_press_release_short }}
</span>
<span class="l">{{ __('letzte PM') }}</span>
</div>
</div>
<div class="flex items-center gap-2 pt-1">
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate class="card-action primary" style="flex:1;">
<flux:icon.arrow-right class="size-3" />
{{ __('Firma öffnen') }}
</a>
<a href="{{ route('me.press-releases.create') }}" wire:navigate class="card-action">
<flux:icon.plus class="size-3" />
{{ __('Neue PM') }}
</a>
</div>
</div>
@endforeach
{{-- Add-Tile am Ende des Grids, nur auf der letzten Seite --}}
@if ($pressKits->currentPage() === $pressKits->lastPage())
<a href="{{ route('me.press-kits.create') }}" wire:navigate class="add-tile" data-testid="add-tile">
<span class="ico">
<flux:icon.plus class="size-5" />
</span>
<span class="lbl">{{ __('Neue Firma anlegen') }}</span>
<span class="sub">
{{ __('Stammdaten und Boilerplate. Die Anlage benötigt keine separate Freigabe.') }}
</span>
</a>
@endif
</div>
@else
{{-- Listen-Ansicht --}}
<div class="panel overflow-hidden" data-state="list">
<div class="overflow-x-auto">
<table class="list">
<colgroup>
<col style="width:88px;" />
<col />
<col style="width:190px;" />
<col style="width:140px;" />
<col style="width:110px;" />
<col style="width:80px;" />
<col style="width:100px;" />
<col style="width:130px;" />
<col style="width:56px;" />
</colgroup>
<thead>
<tr>
<th></th>
<th>{{ __('Firma') }}</th>
<th>{{ __('Portal') }}</th>
<th>{{ __('Rolle') }}</th>
<th>{{ __('Status') }}</th>
<th style="text-align:right;">{{ __('PMs') }}</th>
<th style="text-align:right;">{{ __('Kontakte') }}</th>
<th>{{ __('Letzte PM') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach ($pressKits as $company)
<tr wire:key="firm-row-{{ $company->id }}" data-testid="firm-row-{{ $company->id }}">
<td>
<span class="mini-logo {{ $company->panel_logo_url ? '' : $company->panel_logo_variant }}">
@if ($company->panel_logo_url)
<img src="{{ $company->panel_logo_url }}" alt="{{ $company->name }}" loading="lazy" />
@else
{{ $company->panel_logo_initials }}
@endif
</span>
</td>
<td>
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate class="row-title">
{{ $company->name }}
</a>
@if (filled($company->panel_meta_line))
<div class="row-sub">{{ $company->panel_meta_line }}</div>
@endif
</td>
<td>
<div class="flex flex-wrap items-center gap-1">
@if ($company->portal === \App\Enums\Portal::Both)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@elseif ($company->portal === \App\Enums\Portal::Presseecho)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
@elseif ($company->portal === \App\Enums\Portal::Businessportal24)
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@endif
</div>
</td>
<td>
<span class="role-pill {{ $company->panel_is_admin ? 'admin' : '' }}">
{{ $company->panel_role_label }}
</span>
</td>
<td>
@if ($company->is_active)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
@endif
</td>
<td style="text-align:right;">
<span class="row-num">{{ $company->press_releases_count }}</span>
</td>
<td style="text-align:right;">
<span class="row-num">{{ $company->contacts_count }}</span>
</td>
<td>
<span class="row-num">
{{ $company->panel_last_press_release_date }}
</span>
</td>
<td style="text-align:right;">
<div class="firm-list-actions flex items-center justify-end gap-1">
<flux:button
size="sm"
variant="ghost"
icon="eye"
href="{{ route('me.press-kits.show', $company->id) }}"
wire:navigate
aria-label="{{ __('Firma öffnen') }}"
title="{{ __('Firma öffnen') }}"
/>
<flux:button
size="sm"
variant="ghost"
icon="document-plus"
href="{{ route('me.press-releases.create') }}"
wire:navigate
aria-label="{{ __('Neue Pressemitteilung') }}"
title="{{ __('Neue Pressemitteilung') }}"
/>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</article>
{{-- Pagination --}}
@if ($pressKits->hasPages())
<div class="px-1">
{{ $pressKits->links('components.portal.pagination', ['scrollTo' => '[data-state-host]']) }}
</div>
@endif
{{-- ============== ROLLEN-LEGENDE ============== --}}
<article class="panel-warm p-5">
<div class="grid items-start gap-6" style="grid-template-columns:auto 1fr;">
<div class="min-w-[180px]">
<div class="section-eyebrow">{{ __('Rollen pro Firma') }}</div>
<p class="text-[12px] leading-[1.55] mt-3 m-0 max-w-[220px] text-[color:var(--color-ink-3)]">
{{ __('Mehrere Personen können einer Firma zugeordnet sein. Die Rolle steuert, was im Backend möglich ist.') }}
</p>
</div>
<div class="grid gap-4" style="grid-template-columns:repeat(3,1fr);">
<div>
<span class="role-pill admin" style="margin-bottom:8px;">{{ __('Owner') }}</span>
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
<li>{{ __('Stammdaten & Boilerplate') }}</li>
<li>{{ __('Pressekontakte verwalten') }}</li>
<li>{{ __('PMs erstellen, einreichen, archivieren') }}</li>
<li>{{ __('Weitere Mitglieder einladen') }}</li>
</ul>
</div>
<div>
<span class="role-pill admin" style="margin-bottom:8px;">{{ __('Verantwortlich') }}</span>
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
<li>{{ __('Stammdaten & Boilerplate') }}</li>
<li>{{ __('Pressekontakte verwalten') }}</li>
<li>{{ __('PMs erstellen & einreichen') }}</li>
<li class="text-[color:var(--color-ink-4)]">{{ __('keine Mitglieder-Verwaltung') }}</li>
</ul>
</div>
<div>
<span class="role-pill" style="margin-bottom:8px;">
{{ __('Mitglied') }}
<span class="text-[color:var(--color-ink-4)] font-normal">· {{ __('bald erweitert') }}</span>
</span>
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
<li>{{ __('PMs einsehen') }}</li>
<li>{{ __('Stammdaten lesen') }}</li>
<li class="text-[color:var(--color-ink-4)]">{{ __('keine Bearbeitung') }}</li>
</ul>
</div>
</div>
</div>
</article>
</div>

View file

@ -8,6 +8,7 @@ use App\Models\Contact;
use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
@ -44,6 +45,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public string $publishMode = 'now';
public ?string $scheduledAt = null;
public bool $useEmbargo = false;
public ?string $embargoAt = null;
public function mount(): void
{
$user = auth()->user();
@ -72,6 +79,86 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
unset($this->tags, $this->presubmitChecks);
}
/**
* Live-Re-Validation: sobald für ein Property bereits ein Error im Bag
* liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein
* roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt und
* der User muss nicht erst auf „Entwurf speichern" klicken.
*/
public function updated(string $property): void
{
if (! $this->getErrorBag()->has($property)) {
return;
}
try {
$this->validateOnly($property, $this->formRules());
} catch (\Illuminate\Validation\ValidationException) {
// Field bleibt invalid — Error-Bag wird automatisch befüllt.
}
}
/**
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung.
* Die einzelnen Feld-Errors werden weiterhin direkt am Input angezeigt,
* der Toast dient als zusätzlicher Wegweiser, falls der erste Fehler
* außerhalb des Viewports liegt.
*/
protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void
{
$count = $exception
? array_sum(array_map('count', $exception->errors()))
: count($this->getErrorBag()->all());
Flux::toast(
heading: __('Bitte Eingaben prüfen'),
text: $count > 1
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
variant: 'danger',
duration: 6000,
);
}
/**
* Single Source of Truth für die Validierungsregeln.
*
* @return array<string, array<int, mixed>>
*/
protected function formRules(): array
{
$rules = [
'language' => ['required', Rule::in(['de', 'en'])],
'companyId' => ['required', 'integer'],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
'contactId' => ['nullable', 'integer'],
'title' => ['required', 'string', 'min:5', 'max:255'],
'subtitle' => ['nullable', 'string', 'max:255'],
'text' => ['required', 'string', 'min:50'],
'keywords' => ['nullable', 'string', 'max:255'],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
'publishMode' => ['required', Rule::in(['now', 'scheduled'])],
];
// Termin-Pflicht nur, wenn der User explizit Scheduling gewählt hat.
// Min. 5 Minuten in der Zukunft, damit der Background-Job (alle 5 Min)
// die PM verlässlich rechtzeitig fängt.
if ($this->publishMode === 'scheduled') {
$rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()];
} else {
$rules['scheduledAt'] = ['nullable'];
}
if ($this->useEmbargo) {
$rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()];
} else {
$rules['embargoAt'] = ['nullable'];
}
return $rules;
}
public function addTag(string $tag): void
{
$tag = trim($tag);
@ -110,34 +197,35 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public function save(string $submitStatus = 'draft'): void
{
$this->validate([
'language' => ['required', Rule::in(['de', 'en'])],
'companyId' => ['required', 'integer'],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
'contactId' => ['required', 'integer'],
'title' => ['required', 'string', 'min:5', 'max:255'],
'subtitle' => ['nullable', 'string', 'max:255'],
'text' => ['required', 'string', 'min:50'],
'keywords' => ['nullable', 'string', 'max:255'],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
]);
try {
$this->validate($this->formRules());
} catch (\Illuminate\Validation\ValidationException $e) {
$this->notifyValidationError($e);
throw $e;
}
$user = auth()->user();
$company = $this->selectedCompany();
if (! $company) {
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
$this->notifyValidationError();
return;
}
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
$contact = null;
if (! $contact) {
$this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.'));
if ($this->contactId) {
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
return;
if (! $contact) {
$this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.'));
$this->notifyValidationError();
return;
}
}
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
@ -167,14 +255,28 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
: null,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt
? \Carbon\Carbon::parse($this->scheduledAt)
: null,
'embargo_at' => $this->useEmbargo && $this->embargoAt
? \Carbon\Carbon::parse($this->embargoAt)
: null,
'status' => $status->value,
]);
$pr->contacts()->sync([$contact->id]);
if ($contact) {
$pr->contacts()->sync([$contact->id]);
}
session()->flash('success', $status === PressReleaseStatus::Review
? __('Pressemitteilung zur Prüfung eingereicht.')
: __('Entwurf gespeichert.'));
Flux::toast(
heading: $status === PressReleaseStatus::Review
? __('Eingereicht')
: __('Entwurf gespeichert'),
text: $status === PressReleaseStatus::Review
? __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.')
: __('Deine Änderungen sind gesichert. Du kannst die PM jederzeit weiter bearbeiten.'),
variant: 'success',
);
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
}
@ -246,9 +348,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
],
[
'key' => 'contact',
'status' => $this->contactId ? 'ok' : 'err',
'status' => $this->contactId ? 'ok' : 'warn',
'label' => __('Pressekontakt zugeordnet'),
'sub' => $this->contactId ? '' : __('Mindestens ein Kontakt erforderlich'),
'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'),
],
[
'key' => 'tags',
@ -365,7 +467,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</header>
{{-- ============== 2-COLUMN GRID ============== --}}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr),360px]">
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
<div class="space-y-6 min-w-0">
@ -513,7 +615,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
</section>
{{-- 6) ANHÄNGE (nach Speichern verfügbar) --}}
{{-- 6) ANHÄNGE TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
(Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten).
Wird in einer späteren Phase aktiviert.
<section class="panel">
<div class="p-5">
<div class="flex items-center justify-between mb-3 gap-4">
@ -529,6 +634,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
</div>
</section>
--}}
{{-- 7) BOILERPLATE --}}
<section class="panel">
@ -655,14 +761,46 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
</article>
{{-- Portal (Read-only) --}}
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">
{{ __('Kategorie') }}
<span class="text-[color:var(--color-err)]">*</span>
</span>
</div>
<div class="p-5">
<flux:field>
<flux:select wire:model.live="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($categories as $cat)
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
@endforeach
</flux:select>
<flux:error name="categoryId" />
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}</flux:description>
</flux:field>
</div>
</article>
{{-- Portal (Read-only, Badge in Portal-Farbe) --}}
@php
$portalPillClass = 'portal-pill';
if ($portal === 'presseecho') {
$portalPillClass = 'portal-pill pe';
} elseif ($portal === 'businessportal24') {
$portalPillClass = 'portal-pill bp';
}
@endphp
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Portal') }}</span>
</div>
<div class="p-5">
<div class="flex items-center gap-3">
<span class="badge hub dot">{{ $selectedPortalLabel }}</span>
<span class="{{ $portalPillClass }}">
<span class="pdot"></span>{{ $selectedPortalLabel }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-4)]">
{{ __('automatisch aus der Firma') }}
</span>
@ -691,7 +829,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@endif
@else
<flux:field>
<flux:label>{{ __('Kontakt für diese PM') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:label>{{ __('Kontakt für diese PM') }}</flux:label>
<flux:select wire:model.live="contactId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($selectedCompanyContacts as $contact)
@ -708,18 +846,25 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<flux:error name="contactId" />
</flux:field>
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
@if ($activeContact && empty($activeContact->phone))
@if (! $contactId)
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
<span>{{ __('Noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend.') }}</span>
</div>
@else
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
@if ($activeContact && empty($activeContact->phone))
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
</div>
@endif
@endif
@endif
</div>
</article>
{{-- Themen-Tags + Kategorie --}}
{{-- Themen-Tags --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
@ -769,20 +914,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
@endif
<flux:field>
<flux:label>{{ __('Kategorie') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model.live="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($categories as $cat)
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
<flux:error name="categoryId" />
</flux:field>
<p class="text-[10.5px] text-[color:var(--color-ink-4)] m-0 leading-[1.45]">
{{ __('Tags helfen bei SEO und Auffindbarkeit. Die Kategorie steuert, in welcher Rubrik die PM erscheint.') }}
{{ __('Tags helfen bei SEO und Auffindbarkeit.') }}
</p>
</div>
</article>
@ -792,7 +925,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<div class="panel-head">
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
</div>
<div class="p-5 space-y-2">
<div class="p-5 space-y-3">
<label class="pr-pub-opt {{ $publishMode === 'now' ? 'is-checked' : '' }}">
<input type="radio" wire:model.live="publishMode" value="now" class="sr-only" />
<span class="dot-out"></span>
@ -805,18 +938,53 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</span>
</span>
</label>
<span class="pr-pub-opt is-disabled">
<label class="pr-pub-opt {{ $publishMode === 'scheduled' ? 'is-checked' : '' }}">
<input type="radio" wire:model.live="publishMode" value="scheduled" class="sr-only" />
<span class="dot-out"></span>
<span class="flex-1">
<span class="text-[12.5px] font-semibold text-[color:var(--color-ink-2)] leading-tight flex items-center gap-2">
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] leading-tight">
{{ __('Geplanter Termin') }}
<span class="pr-bald-badge">{{ __('bald') }}</span>
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
{{ __('Datum + Uhrzeit, automatische Veröffentlichung') }}
{{ __('Datum + Uhrzeit — wird automatisch zum Termin veröffentlicht') }}
</span>
</span>
</span>
</label>
@if ($publishMode === 'scheduled')
<flux:field>
<flux:label>{{ __('Veröffentlichungstermin') }}</flux:label>
<flux:input
wire:model.live="scheduledAt"
type="datetime-local"
:min="now()->addMinutes(5)->format('Y-m-d\\TH:i')"
/>
<flux:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
<flux:error name="scheduledAt" />
</flux:field>
@endif
<div class="border-t pt-3" style="border-color: var(--color-line);">
<flux:switch
wire:model.live="useEmbargo"
:label="__('Sperrfrist (Embargo) setzen')"
/>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
{{ __('Die PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }}
</p>
@if ($useEmbargo)
<flux:field class="mt-3">
<flux:label>{{ __('Sperrfrist bis') }}</flux:label>
<flux:input
wire:model.live="embargoAt"
type="datetime-local"
:min="now()->format('Y-m-d\\TH:i')"
/>
<flux:error name="embargoAt" />
</flux:field>
@endif
</div>
</div>
</article>
@ -851,7 +1019,6 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
<ul class="text-[11.5px] text-[color:var(--color-accent-deep)] leading-[1.55] list-none p-0 m-0 space-y-1">
<li>· {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}</li>
<li>· {{ __('Geplante Veröffentlichung / Scheduling') }}</li>
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
</ul>

View file

@ -6,6 +6,7 @@ use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@ -86,11 +87,20 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
try {
app(PressReleaseService::class)->submitForReview($pr);
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
Flux::toast(
heading: __('Eingereicht'),
text: __('Pressemitteilung zur Prüfung eingereicht.'),
variant: 'success',
);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
Flux::toast(text: $e->getMessage(), variant: 'danger');
}
}
@ -158,19 +168,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
<div class="space-y-8">
{{-- ============== FLASH ============== --}}
@if (session('success'))
<div class="px-4 py-3 rounded-[5px] border-l-[3px] text-[12.5px]"
style="border-color: var(--color-ok); background: color-mix(in oklab, var(--color-ok) 10%, var(--color-bg)); color: var(--color-ink);">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border-l-[3px] text-[12.5px]"
style="border-color: var(--color-err); background: color-mix(in oklab, var(--color-err) 12%, var(--color-bg)); color: var(--color-ink);">
{{ session('error') }}
</div>
@endif
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
@ -504,6 +502,18 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
</div>
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.calendar variant="micro" class="size-3" />
<span>{{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }}</span>
</div>
@endif
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.lock-closed variant="micro" class="size-3" />
<span>{{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }}</span>
</div>
@endif
</flux:table.cell>
<flux:table.cell>
@ -520,7 +530,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
@endforeach
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $pressReleases->links() }}
{{ $pressReleases->links('components.portal.pagination') }}
</div>
@elseif ($hasAnyPR && $search !== '')
{{-- Empty: Suche ohne Treffer --}}

View file

@ -5,6 +5,7 @@ use App\Models\PressRelease;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@ -34,12 +35,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
try {
app(PressReleaseService::class)->submitForReview($pr);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
return;
}
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
Flux::toast(
heading: __('Eingereicht'),
text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'),
variant: 'success',
);
}
public function generateShareLink(MagicLinkGenerator $generator): void
@ -52,7 +62,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$this->shareUrl = $share['url'];
$this->shareExpiresAt = $share['expires_at']->format('d.m.Y H:i');
session()->flash('success', __('Vorschau-Link wurde erzeugt.'));
Flux::toast(text: __('Vorschau-Link wurde erzeugt.'), variant: 'success');
}
public function with(): array
@ -114,18 +124,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
};
@endphp
@if (session('success'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
@endif
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
@ -139,6 +138,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ $pr->title }}
</h1>
@if ($pr->subtitle)
<p class="text-[18px] font-medium tracking-[-0.2px] leading-[1.35] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->subtitle }}
</p>
@endif
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->company?->name ?? '' }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
@ -333,8 +337,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ number_format($pr->hits, 0, ',', '.') }}
</div>
</div>
@if ($pr->scheduled_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplante Veröffentlichung') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->scheduled_at->format('d.m.Y H:i') }}
</div>
</div>
@endif
@if ($pr->embargo_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->embargo_at->format('d.m.Y H:i') }}
</div>
</div>
@endif
</div>
@if ($pr->no_export)
<div class="mt-3 flex items-center gap-2 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.no-symbol variant="micro" class="size-3.5" />
<span>{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}</span>
</div>
@endif
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
@if ($statusLogs->isNotEmpty())
@ -406,4 +433,22 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@endif
</div>
</article>
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
@if ($pr->boilerplate_override)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Eigener Abbinder (Boilerplate)') }}</span>
<span class="badge hub">{{ __('Override') }}</span>
</div>
<div class="p-5">
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-3">
{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}
</p>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] leading-[1.6] text-[color:var(--color-ink-2)] whitespace-pre-line">
{{ $pr->boilerplate_override }}
</div>
</div>
</article>
@endif
</div>

View file

@ -1,20 +1,14 @@
<?php
use App\Models\Company;
use App\Models\Profile;
use App\Models\User;
use App\Services\Image\ImageService;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Component
{
use WithFileUploads;
public string $name = '';
public string $language = 'de';
@ -53,26 +47,6 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
public string $billingCountryCode = 'DE';
public ?int $editableCompanyId = null;
public string $companyName = '';
public string $companyAddress = '';
public string $companyEmail = '';
public string $companyPhone = '';
public string $companyWebsite = '';
public string $companyCountryCode = 'DE';
public bool $companyDisableFooterCode = false;
public $companyLogo = null;
public bool $removeCompanyLogo = false;
public function mount(): void
{
$user = auth()->user();
@ -81,7 +55,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
$this->name = (string) $user->name;
$this->language = $user->language ?? 'de';
$this->salutationKey = (string) ($profile->salutation_key ?? 'none');
$this->salutationKey = (string) ($profile?->salutation_key ?? 'none');
$this->firstName = (string) ($profile?->first_name ?? '');
$this->lastName = (string) ($profile?->last_name ?? '');
$this->title = (string) ($profile?->title ?? '');
@ -94,20 +68,12 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
$this->taxIdNumber = (string) ($profile?->tax_id_number ?? '');
$billingAddress = $user->billingAddress;
$this->billingName = (string) ($billingAddress?->name ?? $user->name);
$this->billingName = (string) ($billingAddress?->name ?? '');
$this->billingAddress1 = (string) ($billingAddress?->address1 ?? '');
$this->billingAddress2 = (string) ($billingAddress?->address2 ?? '');
$this->billingPostalCode = (string) ($billingAddress?->postal_code ?? '');
$this->billingCity = (string) ($billingAddress?->city ?? '');
$this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE');
$this->loadEditableCompany();
}
public function selectCompany(int $companyId): void
{
$this->editableCompanyId = $companyId;
$this->loadEditableCompany();
}
public function saveProfile(): void
@ -184,139 +150,20 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
session()->flash('profile-status', __('Profil gespeichert.'));
}
public function saveCompany(ImageService $imageService): void
{
if (! $this->editableCompanyId) {
return;
}
$company = $this->resolveEditableCompany($this->editableCompanyId);
if (! $company) {
throw ValidationException::withMessages([
'companyName' => __('Diese Firma kann von Ihnen nicht bearbeitet werden.'),
]);
}
$this->authorize('update', $company);
$validated = $this->validate([
'companyName' => ['required', 'string', 'max:255'],
'companyAddress' => ['nullable', 'string', 'max:1000'],
'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', [])))],
'companyLogo' => ['nullable', 'image', 'max:'.(int) (ImageService::MAX_LOGO_BYTES / 1024)],
]);
$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,
'disable_footer_code' => $this->companyDisableFooterCode,
]);
if ($this->removeCompanyLogo) {
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
$company->logo_path = null;
$company->logo_variants = null;
}
if ($this->companyLogo) {
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
$stored = $imageService->storeCompanyLogo(
$this->companyLogo,
$company->portal?->value ?? 'presseecho',
$company->id,
);
$company->logo_path = $stored['path'];
$company->logo_variants = $stored['variants'];
}
$company->save();
$this->companyLogo = null;
$this->removeCompanyLogo = false;
session()->flash('company-status', __('Firmendaten gespeichert.'));
}
public function with(): array
{
$user = auth()->user();
$companies = $user->companies()
->withPivot('role')
->orderBy('name')
->get(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id']);
return [
'user' => $user,
'companies' => $companies,
'salutations' => collect((array) config('salutations.items', []))
->map(fn (array $labels) => $labels[$user->language] ?? $labels['de'] ?? '')
->all(),
'countries' => (array) config('countries.items', []),
'editableCompany' => $this->editableCompanyId
? $this->resolveEditableCompany($this->editableCompanyId)
: null,
'billingComplete' => $this->billingIsComplete(),
];
}
private function loadEditableCompany(): void
{
/** @var User $user */
$user = auth()->user();
$editable = Company::query()
->where(function ($query) use ($user): void {
$query->where('owner_user_id', $user->id)
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
->whereIn('company_user.role', ['owner', 'responsible']));
})
->orderBy('name');
$company = $this->editableCompanyId
? $editable->whereKey($this->editableCompanyId)->first()
: $editable->first();
if (! $company) {
$this->editableCompanyId = null;
return;
}
$this->editableCompanyId = $company->id;
$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->companyDisableFooterCode = (bool) $company->disable_footer_code;
}
private function resolveEditableCompany(int $companyId): ?Company
{
/** @var User $user */
$user = auth()->user();
return Company::query()
->where('id', $companyId)
->where(function ($query) use ($user): void {
$query->where('owner_user_id', $user->id)
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
->whereIn('company_user.role', ['owner', 'responsible']));
})
->first();
}
public function billingHasInput(): bool
{
return filled($this->billingName)
@ -347,10 +194,15 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Mein Profil') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Hier pflegen Sie Ihre persönlichen Konto- und Profildaten. Firmendaten verwalten Sie direkt in der jeweiligen Firma.') }}
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[680px] text-[color:var(--color-ink-2)]">
{{ __('Hier verwalten Sie Ihre Rechnungsadresse und persönlichen Profileinstellungen. Firmendaten liegen separat in der Firmenverwaltung.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Firmen verwalten') }}
</flux:button>
</div>
</header>
@if (session('profile-status'))
@ -362,60 +214,42 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
@endif
<form wire:submit="saveProfile" class="space-y-6">
<div class="grid gap-6 lg:grid-cols-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Konto') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:input wire:model="name" :label="__('Name')" required />
<flux:input value="{{ $user->email }}" :label="__('E-Mail')" disabled :description="__('Änderung über Konto-Sicherheit möglich.')" />
<flux:select wire:model="language" :label="__('Sprache')">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
</div>
</article>
<article class="panel" id="profil">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Profil') }}</span>
</div>
<div class="p-5 grid gap-4 sm:grid-cols-2">
<flux:select wire:model="salutationKey" :label="__('Anrede')">
@foreach ($salutations as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</flux:select>
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
<flux:input wire:model="firstName" :label="__('Vorname')" />
<flux:input wire:model="lastName" :label="__('Nachname')" />
<flux:input wire:model="phone" :label="__('Telefon')" />
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
<flux:checkbox wire:model="showStats" :label="__('Statistiken in Pressemitteilungen anzeigen')" class="sm:col-span-2" />
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code in Pressemitteilungen deaktivieren')" class="sm:col-span-2" />
</div>
</article>
</div>
<article class="panel" id="rechnungsadresse">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Diese Angaben werden für künftige Rechnungen verwendet. Eine vollständige Rechnungsadresse benötigt Name, Adresse, PLZ, Ort und Land.') }}
</p>
@if (! $this->billingIsComplete())
<div class="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-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">
{{ __('Rechnungsadresse noch unvollständig. Bitte ergänzen Sie die Pflichtangaben, bevor neue Buchungen sauber abgerechnet werden können.') }}
</div>
</div>
@if ($billingComplete)
<span class="badge ok dot">{{ __('vollständig') }}</span>
@else
<span class="badge warn dot">{{ __('unvollständig') }}</span>
@endif
</div>
<div class="p-5 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<div class="space-y-3">
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
{{ __('Diese Adresse ist die maßgebliche Grundlage für Rechnungen und künftige Buchungen.') }}
</p>
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
{{ __('Pflichtangaben sind Rechnungsname, Adresse, PLZ, Ort und Land. Die USt-ID ist optional.') }}
</p>
@if (! $billingComplete)
<div class="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-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">
{{ __('Bitte ergänzen Sie die Rechnungsadresse, damit neue Buchungen sauber abgerechnet werden können.') }}
</div>
</div>
@else
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
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 mt-0.5" />
<div class="flex-1">
{{ __('Ihre Rechnungsadresse ist vollständig hinterlegt.') }}
</div>
</div>
@endif
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:input wire:model="billingName" :label="__('Rechnungsname')" class="sm:col-span-2" />
@ -432,52 +266,86 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
<flux:error name="billingName" class="sm:col-span-2" />
</div>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Rechnungsadresse speichern') }}
</flux:button>
</div>
</article>
<div class="grid gap-6 lg:grid-cols-2">
<article class="panel" id="profil">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Profileinstellungen') }}</span>
</div>
<div class="p-5 grid gap-4 sm:grid-cols-2">
<flux:input wire:model="name" :label="__('Anzeigename')" required class="sm:col-span-2" />
<flux:select wire:model="salutationKey" :label="__('Anrede')">
@foreach ($salutations as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</flux:select>
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
<flux:input wire:model="firstName" :label="__('Vorname')" />
<flux:input wire:model="lastName" :label="__('Nachname')" />
<flux:input wire:model="phone" :label="__('Telefon')" />
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
<flux:textarea wire:model="address" :label="__('Adresse')" class="sm:col-span-2" />
<flux:select wire:model="countryCode" :label="__('Land')" class="sm:col-span-2">
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Profil speichern') }}
</flux:button>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Konto & Sicherheit') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:input value="{{ $user->email }}" :label="__('E-Mail')" disabled :description="__('Änderung über Konto-Sicherheit möglich.')" />
<flux:select wire:model="language" :label="__('Sprache')">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
<div class="pt-3 border-t border-[color:var(--color-bg-rule)]">
<flux:button size="sm" variant="ghost" icon="shield-check" href="{{ route('me.security') }}" wire:navigate>
{{ __('Konto-Sicherheit öffnen') }}
</flux:button>
</div>
</div>
</article>
</div>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
<span class="section-eyebrow">{{ __('Einstellungen') }}</span>
</div>
<div class="p-5 flex justify-end">
<flux:button type="submit" variant="primary">{{ __('Profil speichern') }}</flux:button>
<div class="p-5 grid gap-4 md:grid-cols-2">
<flux:switch
wire:model="showStats"
align="right"
:label="__('Statistiken anzeigen')"
:description="__('Statistiken und Kennzahlen in Ihren Pressemitteilungen anzeigen.')"
/>
<flux:switch
wire:model="disableFooterCode"
align="right"
:label="__('Footer-Code deaktivieren')"
:description="__('Automatische Footer-Codes in Pressemitteilungen für dieses Profil deaktivieren.')"
/>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Einstellungen speichern') }}
</flux:button>
</div>
</article>
</form>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Zugeordnete Firmen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $companies->count() }} {{ __('Einträge') }}
</span>
</div>
@forelse ($companies as $company)
<div class="flex flex-col gap-2 px-5 py-3 border-b border-[color:var(--color-bg-rule)] last:border-0 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1 min-w-0">
<p class="text-[13px] font-semibold text-[color:var(--color-ink)] m-0">{{ $company->name }}</p>
<div class="flex flex-wrap items-center gap-2">
<span class="badge hub">{{ $company->portal?->label() ?? '' }}</span>
<span class="badge hub">{{ $company->pivot->role ?? 'member' }}</span>
@if ($company->owner_user_id === $user->id)
<span class="badge ok">{{ __('Eigentümer') }}</span>
@endif
</div>
</div>
@if ($company->owner_user_id === $user->id || in_array($company->pivot->role, ['owner', 'responsible'], true))
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
{{ __('Firma verwalten') }}
</flux:button>
@else
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
{{ __('Firma öffnen') }}
</flux:button>
@endif
</div>
@empty
<div class="p-5 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Keine Firmen zugeordnet. Bitte wenden Sie sich an den Administrator.') }}
</div>
@endforelse
</article>
</div>

View file

@ -7,8 +7,7 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
@include('partials.local-fonts')
@vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal')
@livewireStyles

View file

@ -7,10 +7,9 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
{{-- Hub × FluxUI Phase 1: Inter Tight + JetBrains Mono + Source Serif 4
(Source Serif 4 nur für brand-mark in Headern, deshalb mitgeladen). --}}
<link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|jetbrains-mono:400,500,600|source-serif-4:400,500,600,700" rel="stylesheet" />
@include('partials.local-fonts')
{{-- Phase 1 Refinement: NUR portal.css einbinden KEIN resources/js/app.js.
app.js startet Alpine via `Alpine.start()`, aber @fluxScripts (am Ende

View file

@ -0,0 +1,3 @@
<link href="{{ asset('fonts/inter-tight/font.css') }}" rel="stylesheet">
<link href="{{ asset('fonts/source-serif-4/font.css') }}" rel="stylesheet">
<link href="{{ asset('fonts/jetbrains-mono/font.css') }}" rel="stylesheet">

View file

@ -12,7 +12,7 @@
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
@include('partials.local-fonts')
@php
$font = \App\Helpers\ThemeHelper::getFont();
@ -25,12 +25,6 @@
<!-- Additional Styles -->
@stack('styles')
<!-- Domain-spezifische Fonts -->
@if ($font === 'Montserrat')
<link href="https://fonts.bunny.net/css?family=montserrat:400,500,600,700" rel="stylesheet" />
@else
<link href="https://fonts.bunny.net/css?family=montserrat:400,500,600,700" rel="stylesheet" />
@endif
</head>
<body class="antialiased">

View file

@ -13,7 +13,7 @@
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
@include('partials.local-fonts')
@php
$font = \App\Helpers\ThemeHelper::getFont();
@ -22,11 +22,6 @@
@vite([\App\Helpers\ThemeHelper::getThemeCssPath(), 'resources/js/app.js'], $domainConfig['assets_dir'] ?? 'build/web')
@if (in_array(($theme ?? null), ['businessportal24', 'presseecho', 'pressekonto'], true))
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin>
<link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|source-serif-4:400,500,600,700|jetbrains-mono:400,500,600" rel="stylesheet" />
@endif
<!-- Sticky Header Script -->
<script>
document.addEventListener('DOMContentLoaded', function() {
@ -71,10 +66,6 @@
<!-- Additional Styles -->
@stack('styles')
<!-- Domain-spezifische Fonts -->
@if (! in_array(($theme ?? null), ['businessportal24', 'presseecho', 'pressekonto'], true))
<link href="https://fonts.bunny.net/css?family=montserrat:400,500,600,700" rel="stylesheet" />
@endif
</head>
<body class="antialiased" style="background-color: hsl(var(--background)); color: hsl(var(--foreground));">