22-05-2026 Optimierung der User und Admin Panels
This commit is contained in:
parent
d2ba22c0cf
commit
e8c47b7553
73 changed files with 10282 additions and 1546 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
|
|
|||
81
resources/js/portal-form-hooks.js
Normal file
81
resources/js/portal-form-hooks.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
112
resources/views/components/portal/pagination.blade.php
Normal file
112
resources/views/components/portal/pagination.blade.php
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' => '19–25', '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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
218
resources/views/livewire/customer/press-kits/create.blade.php
Normal file
218
resources/views/livewire/customer/press-kits/create.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 --}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
resources/views/partials/local-fonts.blade.php
Normal file
3
resources/views/partials/local-fonts.blade.php
Normal 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">
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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));">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue